查看原文
其他

CADisplayLink前世今生

Augus 搜狐技术产品 2023-07-09

本文字数:19803

预计阅读时间:50分钟

用最通俗的语言,描述最难懂的技术

前情描述

上周同事做code review的时候说到了CADisplayLink的一些变化,感触颇深,提到了接口的一些变动,现在就自己的一些理解加上网上文档的查阅对该对象进行以下的说明:
测试环境
  • 编译环境:Xcode 13.1
  • 运行设备:iPhone X,iOS 14.7.1,iPhone 13 Pro,iOS 15.5

前情描述

一个与屏幕的刷新率一致的定时器对象。
初始化,需要提供一个目标对象和一个方法选择器;添加,需要指定一个运行循环和一个运行循环的模式。
一旦你完成上述步骤之后,当屏幕上的内容需要更新的时候,系统就会调用目标对象上的选择器。
目标对象可以读取CADisplayLinktimestamp属性来检索系统上一帧的时间,比如某个电影程序可能会使用时间戳来计算下一个即将显示的视频帧;一个执行动画的程序可能会使用timestamp来确定可见对象下一帧出现的位置和方式。
duration提供了在最大每秒帧数(maximumFramesPerSecond)下帧之间时间量,要计算实际的的帧持续时间,使用targetTimestamp - timestamp。你可以在应用程序中使用这个值来计算显示的实际帧率,系统显示下一帧的大致时间,并调整绘制行为,以便可以及时显示。
如果你的程序不能在系统提供的时间内提供可用帧,你可能先选择一个较慢的帧率。对于用户而言,一个稳定且慢的帧率慢点应用比跳过帧的应用显的更流畅,你可以设置preferredFramesPerSecond来定义每秒的帧数。
当你的应用通过CADisplayLink完成显示时,调用invalidate将它从运行循环中移除,并将其与目标解除关联。

CADisplayLink接口

/* CoreAnimation - CADisplayLink.h

   Copyright (c) 2009-2021, Apple Inc.
   All rights reserved. */

#import <QuartzCore/CABase.h>
#import <QuartzCore/CAFrameRateRange.h>
#import <Foundation/NSObject.h>

@class NSString, NSRunLoop;

NS_ASSUME_NONNULL_BEGIN

/** 与显示器的垂直同步信号(vsync:vertical synchronization)绑定的定时器的类. **/

API_AVAILABLE(ios(3.1), watchos(2.0), tvos(9.0)) API_UNAVAILABLE(macos)
@interface CADisplayLink : NSObject
{
@private
  void *_impl;
}

/* 为目的显示创建一个新的CADisplayLink的实例对象,它将在目标对象上调用一个名为sel的签名方法 */
+ (CADisplayLink *)displayLinkWithTarget:(id)target selector:(SEL)sel;

/* 将定时器添加到运行循环和某一模式下;除非暂停,直到移除前它会被垂直同步信号每一次触发
 * 也只能被添加到一个运行循环中;但是它可以被添加到多个模式中;当它被添加到一个运行循环
 * 时将隐式引用
 */
- (void)addToRunLoop:(NSRunLoop *)runloop forMode:(NSRunLoopMode)mode;

/* 将定时器从运行循环的某个模式下移除;当它被从最后一个注册的模式中移除的时候它会被
 * 隐式的释放
 */
- (void)removeFromRunLoop:(NSRunLoop *)runloop forMode:(NSRunLoopMode)mode;

/* 将定时器从所有的运行循环模式中移除(如果目标定时器被隐式引用将被殷式释放)
 * 紧接着释放目标对象 */
- (void)invalidate;

/* 获取上一次selector被执行的时间戳 */
@property(readonly, nonatomic) CFTimeInterval timestamp;
/* 获取当前设备的屏幕刷新时间间隔 */
@property(readonly, nonatomic) CFTimeInterval duration;

/* 即将渲染的下一帧的时间戳 */
@property(readonly, nonatomic) CFTimeInterval targetTimestamp
    API_AVAILABLE(ios(10.0), watchos(3.0), tvos(10.0));

/* 当为true时,该定时器被暂停触发,初始状态为false */
@property(getter=isPaused, nonatomic) BOOL paused;

/* 定义每次显示定时器触发时必须经过多少个显示帧;默认值时1,这意味着每一个显示帧都会触发
 * 目标对象的方法签名。将时间间隔设为2,就会导致每隔一帧触发一次目标对象的签名方法
 * 当设置小于1的时候就是未被定义
 */
