美文网首页Android开发经验谈Android技术知识Android开发
脑瓜子嗡嗡的。。Android UI 线程更新UI也会崩溃???

脑瓜子嗡嗡的。。Android UI 线程更新UI也会崩溃???

作者: 唐唐_1388 | 来源:发表于2020-07-19 22:03 被阅读0次

    在平时的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一下就好了”。

    但是...

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

    你作为一只老鸟,肯定立马脑子里闪过:

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

    这多年以前我就看过这样的文章,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和当前线程对比。

    到这里,我可以结尾了吧。如果喜欢,请点个关注呗!

    相关文章

      网友评论

        本文标题:脑瓜子嗡嗡的。。Android UI 线程更新UI也会崩溃???

        本文链接:https://www.haomeiwen.com/subject/kjzrkktx.html