iOS老司机的RunLoop原理探究及实用Tips

语言: CN / TW / HK

本文正在参加「金石计划 . 瓜分6万现金大奖」

前言

  • iOS中的RunLoop除了面试中跟面试官的探讨, 在实际开发中就没用了吗? 初入iOS开发大门时, 可能很多人都会有这个疑惑.
  • 诚然, 日常的iOS开发中, RunLoop的直接使用频率确实相对不高, 但是一旦深入理解RunLoop的原理和机制, 我们就会发现, iOS开发中的方方面面都包含着RunLoop的影子.
  • RunLoop的数据结构设计和机制也体现着iOS操作系统兼顾性能和耗电的用户态内核态切换的精妙.
  • 下面就RunLoop的底层数据结构原理及应用, 跟各位同仁聊一聊自己的浅见, 抛砖引玉.
  • 文章纯手打, 抛砖引玉, 如有错误还请评论区指正, 先行谢过了:)

1. RunLoop的概念和数据结构

1.1 RunLoop的概念

  • 有事做的时候做事,没事做的时候休息
  • 通过内部维护的事件循环来对事件/消息进行管理的一个 对象
  • 没有消息需要处理时, 休眠以避免资源占用
    • 用户态到内核态切换
  • 有消息需要处理时, 立刻被唤醒
    • 内核态到用户态切换 image.png

RunLoop机制官方图.png

1.2 RunLoop的数据结构

  • NSRunLoop是CFRunLoop的封装, 提供了面向对象的API image.png

1.3 RunLoop模式有哪些?

  • 常用的3个Mode:
  • NSDefaultRunLoopMode, 默认的模式, 有事件响应的时候, 会阻塞旧事件
  • NSRunLoopCommonModes, 普通模式, 不会影响任何事件
  • UITrackingRunLoopMode, 只能是有事件的时候才会响应的模式
  • App刚启动的时候会执行一次的模式
  • 系统检测App各种事件的模式
  • 苹果官方文档对5个Mode的介绍: ```

System Run Loop Modes

NSRunLoopCommonModes

A pseudo-mode that includes one or more other run loop modes.

NSDefaultRunLoopMode

The mode set to handle input sources other than connection objects.

NSEventTrackingRunLoopMode

The mode set when tracking events modally, such as a mouse-dragging loop.

NSModalPanelRunLoopMode

The mode set when waiting for input from a modal panel, such as a save or open panel.

UITrackingRunLoopMode

The mode set while tracking in controls takes place. ```

1.4 关于RunLoop的5个类

  1. CFRunLoopRef: 代表RunLoop的对象
  2. CFRunLoopModeRef: 代表RunLoop的运行模式
  3. CFRunLoopSourceRef: 就是RunLoop模型图中提到的输入源(事件源)
  4. CFRunLoopTimerRef: 就是RunLoop模型图中提到的定时源
  5. CFRunLoopObserverRef: 观察者, 能够监听RunLoop的状态改变.
  6. 一个RunLoop对象中包含若干个运行模式.每一个运行模式下又包含若干个输入源、定时源、观察者.
    • 每次RunLoop启动时, 只能指定其中一个运行模式, 这个运行模式被称作当前运行模式CurrentMode.
    • 如果需要切换运行模式, 只能退出当前Loop, 再重新指定一个运行模式进入.
    • 这样做主要是为了分隔开不同组的输入源、定时源、观察者, 让其互不影响. image.png

1.5 CFRunLoopSourceRef

  • CFRunLoopSourceRef是事件源, 有两种分类方法.
  • 按照官方文档来分类
    • Port-Based Sources (基于端口)
    • Custom Input Sources (自定义)
    • Cocoa Perform Selector Sources
  • 按照函数调用栈来分类
    • Source0: 非基于Port
    • Source1: 基于Port, 通过内核和其他线程通信, 接收、分发系统事件

1.6 RunLoop的基本执行原理

  • 原本系统就有一个RunLoop在检测App内的事件, 当输入源有执行操作的时候, 系统的RunLoop会监听输入源的状态, 进而在系统内部做一些对应的操作. 处理完事件后, 会自动回到睡眠状态, 等待下一次被唤醒.

image.png - 在每次运行开启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结束.

image.png

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]; } } ``` image.png
  • 虽然说,在一个 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 发起的请求,可以指定回调的 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]; } } ```

image.png

  • 虽然说,在一个 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 发起的请求,可以指定回调的 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

  1. NSTimer不被手势操作影响
  2. 滑动tableviewcell中的ImageView推迟显示 [self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"tupian"] afterDelay:4.0 inModes:@[NSDefaultRunLoopMode]];

3. 如何用RunLoop原理去监控卡顿

  • 戴銘老师的RunLoop示意图 image.png
  • 卡顿跟FPS关系不大, 24帧的动画也是流畅的
  • 通过监控RunLoop的状态, 就能够发现调用方法是否执行时间过长, 从而判断出是否会出现卡顿.

image.png 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万现金大奖」