Android UI 线程更新UI也会崩溃???

本文已经受权公众号「鸿洋」原创首发。java

你们好,我是鸿洋。android

上个周末是双休,我决定来颠覆一下你们的认知。bash

在平时的Android开发中,若是一个新手遇到一个这样的错:服务器

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8066)
        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1297)
        at android.view.View.requestLayout(View.java:23147)
复制代码

你做为一只老鸟,嘴角露出一丝微笑:微信

“小兄弟,你这个是没有在UI线程执行UI操做致使的错误,你搞个UI线程的handler.post一下就行了”。网络

可是...app

我今天要说,真是是只有UI线程才能更新UI吗?ide

你做为一只老鸟,确定立马脑子里闪过:oop

我知道你这文章写啥了,又要在Activity#onCreate,去搞个线程执行TextView#setText,而后发现更新成功了,是否是?post

这多年之前我就看过这样的文章,ViewRootImpl还没建立而已。

看大家这么强,我这个文章无法写下去了...

可是我这我的专治各类不服好吧,我换个问题:

UI线程更新UI就不会出现上面的错误了吗?

好了,开讲。

下面是一个应届小哥小奇写需求的故事。

注意本文代码为应届小哥角度所写,为了引出问题及原理,不要随意参考,另外若是尝试复现相关代码,务必看好每个字符,甚至xml里面的属性都很关键。

小哥的需求

需求很简单,就是

  1. 点击一个按钮;
  2. Server会下发一个问题,客户端Dialog展现;
  3. 在Dialog交互回答问题;

是否是很简答。

小哥怒写一波代码:

package com.example.testviewrootimpl;

import androidx.appcompat.app.AppCompatActivity;

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

public class MainActivity extends AppCompatActivity {

    private Button mBtnQuestion;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mBtnQuestion = findViewById(R.id.btn_question);

        mBtnQuestion.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                requestAQuestion();
            }
        });
    }

    private void requestAQuestion() {
        new Thread(){
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 模拟服务器请求,返回问题
                String title = "鸿洋帅气吗?";
                showQuestionInDialog(title);
            }
        }.start();
    }

    private void showQuestionInDialog(String title) {
        
    }
}

复制代码

很简单吧,点击按钮,新启动一个线程去模拟网络请求,结果拿到后,把问题展现在Dialog。

下面开始写Dialog的代码:

public class QuestionDialog extends Dialog {

    private TextView mTvTitle;
    private Button mBtnYes;
    private Button mBtnNo;

    public QuestionDialog(@NonNull Context context) {
        super(context);

        setContentView(R.layout.dialog_question);

        mTvTitle = findViewById(R.id.tv_title);
        mBtnYes = findViewById(R.id.btn_yes);
        mBtnNo = findViewById(R.id.btn_no);

    }

    public void show(String title) {
        mTvTitle.setText(title);
        show();
    }
}
复制代码

很简答,就一个标题,两个按钮。

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">


    <TextView
        android:id="@+id/tv_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="24dp"
        android:textStyle="bold"
        tools:text="鸿洋丑的一匹?鸿洋丑的一匹?鸿洋丑的一匹?鸿洋丑的一匹?" />

    <Button
        android:id="@+id/btn_yes"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/tv_title"
        android:layout_marginTop="10dp"
        android:text="是的"></Button>

    <Button
        android:id="@+id/btn_no"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignTop="@id/btn_yes"
        android:layout_alignParentRight="true"
        android:layout_marginLeft="20dp"
        android:layout_toRightOf="@id/btn_yes"
        android:text="不是"></Button>

</RelativeLayout>
复制代码

而后咱们在showQuestionInDialog让它show出来。

private void showQuestionInDialog(String title) {
    QuestionDialog questionDialog = new QuestionDialog(this);
    questionDialog.show(title);
}
复制代码

大家猜结果怎么着...

崩溃了...

第一次崩溃

应届生小齐迎来了第一次工做中的崩溃...

咱们先停下来。

上面的代码很简单吧,那么我想问各位为何会崩溃呢?凭各位多年的经验。

猜测:

new Thread(){

	puublic void run(){
		show("...");
	}

}

