Android为什么子线程不能更新UI(一) ?

160 阅读5分钟

为什么子线程不能更新UI

不同场景验证

不少同学在开发时都碰到过如下报错

AndroidRuntime: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. Expected: main Calling: Thread-1

一查代码,原来自己在子线程中去更新UI了界面。比如,我要按下一个Button时,我需要更新button中的text,代码很简单:

Button button = findViewById(R.id.button);
button.setOnClickListener(v -> new Thread(new Runnable() {
    @Override
    public void run() {
        button.setText("update text in a new thread");
    }
}).start());

就会有如下报错:

AndroidRuntime: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. Expected: main Calling: Thread-1 AndroidRuntime: at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:9994) AndroidRuntime: at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:2082) AndroidRuntime: at android.view.View.requestLayout(View.java:27043) ...

但是细心的同学又会发现,有时候在子线程中更新UI又没有问题,这是怎么一回事?来验证下。

package com.zte.oomsimulator;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;

import java.util.ArrayList;
import java.util.List;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        new Thread(new Runnable() {
            @Override
            public void run() {
                Button button = findViewById(R.id.button);
                button.setText("update text in a new thread --- onCreate");
                Log.d(TAG, "run: onCreate, setText successfully");
            }
        }).start();

        Button button = findViewById(R.id.button);
        button.setOnClickListener(v -> new Thread(new Runnable() {
            @Override
            public void run() {
                button.setText("update text in a new thread when button was clicked");
                Log.d(TAG, "run: onClick, setText successfully");
            }
        }).start());

    }

    @Override
    protected void onResume() {
        super.onResume();
        new Thread(new Runnable() {
            @Override
            public void run() {
                Button button = findViewById(R.id.button);
                button.setText("update text in a new thread --- onResume");
                Log.d(TAG, "run: onResume, setText successfully");
            }
        }).start();
    }
}

按照这个写法,实测onCreate和onResume中的更新UI动作不会有问题,只有在Button中开启的子线程更新UI会报错。问题出在哪里?

子线程更新UI报错

根据报错的堆栈我们可以追溯到,报错的地方是在ViewRootImpl.checkThread, 来看下源代码

