iOS老司机的RunLoop原理探究及实用Tips
本文正在参加「金石计划 . 瓜分6万现金大奖」
前言
- iOS中的
RunLoop
除了面试中跟面试官的探讨, 在实际开发中就没用了吗? 初入iOS开发大门时, 可能很多人都会有这个疑惑. - 诚然, 日常的iOS开发中,
RunLoop
的直接使用频率确实相对不高, 但是一旦深入理解RunLoop
的原理和机制, 我们就会发现, iOS开发中的方方面面都包含着RunLoop
的影子. RunLoop
的数据结构设计和机制也体现着iOS操作系统兼顾性能和耗电的用户态
和内核态
切换的精妙.- 下面就
RunLoop
的底层数据结构原理及应用, 跟各位同仁聊一聊自己的浅见, 抛砖引玉. - 文章纯手打, 抛砖引玉, 如有错误还请评论区指正, 先行谢过了:)
1. RunLoop的概念和数据结构
1.1 RunLoop的概念
- 有事做的时候做事,没事做的时候休息
- 通过内部维护的事件循环来对事件/消息进行管理的一个 对象
- 没有消息需要处理时, 休眠以避免资源占用
-
- 用户态到内核态切换
- 有消息需要处理时, 立刻被唤醒
-
- 内核态到用户态切换
1.2 RunLoop的数据结构
- NSRunLoop是CFRunLoop的封装, 提供了面向对象的API
1.3 RunLoop模式有哪些?
- 常用的3个Mode:
NSDefaultRunLoopMode
, 默认的模式, 有事件响应的时候, 会阻塞旧事件NSRunLoopCommonModes
, 普通模式, 不会影响任何事件UITrackingRunLoopMode
, 只能是有事件的时候才会响应的模式- App刚启动的时候会执行一次的模式
- 系统检测App各种事件的模式
- 苹果官方文档对5个Mode的介绍: ```
System Run Loop Modes
A pseudo-mode that includes one or more other run loop modes.
The mode set to handle input sources other than connection objects.
The mode set when tracking events modally, such as a mouse-dragging loop.
The mode set when waiting for input from a modal panel, such as a save or open panel.
The mode set while tracking in controls takes place. ```
1.4 关于RunLoop的5个类
CFRunLoopRef
: 代表RunLoop的对象CFRunLoopModeRef
: 代表RunLoop的运行模式CFRunLoopSourceRef
: 就是RunLoop模型图中提到的输入源(事件源)CFRunLoopTimerRef
: 就是RunLoop模型图中提到的定时源CFRunLoopObserverRef
: 观察者, 能够监听RunLoop的状态改变.- 一个RunLoop对象中包含若干个运行模式.每一个运行模式下又包含若干个输入源、定时源、观察者.
-
- 每次RunLoop启动时, 只能指定其中一个运行模式, 这个运行模式被称作当前运行模式
CurrentMode
.
- 每次RunLoop启动时, 只能指定其中一个运行模式, 这个运行模式被称作当前运行模式
-
- 如果需要切换运行模式, 只能退出当前Loop, 再重新指定一个运行模式进入.
-
- 这样做主要是为了分隔开不同组的输入源、定时源、观察者, 让其互不影响.
1.5 CFRunLoopSourceRef
CFRunLoopSourceRef
是事件源, 有两种分类方法.- 按照官方文档来分类
-
- Port-Based Sources (基于端口)
-
- Custom Input Sources (自定义)
-
- Cocoa Perform Selector Sources
- 按照函数调用栈来分类
-
- Source0: 非基于Port
-
- Source1: 基于Port, 通过内核和其他线程通信, 接收、分发系统事件
1.6 RunLoop的基本执行原理
- 原本系统就有一个RunLoop在检测App内的事件, 当输入源有执行操作的时候, 系统的RunLoop会监听输入源的状态, 进而在系统内部做一些对应的操作. 处理完事件后, 会自动回到睡眠状态, 等待下一次被唤醒.
- 在每次运行开启RunLoop的时候, 所在线程的RunLoop会自动处理之前未处理的事件, 并且通知相关的观察者. 1. 通知观察者RunLoop已经启动 2. 通知观察者即将要开始定时器 3. 通知观察者任何即将启动的非基于端口的源Source0 4. 启动任何准备好的非基于端口的源Source0 5. 如果基于端口的源Source1准备好并处于等待状态, 立即启动, 并进入步骤9 6. 通知观察者线程进入休眠状态 7. 将线程置于休眠直到下面任一种事件发生: - - 某一事件到达基于端口的源Source1 - - 定时器启动 - - RunLoop设置的时间已经超时 - - RunLoop被显示唤醒 8. 通知观察者线程将被唤醒 9. 处理未处理的事件 - - 如果用户定义的定时器启动, 处理定时器事件并重启RunLoop, 进入步骤2 - - 如果输入源启动, 传递相应的消息 - - 如果RunLoop被显示唤醒而且时间还没超时, 重启RunLoop. 进入步骤2 10. 通知观察者RunLoop结束.
2. RunLoop在iOS中的落地使用细节
2.1 RunLoop和线程的关系
- 在默认情况下, 线程执行完之后就会退出, 就不能再继续任务了. 这时我们需要采用一种方式来让线程能够不断地处理任务, 并不退出. 所以, 我们就有了RunLoop.
- 一条线程对应一个RunLoop对象, 每条线程都有唯一一个与之对应的RunLoop对象.
- RunLoop并不保证线程安全. 我们只能在当前线程内部操作当前线程的RunLoop对象, 而不能在当前线程内部去操作其他线程的RunLoop对象方法.
- RunLoop对象在第一次获取RunLoop时创建, 销毁则是在线程结束的时候.
- 主线程的RunLoop对象系统自动帮助我们创建好了(
UIApplicationMain
函数), 子线程的RunLoop对象需要我们主动创建和维护. ```
import
import "AppDelegate.h"
int main(int argc, char * argv[]) { @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); } } ```
2.1.1 RunLoop与常驻线程
- 常驻线程
-
- 指的就是那些不会停止,一直存在于内存中的线程。
- 后台常驻线程测试代码: ```
-
(void)viewDidLoad { // 创建线程,并调用run1方法执行任务 self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run1) object:nil]; // 开启线程 [self.thread start]; }
-
(void)run1 { // 这里写任务 NSLog(@"----run1-----"); // 添加下边两句代码,就可以开启RunLoop,之后self.thread就变成了常驻线程,可随时添加任务,并交于RunLoop处理 [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode]; [[NSRunLoop currentRunLoop] run]; // 测试是否开启了RunLoop,如果开启RunLoop,则来不了这里,因为RunLoop开启了循环。 NSLog(@"未开启RunLoop"); }
-
(void)touchesBegan:(NSSet
)touches withEvent:(UIEvent )event { // 利用performSelector,在self.thread的线程中调用run2方法执行任务 [self performSelector:@selector(run2) onThread:self.thread withObject:nil waitUntilDone:NO]; } - (void)run2 { NSLog(@"----run2-----"); } ```
2.1.2 AFN2.0 和3.0的主要区别--去除常驻线程
- AFN3.0去除了所有
NSURLConnection
请求的API - AFN3.0使用
NSURLSession
代替AFN2.0的常驻线程
2.1.2.1 AFN2.X常驻线分析
- 常驻线程
-
- 指的就是那些不会停止,一直存在于内存中的线程。
-
- AFNetworking 2.0 专门创建了一个线程来接收 NSOperationQueue 的回调,这个线程其实就是一个常驻线程。 ```
- (void)networkRequestThreadEntryPoint:(id)__unused object { @autoreleasepool { // 先用 NSThread 创建了一个线程 [[NSThread currentThread] setName:@"AFNetworking"]; // 使用 run 方法添加 runloop NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; } } ```
-
虽然说,在一个 App 里网络请求这个动作的占比很高,但也有很多不需要网络的场景,所以线程一直常驻在内存中,也是不合理的。
-
在请求完成后我们需要对数据进行一些处理, 如果我们在主线程中处理就会导致UI卡顿
- 这时我们就需要一个子线程来处理事件和网络请求的回调. 但是子线程在处理完事件后就会自动结束生命周期,
-
- 这时后面的一些网络请求的回调我们就无法接收了,
-
- 所以我们就需要开启子线程的RunLoop使线程常驻来保活线程.
2.1.2.2 AFN3.X不在常驻线程的分析
- AFNetworking 在 3.0 版本时,使用苹果公司新推出的 NSURLSession 替换了 NSURLConnection,从而避免了常驻线程这个坑。
-
- NSURLSession 可以指定回调 NSOperationQueue,这样请求就不需要让线程一直常驻在内存里去等待回调了。
self.operationQueue = [[NSOperationQueue alloc] init]; self.operationQueue.maxConcurrentOperationCount = 1; self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
- NSURLSession 可以指定回调 NSOperationQueue,这样请求就不需要让线程一直常驻在内存里去等待回调了。
-
- NSURLSession 发起的请求,可以指定回调的 delegateQueue,不再需要在当前线程进行代理方法的回调。所以说,NSURLSession 解决了 NSURLConnection 的线程回调问题。
-
AFNetworking 2.0 使用常驻线程也是无奈之举,一旦有方案能够替代常驻线程,它就会毫不犹豫地废弃常驻线程。
-
在AFN3.X中使用的是NSURLSession进行封装,
-
- 对比NSURLConnection, NSURLSession不需要再当前的线程等待网络回调,
-
- 而是可以让开发者自己设定需要回调的队列.
- 在AFN3.X中使用了NSOperationQueue管理网络,
-
- 并设置
self.operationQueue.maxConcurrentOperationCount = 1;
,保证了最大的并发数为1,
- 并设置
-
- 也就是说让网络请求串行执行. 避免了多线程环境下的资源抢夺问题.
-
- AFNetworking 2.0 专门创建了一个线程来接收 NSOperationQueue 的回调,这个线程其实就是一个常驻线程。 ```
- (void)networkRequestThreadEntryPoint:(id)__unused object { @autoreleasepool { // 先用 NSThread 创建了一个线程 [[NSThread currentThread] setName:@"AFNetworking"]; // 使用 run 方法添加 runloop NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; } } ```
- 虽然说,在一个 App 里网络请求这个动作的占比很高,但也有很多不需要网络的场景,所以线程一直常驻在内存中,也是不合理的。
- AFNetworking 在 3.0 版本时,使用苹果公司新推出的 NSURLSession 替换了 NSURLConnection,从而避免了常驻线程这个坑。
-
- NSURLSession 可以指定回调 NSOperationQueue,这样请求就不需要让线程一直常驻在内存里去等待回调了。
self.operationQueue = [[NSOperationQueue alloc] init]; self.operationQueue.maxConcurrentOperationCount = 1; self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
- NSURLSession 可以指定回调 NSOperationQueue,这样请求就不需要让线程一直常驻在内存里去等待回调了。
-
- NSURLSession 发起的请求,可以指定回调的 delegateQueue,不再需要在当前线程进行代理方法的回调。所以说,NSURLSession 解决了 NSURLConnection 的线程回调问题。
- AFNetworking 2.0 使用常驻线程也是无奈之举,一旦有方案能够替代常驻线程,它就会毫不犹豫地废弃常驻线程。
2.2 NSTimer与RunLoop
2.2.1 NSTimer中的scheduledTimerWithTimeInterval
方法和RunLoop的关系.
``` NSTimer *timer1 = [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
/ 上面这句代码调用了scheduledTimer返回的定时器, NSTimer会自动加入到RunLoop的NSDefaultRunLoop模式下, 相当于下面两句代码. / NSTimer timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES]; [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
/* 因为默认已经添加了NSDefaultRunLoopMode, 所以只给timer1添加了UITrackingRunLoopMode后, 效果跟添加了NSRunLoopCommonModes一致, 拖动也不影响定时器 / [[NSRunLoop currentRunLoop] addTimer:timer1 forMode:UITrackingRunLoopMode];
// 开发中推荐使用 NSTimer *timer = [NSTimer timerWithTimeInterval:duration target:self selector:@selector(cs_toastTimerDidFinish:) userInfo:toast repeats:NO]; [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; ```
2.2.2 为什么说NSTimer不准确
- NSTimer的触发时间到的时候, runloop如果在阻塞状态, 触发时间就会推迟到下一个runloop周期
- 可利用GCD优化 ``` NSTimeInterval interval = 1.0; _timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
dispatch_source_set_timer(_timer, dispatch_walltime(NULL, 0), interval * NSEC_PER_SEC, 0);
dispatch_source_ser_evernt_handler(_timer, ^{ NSLog(@"GCD timer test"); });
dispatch_resume(_timer); ```
2.3 RunLoop使用的其他小Tips
NSTimer
不被手势操作影响- 滑动
tableview
时cell
中的ImageView
推迟显示[self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"tupian"] afterDelay:4.0 inModes:@[NSDefaultRunLoopMode]];
3. 如何用RunLoop原理去监控卡顿
- 戴銘老师的RunLoop示意图
- 卡顿跟FPS关系不大, 24帧的动画也是流畅的
- 通过监控RunLoop的状态, 就能够发现调用方法是否执行时间过长, 从而判断出是否会出现卡顿.
1. 要想监听 RunLoop,你就首先需要创建一个 CFRunLoopObserverContext 观察者,代码如下:
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context);
2. 将创建好的观察者 runLoopObserver 添加到主线程 RunLoop 的 common 模式下观察。然后,创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。
3. 一旦发现进入睡眠前的 kCFRunLoopBeforeSources 状态,或者唤醒后的状态 kCFRunLoopAfterWaiting,在设置的时间阈值内一直没有变化,即可判定为卡顿。
4. 接下来,我们就可以通过三方库PLCrashReporter
dump 出堆栈的信息,从而进一步分析出具体是哪个方法的执行时间过长。
发文不易, 喜欢点赞的人更有好运气👍 :), 定期更新+关注不迷路~
ps:欢迎加入笔者18年建立的研究iOS审核及前沿技术的三千人扣群:662339934,坑位有限,备注“掘金网友”可被群管通过~
本文正在参加「金石计划 . 瓜分6万现金大奖」
- iOS老司机聊聊实际项目开发中的<<人月神话>>
- iOS老司机可落地在中大型iOS项目中的5大接地气设计模式合集
- iOS老司机的跨端跨平台Hybrid开发Tips
- iOS老司机的2022年回顾, 聊聊寒冬下的实用<<谈判力>>
- iOS老司机可落地的中大型iOS项目中的设计模式优化Tips_桥接模式
- iOS老司机的多线程PThread学习分享
- iOS老司机整理, iOSer必会的经典算法_2
- iOS老司机的<<蓝海转型>>读书分享
- iOS老司机的<<程序员的自我修养:链接、装载与库>>读书分享
- iOS老司机的接地气算法Tips
- iOS老司机的RunLoop原理探究及实用Tips
- iOS老司机整理, iOSer必会的经典算法_1
- iOS老司机的App启动优化Tips, 让启动速度提升10%
- iOS老司机的网络相关Tips
- 恋上数据结构与算法
- iOS老司机带你一起把App的崩溃率降到0.1%以下
- 探究Swift的String底层实现
- iOS老司机万字整理, 可能是最全的Swift Tips
- iOS老司机可落地的中大型iOS项目中的设计模式优化Tips
- 聊一聊Swift中的闭包