我感觉我学了一个假的Android...看过鸿洋的文章,脑子里只有卧槽

作者:鸿洋


大家好,我是鸿洋。

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

在平时的Android开发中,如果一个新手遇到一个这样的错:

<code>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)/<code>

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

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

但是...

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

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

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

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

看你们这么强,我这个文章没法写下去了...

但是我这个人专治各种不服好吧,我换个问题:

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

好了,开讲。

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

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

一、小哥的需求

需求很简单,就是:

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

是不是很简单。

小哥怒写一波代码:

<code>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) {     } }/<code>

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

下面开始写Dialog的代码:

<code>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();     } }/<code>

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

<code>                /<code>

然后我们在showQuestionInDialog让它show出来。

<code>private void showQuestionInDialog(String title) {     QuestionDialog questionDialog = new QuestionDialog(this);     questionDialog.show(title); }/<code>

你们猜结果怎么着...


崩溃了...

二、第一次崩溃

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


我们先停下来。

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

猜想:

<code>new Thread(){     puublic void run(){         show("...");     } } public void show(String title) {     mTvTitle.setText(title);     show(); }/<code>

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

很有道理,绝不是一个人会这么猜测吧。


下面我们看真正报错的原因:

<code>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.(Handler.java:207)     at android.os.Handler.(Handler.java:119)     at android.app.Dialog.(Dialog.java:133)     at android.app.Dialog.(Dialog.java:162)     at com.example.testviewrootimpl.QuestionDialog.(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)/<code>

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


虽然猜错了,但是依旧有点熟悉的感觉,以前大家在子线程弹toast的时候是不是见过类似的错误。

作为一个老鸟,遇到这个问题,肯定是不在UI线程弹Dialog,但是应届小哥处理问题的方式就不同了。

瞎猫遇到死耗子

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

点开第一篇CSDN的博客:

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

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

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


再次运行App...

这里大家再停一下。

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


会吗?


猜想:

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


有点道理。

看一下运行效果:

没有崩溃...


是不是有一丝的郁闷?

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

大家都知道在Activity#onCreate的时候,我们开个线程去执行Text#setText也不会崩溃,原因是ViewRootImpl那时候还没初始化,所以这次没崩溃也是这个原因。

对应源码解释是这样的:

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

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

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

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

<code># 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;     } }/<code>

果然立马有new ViewRootImpl的代码,你看ViewRootImpl没有创建,所以这和Activity那个是一个情况。


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


我们继续往下看。

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

四、一个隐藏的问题

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

这难不倒我们的小哥:

<code>mBtnNo.setOnClickListener(new View.OnClickListener() {     @Override     public void onClick(View view) {         String s = mTvTitle.getText().toString();         mTvTitle.setText(s+"?");     } });/<code>

运行效果:

很完美。

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


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


当然不是。

我稍微修改一下代码:

<code>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();     } });/<code>

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


看下效果:

发现问题了吗?

出乎自己的意料吗?


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

这个时候,你不能跟我扯什么ViewRootImpl还没有创建了吧?


别急...

还有更刺激的。

五、更刺激的事情

我再改一下代码:

<code>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+"?");                 }             });         }     }); }/<code>

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

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

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


好像是这个道理...

我们跑一下效果:

点击了几下,没崩...

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

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


是吗?

我们多点击几次:

崩溃了...

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


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

好了,再停一停。

我又要问大家一个问题了,这次你猜是什么崩溃?


是不是求我别搞你们了,直接揭秘吧。

<code>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)/<code>

那个熟悉的身影回来了:

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里面抛出的对吧,我们再次来审视一下这段代码:

<code>void checkThread() {     if (mThread != Thread.currentThread()) {         throw new CalledFromWrongThreadException(                 "Only the original thread that created a view hierarchy can touch its views.");     } }/<code>

其实就几行代码。

我们仔细看一下,他这个错误信息并不是:

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


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

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

而不是mThread。


根本原因说完了。

我再带大家看下源码解析:

这个mThread是什么?

是ViewRootImpl的成员变量,我们重点应该关注它什么时候赋值的:

<code>public ViewRootImpl(Context context, Display display) {     mContext = context;     mThread = Thread.currentThread(); }/<code>

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

也就是说,

你ViewRootImpl在哪个线程创建的,你后续的UI更新就需要在哪个线程执行,跟是不是UI线程毫无关系。


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


恰好说明了:

Dialog的ViewRootImpl,其实是在执行show()方法的时候创建的,而我们的Dialog的show放在子线程里面,所以导致后续View更新,执行到ViewRootImpl#checkThread的时候,都在子线程才可以。

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

这里有个思考题,注意我们上面演示的时候,切到UI线程执行setText没有立马崩溃,而是执行了好几次之后才崩溃的,为什么呢?自己想。


大家可能还有个一问题:

ViewRootImpl怎么和View关联起来的

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

<code>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)/<code>

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


我们直接看这个方法:

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

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

见名知意。


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

对,没错。

<code>public abstract class ViewGroup  extends View  implements ViewParent{}/<code>

ViewGroup确实实现了ViewParent接口。

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


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

所以,ViewParent还有另外一个实现类,叫做ViewRootImpl。

现在明白了吧。


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

嗯,我还是写坨代码吧:

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

<code>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 /<code>

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

<code>D/lmj: AppCompatTextView D/lmj:  RelativeLayout D/lmj:      FrameLayout D/lmj:          FrameLayout D/lmj:              DecorView D/lmj:                  ViewRootImpl/<code>

看到没,最底部的是谁。


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

所以当你的TextView触发requestLayout,会辗转到ViewRootImpl的requestLayout,然后再到它的checkThread,而checkThread判断的并非是UI线程和当前线程对比,而是mThread和当前线程对比。


到这里,我可以结尾了吧。


最后,就一件事,如果有收获,阅读本文过程中脑子里闪过卧槽的,可以点个赞,转发一波,也算我花了这两天是值得的。


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


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


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


本篇为一个悲伤的故事,始终没有人按下是,留下来的只有更多的问号。