深入理解RunLoop
上一篇博客介绍了使用 WKWebView 进行性能调优,以及使用中遇到的问题。当在弱网环境下频繁切换 H5 页面时,就会出现应用卡死的情况。使用WKWebView进行性能调优,控制台会报三个错误,其中一个错误是:
2018-02-01 11:09:01.952689+0800 CloudOfficeTest[614:68129] void SendDelegateMessage(NSInvocation *): delegate (webView:decidePolicyForNavigationAction:request:frame:decisionListener:) failed to return after waiting 10 seconds. main run loop mode: kCFRunLoopDefaultMode
错误中出现了 main run loop mode: kCFRunLoopDefaultMode 的信息提示,最后虽然将项目中的 UIWebView 替换为 WKWebView ( WKWebView的内存消耗相比 UIWebView 低了一个数量级)。但却没有将这个报 RunLoop 的错误解释清楚,今天就结合一些实例叙述一下自己对 RunLoop 的浅见。
更新说明
更新记录:
- 2018 年 03 月,第一版。
- 2018 年 04 月,补充利用 RunLoop 解决一些问题的 Demo。
RunLoop 是什么
Runloop 是 iOS 底层机制,就是一个运行循环,确切的说是为了保证程序会一直运行不退出的死循环。
在 iOS 中的入口函数执行类似逻辑,这里打印只会输出 执行了!!!,并不会输出 有没有执行???,这里开启了一个和主线程相关的 RunLoop,导致 UIApplicationMain 不会返回,一直处在运行中。
int main(int argc, char * argv[]) {
@autoreleasepool {
NSLog(@"执行了!!!");
// 主线程死循环 --- RunLoop
int a = UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
NSLog(@"有没有执行???");
return a;
}
}
下面这段内容摘抄自 深入理解RunLoop 中 RunLoop的概念 中的叙述。
一般来讲,一个线程一次只能执行一个任务,执行完成后线程就会退出。如果我们需要一个机制,让线程能随时处理事件但并不退出,通常代码逻辑是这样的:
function loop() {
initialize();
do {
var message = get_next_message();
process_message(message);
} while (message != quit);
}
这种模型通常被称作Event loop。Event Loop 在很多系统和框架里都有实现,比如:
- Node.js 的事件处理
- Windows 程序的消息循环
- OSX/iOS 的 RunLoop
实现这种模型的关键的在于:
如何管理事件/消息,如何让线程在没有处理消息时休眠,以避免资源占用;在有消息到来时立刻被唤醒。
所以,RunLoop 实际上就是一个对象,这个对象管理了其需要处理的事件和消息,并提供了一个入口函数来执行上面的 Event Loop 逻辑。线程执行了这个函数后,就会一直处于这个函数内部 接受消息 –> 等待 –> 处理 的循环中,直到这个循环结束(比如,传入 quit 消息),函数返回。
OSX/iOS 系统中,提供了两个这样的对象:NSRunLoop 和 CFRunLoopRef。NSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但这些 API 不是线程安全的。CFRunLoopRef 是在 CoreFoundation 框架内的,它提供了纯 C函数 的 API,所以这些 API 都是线程安全的。
RunLoop 的作用
- 保住程序不退出,持续运行;
- 负责监听程序中的各种事件,如:网络,触摸,定时器等;
- 渲染 UI;
- 节省 CPU 资源,提高程序性能;
- 线程间的通讯。
RunLoop 与线程的关系
下面这段内容摘抄自 深入理解RunLoop 中 RunLoop 与线程的关系 中的叙述。苹果不允许直接创建 RunLoop,它只提供了两个自动获取的函数:CFRunLoopGetMain() 和 CFRunLoopGetCurrent()。 这两个函数内部的逻辑大概是下面这样:
/// 全局的Dictionary,key 是 pthread_t, value 是 CFRunLoopRef
static CFMutableDictionaryRef loopsDic;
/// 访问 loopsDic 时的锁
static CFSpinLock_t loopsLock;
/// 获取一个 pthread 对应的 RunLoop。
CFRunLoopRef _CFRunLoopGet(pthread_t thread) {
OSSpinLockLock(&loopsLock);
if (!loopsDic) {
// 第一次进入时,初始化全局Dic,并先为主线程创建一个 RunLoop。
loopsDic = CFDictionaryCreateMutable();
CFRunLoopRef mainLoop = _CFRunLoopCreate();
CFDictionarySetValue(loopsDic, pthread_main_thread_np(), mainLoop);
}
/// 直接从 Dictionary 里获取。
CFRunLoopRef loop = CFDictionaryGetValue(loopsDic, thread));
if (!loop) {
/// 取不到时,创建一个
loop = _CFRunLoopCreate();
CFDictionarySetValue(loopsDic, thread, loop);
/// 注册一个回调,当线程销毁时,顺便也销毁其对应的 RunLoop。
_CFSetTSD(..., thread, loop, __CFFinalizeRunLoop);
}
OSSpinLockUnLock(&loopsLock);
return loop;
}
CFRunLoopRef CFRunLoopGetMain() {
return _CFRunLoopGet(pthread_main_thread_np());
}
CFRunLoopRef CFRunLoopGetCurrent() {
return _CFRunLoopGet(pthread_self());
}
- 线程和 RunLoop 是一一对应的;
- 线程刚刚创建时并没有 RunLoop,如果你不主动获取,那它一直不会有;
- RunLoop 的创建发生在第一次获取时,RunLoop 的销毁发生在线程结束时;
- 只能在一个线程的内部获取其 RunLoop (主线程除外)。
CoreFoundation 中 RunLoop 的组成结构
CoreFoundation 中关于 RunLoop 有5个类:
- CFRunLoopModeRef // 运行模式,每次调用时只能选择一种,在不同模式中做不同的操作。
- __CFRunLoop CFRunLoopRef; // 获得当前 RunLoop*
- __CFRunLoopSource CFRunLoopSourceRef*; // 事件源
- __CFRunLoopObserver CFRunLoopObserverRef*; // 观察者
- __CFRunLoopTimer CFRunLoopTimerRef*; // 定时器时间
其中 CFRunLoopModeRef 类并没有对外暴露,只是通过 CFRunLoopRef 的接口进行了封装。他们的关系如下:
- CFRunLoopModeRef,一个 RunLoop 包含若干个 Mode
,每个 Mode 又包含若干个 Source/Timer/Observer。每次调用 RunLoop的主函数时,只能指定其中一个 Mode,这个 Mode 被称为 CurrentMode。如果需要切换 Mode,只能退出 RunLoop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。 - CFRunLoopSourceRef 是事件产生的地方。source 有两个版本:source0 和 source1。
- Source0 只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
- Source1 包含了一个 mach_port 和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程。
- CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimer 是 toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间点到期时,RunLoop 会被唤醒一执行那个回调。
- CFRunLoopObserverRef 是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受这个变化。可以观察的时间点有以下几个:
/* Run Loop Observer Activities */
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 即将进入Loop
kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 Timer
kCFRunLoopBeforeSources = (1UL << 2),// 即将处理 Source
kCFRunLoopBeforeWaiting = (1UL << 5),// 即将进入休眠
kCFRunLoopAfterWaiting = (1UL << 6),// 即将进入休眠
kCFRunLoopExit = (1UL << 7), // 即将退出Loop
kCFRunLoopAllActivities = 0x0FFFFFFFU
};
上面的 Source/Timer/Observer 被统称为 mode item,一个 item 可以同时加入多个 mode。但一个 item 被重复加入同一个 mode 时,是不会有效果的。如果一个 mode 中一个 item 都没有,则 RunLoop 会直接退出,不进入循环。
RunLoop的 Mode
关于 RunLoop 的 Mode 可以通过下面的例子,展开来说,
- (void)viewDidLoad {
[super viewDidLoad];
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerCurrentThread) userInfo:nil repeats:YES];
// 将timer加入到RunLoop中
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}
- (void)timerCurrentThread
{
static int a = 0;
NSLog(@"当前线程%@---%d",[NSThread currentThread],a++);
}
这里将 timer 加入到 RunLoop 中,通过当前运行的 RunLoop
观察事件的执行。这里需要注意给 timer 添加的是 NSDefaultRunLoopMode 模式。
那么,在上面例子的基础上,我再添加一个 UITextView 控件,编译运行后,当滑动控件时,发现控制台不会继续输出 timerCurrentThread 方法中的打印。是因为阻塞了主线程导致的吗?不是的,这里 RunLoop 无法同时处理屏幕触摸事件和 timer 回调。此时,试着将模式替换为 UITrackingRunLoopMode,
[[NSRunLoop currentRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];
再滑动 UITextView 控件,控制台会继续输出。结合上面的所说的一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer,例子中的关系可以这样表示,如图所示,
这里在 UITrackingRunLoopMode(UI模式)下的优先级最高,当通过触摸事件唤醒该模式时,当前 RunLoop 会忽落掉其它模式,优先处理UI模式下的事件。 同样在UI模式下,没有触摸手机屏幕时,即使有 timer 回调也不会继续处理,因此,当不再滑动控件时,控制台就不会再有任何输出了。如果想要兼顾默认模式和UI模式,可以这样做:
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];
将 timer 同时添加到两种模式中,在触摸屏幕时(UI模式),会处理 timer 回调,当不在触摸屏幕时(默认模式),也会处理 timer 回调。那么有没有一种模式可以兼顾这两种模式呢?答案是肯定的。如下,
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
这里的 NSRunLoopCommonModes (占位模式),相当于前两种模式的叠加。
这里有个概念叫 “CommonModes”:一个 Mode 可以将自己标记为”Common”属性(通过将其 ModeName 添加到 RunLoop 的 “commonModes” 中)。每当 RunLoop 的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 “Common” 标记的所有Mode里。
— 摘录自 深入理解RunLoop
系统默认注册了5个Mode,如下:
- NSDefaultRunLoopMode 默认模式
- UITrackingRunLoopMode UI模式
- NSRunLoopCommonModes 占位模式
- 初始化模式
- 系统内核模式
经常用到的是前三种模式。
RunLoop 的内部逻辑
利用 RunLoop 解决一些问题
1. RunLoop 渲染UI — 减少滑动卡顿
在 tableView 上加载多张高清大图时,在拖拽很快的时候,所有的图片渲染都交给 RunLoop 一次循环中处理掉,这样就会导致滑动时卡顿的问题。那么该如何解决呢?这里以 iPhone 6s 为例,tableView 最多一次显示18张图片,分为18次加入到 RunLoop 中,而不是一次。
具体怎么做呢?通过监听 RunLoop 的循环!通过 observer 观察活动的不同状态。具体步骤:
- 添加观察者,观察 Runloop 循环;
- 观察状态变化;
- 将原来添加图片的代码加入到数组中
- 在 Runloop 的回调方法中,拿出数组中加载图片的代码,执行。
关键代码如下:
- (void)addRunloopObserver
{
// 1. 得到runloop
CFRunLoopRef runloop = CFRunLoopGetCurrent();
// 2. 获取上下文
CFRunLoopObserverContext context = {
0,
(__bridge void *)self,
&CFRetain,
&CFRelease,
NULL
};
// 3. 创建观察者
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(NULL, kCFRunLoopBeforeWaiting, YES, 0, &callback, &context);
// 4. 添加观察者
CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);
// 5. 释放
CFRelease(observer);
}
#pragma mark - 在回调里面加载图片(Runloop循环一次加载一次)
void callback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
NSLog(@"%@",info);
LoadImageViewController *vc = (__bridge LoadImageViewController *)info;
if (vc.tasks.count == 0) {
return;
}
runloopBlock taskBlock = vc.tasks.firstObject;
taskBlock();
[vc.tasks removeObjectAtIndex:0];
}
2. 通过 RunLoop 让 Crash 的 App 回光返照
由 SIGABRT 引起的 crash 是系统发这个 SIGABRT 给 App,程序收到这个SIGABRT 后,就会把主线程的 RunLoop 杀死,程序就挂掉了。这个例子只针对 SIGABRT 引起的 Crash 有效。
CFRunLoopRef runloop = CFRunLoopGetCurrent();
//获取所有Mode,因为可能有很多Mode,每个Mode都需要跑,此处可以选择提交下崩溃信息之类的
UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"程序崩溃了" message:@"崩溃信息" delegate:nil cancelButtonTitle:@"取消" otherButtonTitles:nil];
[alertView show];
NSArray *allModes = CFBridgingRelease(CFRunLoopCopyAllModes(runloop));
while (1) {
//快速切换Mode
for (NSString *mode in allModes) {
CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);
}
}
未完善Demo: https://github.com/XibHe/RunLoopCase
写在最后
在准备写这篇博客前,第一反应就是一定要参考 ibireme 两年前写的 深入理解RunLoop 这篇文章,但当花了一天时间看完 ibireme 的文章后,又不知道该如何下手了?脑子里满是 ibireme 博客的影子。ibireme 的这篇文章简直就是 iOS 开发界的 《春江花月夜》,给人一种 “孤篇压全唐” 的感觉。
自己起的调太高了,为了不跑调,就只能假唱了。这篇文章中有一半内容是深入理解RunLoop 的原话。写到最后,才发现这哪里是自己对 RunLoop 的深入理解?写的明明是自己的挣扎与不甘啊!从事 iOS 开发四年的我,又该何去何从呢?愿与诸君共勉:
心之所向,身之所往;道阻且长,行则将至。
最后,得知 ibireme 去岁身体有恙,想来现在早已康复。祝:一切安好!