@property(nonatomic) NSInteger frameInterval
  API_DEPRECATED("preferredFramesPerSecond", ios(3.1, 10.0), 
                 watchos(2.0, 3.0), tvos(9.0, 10.0));

/* 定义定时器回调所需的回调率,单位时帧/秒,如果设置为0,定时器将以硬件的刷新频率被触发
 * 定时器将在硬件的刷新频率下做最大能力的回调
 */

@property(nonatomic) NSInteger preferredFramesPerSecond
  API_DEPRECATED_WITH_REPLACEMENT ("preferredFrameRateRange",
                                   ios(10.0, API_TO_BE_DEPRECATED),
                                   watchos(3.0, API_TO_BE_DEPRECATED),
                                   tvos(10.0, API_TO_BE_DEPRECATED));

/* 定义定时器所需要的回调速率的范围,但是时帧/秒。如果该范围包含相同的最小和最大的帧率
该属性将与preferredFramesPerSecond相同,否则实际的回调将被动态的调整以适配动画源*/

@property(nonatomic) CAFrameRateRange preferredFrameRateRange
    API_AVAILABLE(ios(15.0), watchos(8.0), tvos(15.0));

@end

如何使用

//
//  ViewController.m
//  CADisplayLinkTest
//
//  Created by Augus on 2022/3/26.
//

#import "ViewController.h"


static CGFloat const kImageViewWidth = 100.0f;

@interface ViewController ()

@property (nonatomic, strong) CADisplayLink *displayLink;
@property (nonatomic, strong) UIImageView *imageView;
@property (nonatomic, assign) CGFloat dynamicImageViewY;
@property (nonatomic, strong) UIButton *startButton;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    self.view.backgroundColor = UIColor.lightGrayColor;
    _dynamicImageViewY = 0;
    [self createImageView];
    [self createAnimationButton];
    [self createDisplayLink];
    
}

- (void)createAnimationButton {
    
    _startButton = [UIButton buttonWithType:UIButtonTypeCustom];
    _startButton.frame = CGRectMake(200, 200, 100, 100);
    [_startButton setTitle:@"start" forState:UIControlStateNormal];
    [_startButton addTarget:self action:@selector(pauseAnimation) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:_startButton];

}

- (void)createImageView {
    
    _imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, kImageViewWidth, kImageViewWidth)];
    _imageView.image = [UIImage imageNamed:@"kobe0"];
    [self.view addSubview:_imageView];
}


/// 创建定时器实例
- (void)createDisplayLink {
    _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(startAnimation:)];
    _displayLink.paused = YES;
    _displayLink.frameInterval = 2;
    NSLog(@"0--targetTimestamp:%f,timestamp:%f", _displayLink.targetTimestamp,_displayLink.timestamp);

    [_displayLink  addToRunLoop:[NSRunLoop currentRunLoop]
                      forMode:NSRunLoopCommonModes];
    NSLog(@"1--targetTimestamp:%f,timestamp:%f", _displayLink.targetTimestamp,_displayLink.timestamp);

    
    
}


/// 定时器的回调方法
/// @param sender 定时器的实例对象
- (void)startAnimation:(CADisplayLink *)sender {
    
    NSLog(@"2--targetTimestamp:%f,timestamp:%f", sender.targetTimestamp,sender.timestamp);
    _dynamicImageViewY++;
    if (_dynamicImageViewY == self.view.frame.size.height - kImageViewWidth) {
        _dynamicImageViewY = 0;
    }
    
    self.imageView.frame = CGRectMake(0, _dynamicImageViewY, kImageViewWidth, kImageViewWidth);
    
}


/// 暂停动画
- (void)pauseAnimation{
    
    _displayLink.paused = !self.displayLink.paused;
    if (_displayLink.paused) {
        [_startButton setTitle:@"start" forState:UIControlStateNormal];
    } else {
        [_startButton setTitle:@"pause" forState:UIControlStateNormal];
    }
}


/// 销毁计数器
- (void)stopDisplayLink {
    if (_displayLink) {
        [_displayLink invalidate];
        _displayLink = nil;
    }
}

@end

属性说明

timestamp

