使用runloop检测卡顿

2,141 阅读4分钟

前言

runloop不只是停留在面试的认知上,实际在开发中也可以利用其来处理一些特出情况,例如:通过runloop检测主线程卡顿情况,通过runloop加载较大任务等

本文主要介绍使用runloop检测主线程卡顿情况,并打印出卡顿代码的调用栈信息

源码demo

runloop简介

之前介绍到了runloop有多个mode,也就是有多个不同的运行模式,一次只能在一个模式下运行,且通过mode切换来达到切换状态的效果,其mode,如下所示

NSDefaultRunLoopMode

NSConnectionReplyMode

NSModalPanelRunLoopMode

NSEventTrackingRunLoopMode

NSRunLoopCommonModes

其中最后一个NSRunLoopCommonModes实际上是不属于基本运行mode,他是所有mode的集合,即设置了NSRunLoopCommonModes参数的代码,可以在各个mode模式下正常执行

如果想尽可能减少用户操作时的事件,可以将任务放到NSDefaultRunLoopMode模式下运行

此外在温习一下runloop运行的流程图,可以清楚的看到observer的调用步骤

2868a192697b4816b8fa0ac7d91bec4d_tplv-k3u1fbpfcp-watermark.png

然后查看一下runloop代码枚举给出的可以监听的observer类型

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   //所有状态改变
};

因此当runloop长时间停在kCFRunLoopBeforeSources或者kCFRunLoopAfterWaiting状态,可以认为runloop卡在了任务执行之前,为了避免减少误差,一定次数可以认为是一次有效卡顿,这也是我们小工具的核心逻辑

runloop卡顿检测工具

前面介绍了我们的卡顿是通过观测runloop是否长时间停在kCFRunLoopBeforeSources或者kCFRunLoopAfterWaiting状态来检测卡顿,下面介绍下实现逻辑

创建observer监听主线程状态

设置runloopObserver来监听主队列runloop的状态变化,并设置回调方法,最后添加到commonMode上,以保证所有模式都能监听到状态变化

    //注册observer监听runloop
    CFRunLoopObserverContext context = {0, (__bridge void*)self, NULL, NULL};
    _observer = CFRunLoopObserverCreate(kCFAllocatorDefault, 
        kCFRunLoopAllActivities, YES, 0, &runloopCallback, &context);
    //添加observer到主队列的commonMode上
    CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);

设置runloop的observer改变后的回调方法

void runloopCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    RunloopMonitor *monitor = [RunloopMonitor sharedInstance];
    //使用工具类保存主线程的状态activity
    monitor->_activity = activity;
    //释放信号量(信号值+1,如果信号量值低于0则阻塞)
    dispatch_semaphore_signal(monitor->_semaphore);
}

子线程监听主线程状态activity

在子线程中,配合信号量实现卡顿检测

开始检测功能之前,先开启信号量机制,设置默认信号量为0,当信号量值小于0时,会阻塞当前队列,使用wait方法会使信号量-1,使用signal会使信号量+1

子队列开启循环,每次通过wait方法阻塞子队列,并设置超时时间,一旦超过超时时间则会自动解除阻塞继续执行代码,当子队列收到主队列收到的消息后也会解除阻塞,实现正常功能

可以看到,正常没有卡顿情况下,主队列会在子队列超时之前切换runloopMode从而signal来释放信号,进而解除子线程阻塞,因此wait方法会返回一个为0的参数,表示没有卡顿;

当主线程长时间不调用signal,子线程会等待超时,因此会通过wait方法返回一个不为0的result,来继续执行代码,此时可以认为是主线程存在卡顿可能,因此查看主线程状态是否是kCFRunLoopBeforeSources、kCFRunLoopAfterWaiting,如果是可以认为主线程卡在了此方法中(为了操作巧合,使用了一定次数来矫正为一次卡顿),如果不是处于此状态,表示没有卡在处理方法哪里,可以认为不卡顿

