10

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

 4 years ago
source link: https://blog.csdn.net/lmj623565791/article/details/105624391
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

大家好,我是鸿洋。

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

在平时的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);
}

你们猜结果怎么着…

崩溃了…

6riMBnv.jpg!web

第一次崩溃

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

我们先停下来。

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

猜想:

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,不,百度:

3UZvyqF.jpg!web

点开第一篇CSDN的博客:

iMr632j.jpg!web

然后迅速举一反三,在刚才show Dialog的方法中增加:

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

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

再次运行App…

这里大家再停一下。

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

会吗?

猜想:

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

有点道理。

看一下运行效果:

mq2YbqZ.gif

没有崩溃…

是不是有一丝的郁闷?

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

大家都知道在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+"?");
    }
});

运行效果:

uEFFfuv.gif

很完美。

如果我问,你觉得这个代码有问题吗?

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

当然不是。

我稍微修改一下代码:

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线程。

看下效果:

6BF3Qv2.gif

发现问题了吗?

出乎自己的意料吗?

我们在非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线程执行,严谨而又优雅。

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

按照我写博客的套路,这次肯定是演示崩溃呀,不然博客怎么往下写。

好像是这个道理…

我们跑一下效果:

UVB3Qzr.gif

点击了几下,没崩…

// 配图:小朋友,你是不是有很多问号。

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

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

是吗?

我们多点击几次:

6Fbayyr.gif

崩溃了…

但是刚才在没有添加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模拟器。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK