Android非UI线程更新UI的探索

inf 4年前
   <p>众所周知,在Android中如果在非UI线程更新UI的话,会抛出异常:</p>    <p>Only the original thread that created a view hierarchy can touch its views.</p>    <p>因此我们很自然地认为只能在UI线程更新UI了。但是在实际开发中,有时可能有在非UI线程更新UI的需求,如:想通过非UI线程来预加载View。因此本文将探索在非UI线程更新UI的方式。</p>    <h2>checkThread突破口</h2>    <p>首先来找下突破口。从上面提到的异常开始切入,抛出该异常的代码如下: android.view.ViewRootImpl#checkThread</p>    <pre>  <code class="language-java">void checkThread() {      if (mThread != Thread.currentThread()) {          throw new CalledFromWrongThreadException(                  "Only the original thread that created a view hierarchy can touch its views.");      }  }</code></pre>    <p>这个方法在View更新的一些关键操作中都会调用,如layout,invalid,focus等。而这里的判断条件是如果 ViewRootImpl 中的 mThread 的值和当前调用的线程不一样,就抛出异常。而 mThread 赋值是在 ViewRootImpl 构造时:</p>    <pre>  <code class="language-java">public ViewRootImpl(Context context, Display display) {      // ...      mThread = Thread.currentThread();      // ...  }</code></pre>    <p>这里是将 mThread 直接赋值为构造调用的当前线程。再看看 ViewRootImpl 的构造调用的地方是:</p>    <p>android.view.WindowManagerGlobal#addView</p>    <pre>  <code class="language-java">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);      }      // ...  }</code></pre>    <p>这个 WindowManagerGlobal 其实就是 WindowManager 的具体实现。也就是android.view.WindowManager#addView,最终都会调用到这里。</p>    <p>在平时 View 操作最多的 Activity 中,当 Activity resume 时系统会将 DecorView 添加到 Window 中,代码如下:</p>    <p>android.app.ActivityThread#handleResumeActivity</p>    <pre>  <code class="language-java">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);              }          }          // ...      }      // ...  }</code></pre>    <p>这段逻辑概括起来就是:</p>    <p>ActivityThread.handleResumeActivity -> WindowManagerGlobal.addView -> new ViewRootImpl -> ViewRootImpl.mThread = Thread.currentThread()</p>    <p>这里都是UI线程调用的,而 ViewRootImpl.mThread 也就赋值为UI线程,因此在 Activity 中的 View,我们都是只能在UI线程更新的,如果在非UI线程更新的话,就无法通过 checkThread 检查。</p>    <p>回到开头提到的问题,如果想在非UI线程更新UI,拆分下,大致分为两步:</p>    <ol>     <li>在非UI线程创建 View;</li>     <li>在非UI线程更新 View。</li>    </ol>    <p>而这两步的关键都在怎么通过 checkThread 这个检查。</p>    <h2>能否在非UI线程创建View?</h2>    <p>首先来看第一个问题,能否在非UI线程创建View。</p>    <p>从上面对 checkThread 的分析可以知道,checkThread 只存在于 ViewRootImpl 中,而ViewRootImpl 是当我们通过 WindowManager 向 Window 中添加 View 的时候才构造的一个 rootView。只要我们不向 Window 中添加 View,那么也就不会触发 checkThread。</p>    <p>因此在非UI线程创建 View 理论上是可行的。无论是通过直接 new View,还是通过 LayoutInflater 。</p>    <h2>能否在非UI线程更新View?</h2>    <p>上面只是通过非UI线程来创建 View,那么在非UI更新 View 是否可行呢?这里就涉及到在更新UI时怎么通过 checkThread 的检查。</p>    <p>从上面的分析可以得知,如果 ViewRootImpl.mThread 的值和当前更新UI调用的线程是一样的,那么就不会抛出异常。</p>    <p>那么试想,如果 ViewRootImpl.mThread 的值是非UI线程,而且更新UI也是在同一个非UI线程中,那我们是不是就可以通过 checkThread 检查了呢?</p>    <p>同时还有个问题,怎么将 ViewRootImpl.mThread 赋值为一个非UI线程?</p>    <p>做过悬浮窗开发或者对 WMS 源码熟悉的应该知道,通过 Context 可以获得一个 WindowManager 对象,顾名思义,它就是用来操作 Window 的,Activity 也正是通过它显示在 Window 上的。</p>    <p>结合上面的分析,只要我们将 WindowManager.addView 这一步放到非UI线程去做,那么 ViewRootImpl.mThread 必然指向的是当前调用的非UI线程,后续自然就可以在这个非UI线程去更新这个View了。</p>    <h2>示例</h2>    <p>下面通过一个示例来验证下上述的想法:</p>    <p>一个简单的布局文件:</p>    <pre>  <code class="language-java"><?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></code></pre>    <p>非UI线程创建View和更新View的示例:</p>    <pre>  <code class="language-java">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);          }      });  }</code></pre>    <p>将上述代码在 Activity 中运行下,可以正常的显示,而且点击事件,View 的更新都能正常执行,同时不影响UI线程的正常运行。因此该方案理论上是可行的。</p>    <h2>稳定性</h2>    <p>该方案本人在项目的测试环境上已经做过一些场景的应用,暂未发现任何问题,但是不排除有未知的风险,毕竟这不是常规的方案。</p>    <p>因为 Android 系统默认所有的 View 都在UI线程更新,因此不存在线程间的同步问题。但是如果需要使用多线程来创建更新 View 的话,多线程的问题不得不考虑,比如静态变量的同步问题。</p>    <p>如:Android 中使用最广泛的 TextView,其内部使用 android.text.TextLine 来表示一行文本,同是负责 TextView 的绘制,而这个类内部就有个静态的 cache :TextLine#sCached,不过好在其内部对 sCached 的所有操作都已经加锁。</p>    <p>但是不排除系统中还有其他控件中有未知的坑。</p>    <h2>应用</h2>    <ol>     <li>性能优化:对 View 的预加载,可以使用非UI线程来实例化 View ,然后放到UI线程去更新,节省 View 创建的开销。</li>     <li>浮层:如果有些浮层本身存在大量复杂的绘制操作,而为了避免和UI绘制抢占资源,可以将其放到非UI线程来做,如:视频小窗。</li>    </ol>    <p> </p>    <p>来自:https://techblog.toutiao.com/2017/08/16/untitled-5/</p>    <p> </p>