这是一个只读属性,只有当selector被执行过一次这个值才会被取到,同理targetTimestamp,这个属性是用来比较当前图层时间与上一次selector执行时间只差,从而来计算本次UI应该发生的改变的进度(例如视图做移动效果)。
// 计算实际的帧率
double actualFramesPerSecond = 1 / (_displaylink.targetTimestamp - _displaylink.timestamp);

duration

也是个只读属性,并且也需要selector触发一次才可以取值。值的一提的是,当前iOS设备的刷新频率大都是60HZ。也就是说每16.7ms刷新一次。作用也与timestamp相同,都可以用于辅助计算。不过需要说明的一点是,如果CPU过于繁忙duration的值是会浮动的
两次selector触发的时间间隔是time = frameInterVal * duration。必须注意的是,selector执行所需要的时间一定要小于其触发间隔,否则会造成掉帧情况,也就是卡顿情况
举例说明
一般的iOS设备的刷新频率60HZ也就是每秒60次。那么每一次刷新的时间就是1/60秒大概16.7ms。当我们的frameInterval为1的时候我们需要保证的是CADisplayLink调用的target的函数计算时间不应该大于16.7ms否则就会出现的丢帧现象。
同理如果是iPhone 13 Pro及以上的机器,有120HZ的高刷频率硬件支持也就是1/120,就是8.33ms,那我们的函数计算时间应该不大于8.33ms,否则就会掉帧。
顺便说一下mac应用中我们使用的不是CADisplayLink而是CVDisplayLink它是基于C接口的用起来配置有些麻烦,但是用起来还是很简单的。
iOS并不能保证能以60次/秒的频率调用回调方法,这取决于:
  • CPU的空闲程度。如果CPU忙于其它计算,就没法保证以60HZ执行屏幕的绘制动作,导致跳过若干次调用回调方法的机会,跳过次数取决CPU的忙碌程度;
  • 执行回调方法所用的时间。如果执行回调时间大于重绘每帧的间隔时间,就会导致跳过若干次回调调用机会,这取决于执行时间长短。

preferredFrameRateRange

在iPhone 13 Pro or iPhone 13 Pro Max,Info.plist添加以下字段:
<key>CADisableMinimumFrameDurationOnPhone</key><true/>
以便在应用程序中为CADisplayLink的回调和 CAAnimation动画启用全范围刷新率。

总结

优势

由于和屏幕的刷新频率绑定,其触发时间上是最准确的。也是最适合做UI不断刷新的事件,过渡相对流畅,无卡顿感。

劣势

  • 由于和屏幕的刷新频率绑定,如果CPU不堪重负影响了刷新频率,那么目标对象的selector也会受到影响;
  • selector触发的时间间隔只能是duration的整数倍;
  • selector事件如果大于其触发间隔就会造成掉帧现象;
  • 不能被继承。

CADisplayLink低配版之NSTimer

苹果一直致力于性能和体验,在代码上也一样,有的时候我们不需要使用如此高刷的定时器去处理问题,这个时候就应运而生了NSTimer
提到NSTimer于iOS开发者并不陌生,因为特性极其接近,所以很多人会混淆,比如他们的初始化,以及销毁和相同的痛点。
但是究其本质,我们仍然可以发现其中的一些不同,接下来我们简单看下NSTimer的一些接口和需要注意的点:
/* NSTimer.h
 Copyright (c) 1994-2019, Apple Inc. All rights reserved.
*/

#import <Foundation/NSObject.h>
#import <Foundation/NSDate.h>

NS_ASSUME_NONNULL_BEGIN

@interface NSTimer : NSObject

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;


/// Creates and returns a new NSTimer object initialized with the specified block object. This timer needs to be scheduled on a run loop (via -[NSRunLoop addTimer:]) before it will fire.
/// - parameter:  timeInterval  The number of seconds between firings of the timer. If seconds is less than or equal to 0.0, this method chooses the nonnegative value of 0.1 milliseconds instead
/// - parameter:  repeats  If YES, the timer will repeatedly reschedule itself until invalidated. If NO, the timer will be invalidated after it fires.
/// - parameter:  block  The execution body of the timer; the timer itself is passed as the parameter to this block when executed to aid in avoiding cyclical references
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

/// Creates and returns a new NSTimer object initialized with the specified block object and schedules it on the current run loop in the default mode.
/// - parameter:  ti    The number of seconds between firings of the timer. If seconds is less than or equal to 0.0, this method chooses the nonnegative value of 0.1 milliseconds instead
/// - parameter:  repeats  If YES, the timer will repeatedly reschedule itself until invalidated. If NO, the timer will be invalidated after it fires.
/// - parameter:  block  The execution body of the timer; the timer itself is passed as the parameter to this block when executed to aid in avoiding cyclical references
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

