作者:鸿洋
原文链接:https://mp.weixin.qq.com/s/tg96p50alrqAtRih8a3AhA
我觉得这篇文章很好希望更多人能看到,能学习到共同进步吧,加油!
前言
上个周末是双休,我决定来颠覆一下大家的认知。
在平时的Android开发中,如果一个新手遇到一个这样的错:
android.view.ViewRootImpl$CalledFromWrongThreadException:
Onlytheoriginalthreadthatcreatedaviewhierarchycantouchitsviews.
atandroid.view.ViewRootImpl.checkThread(ViewRootImpl.java:8066)
atandroid.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1297)
atandroid.view.View.requestLayout(View.java:23147)
你作为一只老鸟,嘴角露出一丝微笑:
“小兄弟,你这个是没有在UI线程执行UI操作导致的错误,你搞个UI线程的handler.post一下就好了”。
但是...
我今天要说,真是是只有UI线程才能更新UI吗?
你作为一只老鸟,肯定立马脑子里闪过:
我知道你这文章写啥了,又要在Activity#onCreate,去搞个线程执行TextView#setText,然后发现更新成功了,是不是?
这多年以前我就看过这样的文章,ViewRootImpl还没创建而已。
看你们这么强,我这个文章没法写下去了...
但是我这个人专治各种不服好吧,我换个问题:
UI线程更新UI就不会出现上面的错误了吗?
好了,开讲。
下面是一个应届小哥小奇写需求的故事。
注意本文代码为应届小哥角度所写,为了引出问题及原理,不要随意参考,另外如果尝试复现相关代码,务必看好每一个字符,甚至xml里面的属性都很关键
小哥的需求
需求很简单,就是:
- 点击一个按钮;
- Server会下发一个问题,客户端Dialog展示;
- 在Dialog交互回答问题;
是不是很简答。
小哥怒写一波代码:
packagecom.example.testviewrootimpl;
importandroidx.appcompat.app.AppCompatActivity;
importandroid.os.Bundle;
importandroid.view.View;
importandroid.widget.Button;
publicclassMainActivityextendsAppCompatActivity{
privateButtonmBtnQuestion;
@Override
protectedvoidonCreate(BundlesavedInstanceState){
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mBtnQuestion=findViewById(R.id.btn_question);
mBtnQuestion.setOnClickListener(newView.OnClickListener(){
@Override
publicvoidonClick(Viewview){
requestAQuestion();
}
});
}
privatevoidrequestAQuestion(){
newThread(){
@Override
publicvoidrun(){
try{
Thread.sleep(1000);
}catch(InterruptedExceptione){
e.printStackTrace();
}
//模拟服务器请求,返回问题
Stringtitle="鸿洋帅气吗?";
showQuestionInDialog(title);
}
}.start();
}
privatevoidshowQuestionInDialog(Stringtitle){
}
}
很简单吧,点击按钮,新启动一个线程去模拟网络请求,结果拿到后,把问题展示在Dialog。
下面开始写Dialog的代码:
publicclassQuestionDialogextendsDialog{
privateTextViewmTvTitle;
privateButtonmBtnYes;
privateButtonmBtnNo;
publicQuestionDialog(@NonNullContextcontext){
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);
}
publicvoidshow(Stringtitle){
mTvTitle.setText(title);
show();
}
}
很简答,就一个标题,两个按钮。
<RelativeLayoutxmlns: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出来。
privatevoidshowQuestionInDialog(Stringtitle){
QuestionDialogquestionDialog=newQuestionDialog(this);
questionDialog.show(title);
}
你们猜结果怎么着...
崩溃了...

第一次崩溃
应届生小齐迎来了第一次工作中的崩溃...
我们先停下来。
上面的代码很简单吧,那么我想问各位为什么会崩溃呢?凭各位多年的经验。
猜想:
newThread(){
puublicvoidrun(){
show("...");
}
}
publicvoidshow(Stringtitle){
mTvTitle.setText(title);
show();
}
上面new Thread模拟数据,没有切到UI线程就show Dialog了,而且执行了TextView#setText,肯定是在非UI线程更新UI导致的。
很有道理,绝不是一个人会这么猜测吧。
下面我们看真正报错的原因:
Process:com.example.testviewrootimpl,PID:10544
java.lang.RuntimeException:Can'tcreatehandlerinsidethreadThread[Thread-2,5,main]thathasnotcalledLooper.prepare()
atandroid.os.Handler.<init>(Handler.java:207)
atandroid.os.Handler.<init>(Handler.java:119)
atandroid.app.Dialog.<init>(Dialog.java:133)
atandroid.app.Dialog.<init>(Dialog.java:162)
atcom.example.testviewrootimpl.QuestionDialog.<init>(QuestionDialog.java:17)
atcom.example.testviewrootimpl.MainActivity.showQuestionInDialog(MainActivity.java:46)
atcom.example.testviewrootimpl.MainActivity.access$100(MainActivity.java:10)
atcom.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的方法中增加:
privatevoidshowQuestionInDialog(Stringtitle){
Looper.prepare();//增加部分
QuestionDialogquestionDialog=newQuestionDialog(this);
questionDialog.show(title);
Looper.loop();//增加部分
}
解决问题就是这么简单,嘴角露出一丝对自己满意的笑容。
再次运行App...
这里大家再停一下。
凭各位多年的经验,我想再问一句,这次还会崩溃吗?
会吗?
猜想:
这代码治标不治本,还是没有在UI线程执行相关代码,还是会崩,而却刚才的show里面还有TextView#setText操作
有点道理。
看一下运行效果:

没有崩溃...
是不是有一丝的郁闷?
没关系,作为拥有多年经验的老鸟,总能立马想到解释的理由:
大家都知道在Activity#onCreate的时候,我们开个线程去执行Text#setText也不会崩溃,原因是ViewRootImpl那时候还没初始化,所以这次没崩溃也是这个原因。
对应源码解释是这样的:
#Dialog源码
publicvoidshow(){
//省略一堆代码
mWindowManager.addView(mDecor,l);
}
我们首次创建的Dialog,第一次调用show方法,内部确实会执行mWindowManager.addView,这个代*会码**执行到:
#WindowManagerImpl
@Override
publicvoidaddView(@NonNullViewview,@NonNullViewGroup.LayoutParamsparams){
applyDefaultToken(params);
mGlobal.addView(view,params,mContext.getDisplay(),mParentWindow);
}
这个mGlobal对象是WindowManagerGlobal,我们看它的addView方法:
#WindowManagerGlobal
publicvoidaddView(Viewview,ViewGroup.LayoutParamsparams,
Displaydisplay,WindowparentWindow){
//省略了一堆代码
root=newViewRootImpl(view.getContext(),display);
view.setLayoutParams(wparams);
mViews.add(view);
mRoots.add(root);
mParams.add(wparams);
//dothislastbecauseitfiresoffmessagestostartdoingthings
try{
root.setView(view,wparams,panelParentView);
}catch(RuntimeExceptione){
//BadTokenExceptionorInvalidDisplayException,cleanup.
if(index>=0){
removeViewLocked(index,true);
}
throwe;
}
}
果然立马有new ViewRootImpl的代码,你看ViewRootImpl没有创建,所以这和Activity那个是一个情况。
好像有那么点道理哈...
我们继续往下看。
应届小哥要继续做需求了。
一个隐藏的问题
接下来的需求很奇怪,就是当询问"鸿洋帅气吗?"的时候,如果你点击不是,那么Dialog不消失,在问题的末尾再加一个?号,如此循环,永不关闭。
这难不倒我们的小哥:
mBtnNo.setOnClickListener(newView.OnClickListener(){
@Override
publicvoidonClick(Viewview){
Strings=mTvTitle.getText().toString();
mTvTitle.setText(s+"?");
}
});
运行效果:

很完美。
如果我问,你觉得这个代码有问题吗?
你往上看了几眼,就这两行代码有个鸡儿问题,可能有空指针?
当然不是。
我稍微修改一下代码:
mBtnNo.setOnClickListener(newView.OnClickListener(){
@Override
publicvoidonClick(Viewview){
Strings=mTvTitle.getText().toString();
mTvTitle.setText(s+"?");
booleanuiThread=Looper.myLooper()==Looper.getMainLooper();
Toast.makeText(getContext(),"Uithread="+uiThread,Toast.LENGTH_LONG).show();
}
});
每次点击的时候,我弹了个Toast,输出当前线程是不是UI线程。
看下效果:

发现问题了吗?
出乎自己的意料吗?
我们在非UI线程一直在更新TextView的text。
这个时候,你不能跟我扯什么ViewRootImpl还没有创建了吧?
别急...
还有更刺激的。
更刺激的事情
我再改一下代码:
privateHandlersUiHandler=newHandler(Looper.getMainLooper());
publicQuestionDialog(@NonNullContextcontext){
super(context);
setContentView(R.layout.dialog_question);
mBtnNo.setOnClickListener(newView.OnClickListener(){
@Override
publicvoidonClick(Viewview){
sUiHandler.post(newRunnable(){
@Override
publicvoidrun(){
Strings=mTvTitle.getText().toString();
mTvTitle.setText(s+"?");
}
});
}
});
}
我搞了个UI线程的handler,然后post一下Runnable,确保我们的TextView#setText在UI线程执行,严谨而又优雅。
再停一下,以各位多年经验,这次会崩溃吗?
按照我写博客的套路,这次肯定是演示崩溃呀,不然博客怎么往下写。
好像是这个道理...
我们跑一下效果:

点击了几下,没崩...