// framework/base/core/java/android/view/ViewRootImpl.java
	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());
        }
    }

    public ViewRootImpl(@UiContext Context context, Display display, IWindowSession session,
            WindowLayout windowLayout) {
        mContext = context;
        mWindowSession = session;
        mWindowLayout = windowLayout;
        mDisplay = display;
        mBasePackageName = context.getBasePackageName();
        mThread = Thread.currentThread();	// mThread在ViewRootImpl构造函数中就被赋值了。
		...

checkThread检验当前线程否和mThread一致。如果不一致则会抛出CalledFromWrongThreadException错误。 mThread是什么?在ViewRootImpl构造函数中就已经被赋值了。因此只需要确认在哪里创建的ViewRootImpl即可。

直接在工程中查找 new ViewRootImpl,WindowManagerGlobal#addView中有新建ViewRootImpl,

// core/java/android/view/WindowManagerGlobal.java
    public void addView(View view, ViewGroup.LayoutParams params,
            Display display, Window parentWindow, int userId) {
		...

        ViewRootImpl root;
        View panelParentView = null;

        synchronized (mLock) {
            // Start watching for system property changes.
			...

            if (windowlessSession == null) {
                root = new ViewRootImpl(view.getContext(), display);
            } else {
                root = new ViewRootImpl(view.getContext(), display,
                        windowlessSession, new WindowlessWindowLayout());
            }

            view.setLayoutParams(wparams);

            mViews.add(view);
            mRoots.add(root);
            mParams.add(wparams);

            // do this last because it fires off messages to start doing things
            try {
                root.setView(view, wparams, panelParentView, userId);
			...

    }

那么是哪里调用到了WindowManagerGlobal#addView?其实如果熟悉Activity启动流程很容易追溯,在ActivityThread.handleResumeActivity中调用了WindowManagerGlobal#addView,而且 ActivityThread 所在线程正是应用主线程。

综上所述,即可得到在子线程中不可更新UI的结论。

    public void handleResumeActivity(ActivityClientRecord r, boolean finalStateRequest,
            boolean isForward, boolean shouldSendCompatFakeFocus, String reason) {
		...
        // TODO Push resumeArgs into the activity for consideration
        // skip below steps for double-resume and r.mFinish = true case.
        if (!performResumeActivity(r, finalStateRequest, reason)) {	// performResumeActivity -> xxx -> Activity.onResume
            return;
        }
		...
		...
        if (r.window == null && !a.mFinished && willBeVisible) {
			...
            if (a.mVisibleFromClient) {
                if (!a.mWindowAdded) {
                    a.mWindowAdded = true;
                    wm.addView(decor, l);	// WindowManagerGlobal#addView
                    ...
            }

 			...
        }
		...
    }

onResume中子线程更新UI为什么没问题

重新回到ViewRootImpl,理论上只要触发了checkThread,那么就会检验线程的一致性。难道在onResume中的线程操作没有触发checkTread?重新追溯一遍源码看看。

ViewRootImpl.checkThread是由View.requestLayout调用,果然是有条件的,需要满足 mParent != null && !mParent.isLayoutRequested()

// core/java/android/view/View.java
    @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.requestLayout();	// ViewRootImpl.requestLayout -> ViewRootImpl.checkThread
        }
        if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
            mAttachInfo.mViewRequestingLayout = null;
        }
    }

继续查看View的源代码发现,mParent赋值是在assignParent进行的:

// core/java/android/view/View.java
	@UnsupportedAppUsage
    void assignParent(ViewParent parent) {
        if (mParent == null) {
            mParent = parent;
        } else if (parent == null) {
            mParent = null;
        } else {
            throw new RuntimeException("view " + this + " being added, but"
                    + " it already has a parent");
        }
    }

那是在哪里调用了View#assignParent?在上面 WindowManagerGlobal#addView的源码中,最后调用了root.setView,来看下ViewRootImpl.setView这部分代码:


    public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,
            int userId) {
        synchronized (this) {
				...
                view.assignParent(this);	// 设置View中的mParent为ViewRootImpl实例
                mAddedTouchMode = (res & WindowManagerGlobal.ADD_FLAG_IN_TOUCH_MODE) != 0;
                mAppVisible = (res & WindowManagerGlobal.ADD_FLAG_APP_VISIBLE) != 0;
				...
            }
        }
    }

巧了,正是在此设置了View的mParent。到这里基本上就能解释为什么在onResume中开子线程更新UI不会有任何问题:因为在生命周期执行到onResume时,此时的mParent为空,不会调用到checkThread做线程一致性校验,所以可以开子线程更新UI。

为什么要设计成这种只能在主线程更新UI的模式?

首先,个人认为要从技术角度实现多线程更新UI也是完全可以实现的,但可能有如下弊端:

  1. 会引起并发冲突,Android UI刷新是一个高频操作,如果允许多个线程直接操作 UI,可能会导致竞态条件或者不一致的状态,结果无法预测。解决办法只能做同步处理,若强制要求所有 UI 操作加锁(如 synchronized),虽然能解决线程安全问题,但会增加代码复杂度和性能开销(锁竞争、死锁风险),同步成本太高了。
  2. 保证事件的一致性:
    • 用户事件需要按序处理:用户的点击、滑动等输入事件是按顺序发生的。主线程通过 消息队列(MessageQueue) 机制(如 LooperHandler)确保这些事件按 FIFO(先进先出)顺序处理。
    • 避免界面状态混乱:如果允许其他线程直接更新 UI,可能打断主线程正在处理的逻辑(例如:后台线程突然修改某个控件的尺寸,而主线程正在计算布局),导致界面渲染错误。
  3. 性能优化:集中化 UI 渲染
    • 渲染管线单线程化:Android 的界面渲染流程(测量、布局、绘制)本身是单线程的。系统需要在一个统一的上下文中(如 Choreographer)协调这些操作,以保证渲染效率。
    • 减少多线程切换开销:频繁的线程切换(Context Switching)会浪费 CPU 资源,而主线程集中处理 UI 更新可以降低这种开销。