/// Initializes a new NSTimer object using the block as the main body of execution for the timer. This timer needs to be scheduled on a run loop (via -[NSRunLoop addTimer:]) before it will fire.
/// - parameter:  fireDate   The time at which the timer should first fire.
/// - parameter:  interval  The number of seconds between firings of the timer. If seconds is less than or equal to 0.0, this method chooses the nonnegative value of 0.1 milliseconds instead
/// - parameter:  repeats  If YES, the timer will repeatedly reschedule itself until invalidated. If NO, the timer will be invalidated after it fires.
/// - parameter:  block  The execution body of the timer; the timer itself is passed as the parameter to this block when executed to aid in avoiding cyclical references
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(nullable id)ui repeats:(BOOL)rep NS_DESIGNATED_INITIALIZER;

- (void)fire;

@property (copy) NSDate *fireDate;
@property (readonly) NSTimeInterval timeInterval;

// Setting a tolerance for a timer allows it to fire later than the scheduled fire date, improving the ability of the system to optimize for increased power savings and responsiveness. The timer may fire at any time between its scheduled fire date and the scheduled fire date plus the tolerance. The timer will not fire before the scheduled fire date. For repeating timers, the next fire date is calculated from the original fire date regardless of tolerance applied at individual fire times, to avoid drift. The default value is zero, which means no additional tolerance is applied. The system reserves the right to apply a small amount of tolerance to certain timers regardless of the value of this property.
// As the user of the timer, you will have the best idea of what an appropriate tolerance for a timer may be. A general rule of thumb, though, is to set the tolerance to at least 10% of the interval, for a repeating timer. Even a small amount of tolerance will have a significant positive impact on the power usage of your application. The system may put a maximum value of the tolerance.
@property NSTimeInterval tolerance API_AVAILABLE(macos(10.9), ios(7.0), watchos(2.0), tvos(9.0));

- (void)invalidate;
@property (readonly, getter=isValid) BOOL valid;

@property (nullable, readonly, retain) id userInfo;

@end

NS_ASSUME_NONNULL_END

初始化

首先NSTimer有7个方法可以为我们提供实例,分3类:
  • timer开头的三个类方法;
  • schedule的三个类方法;
  • 以及一个init开头的指定构造初始化方法。
timer开头的两个类方法是灵活度最高的两个方法。这两个方法的不同点在于绑定事件的方式。一个使用NSInvocation进行转发消息,一个使用target/selector模式绑定事件。总之就是绑定timer的触发事件。
后面两个参数分别是用户参数以及重复模式。
但是只生成了实例还是不会触发绑定事件,像CADisplayLink一样我们也需要将它加入到RunLoop中,之后就可以触发绑定事件了。
只要是使用NSTimer就一定要加入到RunLopp中才可以触发绑定事件,你可能会说schedule开头那两个类方法就不用添加RunLoop,其实是系统为你将timer添加到了currentRunLoop中下的defaultModel

属性

fireDate

设置当前timer的事件的触发时间,通常我们使用这个属性来做计时器的暂停与恢复:
///暂停计时器
_timer.fireDate = [NSDate distantFuture];
///恢复计时器
_timer.fireDate = [NSDate distantPast];

timeInterval

只读属性,获取当前timer的事件的触发间隔。

tolerance

允许误差时间。我们知道NSTimer事件的触发事件是不准确的,完全取决于当前Runloop处理的时间。如果当前Runloop在处理复杂运算,则timer执行时间将会被推迟,直到复杂运算结束后立即执行触发事件,之后再按照初始设置的节奏去执行。当设置tolerance之后在允许范围内的延迟可以触发事件,超过的则不触发。关于tolerance的设置,苹果有这么一段介绍:
As the user of the timer, you will have the best idea of what an appropriate tolerance for a timer may be. A general rule of thumb, though, is to set the tolerance to at least 10% of the interval, for a repeating timer. Even a small amount of tolerance will have a significant positive impact on the power usage of your application. The system may put a maximum value of the tolerance.
简言之苹果建议你设置tolerance的值是timeInterval的十分之一。

valid

只读属性,获取当前timer是否有效。

