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

4,441 阅读10分钟

本文正在参加「金石计划 . 瓜分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的状态改变.
  • 一个RunLoop对象中包含若干个运行模式.每一个运行模式下又包含若干个输入源、定时源、观察者.
    • 每次RunLoop启动时, 只能指定其中一个运行模式, 这个运行模式被称作当前运行模式CurrentMode.
    • 如果需要切换运行模式, 只能退出当前Loop, 再重新指定一个运行模式进入.
    • 这样做主要是为了分隔开不同组的输入源、定时源、观察者, 让其互不影响. image.png

1.5 CFRunLoopSourceRef

  • CFRunLoopSourceRef是事件源, 有两种分类方法.
  1. 按照官方文档来分类
    • Port-Based Sources (基于端口)
    • Custom Input Sources (自定义)
    • Cocoa Perform Selector Sources
  1. 按照函数调用栈来分类
    • 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被显示唤醒
  1. 通知观察者线程将被唤醒
  2. 处理未处理的事件
    • 如果用户定义的定时器启动, 处理定时器事件并重启RunLoop, 进入步骤2
    • 如果输入源启动, 传递相应的消息
    • 如果RunLoop被显示唤醒而且时间还没超时, 重启RunLoop. 进入步骤2
  1. 通知观察者RunLoop结束.

image.png

2. RunLoop在iOS中的落地使用细节

2.1 RunLoop和线程的关系

  • 在默认情况下, 线程执行完之后就会退出, 就不能再继续任务了. 这时我们需要采用一种方式来让线程能够不断地处理任务, 并不退出. 所以, 我们就有了RunLoop.
  1. 一条线程对应一个RunLoop对象, 每条线程都有唯一一个与之对应的RunLoop对象.
  2. RunLoop并不保证线程安全. 我们只能在当前线程内部操作当前线程的RunLoop对象, 而不能在当前线程内部去操作其他线程的RunLoop对象方法.
  3. RunLoop对象在第一次获取RunLoop时创建, 销毁则是在线程结束的时候.
  4. 主线程的RunLoop对象系统自动帮助我们创建好了(UIApplicationMain函数), 子线程的RunLoop对象需要我们主动创建和维护.
#import <UIKit/UIKit.h>
#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<UITouch *> *)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 里网络请求这个动作的占比很高,但也有很多不需要网络的场景,所以线程一直常驻在内存中,也是不合理的。
  1. 在请求完成后我们需要对数据进行一些处理, 如果我们在主线程中处理就会导致UI卡顿
  2. 这时我们就需要一个子线程来处理事件和网络请求的回调. 但是子线程在处理完事件后就会自动结束生命周期,
    • 这时后面的一些网络请求的回调我们就无法接收了,
    • 所以我们就需要开启子线程的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 使用常驻线程也是无奈之举,一旦有方案能够替代常驻线程,它就会毫不犹豫地废弃常驻线程。
  1. 在AFN3.X中使用的是NSURLSession进行封装,
    • 对比NSURLConnection, NSURLSession不需要再当前的线程等待网络回调,
    • 而是可以让开发者自己设定需要回调的队列.
  1. 在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);
  1. 将创建好的观察者 runLoopObserver 添加到主线程 RunLoop 的 common 模式下观察。然后,创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。
  2. 一旦发现进入睡眠前的 kCFRunLoopBeforeSources 状态,或者唤醒后的状态 kCFRunLoopAfterWaiting,在设置的时间阈值内一直没有变化,即可判定为卡顿。
  3. 接下来,我们就可以通过三方库PLCrashReporter dump 出堆栈的信息,从而进一步分析出具体是哪个方法的执行时间过长。

发文不易, 喜欢点赞的人更有好运气👍 :), 定期更新+关注不迷路~

ps:欢迎加入笔者18年建立的研究iOS审核及前沿技术的三千人扣群:662339934,坑位有限,备注“掘金网友”可被群管通过~

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