public void show(String title) {
    mTvTitle.setText(title);
    show();
}
复制代码

上面new Thread模拟数据,没有切到UI线程就show Dialog了,并且执行了TextView#setText,确定是在非UI线程更新UI致使的。

颇有道理,毫不是一我的会这么猜想吧。

下面咱们看真正报错的缘由:

Process: com.example.testviewrootimpl, PID: 10544
java.lang.RuntimeException: Can't create handler inside thread Thread[Thread-2,5,main] that has not called Looper.prepare() at android.os.Handler.<init>(Handler.java:207) at android.os.Handler.<init>(Handler.java:119) at android.app.Dialog.<init>(Dialog.java:133) at android.app.Dialog.<init>(Dialog.java:162) at com.example.testviewrootimpl.QuestionDialog.<init>(QuestionDialog.java:17) at com.example.testviewrootimpl.MainActivity.showQuestionInDialog(MainActivity.java:46) at com.example.testviewrootimpl.MainActivity.access$100(MainActivity.java:10) at com.example.testviewrootimpl.MainActivity$2.run(MainActivity.java:40) 复制代码

Can't create handler inside thread Thread[Thread-2,5,main] that has not called Looper.prepare()

虽然猜错了,可是依旧有点熟悉的感受,之前你们在子线程弹toast的时候是否是见过相似的错误。

做为一个老鸟,遇到这个问题,确定是不在UI线程弹Dialog,可是应届小哥就不一样了。

瞎猫遇到死耗子

小哥,直接把报错信息扔进Google,不,百度:

点开第一篇CSDN的博客:

而后迅速触类旁通,在刚才show Dialog的方法中增长:

private void showQuestionInDialog(String title) {
    Looper.prepare(); // 增长部分
    QuestionDialog questionDialog = new QuestionDialog(this);
    questionDialog.show(title);
    Looper.loop(); // 增长部分
}
复制代码

解决问题就是这么简单,嘴角露出一丝对本身满意的笑容。

再次运行App...

这里你们再停一下。

凭各位多年的经验,我想再问一句,此次还会崩溃吗?

会吗?

猜测:

这代码治标不治本,仍是没有在UI线程执行相关代码,仍是会崩,而却刚才的show里面还有TextView#setText操做

有点道理。

看一下运行效果:

没有崩溃...

是否是有一丝的郁闷?

不要紧,做为拥有多年经验的老鸟,总能立马想到解释的理由:

你们都知道在Activity#onCreate的时候,咱们开个线程去执行Text#setText也不会崩溃,缘由是ViewRootImpl那时候还没初始化,因此此次没崩溃也是一个缘由。

对应源码解释是这样的:

# Dialog源码
public void show() {
    
    // 省略一堆代码
    mWindowManager.addView(mDecor, l);
}
复制代码

咱们首次建立的Dialog,第一次调用show方法,内部确实会执行mWindowManager.addView,这个代码会执行到:

# WindowManagerImpl
@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
    applyDefaultToken(params);
    mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
}

复制代码

这个mGlobal对象是WindowManagerGlobal,咱们看它的addView方法:

# WindowManagerGlobal 
public void addView(View view, ViewGroup.LayoutParams params,
    Display display, Window parentWindow) {
	// 省略了一堆代码
    root = new ViewRootImpl(view.getContext(), display);
    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);
    } catch (RuntimeException e) {
        // BadTokenException or InvalidDisplayException, clean up.
        if (index >= 0) {
            removeViewLocked(index, true);
        }
        throw e;
    }
}
复制代码

果真立马有new ViewRootImpl的代码,你看ViewRootImpl没有建立,因此这和Activity那个是一个状况。

好像有那么点道理哈...

咱们继续往下看。

应届小哥要继续作需求了。

一个隐藏的问题

接下来的需求很奇怪,就是当询问"鸿洋帅气吗?"的时候,若是你点击不是,那么Dialog不消失,在问题的末尾再加一个?号,如此循环,永不关闭。

这难不倒咱们的小哥:

mBtnNo.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {

        String s = mTvTitle.getText().toString();
        mTvTitle.setText(s+"?");
    }
});
复制代码

运行效果:

很完美。

若是我问,你以为这个代码有问题吗?

