View的绘制与子线程更新UI

208 阅读6分钟

1. 前言:DecorView 与 ViewRootImpl 的关系

在 Android 系统中,Activity 启动时会创建一套完整的 UI 树,其中最顶层的视图为 DecorView。而 DecorView 的父容器并不是普通的 ViewGroup,而是系统内部的 ViewRootImpl。理解二者之间的关系,是我们剖析 Activity 界面绘制原理及子线程更新 UI 的关键起点。

2. ViewRootImpl 何时创建及与 DecorView 何时绑定

2.1. ViewRootImpl 的创建时机

ViewRootImpl 是在 Activity 的窗口(Window)被创建并设置内容视图时,由 WindowManager 或其实现类(例如 PhoneWindow)内部调用 setContentView 等方法时创建的。它承载了该窗口的底层绘制、事件分发等核心逻辑。

2.2. ViewRootImpl 与 DecorView 绑定的时机

当我们调用 Activity.setContentView(...) 时,系统会在内部将 DecorView 作为根视图,并通过 ViewRootImpl.setView(DecorView, ...) 建立二者的联系。也就是说,DecorView被最终添加到窗口的那一刻ViewRootImplDecorView 才形成实际绑定关系。

image.png

2.3. 与 Activity 生命周期的关系

ViewRootImpl 的创建和绑定,通常发生在 ActivityonCreate 之后、onResume 之前或之中(视系统版本和源码实现而定)。

  • onResume 之前,系统会完成基本的 UI 初始化,但此时还没开始真正的 MeasureLayout 流程。这也是为什么在 onResume 阶段无法获取到准确的 View 宽高,因为整个视图的测量和布局往往要到 onResume 之后或接近末尾 才会触发。

下面一幅图总结一下这几个问题的整体执行时序:

  1. ViewRootImpl何时创建?
  2. 何时ViewRootImpl与DecorView建立联系的?
  3. 它的创建与绑定与Activity的生命周期有关系吗?是判断View何时再子线程更新报错的重要步骤。 image.png

从上边这个示意图也能明白为什么在 onResume 中无法获取 View 的宽高了。因为此时还没有开始测量,另外还需要注意 schedulTraversals 这个方法与上述的流程不是在一个消息中执行的。

3. View 的绘制流程

3.1. Measure、Layout、Draw 的过程

View 的绘制分为 Measure、Layout、Draw 三大步骤,每一步都由 ViewRootImpl 负责调度和触发。当系统调用 ViewRootImpl.performTraversals() 时,会依次执行:

  1. measure():计算每个 View 的大小与位置需求;
  2. layout():将各个子 View 实际摆放到父容器中的具体坐标;
  3. draw():最终根据测量与布局结果进行绘制,包括背景、内容以及子视图等。

20241212-124012.jpg

3.2. 多次测量的典型场景示例(Dialog 二次测量)

有时一个 Dialog 或某个自定义布局需要根据内部控件的大小再次调整自身尺寸,这时往往会触发二次或多次测量。对于Dialog,在窗口尺寸最开始不确定的情况下,需要一次测window,一次测量控件树。例如:

  1. 先测量 Window 大小;
  2. 再根据内部控件测量结果,动态调整整体布局并再次测量。

3.3. 为什么在 onResume 中无法获取 View 宽高

onResume 时,虽然窗口的基本框架已经创建,但还未走到真正的 布局与绘制 阶段,下图就是onResume以及核心方法执行的顺序,从图中可以看到,UI刷新其实是在后边的消息里边处理的,所以 View.getWidth()getHeight() 往往还拿不到最终的值。更准确的时机应当在 布局已完成(例如 View.post() 回调或 ViewTreeObserver.addOnGlobalLayoutListener(...))时获取。

image.png

4. 子线程更新 UI 原理

4.1. ViewRootImpl#checkThread 的检查机制

ViewRootImpl 内部为了保证线程安全,提供了 checkThread() 方法,一旦检测到尝试更新 UI 的线程并非创建该 ViewRootImpl 的线程,就会抛出 CalledFromWrongThreadException。这也是常见的「Only the original thread that created a view hierarchy can touch its views.」错误的根源。

void checkThread() {
    Thread current = Thread.currentThread();
    if (mThread != current) {
        throw new CalledFromWrongThreadException(
            "Only the original thread that created a view hierarchy can touch its views."
            + " Expected: " + mThread.getName()
            + " Calling: " + current.getName());
    }
}

子线程更新UI,只有按照下边的流程,触发了ViewRootImpl的requestLayout方法才可能出现线程检查的逻辑: screenshot-20241212-124216.png 只要我们绕开android.view.ViewRootImpl#checkThread()的调用,就能实现子线程更新UI。

4.2. 如何“绕开” checkThread 的典型方法

  1. 不触发 requestLayout
    如果在子线程对控件做的修改并不会导致视图布局变更(例如只是更改文本、背景等,但不涉及调用 requestLayout()),那么就不会走到 ViewRootImpl.requestLayout() 从而也不会触发 checkThread()
  2. 在子线程创建独立的 ViewRootImpl
    只要 ViewRootImpl 的创建线程与当前线程一致,就不受限制。例如使用 WindowManager.addView(...) 并在子线程中 Looper.prepare() 处理消息循环,从而在子线程管理该 View。

