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的:
那麼問題就來了,為什麼會在友盟中出現這個崩潰呢?
二、再探堆疊
然後仔細看了下報錯資訊有兩行重要資訊被我之前略過了:
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,執行一下:
發現竟然沒問題,這時候又開始懷疑人生了,這到底怎麼回事。ViewRootImpl此時的mThread應該是子執行緒啊,沒道理還能正常執行,怎麼辦呢?debug一步一步除錯吧,一步一步除錯下來,發現在View的requestLayout裡面parent竟然為空了:
然後在仔細看了下當前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; } } }
並且加了一個斷點,來檢視下此時的情況:
確實當前執行緒是JavaBridge執行緒,另外JavaBridge執行緒中已經提前給開發者準備好了Looper。所以也難怪一方面奇怪自己怎麼沒有寫Looper的印象,一方面又很好奇為什麼這個執行緒在開發者沒有準備Looper的情況下也能正常彈出Toast。
總結
至此,真相終於找出來了。
相比較發生這個bug 的原因,解決方案就顯得非常簡單了。
只需要在CommonToast的showShortToast方法內部判斷是否為主執行緒呼叫,如果不是的話,new一個主執行緒的Handler,將Toast扔到主執行緒彈出來。
這樣就會避免了子執行緒彈出。
PS:本人還得吐槽一下Android,Android官方一方面明明宣稱不能在主執行緒以外的執行緒進行UI的更新, 另一方面在初始化ViewRootImpl的時候又不把主執行緒作為成員變數儲存起來,而是直接獲取當前所處的執行緒作為mThread儲存起來,這樣做就有可能會出現子執行緒更新UI的操作。 從而引起類似我今天的這個bug。
- C 11精要:部分語言特性
- 吳恩達來信:人工智慧領域的求職小 tips
- EasyCV帶你復現更好更快的自監督演算法-FastConvMAE
- 某車聯網App 通訊協議加密分析(四) Trace Code
- 帶你瞭解CANN的目標檢測與識別一站式方案
- EasyNLP玩轉文字摘要(新聞標題)生成
- PostgreSQL邏輯複製解密
- 基於 CoreDNS 和 K8s 構建雲原生場景下的企業級 DNS
- 迴圈神經網路(RNN)可是在語音識別、自然語言處理等其他領域中引起了變革!
- 技術分享| 分散式系統中服務註冊發現元件的原理及比較
- 利用谷歌地圖採集外貿客戶的電話和手機號碼
- 跟我學Python影象處理丨關於影象金字塔的影象向下取樣和向上取樣
- 帶你掌握如何使用CANN 運算元ST測試工具msopst
- 一招教你如何高效批量匯入與更新資料
- 一步步搞懂MySQL元資料鎖(MDL)
- 你知道如何用 PHP 實現多程序嗎?
- KubeSphere 閘道器的設計與實現(解讀)
- 京東金融客戶端使用者觸達方式的探索與實踐
- 如何使用 Junit Mockito 實踐單元測試
- 演算法基礎(二)| 高精度演算法詳解