作者:鴻洋
大家好,我是鴻洋。
上個週末是雙休,我決定來顛覆一下大家的認知。
在平時的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模擬器。
本篇為一個悲傷的故事,始終沒有人按下是,留下來的只有更多的問號。