眾所周知,在Android中如果在非UI線程更新UI的話,會拋出異常:
Only the original thread that created a view hierarchy can touch its views.
因此我們很自然地認為只能在UI線程更新UI了。但是在實際開發中,有時可能有在非UI線程更新UI的需求,如:想通過非UI線程來預加載View。因此本文將探索在非UI線程更新UI的方式。
checkThread突破口
首先來找下突破口。從上面提到的異常開始切入,拋出該異常的代碼如下: android.view.ViewRootImpl#checkThread
void checkThread() { if (mThread != Thread.currentThread()) { throw new CalledFromWrongThreadException( "Only the original thread that created a view hierarchy can touch its views."); } }
這個方法在View更新的一些關鍵操作中都會調用,如layout,invalid,focus等。而這里的判斷條件是如果 ViewRootImpl 中的 mThread 的值和當前調用的線程不一樣,就拋出異常。而 mThread 賦值是在 ViewRootImpl 構造時:
public ViewRootImpl(Context context, Display display) { // ... mThread = Thread.currentThread(); // ... }
這里是將 mThread 直接賦值為構造調用的當前線程。再看看 ViewRootImpl 的構造調用的地方是:
android.view.WindowManagerGlobal#addView
public void addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow) { // ... ViewRootImpl root; View panelParentView = null; synchronized (mLock) { // ... root = new ViewRootImpl(view.getContext(), display); view.setLayoutParams(wparams); mViews.add(view); mRoots.add(root); mParams.add(wparams); } // ... }
這個 WindowManagerGlobal 其實就是 WindowManager 的具體實現。也就是android.view.WindowManager#addView,最終都會調用到這里。
在平時 View 操作最多的 Activity 中,當 Activity resume 時系統會將 DecorView 添加到 Window 中,代碼如下:
android.app.ActivityThread#handleResumeActivity
final void handleResumeActivity(IBinder token, boolean clearHide, boolean isForward, boolean reallyResume) { // ... ActivityClientRecord r = performResumeActivity(token, clearHide); if (r != null) { final Activity a = r.activity; // ... if (r.window == null && !a.mFinished && willBeVisible) { r.window = r.activity.getWindow(); View decor = r.window.getDecorView(); decor.setVisibility(View.INVISIBLE); ViewManager wm = a.getWindowManager(); WindowManager.LayoutParams l = r.window.getAttributes(); a.mDecor = decor; l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION; l.softInputMode |= forwardBit; if (a.mVisibleFromClient) { a.mWindowAdded = true; wm.addView(decor, l); } } // ... } // ... }
這段邏輯概括起來就是:
ActivityThread.handleResumeActivity -> WindowManagerGlobal.addView -> new ViewRootImpl -> ViewRootImpl.mThread = Thread.currentThread()
這里都是UI線程調用的,而 ViewRootImpl.mThread 也就賦值為UI線程,因此在 Activity 中的 View,我們都是只能在UI線程更新的,如果在非UI線程更新的話,就無法通過 checkThread 檢查。
回到開頭提到的問題,如果想在非UI線程更新UI,拆分下,大致分為兩步:
- 在非UI線程創建 View;
- 在非UI線程更新 View。
而這兩步的關鍵都在怎么通過 checkThread 這個檢查。
能否在非UI線程創建View?
首先來看第一個問題,能否在非UI線程創建View。
從上面對 checkThread 的分析可以知道,checkThread 只存在于 ViewRootImpl 中,而ViewRootImpl 是當我們通過 WindowManager 向 Window 中添加 View 的時候才構造的一個 rootView。只要我們不向 Window 中添加 View,那么也就不會觸發 checkThread。
因此在非UI線程創建 View 理論上是可行的。無論是通過直接 new View,還是通過 LayoutInflater 。
能否在非UI線程更新View?
上面只是通過非UI線程來創建 View,那么在非UI更新 View 是否可行呢?這里就涉及到在更新UI時怎么通過 checkThread 的檢查。
從上面的分析可以得知,如果 ViewRootImpl.mThread 的值和當前更新UI調用的線程是一樣的,那么就不會拋出異常。
那么試想,如果 ViewRootImpl.mThread 的值是非UI線程,而且更新UI也是在同一個非UI線程中,那我們是不是就可以通過 checkThread 檢查了呢?
同時還有個問題,怎么將 ViewRootImpl.mThread 賦值為一個非UI線程?
做過懸浮窗開發或者對 WMS 源碼熟悉的應該知道,通過 Context 可以獲得一個 WindowManager 對象,顧名思義,它就是用來操作 Window 的,Activity 也正是通過它顯示在 Window 上的。
結合上面的分析,只要我們將 WindowManager.addView 這一步放到非UI線程去做,那么 ViewRootImpl.mThread 必然指向的是當前調用的非UI線程,后續自然就可以在這個非UI線程去更新這個View了。
示例
下面通過一個示例來驗證下上述的想法:
一個簡單的布局文件:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <Button android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="wrap_content"/> </FrameLayout>
非UI線程創建View和更新View的示例:
public static void showViewInNonUiThread(final Activity context) { final HandlerThread handlerThread = new HandlerThread("view_test"); handlerThread.start(); final Handler handler = new Handler(handlerThread.getLooper()); handler.post(new Runnable() { @Override public void run() { WindowManager.LayoutParams lp = new WindowManager.LayoutParams(); lp.width = WindowManager.LayoutParams.MATCH_PARENT; lp.height = WindowManager.LayoutParams.WRAP_CONTENT; lp.gravity = Gravity.LEFT | Gravity.TOP; lp.format = PixelFormat.RGBA_8888; lp.type = WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL; lp.token = context.getWindow().getDecorView().getWindowToken(); lp.packageName = context.getPackageName(); WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); View contentView = LayoutInflater.from(context).inflate(R.layout.layout_test, null); final Button button = (Button) contentView.findViewById(R.id.button); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Toast.makeText(v.getContext(), "test click", Toast.LENGTH_SHORT).show(); button.setText("test update ui3."); } }); button.setText("test update ui."); handler.post(new Runnable() { @Override public void run() { button.setText("test update ui2."); } }); windowManager.addView(contentView, lp); } }); }
將上述代碼在 Activity 中運行下,可以正常的顯示,而且點擊事件,View 的更新都能正常執行,同時不影響UI線程的正常運行。因此該方案理論上是可行的。
穩定性
該方案本人在項目的測試環境上已經做過一些場景的應用,暫未發現任何問題,但是不排除有未知的風險,畢竟這不是常規的方案。
因為 Android 系統默認所有的 View 都在UI線程更新,因此不存在線程間的同步問題。但是如果需要使用多線程來創建更新 View 的話,多線程的問題不得不考慮,比如靜態變量的同步問題。
如:Android 中使用最廣泛的 TextView,其內部使用 android.text.TextLine 來表示一行文本,同是負責 TextView 的繪制,而這個類內部就有個靜態的 cache :TextLine#sCached,不過好在其內部對 sCached 的所有操作都已經加鎖。
但是不排除系統中還有其他控件中有未知的坑。
應用
- 性能優化:對 View 的預加載,可以使用非UI線程來實例化 View ,然后放到UI線程去更新,節省 View 創建的開銷。
- 浮層:如果有些浮層本身存在大量復雜的繪制操作,而為了避免和UI繪制搶占資源,可以將其放到非UI線程來做,如:視頻小窗。
來自:https://techblog.toutiao.com/2017/08/16/untitled-5/
掃碼二維碼 獲取免費視頻學習資料
- 本文固定鏈接: http://www.wangchenghua.com/post/5781/
- 轉載請注明:轉載必須在正文中標注并保留原文鏈接
- 掃碼: 掃上方二維碼獲取免費視頻資料