DispatchAsync到主队列无法用于判断列表刷新完成

背景

一直以来都有博客说明以下代码能够获得列表reloadData完成的时机,但实际上我在工作中遇到了以下代码不生效的情况。另外,stack overflow中也有人遇到了与我有相同的问题。

[self.tableView reloadData];
dispatch_async(dispatch_get_main_queue(), ^{
  //列表刷新完成
});
复制代码

因此,本篇文章通过分析runloop的源码和UI刷新时机来说明为什么以上代码有时生效有时又不生效。

分析

问题说明

代码1

代码如下:

[self.tableView reloadData];
NSLog(@"reloadData");
dispatch_async(dispatch_get_main_queue(), ^{
  NSLog(@"dispatch_get_main_queue");
});
复制代码

输出如下,在reloadData后紧接着输出了mainQueue的log,接着又经历了一个runloop循环,直到kCFRunLoopBeforeWaiting时触发了cellForRow。显然,可以发现这段代码是不能用于判断列表刷新完成的。

kCFRunLoopAfterWaiting
kCFRunLoopBeforeTimers
kCFRunLoopBeforeSources
reloadData
dispatch_get_main_queue
kCFRunLoopBeforeTimers
kCFRunLoopBeforeSources
kCFRunLoopBeforeWaiting
cellForRowAtIndexPath
复制代码

但有时候,通过点击一个按钮来调用reloadData,它的输出如下。可以发现,这段代码又很神奇的能判断列表刷新完成了。

kCFRunLoopAfterWaiting
kCFRunLoopBeforeTimers
kCFRunLoopBeforeSources
reloadData
cellForRowAtIndexPath
dispatch_get_main_queue
kCFRunLoopBeforeTimers
kCFRunLoopBeforeSources
kCFRunLoopBeforeWaiting
复制代码

代码2

接着再看一段代码如下,为了方便,我们简称外层的block为block1,内层block为block2。

//block1
dispatch_async(dispatch_get_main_queue(), ^{
  NSLog(@"dispatch_get_main_queue_1");
  [self.tableView reloadData];
  NSLog(@"reloadData");
  //block2
  dispatch_async(dispatch_get_main_queue(), ^{
    NSLog(@"dispatch_get_main_queue_2");
  });
});
复制代码

输出如下,block1中调用了reloadData,接着循环runloop,走到了beforeWaitding阶段开始触发列表cellForRow。之后runloop进入休眠,在休眠过后执行派发到主队列的block2。从log中可以得到一个结论,那就是在block2中列表确实刷新完成了。

kCFRunLoopAfterWaiting
kCFRunLoopBeforeTimers
kCFRunLoopBeforeSources
dispatch_get_main_queue_1
reloadData
kCFRunLoopBeforeTimers
kCFRunLoopBeforeSources
kCFRunLoopBeforeWaiting
cellForRowAtIndexPath
kCFRunLoopAfterWaiting
dispatch_get_main_queue_2
复制代码

Runloop源码分析

在解释上述问题前,先需要回顾一遍runloop的源码,其伪代码如下。

static int32_t __CFRunLoopRun(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFTimeInterval seconds, Boolean stopAfterHandle, CFRunLoopModeRef previousMode) {
  do {
    //通知监听kCFRunLoopBeforeTimers的observer
    __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers);

    //通知监听kCFRunLoopBeforeSources的observer
    __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources);

    //调用添加到runloop的block
    __CFRunLoopDoBlocks(rl, rlm);

    //调用source0
    Boolean sourceHandledThisLoop = __CFRunLoopDoSources0(rl, rlm, stopAfterHandle);
    if (sourceHandledThisLoop) {
        __CFRunLoopDoBlocks(rl, rlm);
    }

    if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) {
      //如果有CGD派发到主队列的任务可以消费,goto到handle_msg来跳过runloop休眠
      if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) {
        goto handle_msg;
      }
    }
    
    didDispatchPortLastTime = false;

    //通知监听kCFRunLoopBeforeWaiting的observer
    __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting);

    //runloop休眠
    __CFRunLoopSetSleeping(rl);

    ...

    //被唤醒

    //唤醒后,通知监听kCFRunLoopAfterWaiting的observer
    __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting);
    
    //刚刚的goto定义在这里
    handle_msg:;

    if (MACH_PORT_NULL == livePort) {
      //啥也不做
    } else if (livePort == rl->_wakeUpPort) {
      //啥也不做
    } else if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort) {
        //处理Timer事件
        if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) {
          __CFArmNextTimerInMode(rlm, rl);
      }
    } else if (livePort == dispatchPort) {
      //处理dispatch到主队列的事件
      __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
      didDispatchPortLastTime = true;
    } else {
      //处理source1
      __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply)
    }

  } while (...)

}
复制代码

runloop源码最重要的逻辑在于do-while循环,主要逻辑描述如下:

  1. 通知kCFRunLoopBeforeTimers
  2. 通知kCFRunLoopBeforeSources
  3. 执行Sources0
  4. 若主队列有任务且上一次循环没有处理过派发到主队列的任务,则跳转到9
  5. 通知kCFRunLoopBeforeWaiting
  6. 休眠
  7. 被唤醒
  8. 通知kCFRunLoopAfterWaiting
  9. 条件判断
    1. 若因timer唤醒,处理timer任务
    2. 若因dispatch唤醒,处理派发到主队列的任务
    3. 若因source1唤醒,处理source1事件

太长不看版:

CellForRow调用时机

CATransaction注册了runloop的observer监听kCFRunLoopBeforeWaiting时机,会在该阶段调用commit触发UI更新。列表的cellForRow代理会在CATransaction的commit方法中触发。

另外,runloop由source1唤醒后会继续在source0中处理任务,比如说处理手势任务。如下Trace,在这个过程中,UIKit有些情况下会调用CATransaction的flush方法来触发UI刷新,因此source0中也有可能调用cellForRow。

通过查看反汇编UIKitCore代码,可以看出来在某种情况下会触发CATransaction的flush方法。

问题解答

代码1

在代码1中,reloadData会在source0事件中触发,紧接着runloop会判断是否有GCD的主队列任务可以处理,若可以处理则会直接goto去处理,这样就跳过了beforeWaiting和休眠,主队列任务也就在cellForRow之前执行。

处理GCD主队列任务后有设置标识符didDispatchPortLastTime为true,下次处理完source0后会判断标识符,若为true则不能直接跳过再次去处理GCD任务了。这个逻辑很好理解,可以避免当CGD主队列一直有任务时,runloop循环会一直去处理。

在代码1中,若reloadData调用后,由于某种原因UIKit触发了CATransaction的flush方法,那么会同步调用cellForRow,此时GCD主队列任务一定会在列表刷新完成后触发。

代码2

在代码2中,runloop处理完source0,检测到GCD主队列有任务,因此直接goto到休眠后处理GCD主队列任务,在block1中触发reloadData。之后由于本次loop唤醒后处理过GCD主队列,因此在处理完source0后不能继续goto来跳过休眠,而是走到了beforeWaiting,触发了列表的cellForRow,之后runloop休眠。在休眠后,会继续处理GCD主队列任务,此时block2肯定在cellForRow之后,因此可以判断列表刷新完成。

结论

列表存在同步刷新和runloop的beforeWaiting时机刷新两种情况,GCD主队列任务存在跳过beforeWaiting时机直接处理和等待休眠后处理两种情况,因此在实际开发中用GCD判断列表刷新完成有时生效有时失效。

猜你喜欢

转载自juejin.im/post/7018001623225991182