检测到一定次数的卡顿后,认为一次有效卡顿,则可以回调对应的方法,或者打印调用栈信息等

代码如下所示:

//初始化信号量等相关参数
_semaphore = dispatch_semaphore_create(0);
_semaphoreCount = 0;
_cardCount = 0;

__weak typeof(self) wself = self;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    __strong typeof(self) sself = wself;
    if (!sself) return;

    while (sself->_isMonitor) {
        //使用信号量阻塞当前线程(信号量值-1,如果信号量值低于0则阻塞),设置一个超时时间400ms
        //如果超时时间到了,则会停止阻塞,信号量恢复,并返回一个非零的resut
        //如果是正常signal唤醒,则result返回0
        intptr_t result = dispatch_semaphore_wait(sself->_semaphore, 
            dispatch_time(DISPATCH_TIME_NOW, sself->_minInterval * NSEC_PER_MSEC));
        //如果等待超时,runloop仍然在等到sources处理或者刚刚唤醒状态,被认为一次卡顿
        if (result != 0 && (sself->_activity == kCFRunLoopBeforeSources 
            || sself->_activity == kCFRunLoopAfterWaiting)) {
            if (++sself->_cardCount >= sself->_maxCount) {
                //大于或者等于为一次有效卡顿,回调卡顿提示block
                if (sself.runloopMonitorCardCallback) sself.runloopMonitorCardCallback(sself);
                if (sself.isPrintStackSymbols) __printStackSymbols(sself);
                sself->_cardCount = 0;
            }
        }else {
            //没有超时,则重置卡顿此时
            sself->_cardCount = 0;
        }
    };
});

此外,还加入了调用栈信息打印的方法,且去除了重复的调用栈打印信息(具体的打印方法是以前看来的,忘了哪里看的),注意仅仅支持在debug下打印,且xcode有时打印会有问题,可以重新尝试

代码如下所示

//打印堆栈信息
void __printStackSymbols(RunloopMonitor *self) {
    NSString *callStackSymbols =  [LSCallStack ls_backtraceOfMainThread];
    //仅仅显示2s之外的重复卡顿信息,为了方便调试
    if (!self->_lastCallStackSymbols || 
        ![self->_lastCallStackSymbols isEqualToString:callStackSymbols] || 
        (self->_lastInterval && CACurrentMediaTime() - self->_lastInterval > 2) ) {
        NSLog(@"检测到了卡顿\n 堆栈信息---callStackSymbols:\n%@\n", callStackSymbols);
    }
    self->_lastCallStackSymbols = callStackSymbols;
    self->_lastInterval = CACurrentMediaTime();
}

测试效果

接下来我们开启检测功能

[[RunloopMonitor sharedInstance] startMonitor];

加入测试案例:

在tableView中sleep,设置了一个tap事件,方便后面单次点击测试

- (void)initTableView {
    UITableView *tableView = [[UITableView alloc] initWithFrame:self.view.frame 
        style:UITableViewStylePlain];
    tableView.dataSource = self;
    tableView.delegate = self;
    [tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"identifier"];
    [self.view addSubview:tableView];
    
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] 
        initWithTarget:self action:@selector(onTapTableView)];
    [tableView addGestureRecognizer:tap];
}

- (void)onTapTableView {
    [NSThread sleepForTimeInterval:3];
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 1000;
}

- (UITableViewCell *)tableView:(UITableView *)tableView 
    cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView 
        dequeueReusableCellWithIdentifier:@"identifier" forIndexPath:indexPath];
    cell.textLabel.text = @"我就测试一下";
    if (indexPath.row % 10 == 0) {
        [NSThread sleepForTimeInterval:1];
    }
    return cell;
}

打印效果图如下所示,可以看到效果非常nice

image.png

最后

可以参考着源码查看理解,这里代码粘了一部分,这边是runloop的应用之一了,下一章介绍通过runloop加载任务(最多的是大图)