天天看點

Android技術分享|【Android踩坑】懷疑人生,主線程修改UI也會崩潰?

前言

某天早晨,吃完早餐,坐回工位,打開電腦,開啟chrome,進入友盟頁面,發現了一個崩潰資訊:

java.lang.RuntimeException: Unable to resume activity {com.youdao.youdaomath/com.youdao.youdaomath.view.PayCourseVideoActivity}: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
    at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3824)
    at android.app.ActivityThread.handleResumeActivity(ActivityThread.java:3856)
    at android.app.servertransaction.ResumeActivityItem.execute(ResumeActivityItem.java:51)
    at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:145)
    at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70)
    at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1831)
    at android.os.Handler.dispatchMessage(Handler.java:106)
    at android.os.Looper.loop(Looper.java:201)
    at android.app.ActivityThread.main(ActivityThread.java:6806)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)
Caused by: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
    at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8000)
    at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1292)
    at android.view.View.requestLayout(View.java:23147)
    at android.view.View.requestLayout(View.java:23147)
    at android.widget.TextView.checkForRelayout(TextView.java:8914)
    at android.widget.TextView.setText(TextView.java:5736)
    at android.widget.TextView.setText(TextView.java:5577)
    at android.widget.TextView.setText(TextView.java:5534)
    at android.widget.Toast.setText(Toast.java:332)
    at com.youdao.youdaomath.view.common.CommonToast.showShortToast(CommonToast.java:40)
    at com.youdao.youdaomath.view.PayCourseVideoActivity.checkNetWork(PayCourseVideoActivity.java:137)
    at com.youdao.youdaomath.view.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
    at android.app.Instrumentation.callActivityOnResume(Instrumentation.java:1413)
    at android.app.Activity.performResume(Activity.java:7400)
    at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3816)
           

一眼看上去似乎是比較常見的子線程修改UI的問題。并且是在Toast上面報出的,常識告訴我Toast在子線程彈出是會報錯,但是應該是提示Looper沒有生成的錯,而不應該是上面所報出的錯誤。那麼會不會是生成Looper以後報的錯的?

一、Demo 驗證

是以我先做了一個demo,如下:

@Override
    protected void onResume() {
        super.onResume();
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                Toast.makeText(MainActivity.this,"子線程彈出Toast",Toast.LENGTH_SHORT).show();
            }
        });
        thread.start();
    }
           

運作一下,果不其然崩潰掉,錯誤資訊就是提示我必須準備好looper才能彈出toast:

java.lang.RuntimeException: Can't toast on a thread that has not called Looper.prepare()
        at android.widget.Toast$TN.<init>(Toast.java:393)
        at android.widget.Toast.<init>(Toast.java:117)
        at android.widget.Toast.makeText(Toast.java:280)
        at android.widget.Toast.makeText(Toast.java:270)
        at com.netease.photodemo.MainActivity$1.run(MainActivity.java:22)
        at java.lang.Thread.run(Thread.java:764)
           

接下來就在toast裡面準備好looper,再試試吧:

Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                Looper.prepare();
                Toast.makeText(MainActivity.this,"子線程彈出Toast",Toast.LENGTH_SHORT).show();
                Looper.loop();
            }
        });
        thread.start();
           

運作發現是能夠正确的彈出Toast的:

Android技術分享|【Android踩坑】懷疑人生,主線程修改UI也會崩潰?

那麼問題就來了,為什麼會在友盟中出現這個崩潰呢?

二、再探堆棧

然後仔細看了下報錯資訊有兩行重要資訊被我之前略過了:

at com.youdao.youdaomath.view
.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
android.widget.Toast.setText(Toast.java:332)
           

發現是在主線程報了Toast設定Text的時候的錯誤。這就讓我很納悶了,子線程修改UI會報錯,主線程也會報錯?

感覺這麼多年Android白做了。這不是最基本的知識麼?

于是我隻能硬着頭皮往源碼深處看了:

先來看看Toast是怎麼setText的:

public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
            @NonNull CharSequence text, @Duration int duration) {
        Toast result = new Toast(context, looper);

        LayoutInflater inflate = (LayoutInflater)
                context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
        TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
        tv.setText(text);

        result.mNextView = v;
        result.mDuration = duration;

        return result;
    }
           

很正常的一個做法,先是inflate出來一個View對象,再從View對象找出對應的TextView,然後TextView将文本設定進去。

至于setText在之前有詳細說過,是在ViewRootImpl裡面進行checkThread是否在主線程上面。是以感覺似乎一點問題都沒有。那麼既然出現了這個錯誤,總得有原因吧,或許是自己源碼看漏了?

那就重新再看一遍ViewRootImpl#checkThread方法吧:

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

這一看,還真的似乎給我了一點頭緒,系統在checkThread的時候并不是将Thread.currentThread和MainThread作比較,而是跟mThread作比較,那麼有沒有一種可能mThread是子線程?

一想到這裡,我就興奮了,全類檢視mThread到底是怎麼初始化的:

public ViewRootImpl(Context context, Display display) {
        ...代碼省略...
        mThread = Thread.currentThread();
       ...代碼省略...
    }
           

可以發現全類隻有這一處對mThread進行了指派。那麼會不會是子線程初始化了ViewRootimpl呢?似乎我之前好像也沒有研究過Toast為什麼會彈出來,是以順便就先去了解下Toast是怎麼show出來的好了:

/**
     * Show the view for the specified duration.
     */
    public void show() {
        if (mNextView == null) {
            throw new RuntimeException("setView must have been called");
        }

        INotificationManager service = getService();
        String pkg = mContext.getOpPackageName();
        TN tn = mTN;
        tn.mNextView = mNextView;

        try {
            service.enqueueToast(pkg, tn, mDuration);
        } catch (RemoteException e) {
            // Empty
        }
    }
           

調用Toast的show方法時,會通過Binder擷取Service即NotificationManagerService,然後執行enqueueToast方法(NotificationManagerService的源碼就不做分析),然後會執行Toast裡面如下方法:

@Override
        public void show(IBinder windowToken) {
            if (localLOGV) Log.v(TAG, "SHOW: " + this);
            mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
        }
           

發送一個Message,通知進行show的操作:

@Override
        public void show(IBinder windowToken) {
            if (localLOGV) Log.v(TAG, "SHOW: " + this);
            mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
        }
           

在Handler的handleMessage方法中找到了SHOW的case,接下來就要進行真正show的操作了:

public void handleShow(IBinder windowToken) {
            if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
                    + " mNextView=" + mNextView);
            // If a cancel/hide is pending - no need to show - at this point
            // the window token is already invalid and no need to do any work.
            if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
                return;
            }
            if (mView != mNextView) {
                // remove the old view if necessary
                handleHide();
                mView = mNextView;
                Context context = mView.getContext().getApplicationContext();
                String packageName = mView.getContext().getOpPackageName();
                if (context == null) {
                    context = mView.getContext();
                }
                mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
                // We can resolve the Gravity here by using the Locale for getting
                // the layout direction
                final Configuration config = mView.getContext().getResources().getConfiguration();
                final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
                mParams.gravity = gravity;
                if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
                    mParams.horizontalWeight = 1.0f;
                }
                if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
                    mParams.verticalWeight = 1.0f;
                }
                mParams.x = mX;
                mParams.y = mY;
                mParams.verticalMargin = mVerticalMargin;
                mParams.horizontalMargin = mHorizontalMargin;
                mParams.packageName = packageName;
                mParams.hideTimeoutMilliseconds = mDuration ==
                    Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
                mParams.token = windowToken;
                if (mView.getParent() != null) {
                    if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                    mWM.removeView(mView);
                }
                if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
                // Since the notification manager service cancels the token right
                // after it notifies us to cancel the toast there is an inherent
                // race and we may attempt to add a window after the token has been
                // invalidated. Let us hedge against that.
                try {
                    mWM.addView(mView, mParams);
                    trySendAccessibilityEvent();
                } catch (WindowManager.BadTokenException e) {
                    /* ignore */
                }
            }
        }
           

代碼有點長,我們最需要關心的就是mWm.addView方法。