userInfo

用户参数,在初始化的时候传入的用户参数。

实例方法

fire

官方文档:
You can use this method to fire a repeating timer without interrupting its regular firing schedule. If the timer is non-repeating, it is automatically invalidated after firing, even if its scheduled fire date has not arrived.
fire并不是立即激活定时器,而是立即执行一次定时器绑定的方法。当加入到RunLooptimer不需要激活即可按照设定的时间触发事件。fire只是相当于手动让timer触发一次事件。如果timer设置的repeat=NO,则fire之后timer立即失效。如果timerrepeat=YES,则到了之前设置的时间它依旧会按部就班的触发事件。fire只是单独触发了一次事件,并不影响原timer的节奏。

invalid

我们知道NSTimer使用的时候如果不注意的话,是会造成内存泄漏的。原因是我们生成实例的时候,会对控制器retain一下。如果不对其进行管理则ViewController的永远不会引用计数为零,进而造成内存泄漏。
如果不需要timer的时候,进行下面操作,而且在合适的时机进行调用:
[_timer invalid];
_timer = nil;
如果生成timer实例的时候repeat=NO,那当触发事件结束后,系统也会自动调用invalid一次。
在iOS 10以后系统也为我们针对内存泄露的问题新增了两个类方法,就是上面带block的方法。

共同的问题

因为设计相似,所以在实际的开发中他们有着一个共同的问题,那就是循环引用。
场景描述:点击当前控制器A上的一个按钮,push进入一个新的控制器B,然后在B控制器运行一个CADisplayLinkNSTimer,进行分别打印各自的方法。
然后点击返回到控制器A,这个时候观察现象:
@interface GTFourController ()

@property (nonatomic, strong) GTControllableCThread *augusThread;

@property (nonatomic, strong) NSTimer *augusTimer;
@property (nonatomic, strong) CADisplayLink *augusDisplayLink;

@end

@implementation GTFourController
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    self.title = @"Four";
    self.view.backgroundColor = UIColor.whiteColor;
    
    self.augusTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(testTimer) userInfo:nil repeats:YES];
    
    self.augusDisplayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(testLink)];
    [self.augusDisplayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
   
}


- (void)testTimer {
    NSLog(@"%s",__func__);
}


- (void)testLink {
    NSLog(@"%s",__func__);
}


@end
很容易就会发现,两个定时器都不会销毁。

问题分析

我们拿NSTimer举例(CADisplayLink是一样的)。
首先我们的控制器B对当前的_augusTimer是一个强引用,然后我们的NSTimer内部对传入的self控制器B的实例也是一个强引用。
这就导致了双方互相强引用无法释放。
那么有的同学就说了,那就把_augusTimer属性的修饰符换成weak就行了吧?这样以来控制器B_augusTimer会是一个弱饮用打破循环。
当然不行,原因就是NSTimer内部会有一个强引用属性target,它被框架设置为强引用,所以无论你穿入的是什么引用,都不会影响内部的引用

问题解决

既然我们已经分析了问题的原因,那么我就自己了解到的知识对上述问题进行解决。
因为是双方都互相引用,我们引入一个中间target,让中间target对控制器B是一个弱引用即可,为什么不能对NSTimer呢?因为框架是闭源的,我们无法修改内部的属性。

代码实现

@interface GTProxyTarget : NSProxy

+ (instancetype)proxyWithTarget:(id)target;
@property (nonatomic, weak) id target;


@end
  
  
#import "GTProxyTarget.h"

@implementation GTProxyTarget

+ (instancetype)proxyWithTarget:(id)target {
    GTProxyTarget *proxy = [GTProxyTarget alloc];
    proxy.target = target;
    return proxy;
}


- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.target methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    
    [invocation invokeWithTarget:self.target];
}

@end
  
  
// 然后控制器B代码修改为
 
self.augusTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:[GTProxyTarget proxyWithTarget:self] selector:@selector(testTimer) userInfo:nil repeats:YES];
当然这只是一种思路,还有很多可以解决,其余的大家自己发散思维即可。
我们继续看另外的问题:
为什么以下代码中的timer绑定方法不会执行,应该如何修改,为什么?
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
  
   // [self createNSTimerOfRunLoop];
  
    // [self createNSTimerOfRunLoopMode];
}