你往上看了几眼,就这两行代码有个鸡儿问题,可能有空指针?

固然不是。

我稍微修改一下代码:

mBtnNo.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {

        String s = mTvTitle.getText().toString();
        mTvTitle.setText(s+"?");


        boolean uiThread = Looper.myLooper() == Looper.getMainLooper();
        Toast.makeText(getContext(),"Ui thread = " + uiThread , Toast.LENGTH_LONG).show();
    }
});
复制代码

每次点击的时候,我弹了个Toast,输出当前线程是否是UI线程。

看下效果:

发现问题了吗?

出乎本身的意料吗?

咱们在非UI线程一直在更新TextView的text。

这个时候,你不能跟我扯什么ViewRootImpl尚未建立了吧?

别急...

还有更刺激的。

更刺激的事情

我再改一下代码:

private Handler sUiHandler = new Handler(Looper.getMainLooper());

public QuestionDialog(@NonNull Context context) {
    super(context);

    setContentView(R.layout.dialog_question);


    mBtnNo.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {

            sUiHandler.post(new Runnable() {
                @Override
                public void run() {
                    String s = mTvTitle.getText().toString();
                    mTvTitle.setText(s+"?");
                }
            });
        }
    });

}
复制代码

我搞了个UI线程的handler,而后post一下Runnable,确保咱们的TextView#setText在UI线程执行,严谨而又优雅。

再停一下,以各位多年经验,此次会崩溃吗?

按照我写博客的套路,此次确定是演示崩溃呀,否则博客怎么往下写。

好像是这个道理...

咱们跑一下效果:

点击了几下,没崩...

// 配图:小朋友,你是否是有不少问号。

做为拥有多年经验的老鸟,总能立马想到解释的理由:

UI线程更新固然不会崩溃呀(言语中有一丝不自信)。

是吗?

咱们多点击几回:

崩溃了...

可是刚才在没有添加UiHandler.post以前可没有崩溃哟。

这个结果,我都得把代码露出来了,怕大家说我演大家...

好了,再停一停。

我又要问你们一个问题了,此次你猜是什么崩溃?

是否是求我别搞大家了,直接揭秘吧。

com.example.testviewrootimpl E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.example.testviewrootimpl, PID: 18323
    android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8188)
        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1421)
        at android.view.View.requestLayout(View.java:24434)
        at android.view.View.requestLayout(View.java:24434)
        at android.view.View.requestLayout(View.java:24434)
        at android.view.View.requestLayout(View.java:24434)
        at android.widget.RelativeLayout.requestLayout(RelativeLayout.java:380)
        at android.view.View.requestLayout(View.java:24434)
        at android.widget.TextView.checkForRelayout(TextView.java:9667)
        at android.widget.TextView.setText(TextView.java:6261)
        at android.widget.TextView.setText(TextView.java:6089)
        at android.widget.TextView.setText(TextView.java:6041)
        at com.example.testviewrootimpl.QuestionDialog$1$1.run(QuestionDialog.java:38)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.app.ActivityThread.main(ActivityThread.java:7319)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:934)
复制代码

那个熟悉的身影回来了:

Only the original thread that created a view hierarchy can touch its views.

复制代码

可是!

可是!

此次但是在切换到UI线程抛出来的。

对应我开头的灵魂拷问:

UI线程更新UI就不会出现上面的错误了吗?

是否是在一股懵逼又刺激的感受中没法自拔...

还有更刺激的事情...嗯,篇幅问题,本篇咱们就到这了,更刺激的事情咱们下次再写。

别怕,没完,我总得告诉大家为何吧。

小作揭秘

其实这一切的根源都在于咱们长久的一个错误的概念。

就是UI线程才能更新UI,这是不对的,为何这么说呢?

Only the original thread that created a view hierarchy can touch its views.

复制代码

这个异常是在ViewRootImpl里面抛出的对吧,咱们再次来审视一下这段代码:

void checkThread() {
    if (mThread != Thread.currentThread()) {
        throw new CalledFromWrongThreadException(
                "Only the original thread that created a view hierarchy can touch its views.");
    }
}
复制代码

其实就几行代码。

咱们仔细看一下,他这个错误信息并非:

Only the UI Thread ... 而是 Only the original thread

对吧,若是真的想强制为Only the Ui Thread,上面的if语句应该写成:

if(UI Thread== Thread.currentThread()){}

复制代码

而不是mThread。

根本缘由说完了。

我再带你们看下源码解析:

这个mThread是什么?

是ViewRootImpl的成员变量,咱们重点应该关注它何时赋值的:

public ViewRootImpl(Context context, Display display) {
    mContext = context;
    
    mThread = Thread.currentThread();

}
复制代码

在ViewRootImpl构造的时候赋值的,赋值的就是当前的Thread对象。

也就是说,你ViewRootImpl在哪一个线程建立的,你后续的UI更新就须要在哪一个线程执行,跟是否是UI线程毫无关系。

对应到上面的例子,咱们中间也有段贴源码的地方。

刚好说明了:

Dialog的ViewRootImpl,实际上是在执行show()方法的时候建立的,而咱们的Dialog的show放在子线程里面,因此致使后续View更新,执行到ViewRootImpl#checkThread的时候,都在子线程才能够。

这就说明了,为何咱们刚才切到UI线程去执行TextView#setText为啥崩了。

这里有个思考题,注意咱们上面演示的时候,切到UI线程执行setText没有立马崩溃,而是执行了好几回以后才崩溃的,为何呢?本身想。

你们可能还有个一问题:

ViewRootImpl怎么和View关联起来的

其实咱们看报错堆栈很好找到相关代码:

com.example.testviewrootimpl E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.example.testviewrootimpl, PID: 18323
    android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8188)
        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1421)
        at android.view.View.requestLayout(View.java:24434)

复制代码

报错的堆栈都是由View.requestLayout触发到ViewRootImpl的。

咱们直接看这个方法:

public void requestLayout() {
    
    if (mParent != null && !mParent.isLayoutRequested()) {
        mParent.requestLayout();
    }

复制代码

注意里面这个mParent变量,它的类型是ViewParent接口。

见名知意。

我要问你一个View的mParent是什么,你确定会回答是它的父View,也就是个ViewGroup。

对,没错。

public abstract class ViewGroup extends View implements ViewParent{}
复制代码

ViewGroup确实实现了ViewParent接口。

可是还有个问题,一个界面的最最最上面那个ViewGroup它的mParent是谁?

对吧,总不能仍是ViewGroup吧,那岂不是没完没了了。

因此,ViewParent还有另一个实现类,叫作ViewRootImpl。

如今明白了吧。

按照ViewParent的体系,咱们的界面结构是这样的。

嗯,我仍是写坨代码吧:

仍是刚才Dialog,当咱们点击No的时候,咱们打印下ViewParent体系:

mBtnNo.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        printViewParentHierarchy(mTvTitle, 0);

    }
});

private void printViewParentHierarchy(Object view, int level) {
    if (view == null) {
        return;
    }
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < level; i++) {
        sb.append("\t");
    }
    sb.append(view.getClass().getSimpleName());
    Log.d("lmj", sb.toString());

    if (view instanceof View) {
        printViewParentHierarchy(((View) view).getParent(), level + 1);
    }

}
复制代码

很简单,咱们就打印mTbTitle,一直往上的ViewParent体系。

D/lmj: AppCompatTextView
D/lmj: 	RelativeLayout
D/lmj: 		FrameLayout
D/lmj: 			FrameLayout
D/lmj: 				DecorView
D/lmj: 					ViewRootImpl
复制代码

看到没,最底部的是谁。

是它,是它,就是它,咱们的ViewRootImpl。

因此当你的TextView触发requestLayout,会展转到ViewRootImpl的requestLayout,而后再到它的checkThread,而checkThread判断的并不是是UI线程和当前线程对比,而是mThread和当前线程对比。

到这里,我能够结尾了吧。

下一篇我可能要写:Google好像在秀咱们,欢迎关注等文,具体时间未定,思路暂无。

再留个思考题:这篇文章咱们以Dialog为案例,你还能想到别的案例吗?

本文测试设备:Android 29模拟器。

也欢迎关注个人公众号,微信搜索「鸿洋」,拜了个拜!