4.3. 在子线程创建 ViewRootImpl 的示例

@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_add_view_without_main_thread)

    thread {
        Looper.prepare() // 必须,因为更新UI是发送的消息,需要有线程能够处理这些任务。
        val button = Button(context = this)
        button.setBackgroundColor(Color.MAGENTA)
        button.text = "I will be added on child thread."
        button.setOnClickListener {
            (it as Button).text = "${Thread.currentThread().name} ${SystemClock.uptimeMillis()}"
        }
        windowManager.addView(button, WindowManager.LayoutParams().apply {
            this.width = WindowManager.LayoutParams.WRAP_CONTENT
            this.height = WindowManager.LayoutParams.WRAP_CONTENT
        })

        Looper.loop()
    }
}

通过上述方式,子线程将持有自己的 ViewRootImpl,从而可以在子线程内安全更新该 View 层次结构。

5. 实战案例:子线程更新 UI 的多种姿势

5.1. 示例一:连续两次触发 requestLayout

textView.setOnClickListener { view ->
    // 第一次在主线程触发,打上“需要布局”的标记,但由于在布局中,setFlag 后不会继续走到底层
    view.requestLayout()
    thread {
        // 子线程更新仅做小范围的 UI 变更
        textView.text = "onCreate!"
    }
}

通过连续两次触发requestLayout,第一次在主线程调用 it.requestLayout(),会触发一次绘制流程,会让android.view.ViewRootImpl#requestLayout内部的mHandlingLayoutInLayoutRequest变为true,然后子线程再触发一次更新,这样就能够通过不进入android.view.ViewRootImpl#requestLayout的内部逻辑绕开这个限制,实现子线程更新UI。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_wrap_sample)

    val textView: TextView = findViewById<TextView>(R.id.textView)
    textView.setOnClickListener { it: View ->
        it.requestLayout()
        thread {
            textView.text = "onCreate!"
        }
    }
}

在这类场景下,第二次子线程的调用并没有真正触发 ViewRootImpl.requestLayout(),这是为什么呢?这就需要看View的requestLayout方法了,它里边有下边的逻辑:

@CallSuper
public void requestLayout() {
    if (isRelayoutTracingEnabled()) {
        Trace.instantForTrack(TRACE_TAG_APP, "requestLayoutTracing",
                mTracingStrings.classSimpleName);
        printStackStrace(mTracingStrings.requestLayoutStacktracePrefix);
    }

    if (mMeasureCache != null) mMeasureCache.clear();

    if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == null) {
        // Only trigger request-during-layout logic if this is the view requesting it,
        // not the views in its parent hierarchy
        ViewRootImpl viewRoot = getViewRootImpl();
        if (viewRoot != null && viewRoot.isInLayout()) {
            if (!viewRoot.requestLayoutDuringLayout(this)) {
                return;
            }
        }
        mAttachInfo.mViewRequestingLayout = this;
    }

    mPrivateFlags |= PFLAG_FORCE_LAYOUT;
    mPrivateFlags |= PFLAG_INVALIDATED;

    if (mParent != null && !mParent.isLayoutRequested()) { // 只要添加上边的标记mParent.isLayoutRequested()就会返回true
        mParent.requestLayout();
    }
    if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
        mAttachInfo.mViewRequestingLayout = null;
    }
}

因为前一次调用还处于「布局中」,相关的 flag 状态已经设置,实际不会再次进入 requestLayout() 的深层逻辑。mPrivateFlags的状态值在requestLayout的时候会添加上PFLAG_FORCE_LAYOUT的flag,有这个flag表示View会在下一次布局的时候进行布局操作,在layout之后,这个flag会被清除。在上边的例子中,我们通过it.requestLayout()会让这个View和他所有的父View都打上这个标记:

screenshot-20241212-131242.png

5.2. 示例二:仅更新文本且不触发布局变更

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_wrap_sample)

    val textView: TextView = findViewById<TextView>(R.id.textView)
    textView.setOnClickListener { it: View ->
        thread {
            textView.text = "onCreate!"
        }
    }
}
// 布局文件:
<TextView
   android:layout_gravity="center"
   android:id="@+id/textView'
   android:layout_width="10@dp"
   android:layout_height="10@dp"                                                                  android:text="Hello"/>

如果仅仅是 invalidate() 或只更新文本内容,在大多数情形下不会导致布局尺寸变化,从而不会调用到 checkThread()。因此也不会报错。

5.3. 示例三:硬件加速方式更新 UI

在硬件加速的⽀持下,如果控件只是进⾏了 invalidate() ,⽽没有触发 requestLayout() 是 不会触发 ViewRootImpl#checkThread() 的。

textView.text = "Just update text"

5.4. 示例四:使用 SurfaceView 或硬件加速方式更新 UI

使用 SurfaceView 时,绘制过程独立于主线程进行,对画面的更新可在子线程完成;不过,这属于更底层的图形体系,通常需要配合 SurfaceHolderCanvas 等 API 来实现。