在Android中如果在非UI线程更新UI会抛出异常

五陵年少金市东,银鞍白马渡春风。这篇文章主要讲述在Android中如果在非UI线程更新UI会抛出异常相关的知识,希望能为你提供帮助。
【在Android中如果在非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,拆分下,大致分为两步:
  1. 在非UI线程创建 View;
  2. 在非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 的所有操作都已经加锁。
但是不排除系统中还有其他控件中有未知的坑。
应用
  1. 性能优化:对 View 的预加载,可以使用非UI线程来实例化 View ,然后放到UI线程去更新,节省 View 创建的开销。
  2. 浮层:如果有些浮层本身存在大量复杂的绘制操作,而为了避免和UI绘制抢占资源,可以将其放到非UI线程来做,如:视频小窗。

    推荐阅读