- (void)createNSTimerOfRunLoop {
  
    _timer = [NSTimer timerWithTimeInterval:2 repeats:YES block:^(NSTimer * _Nonnull timer) {
        NSLog(@"timer run of runLoop");
    }];
    
    // 关于runLoop
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addTimer:self->_timer forMode:NSRunLoopCommonModes];
    });
}


- (void)createNSTimerOfRunLoopMode {
    _timer = [NSTimer timerWithTimeInterval:2 repeats:YES block:^(NSTimer * _Nonnull timer) {
        NSLog(@"timer run of mode");
    }];
    
    // 关于runLoop mode
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addTimer:self->_timer forMode:UITrackingRunLoopMode];
        [runLoop run];
    });
}

通过以上两个小的问题,我们知道两点:
  • 其一,子线程的运行循环是默认关闭的,需要手动开启;
  • 其二,运行循环是通过不同的mode来处理不同的输入源。
那么问题来了,运行循环的输入源有哪些?又有哪些modeCADisplayLinkNSTimer的底层驱动又是什么?
我们带着这些问题继续探索。

关于RunLoop

RunLoop

运行循环和输入源的结构图:
运行循环从两种不同类型的源接受事件。第一种输入源(Input Source)提供异步事件,通常是来自另外的线程或者是不同的应用程序的消息;定时器源(Timer Source)在一个预定的时间或者重复的时间间隔内发生。当事件来到时,这两种类型的源都使用同一种特殊的应用程序进行消息处理。
CoreFoundation里面关于RunLoop有五个类:
CFRunLoopRef
CFRunLoopModeRef
CFRunLoopSourceRef
CFRunLoopTimerRef
CFRunLoopObserverRef
其中CFRunLoopModeRef接口并没有暴露,只是通过CFRunLoopRef的接口进行了封装,关系如下:
一个RunLoop包好若干个Mode,每个Mode又包含若干个Souce/Timer/Observer。每次调用RunLoop的主函数时,只能指定一个Mode运行,这个Mode称作CurrentMode,如果需要切换Mode,只能退出当前Loop,再重新指定一个新的Mode进入。这样做就是为了分隔开不同组的Source/Timer/Observer,让其互不影响。

CFRunLoopSourceRef

它是事件产生的地方,有两个版本,souce0source1
  • source0:只包含一个回调函数指针,并不能主动触发事件,你要先调用CFRunLoopSourceSignal(source)来将这个source标记为待处理,然后手动调用CFRunLoopWakeUp(runloop)来唤醒RunLoop,让其处理这个事件;
  • source1:包含一个mach_port和一个回调函数指针,被用于通过内核和其他线程相互发送消息。能主动唤醒RunLoop线程。

CFRunLoopTimerRef

是基于时间的触发器,它和NSTimertoll-free bridged 的,可以混用。其包含一个时间长度和一个回调函数指针。当其加入到RunLoop时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调。

CFRunLoopObserverRef

运行循环观察者,每个Observer都包含了一个回调函数指针,当RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry         = (1UL << 0), // 即将进入Loop
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理 Timer
    kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
    kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒
    kCFRunLoopExit          = (1UL << 7), // 即将退出Loop
};
上面的Source/Timer/Observer被统称为mode item,一个item可以被同时加入多个mode。但一个item被重复加入同一个mode时是不会有效果的。如果一个mode中一个item都没有,则RunLoop会直接退出,不进入循环。

NSTimer & CADisplayLink

NStimer本质就是CFRunLoopTimerRef,它们之间是toll-free bridged的。一个NSTimer注册到RunLoop后,RunLoop会为其重复的时间点注册好事件。例如13:05, 13:10, 13:15这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个TimerTimer有个属性叫做Tolerance(允许误差时间),标识了当时间点到后,容许有多少最大误差。
NSTimer是用XNU内核的mk_timer驱动的,timer的部分源码,以下是CFRunLoop.c的宏定义。
#if DEPLOYMENT_TARGET_MACOSX
#define USE_DISPATCH_SOURCE_FOR_TIMERS 1
#define USE_MK_TIMER_TOO 1
#else
#define USE_DISPATCH_SOURCE_FOR_TIMERS 0
#define USE_MK_TIMER_TOO 1
#endif
举例:如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如作者平时锻炼,如果22:10时我忙着刷关注流错过了锻炼时间,按我的规定是只能第二天22:10再锻炼了。
CADisplayLink是一个和屏幕刷新率一致的定时器,和NSTimer并不一样,其内部实际是操作了一个Source1。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和NSTimer相似),造成界面卡顿的感觉。

