Android UI 線程更新UI也會崩潰???

本文已經受權公衆號「鴻洋」原創首發。java

你們好,我是鴻洋。android

上個週末是雙休,我決定來顛覆一下你們的認知。bash

在平時的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一下就行了」。網絡

可是...app

我今天要說,真是是隻有UI線程才能更新UI嗎?ide

你做爲一隻老鳥,確定立馬腦子裏閃過:oop

我知道你這文章寫啥了,又要在Activity#onCreate,去搞個線程執行TextView#setText,而後發現更新成功了,是否是?post

這多年之前我就看過這樣的文章,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和當前線程對比。

到這裏,我能夠結尾了吧。

下一篇我可能要寫:Google好像在秀咱們,歡迎關注等文,具體時間未定,思路暫無。

再留個思考題:這篇文章咱們以Dialog爲案例,你還能想到別的案例嗎?

本文測試設備:Android 29模擬器。

也歡迎關注個人公衆號,微信搜索「鴻洋」,拜了個拜!

相關文章
相關標籤/搜索