作为拥有多年经验的老鸟,总能立马想到解释的理由:
UI线程更新怎么会崩溃呀(言语中有一丝不自信)。
是吗?
我们多点击几次:

崩溃了...
但是刚才在没有添加UiHandler.post之前可没有崩溃哟。
这个结果,我都得把代码露出来了,怕你们说我演你们...
好了,再停一停。
我又要问大家一个问题了,这次你猜是什么崩溃?
是不是求我别搞你们了,直接揭秘吧。
com.example.testviewrootimplE/AndroidRuntime:FATALEXCEPTION:main
Process:com.example.testviewrootimpl,PID:18323
android.view.ViewRootImpl$CalledFromWrongThreadException:Onlytheoriginalthreadthatcreatedaviewhierarchycantouchitsviews.
atandroid.view.ViewRootImpl.checkThread(ViewRootImpl.java:8188)
atandroid.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1421)
atandroid.view.View.requestLayout(View.java:24434)
atandroid.view.View.requestLayout(View.java:24434)
atandroid.view.View.requestLayout(View.java:24434)
atandroid.view.View.requestLayout(View.java:24434)
atandroid.widget.RelativeLayout.requestLayout(RelativeLayout.java:380)
atandroid.view.View.requestLayout(View.java:24434)
atandroid.widget.TextView.checkForRelayout(TextView.java:9667)
atandroid.widget.TextView.setText(TextView.java:6261)
atandroid.widget.TextView.setText(TextView.java:6089)
atandroid.widget.TextView.setText(TextView.java:6041)
atcom.example.testviewrootimpl.QuestionDialog$1$1.run(QuestionDialog.java:38)
atandroid.os.Handler.handleCallback(Handler.java:883)
atandroid.os.Handler.dispatchMessage(Handler.java:100)
atandroid.os.Looper.loop(Looper.java:214)
atandroid.app.ActivityThread.main(ActivityThread.java:7319)
atjava.lang.reflect.Method.invoke(NativeMethod)
atcom.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
atcom.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里面抛出的对吧,我们再次来审视一下这段代码:
voidcheckThread(){
if(mThread!=Thread.currentThread()){
thrownewCalledFromWrongThreadException(
"Onlytheoriginalthreadthatcreatedaviewhierarchycantouchitsviews.");
}
}
其实就几行代码。
我们仔细看一下,他这个错误信息并不是:
Only the UI Thread ... 而是 Only the original thread。
对吧,如果真的想强制为Only the Ui Thread,上面的if语句应该写成:
if(UI Thread != Thread.currentThread()){}
而不是mThread。
根本原因说完了。
我再带大家看下源码解析:
这个mThread是什么?
是ViewRootImpl的成员变量,我们重点应该关注它什么时候赋值的:
publicViewRootImpl(Contextcontext,Displaydisplay){
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.testviewrootimplE/AndroidRuntime:FATALEXCEPTION:main
Process:com.example.testviewrootimpl,PID:18323
android.view.ViewRootImpl$CalledFromWrongThreadException:Onlytheoriginalthreadthatcreatedaviewhierarchycantouchitsviews.
atandroid.view.ViewRootImpl.checkThread(ViewRootImpl.java:8188)
atandroid.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1421)
atandroid.view.View.requestLayout(View.java:24434)
报错的堆栈都是由View.requestLayout触发到ViewRootImpl的。
我们直接看这个方法:
publicvoidrequestLayout(){
if(mParent!=null&&!mParent.isLayoutRequested()){
mParent.requestLayout();
}
}
注意里面这个mParent变量,它的类型是ViewParent接口。
见名知意。
我要问你一个View的mParent是什么,你肯定会回答是它的父View,也就是个ViewGroup。
对,没错。
publicabstractclassViewGroup
extendsView
implementsViewParent{}
ViewGroup确实实现了ViewParent接口。
但是还有个问题,一个界面的最最最上面那个ViewGroup它的mParent是谁?
对吧,总不能还是ViewGroup吧,那岂不是没完没了了。
所以,ViewParent还有另外一个实现类,叫做ViewRootImpl。
现在明白了吧。
按照ViewParent的体系,我们的界面结构是这样的。
嗯,我还是写坨代码吧:
还是刚才Dialog,当我们点击No的时候,我们打印下ViewParent体系:
mBtnNo.setOnClickListener(newView.OnClickListener(){
@Override
publicvoidonClick(Viewview){
printViewParentHierarchy(mTvTitle,0);
}
});
privatevoidprintViewParentHierarchy(Objectview,intlevel){
if(view==null){
return;
}
StringBuildersb=newStringBuilder();
for(inti=0;i<level;i++){
sb.append("\t");
}
sb.append(view.getClass().getSimpleName());
Log.d("lmj",sb.toString());
if(viewinstanceofView){
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和当前线程对比。