二、runloop应用
2.1 NSTimer
前面一直提到Timer Source作为事件源,事实上它的上层对应就是NSTimer(其实就是CFRunloopTimerRef)这个开发者经常用到的定时器(底层基于使用mk_timer实现),甚至很多开发者接触RunLoop还是从NSTimer开始的。其实NSTimer定时器的触发正是基于RunLoop运行的,所以使用NSTimer之前必须注册到RunLoop,但是RunLoop为了节省资源并不会在非常准确的时间点调用定时器,如果一个任务执行时间较长,那么当错过一个时间点后只能等到下一个时间点执行,并不会延后执行(NSTimer提供了一个tolerance属性用于设置宽容度,如果确实想要使用NSTimer并且希望尽可能的准确,则可以设置此属性)。
NSTimer的创建通常有两种方式,尽管都是类方法,一种是timerWithXXX
,另一种scheduedTimerWithXXX
。
+ (NSTimer *)timerWithTimeInterval:(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 *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
二者最大的区别就是后者除了创建一个定时器外会自动以NSDefaultRunLoopModeMode
添加到当前线程RunLoop中,不添加到RunLoop中的NSTimer
是无法正常工作的。例如下面的代码中如果timer2不加入到RunLoop
中是无法正常工作的。同时注意如果滚动UIScrollView(UITableView、UICollectionview是类似的)
二者是无法正常工作的,但是如果将NSDefaultRunLoopMode改为NSRunLoopCommonModes则可以正常工作,这也解释了前面介绍的Mode内容。
-(NSTimer *)timer1{
if(_timer1 == nil){
_timer1 = [NSTimer scheduledTimerWithTimeInterval:1.0f target:self selector:@selector(timeInterval:) userInfo:nil repeats:YES];
}
return _timer1;
}
-(NSTimer *)timer2{
if(_timer2 == nil){
_timer2 = [NSTimer timerWithTimeInterval:1.0f target:self selector:@selector(timeInterval:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop]addTimer:_timer2 forMode:NSDefaultRunLoopMode];
}
return _timer2;
}
问题一、target无法释放导致内存泄漏
定时器添加到runloop中后,runloop会将当前计时器retain;定时器在初始化时候会指定一个target,会导致target无法释放。
以下方法可以避免:
viewcontroller中
-(void)viewWillDisappear:(BOOL)animated{
[super viewWillDisappear:animated];
[self.timer1 invalidate];
[self.timer2 invalidate];
self.timer1 = nil;
self.timer2 = nil;
}
View中
-(void)removeFromSuperview{
[super removeFromSuperview];
[self.timer invalidate];
self.timer = nil;
}
原文中还提到个target转移方式, NSTimer-Block
尝试了下好像在view/viewcontroller里面都无法释放。
官方也意识到这个问题在IOS 10系统后添加了timer 的 block实现方式scheduledTimerWithTimeInterval
。
问题二、Timer不是一种实时机制
那就是NSTimer不是一种实时机制,官方文档明确说明在一个循环中如果RunLoop没有被识别(这个时间大概在50-100ms)或者说当前RunLoop在执行一个长的call out(例如执行某个循环操作)则NSTimer可能就会存在误差,RunLoop在下一次循环中继续检查并根据情况确定是否执行(NSTimer的执行时间总是固定在一定的时间间隔,例如1:00:00、1:00:01、1:00:02、1:00:05则跳过了第4、5次运行循环)。
下面一个例子:
@interface TimerRunloopViewController ()
@property (weak, nonatomic) IBOutlet UILabel *labelShow;
@property(nonatomic,strong) NSThread *thread1;
@property(nonatomic,strong) NSTimer *timer1;
@end
@implementation TimerRunloopViewController
-(NSThread *)thread1{
if(_thread1 == nil){
_thread1 = [[NSThread alloc]initWithTarget:self selector:@selector(performTask) object:nil];
}
return _thread1;
}
-(NSTimer *)timer1{
if(_timer1 == nil){
_timer1 = [NSTimer timerWithTimeInterval:1.0f target:self selector:@selector(timerPerform) userInfo:nil repeats:YES];
}
return _timer1;
}
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
[self TRDataInit];
}
-(void)dealloc{
NSLog(@"timerRunloop dealloc");
}
- (void)viewWillDisappear:(BOOL)animated{
[super viewWillDisappear:animated];
// [self.thread1 cancel];
[self.timer1 invalidate];
self.timer1 = nil;
}
- (void)TRDataInit{
[self.thread1 start];
}
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
[super touchesBegan:touches withEvent:event];
[self.thread1 cancel];
}
#pragma mark - 线程执行任务
- (void)performTask{
[[NSRunLoop currentRunLoop]addTimer:self.timer1 forMode:NSDefaultRunLoopMode];
NSLog(@"runloop before performSelector:%@",[NSRunLoop currentRunLoop]);
// 区分直接调用和「performSelector:withObject:afterDelay:」区别,下面的直接调用无论是否运行RunLoop一样可以执行,但是后者则不行。
//[self caculate];
[self performSelector:@selector(caculate) withObject:nil afterDelay:2.0];
// 取消当前RunLoop中注册测selector(注意:只是当前RunLoop,所以也只能在当前RunLoop中取消)
//[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(caculate) object:nil];
NSLog(@"runloop after performSelector:%@",[NSRunLoop currentRunLoop]);
// 非主线程RunLoop必须手动调用
[[NSRunLoop currentRunLoop] run];
NSLog(@"注意:如果RunLoop不退出(运行中),这里的代码并不会执行,RunLoop本身就是一个循环.");
}
#pragma mark - 定时器执行任务
- (void)timerPerform{
static unsigned long count = 0;
if([NSThread currentThread].isCancelled){
[self.timer1 invalidate];
self.timer1 = nil;
}
count ++;
NSLog(@"timer count 值 %ld",count);
dispatch_async(dispatch_get_main_queue(), ^{
self.labelShow.text = [NSString stringWithFormat:@"timer count 值 %ld",count];
});
}
- (void)caculate{
for (int i = 0;i < 9999;++i) {
NSLog(@"%i,%@",i,[NSThread currentThread]);
if ([NSThread currentThread].isCancelled) {
return;
}
}
}
如果运行并且不退出上面的程序会发现,前两秒NSTimer可以正常执行,但是两秒后由于同一个RunLoop中循环操作的执行造成定时器跳过了中间执行的机会一直到caculator循环完毕,这也正说明了NSTimer不是实时系统机制的原因。
但是以上程序还有几点需要说明一下:
NSTimer会
对Target进行强引用
直到任务结束或exit之后才会释放。如果上面的程序没有进行线程cancel而终止任务则及时关闭控制器也无法正确释放。非主线程的RunLoop并不会自动运行(同时注意默认情况下非主线程的RunLoop并不会自动创建,直到第一次使用),RunLoop运行必须要在加入NSTimer或Source0、Sourc1、Observer输入后运行否则会直接退出。例如上面代码如果run放到NSTimer创建之前则既不会执行定时任务也不会执行循环运算。
performSelector:withObject:afterDelay:执行的本质还是通过创建一个NSTimer然后加入到当前线程RunLoop(通而过前后两次打印RunLoop信息可以看到此方法执行之后RunLoop的timer会增加1个。类似的还有performSelector:onThread:withObject:afterDelay:,只是它会在另一个线程的RunLoop中创建一个Timer),所以此方法事实上在任务执行完之前会对触发对象形成引用,任务执行完进行释放(例如上面会对ViewController形成引用,注意:performSelector: withObject:等方法则等同于直接调用,原理与此不同)。
同时上面的代码也充分说明了RunLoop是一个循环事实,run方法之后的代码不会立即执行,直到RunLoop退出。
上面程序的运行过程中如果突然dismiss,则程序的实际执行过程要分为两种情况考虑:如果循环任务caculate还没有开始则会在timer1中停止timer1运行(停止了线程中第一个任务),然后等待caculate执行并break(停止线程中第二个任务)后线程任务执行结束释放对控制器的引用;如果循环任务caculate执行过程中dismiss则caculate任务执行结束,等待timer1下个周期运行(因为当前线程的RunLoop并没有退出,timer1引用计数器并不为0)时检测到线程取消状态则执行invalidate方法(第二个任务也结束了),此时线程释放对于控制器的引用。
CADisplayLink是一个执行频率(fps)和屏幕刷新相同(可以修改preferredFramesPerSecond改变刷新频率)的定时器,它也需要加入到RunLoop才能执行。与NSTimer类似,CADisplayLink同样是基于CFRunloopTimerRef实现,底层使用mk_timer(可以比较加入到RunLoop前后RunLoop中timer的变化)。和NSTimer相比它精度更高(尽管NSTimer也可以修改精度),不过和NStimer类似的是如果遇到大任务它仍然存在丢帧现象。通常情况下CADisaplayLink用于构建帧动画,看起来相对更加流畅,而NSTimer则有更广泛的用处。
2.2 AutoreleasePool
AutoreleasePool是另一个与RunLoop相关讨论较多的话题。其实从RunLoop源代码分析,AutoreleasePool与RunLoop并没有直接的关系,之所以将两个话题放到一起讨论最主要的原因是因为在iOS应用启动后会注册两个Observer管理和维护AutoreleasePool。不妨在应用程序刚刚启动时打印currentRunLoop可以看到系统默认注册了很多个Observer,其中有两个Observer的callout都是** _ wrapRunLoopWithAutoreleasePoolHandler**,这两个是和自动释放池相关的两个监听。
第一个Observer会监听RunLoop的进入,它会回调objc_autoreleasePoolPush()向当前的AutoreleasePoolPage增加一个哨兵对象标志创建自动释放池。这个Observer的order是-2147483647优先级最高,确保发生在所有回调操作之前。
第二个Observer会监听RunLoop的进入休眠
和即将退出RunLoop
两种状态,在即将进入休眠时会调用objc_autoreleasePoolPop() 和 objc_autoreleasePoolPush() 根据情况从最新加入的对象一直往前清理直到遇到哨兵对象。而在即将退出RunLoop时会调用objc_autoreleasePoolPop() 释放自动自动释放池内对象。这个Observer的order是2147483647,优先级最低,确保发生在所有回调操作之后。
主线程的其他操作通常均在这个AutoreleasePool之内(main函数中),以尽可能减少内存维护操作(当然你如果需要显式释放【例如循环】时可以自己创建AutoreleasePool否则一般不需要自己创建)。
其实在应用程序启动后系统还注册了其他Observer(例如即将进入休眠时执行注册回调_UIGestureRecognizerUpdateObserver
用于手势处理、回调为_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv
的Observer用于界面实时绘制更新)和多个Source1(例如context为CFMachPort的Source1用于接收硬件事件响应进而分发到应用程序一直到UIEvent),这里不再一一详述。
2.3UI更新
如果打印App启动之后的主线程RunLoop可以发现另外一个callout为_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv的Observer,这个监听专门负责UI变化后的更新,比如修改了frame、调整了UI层级(UIView/CALayer)或者手动设置了setNeedsDisplay/setNeedsLayout之后就会将这些操作提交到全局容器。而这个Observer监听了主线程RunLoop的即将进入休眠
和退出
状态,一旦进入这两种状态则会遍历所有的UI更新并提交进行实际绘制更新。
通常情况下这种方式是完美的,因为除了系统的更新,还可以利用setNeedsDisplay等方法手动触发下一次RunLoop运行的更新。但是如果当前正在执行大量的逻辑运算可能UI的更新就会比较卡,因此facebook推出了AsyncDisplayKit来解决这个问题。AsyncDisplayKit其实是将UI排版和绘制运算尽可能放到后台,将UI的最终更新操作放到主线程(这一步也必须在主线程完成),同时提供一套类UIView或CALayer的相关属性,尽可能保证开发者的开发习惯。这个过程中AsyncDisplayKit在主线程RunLoop中增加了一个Observer监听即将进入休眠和退出RunLoop两种状态,收到回调时遍历队列中的待处理任务一一执行。
2.4 NSURLConnection
在前面的网络开发的文章中已经介绍过NSURLConnection的使用,一旦启动NSURLConnection以后就会不断调用delegate方法接收数据,这样一个连续的的动作正是基于RunLoop来运行。
一旦NSURLConnection设置了delegate会立即创建一个线程com.apple.NSURLConnectionLoader,同时内部启动RunLoop并在NSDefaultMode模式下添加4个Source0。其中CFHTTPCookieStorage用于处理cookie ;CFMultiplexerSource负责各种delegate回调并在回调中唤醒delegate内部的RunLoop(通常是主线程)来执行实际操作。
早期版本的AFNetworking库也是基于NSURLConnection实现,为了能够在后台接收delegate回调AFNetworking内部创建了一个空的线程并启动了RunLoop,当需要使用这个后台线程执行任务时AFNetworking通过**performSelector: onThread: **将这个任务放到后台线程的RunLoop中。
2.5 GCD和RunLoop的关系
在RunLoop的源代码中可以看到用到了GCD的相关内容,但是RunLoop本身和GCD并没有直接的关系。当调用了dispatch_async(dispatch_get_main_queue(), <#^(void)block#>)时libDispatch会向主线程RunLoop发送消息唤醒RunLoop,RunLoop从消息中获取block,并且在CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE回调里执行这个block。不过这个操作仅限于主线程,其他线程dispatch操作是全部由libDispatch驱动的。
2.6 更多RunLoop使用
前面看了很多RunLoop的系统应用和一些知名第三方库使用,那么除了这些究竟在实际开发过程中我们自己能不能适当的使用RunLoop帮我们做一些事情呢?
思考这个问题其实只要看RunLoopRef的包含关系就知道了,RunLoop包含多个Mode,而它的Mode又是可以自定义的,这么推断下来其实无论是Source1、Timer还是Observer开发者都可以利用,但是通常情况下不会自定义Timer,更不会自定义一个完整的Mode,利用更多的其实是Observer和Mode的切换。
例如很多人都熟悉的使用perfromSelector在默认模式下设置图片,防止UITableView滚动卡顿([[UIImageView allocinitWithFrame:CGRectMake(0, 0, 100, 100)] performSelector:@selector(setImage:) withObject:myImage afterDelay:0.0 inModes:@NSDefaultRunLoopMode])。还有sunnyxx的UITableView+FDTemplateLayoutCell利用Observer在界面空闲状态下计算出UITableViewCell的高度并进行缓存。再有老谭的PerformanceMonitor关于iOS实时卡顿监控,同样是利用Observer对RunLoop进行监视。
参考文献:
iOS刨根问底-深入理解RunLoop
[内核Kernel Programming Guide]看Mach章节(https://developer.apple.com/library/content/documentation/Darwin/Conceptual/KernelProgramming/Mach/Mach.html#//apple_ref/doc/uid/TP30000905-CH209-TPXREF101)