相信看過ActivityThread的同學應該知道mWm.addView方法是在ActivityThread的handleResumeActivity裡面也有調用過,意思就是進行ViewRootImpl的初始化,然後通過ViewRootImp進行View的測量,布局,以及繪制。

看到這裡,我想到了一個可能的原因:

那就是我的Toast是一個全局靜态的Toast對象,然後第一次是在子線程的時候show出來,這個時候ViewRootImpl在初始化的時候就會将子線程的對象作為mThread,然後下一次在主線程彈出來就出錯了吧?想想應該是這樣的。

三、再探Demo

是以繼續做我的demo來印證我的想法:

@Override
    protected void onResume() {
        super.onResume();
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                Looper.prepare();
                sToast = Toast.makeText(MainActivity.this,"子線程彈出Toast",Toast.LENGTH_SHORT);
                sToast.show();
                Looper.loop();
            }
        });
        thread.start();
    }

    public void click(View view) {
        sToast.setText("主線程彈出Toast");
        sToast.show();
    }
           

做了個靜态的toast,然後點選按鈕的時候彈出toast,運作一下:

Android技術分享|【Android踩坑】懷疑人生,主線程修改UI也會崩潰?

發現竟然沒問題,這時候又開始懷疑人生了,這到底怎麼回事。ViewRootImpl此時的mThread應該是子線程啊,沒道理還能正常運作,怎麼辦呢?debug一步一步調試吧,一步一步調試下來,發現在View的requestLayout裡面parent竟然為空了:

Android技術分享|【Android踩坑】懷疑人生,主線程修改UI也會崩潰?

然後在仔細看了下目前View是一個LinearLayout,然後這個View的子View是TextView,文本内容是"主線程彈出toast",是以應該就是Toast在new的時候inflate的布局

View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
           

找到了對應的toast布局檔案,打開一看,果然如此:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:background="?android:attr/toastFrameBackground">

    <TextView
        android:id="@android:id/message"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:layout_marginHorizontal="24dp"
        android:layout_marginVertical="15dp"
        android:layout_gravity="center_horizontal"
        android:textAppearance="@style/TextAppearance.Toast"
        android:textColor="@color/primary_text_default_material_light"
        />

</LinearLayout>
           

也就是說此時的View已經是頂級View了,它的parent應該就是ViewRootImpl,那麼為什麼ViewRootImpl是null呢,明明之前已經show過了。看來隻能往Toast的hide方法找原因了

四、深入源碼

是以重新回到Toast的類中,檢視下Toast的hide方法(此處直接看Handler的hide處理,之前的操作與show類似):

public void handleHide() {
    if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
    if (mView != null) {
        // note: checking parent() just to make sure the view has
        // been added...  i have seen cases where we get here when
        // the view isn't yet added, so let's try not to crash.
        if (mView.getParent() != null) {
            if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
            mWM.removeViewImmediate(mView);
        }

        // Now that we've removed the view it's safe for the server to release
        // the resources.
        try {
            getService().finishToken(mPackageName, this);
        } catch (RemoteException e) {
        }

        mView = null;
    }
}
           

此處調用了mWm的removeViewImmediate,即WindowManagerImpl裡面的removeViewImmediate方法:

@Override
    public void removeViewImmediate(View view) {
        mGlobal.removeView(view, true);
    }
           

會調用WindowManagerGlobal的removeView方法:

public void removeView(View view, boolean immediate) {
        if (view == null) {
            throw new IllegalArgumentException("view must not be null");
        }

        synchronized (mLock) {
            int index = findViewLocked(view, true);
            View curView = mRoots.get(index).getView();
            removeViewLocked(index, immediate);
            if (curView == view) {
                return;
            }

            throw new IllegalStateException("Calling with view " + view
                    + " but the ViewAncestor is attached to " + curView);
        }
    }
           

然後調用removeViewLocked方法:

