本文字数:19803字
预计阅读时间:50分钟
用最通俗的语言,描述最难懂的技术
前情描述
上周同事做code review
的时候说到了CADisplayLink
的一些变化,感触颇深,提到了接口的一些变动,现在就自己的一些理解加上网上文档的查阅对该对象进行以下的说明:
测试环境
编译环境:Xcode 13.1
运行设备:iPhone X,iOS 14.7.1,iPhone 13 Pro,iOS 15.5
前情描述
一个与屏幕的刷新率一致的定时器对象。
初始化,需要提供一个目标对象和一个方法选择器;添加,需要指定一个运行循环和一个运行循环的模式。
一旦你完成上述步骤之后,当屏幕上的内容需要更新的时候,系统就会调用目标对象上的选择器。
目标对象可以读取CADisplayLink
的timestamp
属性来检索系统上一帧的时间,比如某个电影程序可能会使用时间戳来计算下一个即将显示的视频帧;一个执行动画的程序可能会使用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
并不是立即激活定时器,而是立即执行一次定时器绑定的方法。当加入到RunLoop
中timer
不需要激活即可按照设定的时间触发事件。fire
只是相当于手动让timer
触发一次事件。如果timer
设置的repeat=NO
,则fire
之后timer
立即失效。如果timer
的repeat=YES
,则到了之前设置的时间它依旧会按部就班的触发事件。fire
只是单独触发了一次事件,并不影响原timer
的节奏。
invalid
我们知道NSTimer
使用的时候如果不注意的话,是会造成内存泄漏的。原因是我们生成实例的时候,会对控制器retain
一下。如果不对其进行管理则ViewController
的永远不会引用计数为零,进而造成内存泄漏。
如果不需要timer
的时候,进行下面操作,而且在合适的时机进行调用:
[_timer invalid];
_timer = nil;
如果生成timer
实例的时候repeat=NO
,那当触发事件结束后,系统也会自动调用invalid
一次。
在iOS 10以后系统也为我们针对内存泄露的问题新增了两个类方法,就是上面带block
的方法。
共同的问题
因为设计相似,所以在实际的开发中他们有着一个共同的问题,那就是循环引用。
场景描述:点击当前控制器A上的一个按钮,push
进入一个新的控制器B,然后在B控制器运行一个CADisplayLink
和NSTimer
,进行分别打印各自的方法。
然后点击返回到控制器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
来处理不同的输入源。
那么问题来了,运行循环的输入源有哪些?又有哪些mode
?CADisplayLink
和NSTimer
的底层驱动又是什么?
我们带着这些问题继续探索。
关于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
它是事件产生的地方,有两个版本,souce0
和source1
。
source0
:只包含一个回调函数指针,并不能主动触发事件,你要先调用CFRunLoopSourceSignal(source)
来将这个source
标记为待处理,然后手动调用CFRunLoopWakeUp(runloop)
来唤醒RunLoop
,让其处理这个事件;source1
:包含一个mach_port
和一个回调函数指针,被用于通过内核和其他线程相互发送消息。能主动唤醒RunLoop
线程。
CFRunLoopTimerRef
是基于时间的触发器,它和NSTimer
是toll-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
为了节省资源,并不会在非常准确的时间点回调这个Timer
。Timer
有个属性叫做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
作用
|
|
| 界面跟踪 |
| 在刚启动 |
| 接受系统事件的内部 |
| 这是一个占位的 |
苹果公开提供的Mode
有两个:kCFRunLoopDefaultMode (NSDefaultRunLoopMode)
和UITrackingRunLoopMode
。
你可以用这两个Mode Name
来操作其对应的Mode
。
同时苹果还提供了一个操作Common
标记的字符串:kCFRunLoopCommonModes (NSRunLoopCommonModes)
,其本质就是UITrackingRunLoopMode | kCFRunLoopDefaultMode
。
PerformSelecter
当调用NSObject
的performSelecter: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
是由一个UIKit
的Source0
驱动:在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