面試官:子線程 真的不能更新UI ?

我們從一個異常說起:

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8820)
        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1530)
        at android.view.View.requestLayout(View.java:24648)
        at android.widget.TextView.checkForRelayout(TextView.java:9752)
        at android.widget.TextView.setText(TextView.java:6326)
        at android.widget.TextView.setText(TextView.java:6154)
        at android.widget.TextView.setText(TextView.java:6106)
        at com.hfy.demo01.MainActivity$9.run(MainActivity.java:414)
        at android.os.Handler.handleCallback(Handler.java:888)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:213)
        at android.app.ActivityThread.main(ActivityThread.java:8147)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1101)

一般情況,我們在子線程直接操作UI,沒有用handler切到主線程,就會報這個錯。

那如果我說,我這裏的這個錯誤就發生在 主線程,你信嗎?

下面是具體代碼,handleAddWindow()按在MainActivity 的onCreate中執行。

    private void handleAddWindow() {

        //子線程創建window,只能由這個子線程訪問 window的view
        Button button = new Button(MainActivity.this);
        button.setText("添加到window中的button");
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                MyToast.showMsg(MainActivity.this, "點了button");
            }
        });

        new Thread(new Runnable() {
            @Override
            public void run() {
				//因爲添加window是IPC操作,回調回來時,需要handler切換線程,所以需要Looper
                Looper.prepare();

                addWindow(button);

                new Handler().postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        button.setText("文字變了!!!");
                    }
                },3000);
                
				//開啓looper,循環取消息。
                Looper.loop();
            }
        }).start();

        //這裏執行就會報錯:Only the original thread that created a view hierarchy can touch its views.
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                button.setText("文字 you 變了!!!");
            }
        },4000);
    }

    private void addWindow(Button view) {
        WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
                WindowManager.LayoutParams.WRAP_CONTENT,
                WindowManager.LayoutParams.WRAP_CONTENT,
                0, 0,
                PixelFormat.TRANSPARENT
        );
        // flag 設置 Window 屬性
        layoutParams.flags= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
        // type 設置 Window 類別(層級)
        layoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
        }

        layoutParams.gravity = Gravity.TOP | Gravity.LEFT;
        layoutParams.x = 100;
        layoutParams.y = 100;

        WindowManager windowManager = getWindowManager();
        windowManager.addView(view, layoutParams);
    }

主要是:開了個子線程,然後添加了一個系統window,window中只有一個button。然後3秒後在子線程中直接改變Button的文字,然後又過一秒,在主線程中再改變button文字。

(其中涉及知識有handlerwindow。可點擊查看相關知識)

執行效果如下,可見 打開App後,左上角的Button,3秒後變了,接着一秒後crash了。
在這裏插入圖片描述

那爲啥 子線程更新UI沒報錯,主線程報錯呢?

首先,我們看報錯原因的描述: Only the original thread that created a view hierarchy can touch its views. 翻譯就是說 只有創建了view樹的線程,才能訪問它的子view。並沒有說子線程一定不能訪問UI。那可以猜想到,button的確實是在子線程被添加到window中的,子線程確實可以直接訪問,而主線程訪問確實會拋出異常。看來可以解釋這個錯誤的原因了。
下面就具體分析下。

錯誤的發生在ViewRootImpl的checkThread方法中,且UI的更新都會走到這個方法:

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

(ViewRootImpl相關知識可以戳這裏View的工作原理

通過window的相關知識,我們知道,調用windowManager.addView添加window時會給這個window創建一個ViewRootImpl實例:

    public void addView(View view, ViewGroup.LayoutParams params,
            Display display, Window parentWindow) {
        ...

        	ViewRootImpl root;
        	View panelParentView = null;
        ...
            root = new ViewRootImpl(view.getContext(), display);
            view.setLayoutParams(wparams);
            mViews.add(view);
            mRoots.add(root);
            mParams.add(wparams);
...
        }
    }

然後ViewRootImpl構造方法中會拿到當前的線程,

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

所以在ViewRootImpl的checkThread()中,確實是 拿 當前想要更新UI的線程 和 添加window時的線程作比較,不是同一個線程機會報錯。

通過window的相關知識,我們還知道,Activity也是一個window,window的添加是在ActivityThread的handleResumeActivity()。ActivityThread就是主線程,所以Activity的view訪問只能在主線程中

一般情況,UI就是指Activity的view,這也是我們通常稱主線程爲UI線程的原因,其實嚴謹叫法應該是activity的UI線程。而我們這個例子中,這個子線程也可以稱爲button的UI線程。

那爲啥要一定需要checkThread呢?根據handler的相關知識:

因爲UI控件不是線程安全的。那爲啥不加鎖呢?一是加鎖會讓UI訪問變得複雜;二是加鎖會降低UI訪問效率,會阻塞一些線程訪問UI。所以乾脆使用單線程模型處理UI操作,使用時用Handler切換即可。

我們再看一個問題,Toast可以在子線程show嗎答案是可以的

        new Thread(new Runnable() {
            @Override
            public void run() {
                //因爲添加window是IPC操作,回調回來時,需要handler切換線程,所以需要Looper
                Looper.prepare();

                addWindow(button);

                new Handler().postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        button.setText("文字變了!!!");
                    }
                },3000);

                Toast.makeText(MainActivity.this, "子線程showToast", Toast.LENGTH_SHORT).show();

                //開啓looper,循環取消息。
                Looper.loop();
            }
        }).start();

在上面的例子,線程中showToast,運行發現確實可以的。因爲根據window的相關知識,知道Toast也是window,show的過程就是添加Window的過程。

另外注意1,這個線程中Looper.prepare()和Looper.loop(),這是必要的
因爲添加window的過程是和WindowManagerService進行IPC的過程,IPC回來時是執行在binder線程池的,而ViewRootImpl中是默認有Handler實例的,這個handler就是用來切換binder線程池的消息到當前線程。 另外Toast還與NotificationMamagerService進行IPC,也是需要Handler實例。既然需要handler,那所以線程是需要looper的。另另外Activity還與ActivityManagerService進行IPC交互,而主線程是默認有Looper的。
擴展開,想在子線程show Toast、Dialog、popupWindow、自定義window,只要在前後調Looper.prepare()和Looper.loop()即可。

另外注意2,在activity的onCreate到首次onResume的時期,創建子線程在其中更新UI也是可以的。這不是違背上面的結論了嗎?其實沒有,上面說了,因爲Activity的window添加在首次onResume之後執行的的,那ViewRootImpl的創建也是在這之後,所以也就無法checkThread了。實際上這個時期也不checkThread,因爲View根本還沒有顯示出來。

onCreate()中執行是OK的:

new Thread(new Runnable() {
    @Override
    public void run() {
        tv.setText("text");
    }
}).start();
發佈了54 篇原創文章 · 獲贊 16 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章