深入理解RunLoop

Author Avatar
XibHe 3月 18, 2018
  • 在其它设备中阅读本文章

上一篇博客介绍了使用 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 是什么

RunloopiOS 底层机制,就是一个运行循环,确切的说是为了保证程序会一直运行不退出的死循环。

iOS 中的入口函数执行类似逻辑,这里打印只会输出 执行了!!!,并不会输出 有没有执行???,这里开启了一个和主线程相关的 RunLoop,导致 UIApplicationMain 不会返回,一直处在运行中。

int main(int argc, char * argv[]) {
    @autoreleasepool {
        NSLog(@"执行了!!!");
        // 主线程死循环 --- RunLoop
        int a = UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
        NSLog(@"有没有执行???");
        return a;
    }
}
下面这段内容摘抄自 深入理解RunLoopRunLoop的概念 中的叙述。

一般来讲,一个线程一次只能执行一个任务,执行完成后线程就会退出。如果我们需要一个机制,让线程能随时处理事件但并不退出,通常代码逻辑是这样的:

function loop() {
    initialize();
    do {
        var message = get_next_message();
        process_message(message);
    } while (message != quit);
}

这种模型通常被称作Event loopEvent Loop 在很多系统和框架里都有实现,比如:

  • Node.js 的事件处理
  • Windows 程序的消息循环
  • OSX/iOSRunLoop

实现这种模型的关键的在于:

如何管理事件/消息,如何让线程在没有处理消息时休眠,以避免资源占用;在有消息到来时立刻被唤醒。

所以,RunLoop 实际上就是一个对象,这个对象管理了其需要处理的事件和消息,并提供了一个入口函数来执行上面的 Event Loop 逻辑。线程执行了这个函数后,就会一直处于这个函数内部 接受消息 –> 等待 –> 处理 的循环中,直到这个循环结束(比如,传入 quit 消息),函数返回。

OSX/iOS 系统中,提供了两个这样的对象:NSRunLoopCFRunLoopRefNSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但这些 API 不是线程安全的。CFRunLoopRef 是在 CoreFoundation 框架内的,它提供了纯 C函数API,所以这些 API 都是线程安全的。

RunLoop 的作用

  1. 保住程序不退出,持续运行;
  2. 负责监听程序中的各种事件,如:网络,触摸,定时器等;
  3. 渲染 UI
  4. 节省 CPU 资源,提高程序性能;
  5. 线程间的通讯。

RunLoop 与线程的关系

下面这段内容摘抄自 深入理解RunLoopRunLoop 与线程的关系 中的叙述。

苹果不允许直接创建 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());
}
  1. 线程和 RunLoop 是一一对应的;
  2. 线程刚刚创建时并没有 RunLoop,如果你不主动获取,那它一直不会有;
  3. RunLoop 的创建发生在第一次获取时,RunLoop 的销毁发生在线程结束时;
  4. 只能在一个线程的内部获取其 RunLoop (主线程除外)。

CoreFoundation 中 RunLoop 的组成结构

CoreFoundation 中关于 RunLoop 有5个类:

  • CFRunLoopModeRef // 运行模式,每次调用时只能选择一种,在不同模式中做不同的操作。
  • __CFRunLoop CFRunLoopRef; // 获得当前 RunLoop*
  • __CFRunLoopSource CFRunLoopSourceRef*; // 事件源
  • __CFRunLoopObserver CFRunLoopObserverRef*; // 观察者
  • __CFRunLoopTimer CFRunLoopTimerRef*; // 定时器时间
下面这段内容摘抄自 深入理解RunLoopRunLoop 对外接口 中的叙述。

其中 CFRunLoopModeRef 类并没有对外暴露,只是通过 CFRunLoopRef 的接口进行了封装。他们的关系如下:

CFRunLoopModeRef的关系

  1. CFRunLoopModeRef,一个 RunLoop 包含若干个 Mode
    ,每个 Mode 又包含若干个 Source/Timer/Observer。每次调用 RunLoop的主函数时,只能指定其中一个 Mode,这个 Mode 被称为 CurrentMode。如果需要切换 Mode,只能退出 RunLoop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。
  2. CFRunLoopSourceRef 是事件产生的地方。source 有两个版本:source0source1
    1. Source0 只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
    2. Source1 包含了一个 mach_port 和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程。
  3. CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimertoll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间点到期时,RunLoop 会被唤醒一执行那个回调。
  4. 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

关于 RunLoopMode 可以通过下面的例子,展开来说,

- (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,如下:

  1. NSDefaultRunLoopMode 默认模式
  2. UITrackingRunLoopMode UI模式
  3. NSRunLoopCommonModes 占位模式
  4. 初始化模式
  5. 系统内核模式

经常用到的是前三种模式。

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 去岁身体有恙,想来现在早已康复。祝:一切安好!

参考资料

深入理解RunLoop

RunLoopWorkDistribution