private void removeViewLocked(int index, boolean immediate) {
        ViewRootImpl root = mRoots.get(index);
        View view = root.getView();

        if (view != null) {
            InputMethodManager imm = InputMethodManager.getInstance();
            if (imm != null) {
                imm.windowDismissed(mViews.get(index).getWindowToken());
            }
        }
        boolean deferred = root.die(immediate);
        if (view != null) {
            //此處調用View的assignParent方法将viewParent置空
            view.assignParent(null);
            if (deferred) {
                mDyingViews.add(view);
            }
        }
    }
           

是以也就是說在Toast時間到了以後,會調用hide方法,此時會将parent置成空,是以我剛才試的時候才沒有問題。那麼按道理說隻要在Toast沒有關閉的時候點選再次彈出toast應該就會報錯。

是以還是原來的代碼,再來一次,這次不等Toast關閉,再次點選:

果然如預期所料,此時在主線程彈出Toast就會崩潰。

五、發現原因

那麼問題原因找到了:

是在項目子線程中有彈出過Toast,然後Toast并沒有關閉,又在主線程彈出了同一個對象的toast,會造成崩潰。

此時内心有個困惑:

如果是子線程彈出Toast,那我就需要寫Looper.prepare方法和Looper.loop方法,為什麼我自己一點印象都沒有。

于是我全局搜尋了Looper.prepare,發現并沒有找到對應的代碼。是以我就全局搜尋了Toast調用的地方,發現在JavaBridge的回調當中找到了:

class JSInterface {
        @JavascriptInterface
        public void handleMessage(String msg) throws JSONException {
            LogHelper.e(TAG, "msg::" + msg);
            JSONObject jsonObject = new JSONObject(msg);
            String callType = jsonObject.optString(JS_CALL_TYPE);
            switch (callType) {
                ...代碼省略..
                case JSCallType.SHOW_TOAST:
                    showToast(jsonObject);
                    break;
                default:
                    break;
            }
        }
    }

    /**
     * 彈出吐司
     * @param jsonObject
     * @throws JSONException
     */
    public void showToast(JSONObject jsonObject) throws JSONException {
        JSONObject payDataObj = jsonObject.getJSONObject("data");
        String message = payDataObj.optString("data");
        CommonToast.showShortToast(message);
    }
           

但是看到這段代碼,又有疑問了,我并沒有在Javabridge的回調中看到有任何準備Looper的地方,那麼為什麼Toast沒有崩潰掉?

是以在此處加了一段代碼:

class JSInterface {
        @JavascriptInterface
        public void handleMessage(String msg) throws JSONException {
            LogHelper.e(TAG, "msg::" + msg);
            JSONObject jsonObject = new JSONObject(msg);
            String callType = jsonObject.optString(JS_CALL_TYPE);
            Thread currentThread = Thread.currentThread();
            Looper looper = Looper.myLooper();
            switch (callType) {
                ...代碼省略..
                case JSCallType.SHOW_TOAST:
                    showToast(jsonObject);
                    break;
                default:
                    break;
            }
        }
    }
           

并且加了一個斷點,來檢視下此時的情況:

Android技術分享|【Android踩坑】懷疑人生,主線程修改UI也會崩潰?

确實目前線程是JavaBridge線程,另外JavaBridge線程中已經提前給開發者準備好了Looper。是以也難怪一方面奇怪自己怎麼沒有寫Looper的印象,一方面又很好奇為什麼這個線程在開發者沒有準備Looper的情況下也能正常彈出Toast。

總結

至此,真相終于找出來了。

相比較發生這個bug 的原因,解決方案就顯得非常簡單了。

隻需要在CommonToast的showShortToast方法内部判斷是否為主線程調用,如果不是的話,new一個主線程的Handler,将Toast扔到主線程彈出來。

這樣就會避免了子線程彈出。

PS:本人還得吐槽一下Android,Android官方一方面明明宣稱不能在主線程以外的線程進行UI的更新,另一方面在初始化ViewRootImpl的時候又不把主線程作為成員變量儲存起來,而是直接擷取目前所處的線程作為mThread儲存起來,這樣做就有可能會出現子線程更新UI的操作。進而引起類似我今天的這個bug。

Android技術分享|【Android踩坑】懷疑人生,主線程修改UI也會崩潰?