CFRunLoopMode

Mode
作用


kCFRunLoopDefaultMode
App的默认Mode,通常主线程是在这个Mode下运行的
UITrackingRunLoopMode
界面跟踪Mode,用于ScrollView追踪触摸滑动,保证界面滑动时不受其他Mode影响
UIInitializationRunLoopMode
在刚启动App时第进入的第一个Mode,启动完成后就不再使用。
GSEventReceiveRunLoopMode
接受系统事件的内部Mode,通常用不到
kCFRunLoopCommonModes
这是一个占位的Mode,没有实际作用。
苹果公开提供的Mode有两个:kCFRunLoopDefaultMode (NSDefaultRunLoopMode)UITrackingRunLoopMode
你可以用这两个Mode Name来操作其对应的Mode
同时苹果还提供了一个操作Common标记的字符串:kCFRunLoopCommonModes (NSRunLoopCommonModes),其本质就是UITrackingRunLoopMode | kCFRunLoopDefaultMode

PerformSelecter

当调用NSObjectperformSelecter:afterDelay:后,实际上其内部会创建一个Timer并添加到当前线程的RunLoop中。所以如果当前线程没有RunLoop,则这个方法会失效。
当调用performSelector:onThread:时,实际上其会创建一个Timer加到对应的线程去,同样的,如果对应线程没有RunLoop该方法也会失效。

现在的CADiplayLink有什么不同?

随着iOS 15的更新,笔者发现官方并没有给我透露太多关于底层的改动带来的影响,只是单纯的在接口文档中进行了更新以及App层级设置的变化。
根据网上文档查询发现这个系统的更新导致了ProMotion设备的渲染事件的驱动方式发生了变化,由之前的跟屏幕的Vsync绑定更新为了由UIKit内部的一个Source0信号驱动回调,初始化,添加等方式跟之前版本一致,接下来我们就一一验证。

DisplayLink驱动方式的变化

CADisplayLink回调方法上设置断点,分别在iPhone 7 iOS 14和iPhone 13 Pro iOS 15设备运行。
  • 在iPhone 7 iOS 14上CADisplayLink是通过source 1 mach_port直接接受VSync信号驱动的:
  • 在iPhone 13 Pro iOS 15上CADisplayLink是由一个UIKitSource0驱动:
  • 在iOS 15上CADisplayLink第一次创建并添加到RunLoop的时候,会注册一个source1信号,这和iOS 14保持一致:

  • 其中callout回调对应符号同样为IODispatchCalloutFromCFMessage
这也可以解释为什么iOS 15上Vsync信号确实会唤醒一次RunLoop,只是这次唤醒不一定触发DisplayLink的回调,这就说明IODispatchCalloutFromCFMessage行为和iOS 14相比发生了某种变化。

源码验证

拿到QuarcCore动态库的二进制文件,使用ida打开,在左侧方法列表搜索CA::Display::TimerDisplayLink::定位到CA::Display::DisplayLink::dispatch_items
然后使用交叉引用,定位到上一步(up)的调用函数,点击确认:
也就是CA::Display::TimerDisplayLink::callback的实现:
观察反汇编代码可以发现,如果CA::Display::DisplayShmemInfo::power_state返回了1才会去触发CA::DisplayLink::dispatch_items,否则后续不会触发,而这个方法也就是不同系统差异的所在。

如何解决

通过在CADisplayLink中回调中确认duration参数,得到当前屏幕的实时的刷新率,并修改preferredFrameRateRange进行跟踪:
NSInteger currentFPS = (NSInteger)ceil(1.0 / _displayLink.duration);
// CAFrameRateRange.minimum 传最小值10.0,preferred 传0.0,让该CADisplayLink只用于监控当前的系统帧率,而不影响帧率的动态选择
_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(10.0, currentFPS, 0.0);

参考文档

  • https://developer.apple.com/documentation/quartzcore/cadisplaylink?language=objc
  • https://blog.ibireme.com/2015/05/18/runloop/
  • https://developer.apple.com/documentation/quartzcore/optimizing_promotion_refresh_rates_for_iphone_13_pro_and_ipad_pro?language=objc
  • https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/RunLoopManagement/RunLoopManagement.html
  • https://blog.csdn.net/ByteDanceTech/article/details/123437098

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存