使用钩子实现了对字典和数组的赋值的校验,顺便随手撸了一个简单的jsonToModel,iOS除了runtime还有一个东西的叫做runloop,各位看官老爷一定都有了解,那么今天这篇文章初识一下runloop。
什么是runloop
简单来讲runloop就是一个循环,我们写的程序,一般没有循环的话,执行完就结束了,那么我们手机上的APP是如何一直运行不停止的呢?APP就是用到了runloop,保证程序一直运行不退出,在需要处理事件的时候处理事件,不处理事件的时候进行休眠,跳出循环程序就结束。用伪代码实现一个runloop其实是这样子的
1  | int ret = 0;  | 
获取runloop
iOS中有两套可以获取runloop代码,一个是Foundation、一个是Core Foundation。Foundation其实是对Core Foundation的一个封装,
1  | NSRunLoop * runloop1 = [NSRunLoop currentRunLoop];  | 
runloop1和mainloop1地址一致,说明当前的runloop是mainrunloop,runloop1作为对象输出的结果其实也是runloop2的地址,证明Foundation runloop是对Core Foundation的一个封装。
RunLoop底层我们猜测应该是结构体,我们都了解到其实OC就是封装了c/c++,那么c厉害之处就是指针和结构体基本解决常用的所有东西。我们窥探一下runloop的真是模样,通过CFRunLoopRef *runloop = CFRunLoopGetMain();查看CFRunloop是typedef struct CF_BRIDGED_MUTABLE_TYPE(id) __CFRunLoop * CFRunLoopRef;,我们常用的CFRunLoopRef是__CFRunLoop *类型的,那么再在源码(可以下载最新的源码)中搜索一下 struct __CFRunLoop {在runloop.c 637行如下所示:
1  | struct __CFRunLoop {  | 
经过简化之后:
1  | struct __CFRunLoop {  | 
runloop中包含一个线程_pthread,一一对应的CFMutableSetRef _modes可以有多个modeCFRunLoopModeRef _currentMode当前mode只能有一个
那么mode里边有什么内容呢?我们猜测他应该和runloop类似,在源码中搜索CFRuntimeBase _base看到在runloop.c  line 524看到具体的内容:
1  | struct __CFRunLoopMode {  | 
经过简化之后是:
1  | struct __CFRunLoopMode {  | 
一个mode可以有多个timer、souces0、souces1、observers、timers
那么使用图更直观的来表示:

一个runloop包含多个mode,但是同时只能运行一个mode,这点和大家开车的驾驶模式类似,运动模式和环保模式同时只能开一个模式,不能又运动又环保,明显相悖。多个mode被隔离开有点是处理事情更专一,不会因为多个同时处理事情造成卡顿或者资源竞争导致的一系列问题。
souces0
- 触摸事件
 - performSelector:onThread:
 
测试下点击事件处理源
1  | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{  | 
#1看到现在是在队列queue = ‘com.apple.main-thread’中,#10 Runloop启动,#9进入到__CFRunLoopDoSources0,最终__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__调用了__handleEventQueueInternal->[UIApplication sendEvent:]->[UIWindow sendEvent:]->[UIWindow _sendTouchesForEvent:]->[UIResponder touchesBegan:withEvent:]->-[ViewController touchesBegan:withEvent:](self=0x00007fc69ec087e0, _cmd="touchesBegan:withEvent:", touches=1 element, event=0x00006000012a01b0) at ViewController.mm:22:2,可以看到另外一个知识点,手势的传递是从上往下的,顺序是UIApplication -> UIWindow -> UIResponder -> ViewController。
Source1
- 基于Port的线程间通信
 - 系统事件捕捉
 
Timers
- NSTimer
 - performSelector:withObject:afterDelay:
 
1  | timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());  | 
最终进入函数__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__调用了libdispatch的_dispatch_main_queue_callback_4CF函数,具体实现有兴趣的大佬可以看下源码的实现。
Observers
- 用于监听RunLoop的状态
 - UI刷新(BeforeWaiting)
 - Autorelease pool(BeforeWaiting)
 
Mode类型都多个,系统暴露在外的就两个,
1  | CF_EXPORT const CFRunLoopMode kCFRunLoopDefaultMode;  | 
那么这两个Mode都是在什么情况下运行的呢?
kCFRunLoopDefaultMode(NSDefaultRunLoopMode):App的默认Mode,通常主线程是在这个Mode下运行UITrackingRunLoopMode:界面跟踪Mode,用于ScrollView追踪触摸滑动,保证界面滑动时不受其他Mode影响
进入到某个Mode,处理事情也应该有先后顺序和休息的时间,那么现在需要一个状态来表示此时此刻的status,系统已经准备了CFRunLoopActivity来表示当前的状态
1  | typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {  | 
1UL表示无符号长整形数字1,再次看到这个(1UL << 1)我么猜测用到了位域或者联合体,达到省空间的目的。kCFRunLoopAllActivities = 0x0FFFFFFFU转换成二进制就是28个1,再进行mask的时候,所有的值都能取出来。
现在我们了解到:
CFRunloopRef代表RunLoop的运行模式- 一个
Runloop包含若干个Mode,每个Mode包含若干个Source0/Source1/Timer/Obser Runloop启动只能选择一个Mode作为currentMode- 如果需要切换
Mode,只能退出当前Loop,再重新选择一个Mode进入 - 不同组的
Source0/Source1/Timer/Observer能分隔开来,互不影响 - 如果
Mode没有任何Source0/Source1/Timer/Observer,Runloop立马退出。 
runloop切换Mode
1  | CFRunLoopObserverRef obs= CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {  | 
当runloop切换mode的时候,会退出当前kCFRunLoopDefaultMode,加入到其他的UITrackingRunLoopMode,当前UITrackingRunLoopMode完成之后再退出之后再加入到kCFRunLoopDefaultMode。
我们再探究下runloop的循环的状态到底是怎样来变更的。
1  | // //获取loop  | 
runloop唤醒之后不是立马处理事件的,而是看看timer有没有事情,然后是sources,发现有触摸事件就处理了,然后又循环查看timer和sources一般循环2次进入休眠状态,处理source之后是循环三次。
RunLoop在不获取的时候不存在,获取才生成
RunLoop是在主动获取的时候才会生成一个,主线程是系统自己调用生成的,子线程开发者调用,我们看下CFRunLoopGetCurrent
1  | CFRunLoopRef CFRunLoopGetCurrent(void) {  | 
看到到这里相信大家已经对runloop有了基本的认识,那么我们再探究一下底层runloop是怎么运转的。
首先看官方给的图:

那我又整理了一个表格来更直观的了解状态运转
| 步骤 | 任务 | 
|---|---|
| 1 | 通知Observers:进入Loop | 
| 2 | 通知Observers:即将处理Timers | 
| 3 | 通知Observers:即将处理Sources | 
| 4 | 处理blocks | 
| 5 | 处理Source0(可能再处理Blocks) | 
| 6 | 如果存在Source1,跳转第8步 | 
| 7 | 通知Observers:开始休眠 | 
| 8 | 通知Observers:结束休眠1.处理Timer2.处理GCD Asyn To Main Queue 3.处理Source1 | 
| 9 | 处理Blocks | 
| 10 | 根据前面的执行结果,决定如何操作1.返回第2步,2退出loop | 
| 11 | 通知Observers:退出Loop | 
查看runloop源码中runloop.c2333行
1  | //入口函数  | 
经过及进一步精简
1  | //入口函数  | 
精简到这里基本都能看懂了,还写了很多注释,基本和上面整理的表格一致。
这里的线程休眠__CFRunLoopServiceMachPort是调用内核函数mach_msg()进行休眠,和我们平时while(1)大不同,while(1)叫死循环,其实系统每时每刻都在判断是否符合条件,耗费很高的CPU,内核则不同,Mach内核提供面向消息,基于基础的进程间通信。
保活机制
一个程序运行完毕结束了就死掉了,timer和变量也一样,运行完毕就结束了,那么我们怎么可以保证timer一直活跃和线程不结束呢?
timer保活和多mode运行
timer可以添加到self的属性保证一直活着,只要self不死,timer就不死。timer默认是添加到NSDefaultRunLoopMode模式中,因为RunLoop同时运行只能有一个模式,那么在滑动scroller的时候怎Timer会卡顿停止直到再次切换回来,那么如何保证同时两个模式都可以运行呢?Foundation提供了一个API(void)addTimer:(NSTimer *)timer forMode:(NSRunLoopMode)mode添加上,mode值为NSRunLoopCommonModes可以保证同时兼顾2种模式。
测试代码:
1  | static int i = 0;  | 
当滑动的时候timer的时候,timer还是如此丝滑,没有一点停顿。
没有卡顿之后我们VC -> dealloc中timer还是在执行,那么需要在dealloc中去下和删除观察者
1  | -(void)dealloc{  | 
退出vc之后dealloc照常执行,日志只有-[ViewController dealloc],而且数字没有继续输出,说明删除观察者和取消source都成功了。
那么NSRunLoopCommonModes是另外一种模式吗?
通过源码查看得知,在runloop.c line:1632  line:2608
1  | if (CFStringGetTypeID() == CFGetTypeID(curr->_mode)) {  | 
还有很多地方均可以看出,当是currentMode需要和_mode相等才去执行,当是kCFRunLoopCommonModes的时候,只需要包含curMode即可执行。可见kCFRunLoopCommonModes其实是一个集合,不是某个特定的mode。
线程保活
线程为什么需要保活?性能其实很大的瓶颈是在于空间的申请和释放,当我们执行一个任务的时候创建了一个线程,任务结束就释放掉该线程,如果任务频率比较高,那么一个一直活跃的线程来执行我们的任务就省去申请和释放空间的时间和性能。上边已经讲过了runloop需要有任务才能不退出,总不可能直接让他执行while(1)吧,这种方法明显不对的,由源码得知,当有监测端口的时候,也不会退出,也不会影响应能。所以在线程初始化的时候使用
1  | [[NSRunLoop currentRunLoop] addPort:[NSPort port]  | 
来保活。
在主线程使用是没有意义的,系统已经在APP启动的时候进行了调用,则已经加入到全局的字典中了。
验证线程保活
1  | @property (nonatomic,strong) FYThread *thread;  | 
[[NSRunLoop currentRunLoop] addPort:[NSPort port]forMode:NSDefaultRunLoopMode]添加端口注释掉,直接执行了--end--,线程虽然strong强引用,但是runloop已经退出了,所以函数alive没有执行,不注释的话,alive还会执行,end一直不会执行,因为进入了runloop,而且没有退出,代码就不会向下执行。
那我们测试下该线程声明周期多长?
1  | - (void)viewDidLoad {  | 
拥有该线程的是VC,点击pop的时候,但是VC和thread没释放掉,好像thread和VC建立的循环引用,当self.thread=[[FYThread alloc]initWithTarget:self selector:@selector(test) object:nil];注释了,则VC可以进行正常释放。
通过测试了解到
这个线程达到了永生,就是你杀不死他,简直了死待。查找了不少资料才发现官方文档才是最稳的。有对这句[[NSRunLoop currentRunLoop] run]的解释
If no input sources or timers are attached to the run loop, this method exits immediately; otherwise, it runs the receiver in the NSDefaultRunLoopMode by repeatedly invoking runMode:beforeDate:. In other words, this method effectively begins an infinite loop that processes data from the run loop’s input sources and timers.
就是系统写了以一个死循环但是没有阻止他的参数,相当于一直在循环调用runMode:beforeDate:,那么该怎么办呢?
官方文档给出了解决方案
1  | 
  | 
将代码改成下面的成功将死待杀死了。
1  | - (void)test {  | 
点击popVC:首先将self.shouldKeepRunning = NO,然后子线程执行CFRunLoopStop(CFRunLoopGetCurrent()),然后在主线程执行pop函数,最终返回上级页面而且成功杀死VC和死待。
当然这个死待其实也是有用处的,当使用单例模式作为下载器的时候使用死待也没问题。这样子处理比较复杂,我们可以放在VC的dealloc看看是否能成功。
关键函数稍微更改:
1  | //停止子线程线程  | 
当点击返回按钮VC和线程都没死,原来他们形成了强引用无法释放,就是VC始终无法执行dealloc。将函数改成block实现
1  | __weak typeof(self) __weakSelf = self;  | 
测试下崩溃了,崩溃到了:
1  | while (__weakSelf.shouldKeepRunning ){  | 
怎么想感觉不对劲啊,怎么会不行呢?VC销毁的时候调用子线程stop,最后打断点发现到了崩溃的地方self已经不存在了,说明是异步执行的,往前查找使用异步的函数最后出现在了[self performSelector:@selector(stopThread) onThread:self.thread withObject:nil waitUntilDone:NO];,表示不用等待stopThread函数执行时间,直接向前继续执行,所以VC释放掉了,while (__weakSelf.shouldKeepRunning )是true,还真进去了,访问了exe_bad_access,所以改成while (__weakSelf&&__weakSelf.shouldKeepRunning )再跑一下
1  | //log  | 
如牛奶般丝滑,解决了释放问题,也解决了复杂操作。本文章所有代码均在底部链接可以下载。
使用这个思路自己封装了一个简单的功能,大家可以自己封装一下然后对比一下我的思路,说不定有惊喜!
资料参考
- runloop源码
 - 小码哥视频
 - 任务调度
 - libdispatch
资料下载
 - 学习资料下载git
 - demo code git
 - runtime可运行的源码git
 - thread保活c语言版本
 - thread 保活
 
最怕一生碌碌无为,还安慰自己平凡可贵。
广告时间
