22、View的线程与绘制:UI线程与绘制的关系、Handler与Looper在绘制中的作用、Choreographer与帧同步

好,咱们今天聊一个特别有意思的话题——View的线程与绘制

说实话,我刚开始做Android开发那会儿,对“UI线程”这个概念的理解特别肤浅。就觉得“哦,不能在子线程更新UI”,仅此而已。直到后来遇到一个线上问题——列表滑动卡顿,排查了半天,发现是某个同事在子线程里疯狂发Message更新UI,导致主线程Looper积压了上百个消息……嗯,从那以后,我才真正开始研究UI线程和绘制到底是怎么配合的。

今天我就把这块儿掰开了讲清楚。咱们分三个部分:UI线程与绘制的关系Handler与Looper在绘制中的作用、以及Choreographer与帧同步

UI线程与绘制的关系

先问一个问题:为什么Android要求必须在UI线程更新View?

说白了,因为View的绘制不是线程安全的。你想想看,如果两个线程同时修改同一个View的坐标,那渲染出来的画面会是什么鬼样子?所以Android设计了一个简单的规则——所有对View的操作,都必须在同一个线程里串行执行。这个线程就是UI线程,也叫主线程。

但这里有个容易混淆的点:UI线程不等于绘制线程

什么意思呢?UI线程负责处理输入事件、执行布局、触发绘制。但真正的“画”这件事,是由RenderThread(渲染线程)来完成的。UI线程只是把绘制指令打包好,交给RenderThread去执行。这样UI线程就能快速释放,去处理下一帧的输入。

我在项目中遇到过一个问题:某个页面打开特别慢,用Systrace一看,UI线程上有一个耗时操作占了200ms。但奇怪的是,页面上的动画却依然流畅。为什么?因为动画的绘制已经交给RenderThread了,UI线程虽然卡,但只要它还能发指令,RenderThread就能继续画。

核心结论:UI线程是绘制的“指挥官”,RenderThread是“执行者”。指挥官可以换,但执行者不能停。

Handler与Looper在绘制中的作用

好,那UI线程是怎么工作的呢?答案就是Handler + Looper + MessageQueue这套机制。

每个UI线程都有一个Looper,Looper里有一个死循环,不停地从MessageQueue里取消息。取到消息后,交给Handler去处理。处理完了,再取下一条。

绘制指令也是以消息的形式发到MessageQueue里的。比如你调用了invalidate(),最终会通过ViewRootImpl.scheduleTraversals()向主线程的Handler发送一个DO_TRAVERSAL消息。Looper取出这个消息后,就会执行performTraversals(),触发measure、layout、draw。

我个人的习惯是,在分析卡顿问题时,一定会先看主线程的MessageQueue里有没有积压消息。如果积压了,那绘制消息就得排队等,帧率自然就掉下来了。

// 伪代码:ViewRootImpl中的scheduleTraversals
void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        // 向主线程Handler发送一个同步屏障消息
        mChoreographer.postCallback(
            Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
    }
}

注意这里有个关键点:同步屏障。Handler的消息分两种:同步消息和异步消息。同步屏障的作用是让Looper优先处理异步消息,跳过所有同步消息。绘制消息就是异步消息,所以它能插队到前面去执行。这就是为什么即使主线程有大量同步消息在排队,绘制依然能及时响应的原因。

避坑指南:我曾经在项目里用Handler发送了大量延迟消息(比如定时刷新UI),结果导致绘制消息被阻塞。后来发现,延迟消息虽然是异步的,但如果MessageQueue里积压太多,Looper处理不过来,绘制还是会受影响。所以,不要在主线程上发太多延迟消息

Choreographer与帧同步

接下来聊Choreographer。这个名字很有意思,直译过来是“编舞者”。它干的事也确实像编舞——协调各个模块,让它们按固定的节奏(帧率)来工作。

Choreographer的核心作用是帧同步。它监听VSync信号(垂直同步信号),每次收到VSync,就通知各个回调去执行。这些回调包括:

  • CALLBACK_INPUT:处理输入事件
  • CALLBACK_ANIMATION:处理动画
  • CALLBACK_TRAVERSAL:处理布局和绘制

这三个回调按顺序执行,而且必须在VSync信号到来后的16.6ms内完成(假设是60fps)。如果超时了,下一帧的VSync信号来了,但上一帧还没画完,就会丢帧。

我画了一张图,帮你理解这个流程:

Choreographer 帧同步流程 VSync信号 Choreographer CALLBACK_INPUT CALLBACK_ANIMATION CALLBACK_TRAVERSAL 绘制 时间 帧1 (0~16.6ms) 帧2 (16.6~33.3ms) 帧3 (33.3~50ms) VSync VSync VSync 如果帧1超时 → 丢帧

从图上你能看到,VSync信号就像一个节拍器,每16.6ms敲一下。Choreographer听到节拍后,依次触发输入处理、动画更新、布局绘制。如果任何一个环节超时,下一帧的VSync来了,但上一帧还没画完,就会丢帧。

那Choreographer是怎么知道VSync信号来了的呢?它通过FrameDisplayEventReceiver来监听底层的VSync事件。这个Receiver本质上是一个Handler,它收到VSync事件后,会调用doFrame()方法,开始执行上面说的三个回调。

// Choreographer.doFrame 简化版
void doFrame(long frameTimeNanos, int frame) {
    // 计算这一帧的预期时间
    long intendedFrameTimeNanos = frameTimeNanos;
    
    // 如果实际执行时间比预期晚了很多,说明丢帧了
    if (frameTimeNanos - intendedFrameTimeNanos > 16.6 * 1e6) {
        // 记录丢帧
        logJank();
    }
    
    // 执行回调
    doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
    doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
    doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
}

这里有个细节:doFrame里会计算实际执行时间和预期时间的差值。如果差值超过16.6ms,就说明丢帧了。这个逻辑就是Android系统检测卡顿的基础。

注意:Choreographer的回调是在主线程执行的。如果你在主线程上做了耗时操作,比如解析JSON、读写文件,那doFrame就会被延迟执行,导致丢帧。我曾经在一个项目里发现,某个页面滑动卡顿,就是因为onScroll回调里做了Bitmap的解码操作。后来把解码放到子线程,卡顿就消失了。

总结一下

好,咱们把今天的内容串起来:

  • UI线程是绘制的指挥官,负责处理输入、布局、触发绘制。真正的绘制由RenderThread执行。
  • Handler + Looper是UI线程的工作引擎。绘制指令通过scheduleTraversals发送到MessageQueue,Looper取出后执行。同步屏障机制保证了绘制消息能优先处理。
  • Choreographer是帧同步的核心。它监听VSync信号,按顺序触发输入、动画、绘制三个回调。任何回调超时都会导致丢帧。

说白了,Android的绘制系统就是一个生产者-消费者模型。VSync是生产者,Choreographer是调度器,UI线程是消费者。只要消费者处理得够快,画面就流畅。一旦消费者卡住了,帧率就掉下来了。

我个人觉得,理解这套机制是优化UI性能的基础。下次你遇到卡顿问题,不妨先看看主线程的MessageQueue里有没有积压消息,再看看Choreographer有没有丢帧。这两个地方查完,基本就能定位到问题了。


公众号:蓝海资料掘金营,微信deep3321