写点什么

Android 子线程 UI 操作真的不可以?

  • 2022 年 5 月 24 日
  • 本文字数:12919 字

    阅读完需:约 42 分钟

作者:vivo 互联网大前端团队- Zhang Xichen

一、背景及问题

某 SDK 有 PopupWindow 弹窗及动效,由于业务场景要求,对于 App 而言,SDK 的弹窗弹出时机具有随机性。

在弹窗弹出时,若 App 恰好也有动效执行,则可能出现主线程同时绘制两个动效,进而导致的卡顿,如下图。

我们以水平移动的方块模拟 App 正在进行的动效(如:页面切换);可以看出,在 Snackabr 弹窗弹出时,方块动效有明显的卡顿(移动至约 1/3 处)。



这个问题的根本原因可以简述为:不可控的动效冲突(业务随机性) + 无从安置的主线程耗时方法(弹窗实例化、视图 infalte)。

因此我们要寻求一个方案来解决动效冲突导致的卡顿问题。我们知道 Android 编码规范在要求子线程不能操作 UI,但一定是这样吗?

通过我们的优化,我们可以达到最终达成完美的效果,动效流畅,互不干涉:

二、优化措施


【优化方式一】:动态设置弹窗的延迟实例化及展示时间,躲避业务动效。

结论:可行,但不够优雅。用于作为兜底方案。


【优化方式二】:能否将弹窗的耗时操作(如实例化、infalte)移至子线程运行,仅在展示阶段(调用 show 方法)在主线程执行?

结论:可以。attach 前的 view 操作,严格意义上讲,并不是 UI 操作,只是简单的属性赋值。


【优化方式三】:能否将整个 Snackbar 的实例化、展示、交互全部放置子线程执行?

结论:可以,但有些约束场景,「UI 线程」虽然大部分时候可以等同理解为「主线程」,但严格意义上,Android 源码中从未限定「UI 线程」必须是「主线程」。

三、原理分析


下面我们分析一下方案二、三的可行性原理


3.1 概念辨析


【主线程】:实例化 ActivityThread 的线程,各 Activity 实例化线程。

【UI 线程】:实例化 ViewRootImpl 的线程,最终执行 View 的 onMeasure/onLayout/onDraw 等涉及 UI 操作的线程。

【子线程】:相对概念,相对于主线程,任何其他线程均为子线程。相对于 UI 线程同理。

3.2 CalledFromWrongThreadException 来自哪里


众所周知,我们在更新界面元素时,若不在主线程执行,系统会抛 CalledFromWrongThreadException,观察异常堆栈,不难发现,该异常的抛出是从 ViewRootImpl#checkThread 方法中抛出。

// ViewRootImpl.javavoid checkThread() {    if (mThread != Thread.currentThread()) {        throw new CalledFromWrongThreadException(                "Only the original thread that created a view hierarchy can touch its views.");    }}
复制代码

通过方法引用可以看到,ViewRootImpl#checkThread 方法会在几乎所有的 view 更新方法中调用,用以防止多线程的 UI 操作。

为了便于深入分析,我们以 TextView#setText 方法为例,进一步观察触发异常前,究竟都做了些什么。


通过查看方法调用链(Android Studio: alt + ctrl + H)我们可以看到 UI 更新的操作,走到了 VIew 这个公共父类的 invalidate 方法。


其实该方法是触发 UI 更新的一个必经方法,View#invalidate 调用后,会在后续的操作中逐步执行 View 的重新绘制。

ViewRootImpl.checkThread()  (android.view)  ViewRootImpl.invalidateChildInParent(int[], Rect)  (android.view)    ViewGroup.invalidateChild(View, Rect)  (android.view)      ViewRootImpl.invalidateChild(View, Rect)  (android.view)        View.invalidateInternal(int, int, int, int, boolean, boolean)  (android.view)          View.invalidate(boolean)  (android.view)            View.invalidate()  (android.view)              TextView.checkForRelayout()(2 usages)  (android.widget)                TextView.setText(CharSequence, BufferType, boolean, int)  (android.widget)
复制代码

3.3 理解 View#invalidate 方法


深入看一下该方法的源码,我们忽略不重要的代码,invalidate 方法其实是在标记 dirty 区域,并继续向父 View 传递,并最终由最顶部的那个 View 执行真正的 invalidate 操作。


可以看到,若要让代码开始递归执行,几个必要条件需要满足:


  • 父 View 不为空:该条件显而易见,父 view 为空时,是无法调用 ParentView#invalidateChild 方法的。

  • Dirty 区域坐标合法:同样显而易见。

  • AttachInfo 不为空:目前唯一的变量,该方法为空时,不会真正执行 invalidate。


那么,在条件 1、2 都显而易见的情况下,为何多判断一次 AttachInfo 对象?这个 AttachInfo 对象中都有什么信息?

void invalidateInternal(int l, int t, int r, int b, boolean invalidateCache,            boolean fullInvalidate) {    // ...     // Propagate the damage rectangle to the parent view.    final AttachInfo ai = mAttachInfo; // 此处何时赋值    final ViewParent p = mParent;    if (p != null && ai != null && l < r && t < b) { // 此处逻辑若不通过,实际也不会触发invalidate        final Rect damage = ai.mTmpInvalRect;        damage.set(l, t, r, b);        p.invalidateChild(this, damage);    }     // ... }
复制代码


mAttachInfo 里有什么?

注释描述:attachInfo 是一个 view 在 attach 至其父 window 被赋值的一系列信息。

其中可以看到有一些关键内容:

  1. 窗口(Window)相关的类、信息及 IPC 类。

  2. ViewRootImpl 对象:这个类就是会触发 CalledFromWrongThreadException 的来源。

  3. 其他信息。


其实通过上面 TextView#setText 方法调用链的信息,我们已经知道,所有的成功执行的 view#invalidate 方法,最终都会走到 ViewRootImpl 中的方法,并在 ViewRootImpl 中检查尝试更新 UI 的线程。


也就是说当一个 View 由于其关联的 ViewRootImpl 对象时,才有可能触发 CalledFromWrongThreadException 异常,因此 attachInfo 是 View 继续有效执行 invalidate 方法的必要对象。

// android.view.view /** * A set of information given to a view when it is attached to its parent * window. */final static class AttachInfo {     // ...     final IBinder mWindowToken;     /**     * The view root impl.     */    final ViewRootImpl mViewRootImpl;     // ...     AttachInfo(IWindowSession session, IWindow window, Display display,            ViewRootImpl viewRootImpl, Handler handler, Callbacks effectPlayer,            Context context) {         // ...         mViewRootImpl = viewRootImpl;         // ...    }}
复制代码

正如注释描述,结合源码观察,mAttachInfo 赋值时刻确实只有 view 的 attach 与 detach 两个时刻。


所以我们进一步推测:view 在 attach 前的 UI 更新操作是不会触发异常的。我们是不是可以在 attach 前把实例化等耗时操作在子线程执行完成呢?


那一个 view 是何时与 window 进行 attach 的?


正如我们编写布局文件,视图树的构建,是通过一个个 VIewGroup 通过 addView 方法构建出来的,观察 ViewGroup#addViewInner 方法,可以看到子 view 与 attachInfo 进行关系绑定的代码。


ViewGroup#addView →ViewGroup#addViewInner

// android.view.ViewGroup private void addViewInner(View child, int index, LayoutParams params,        boolean preventRequestLayout) {    // ...                                                                          AttachInfo ai = mAttachInfo;    if (ai != null && (mGroupFlags & FLAG_PREVENT_DISPATCH_ATTACHED_TO_WINDOW) == 0) {         // ...        child.dispatchAttachedToWindow(mAttachInfo, (mViewFlags&VISIBILITY_MASK));        // ...    }    // ...}
复制代码

在我们的背景案例中,弹窗的布局 inflate 操作是耗时的,那这个操作执行时是否已经完成了 attachWindow 操作呢?


实际上 infalte 时,可以由开发者自由控制是否执行 attach 操作,所有的 infalte 重载方法最终都会执行到 LayoutInfaltor#tryInflatePrecompiled。


也就是说,我们可以将 inflate 操作与 addView 操作分两步执行,而前者可以在子线程完成。


(事实上 google 提供的 Androidx 包中的 AsyncLayoutInflater 也是这样操作的)。

private View tryInflatePrecompiled(@LayoutRes int resource, Resources res, @Nullable ViewGroup root,    boolean attachToRoot) {    // ...    if (attachToRoot) {        root.addView(view, params);    } else {        view.setLayoutParams(params);    }    // ...}
复制代码


到此为止,看来一切都比较清晰了,一切都与 ViewRootImpl 有关,那么我们仔细观察一下它:


首先 ViewRootImpl 从哪里来?—— 在 WindowManager#addView


当我们可以通过 WindowManager#addView 方式新增一个窗口,该方法的实现 WindowManagerGlobal#addView 中会对 ViewRootImpl 进行实例化,并将新实例化的 ViewRootImpl 设置为被添加 View 的 Parent,同时该 View 也被认定为 rootView。

// android.view.WindowManagerGlobal public void addView(View view, ViewGroup.LayoutParams params,        Display display, Window parentWindow) {    // ...     root = new ViewRootImpl(view.getContext(), display);     // ...     try {        root.setView(view, wparams, panelParentView);    } catch (RuntimeException e) {        // ...    }}  // android.view.RootViewImpl public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {    // ...    mView = view;    // ...    mAttachInfo.mRootView = view;    // ...    view.assignParent(this);    // ...}
复制代码


我们再观察一下 WindowManagerGlobal#addView 方法的调用关系,可以看到很多熟悉类的调用时刻:

WindowManagerGlobal.addView(View, LayoutParams, Display, Window)  (android.view)    WindowManagerImpl.addView(View, LayoutParams)  (android.view)        Dialog.show()  (android.app) // Dialog的显示方法        PopupWindow.invokePopup(LayoutParams)  (android.widget)            PopupWindow.showAtLocation(IBinder, int, int, int)  (android.widget) // PopupWindow的显示方法        TN in Toast.handleShow(IBinder)  (android.widget) // Toast的展示方法
复制代码

从调用关系我们看到,如 Dialog、PopupWindow、Toast 等,均是在调用展示方法时才 attach 窗口并与 RootViewImpl 关联,因而理论上,我们仅需要保障 show 方法在主线程调用即可。


另外的,对于弹窗场景,Androidx 的 material 包也同样会提供 Snackbar,我们观察一下 material 包中 Snackbar 的 attach 时机及逻辑:


可以发现这个弹窗其实是在业务传入的 View 中直接通过 addView 方法绑定到现有视图树上的,并非通过 WindowManager 新增窗口的方式展示。其 attach 的时机,同样是在调用 show 的时刻。

// com.google.android.material.snackbar.BaseTransientBottomBar final void showView() {     // ...     if (this.view.getParent() == null) {      ViewGroup.LayoutParams lp = this.view.getLayoutParams();           if (lp instanceof CoordinatorLayout.LayoutParams) {        setUpBehavior((CoordinatorLayout.LayoutParams) lp);      }           extraBottomMarginAnchorView = calculateBottomMarginForAnchorView();      updateMargins();           // Set view to INVISIBLE so it doesn't flash on the screen before the inset adjustment is      // handled and the enter animation is started      view.setVisibility(View.INVISIBLE);      targetParent.addView(this.view);    }     // ... }
复制代码


至此,我们可以得出第一个结论:一个未被 attach 的 View 的实例化及其中属性的操作,由于其顶层 parent 是不存在 viewRootImpl 对象的,无论调用什么方法,都不会触发到 checkThread,因此是完全可以放在子线程中进行的。


仅在 view 被 attach 至 window 时,它才会作为 UI 的一部分(挂载至 ViewTree),需要被固定线程进行控制、更新等管理操作。


而一个 view 若想 attach 至 window,有两种途径:

  1. 由一个已 attachWindow 的父 View 调用其 addView 方法,将子 view 也 attach 至同一个 window,从而拥有 viewRootImpl。(material Snackbar 方式)

  2. 通过 WindowManager#addView,自建一个 Window 及 ViewRootImpl,完成 view 与 window 的 attach 操作。(PopupWindow 方式)


如何理解 Window 和 View 以及 ViewRootImpl 呢?


Window 是一个抽象的概念,每一个 Window 都对应着一个 View 和一个 ViewRootImpl,Window 和 View 通过 ViewRootImpl 来建立联系。——《Android 开发艺术探索》


// 理解:每个 Window 对应一个 ViewTree,其根节点是 ViewRootImpl,ViewRootImpl 自上而下地控制着 ViewTree 的一切(事件 & 绘制 & 更新)


问题来了:那么,这个控制 View 的固定线程一定要是主线程吗?

/** * Invalidate the whole view. If the view is visible, * {@link #onDraw(android.graphics.Canvas)} will be called at some point in * the future. * <p> * This must be called from a UI thread. To call from a non-UI thread, call * {@link #postInvalidate()}. */// 咬文嚼字:「from a UI thread」,不是「from the UI thread」public void invalidate() {    invalidate(true);}
复制代码

3.4 深入观察 ViewRootImpl 及 Android 屏幕刷新机制


我们不妨将问题换一个表述:是否可以安全地不在主线程中更新 View?我们能否有多个 UI 线程?


要回到这个问题,我们还是要回归 CalledFromWrongThreadException 的由来。

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


再次观察代码我们可以看到 checkThread 方法的判断条件,是对 mThread 对象与当前代码的 Thread 对象是否一致进行判断,那么 ViewRootImpl.mThread 成员变量,就一定是 mainThread 吗?


其实不然,纵观 ViewRootImpl 类,mThread 成员变量的赋值仅有一处,即在 ViewRootImpl 对象构造函数中,实例化时获取当前的线程对象。

// ViewRootImpl.java public ViewRootImpl(Context context, Display display) {    // ...    mThread = Thread.currentThread();    // ...    mChoreographer = Choreographer.getInstance();}
复制代码


因此我们可以做出推论,checkThread 方法判定的是 ViewRootImpl 实例化时的线程,与 UI 更新操作的线程是否一致。而不强约束是应用主进程。


前文中,我们已经说明,ViewRootImpl 对象的实例化是由 WindowManager#addView → WindowManagerGlobal#addView → new ViewRootImpl 调用过来的,这些方法都是可以在子线程中触发的。


为了验证我们的推论,我们先从源码层面做一步分析。


首先我们观察一下 ViewRootImpl 的注释说明:

The top of a view hierarchy, implementing the needed protocol between View and the WindowManager. This is for the most part an internal implementation detail of WindowManagerGlobal.


文档中指出 ViewRootImpl 是视图树的最顶部对象,实现了 View 与 WindowManager 中必要的协议。作为 WindowManagerGlobal 中大部分的内部实现。也即 WindowManagerGlobal 中的大多重要方法,最终都走到了 ViewRootImpl 的实现中。


ViewRootImpl 对象中有几个非常重要的成员变量和方法,控制着视图树的测绘操作。

在这里我们,简单介绍一下 Android 屏幕刷新的机制,以及其如何与上述几个核心对象和方法交互,以便于我们更好地进一步分析。


理解 Android 屏幕刷新机制


我们知道,View 绘制时由 invalidate 方法触发,最终会走到其 onMeasure、onLayout、onDraw 方法,完成绘制,这期间的过程,对我们理解 UI 线程管理有很重要的作用。


我们通过源码,查看一下 Andriod 绘制流程:


首先 View#invalidate 方法触发,逐级向父级 View 传递,并最终传递至视图树顶层 ViewRootImpl 对象,完成 dirty 区域的标记。

// ViewRootImpl.java public ViewParent invalidateChildInParent(int[] location, Rect dirty) {     // ...                                                                            invalidateRectOnScreen(dirty);                                                                            return null;} private void invalidateRectOnScreen(Rect dirty) {     // ...         if (!mWillDrawSoon && (intersected || mIsAnimating)) {        scheduleTraversals();    }}
复制代码

ViewRootImpl 紧接着会执行 scheduleTraversal 方法,规划 UI 视图树绘制任务:


  1. 首先会在 UI 线程的消息队列中添加同步消息屏障,保障后续的绘制异步消息的优先执行;

  2. 之后会向 Choreographer 注册一个 Runnable 对象,由前者决定何时调用 Runnable 的 run 方法;

  3. 而该 Runnable 对象就是 doTraversal 方法,即真正执行视图树遍历绘制的方法。


// ViewRootImpl.javafinal class TraversalRunnable implements Runnable {    @Override    public void run() {        doTraversal();    }}final TraversalRunnable mTraversalRunnable = new TraversalRunnable(); void scheduleTraversals() {    // ...    mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();    mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);    // ...}
复制代码


Choreographer 被调用后,会先后经过以下方法,最终调用到 DisplayEventReceiver#scheduleVsync,最终调用到 nativeScheduleVsync 方法,注册接受一次系统底层的垂直同步信号。


Choreographer#postCallback →postCallbackDelayed →

postCallbackDelayedInternal→mHandler#sendMessage →MSG_DO_SCHEDULE_CALLBACK


MessageQueue#next→ mHandler#handleMessage →MSG_DO_SCHEDULE_CALLBACK→ doScheduleCallback→scheduleFrameLocked → scheduleVsyncLocked→DisplayEventReceiver#scheduleVsync


// android.view.DisplayEventReceiver /** * Schedules a single vertical sync pulse to be delivered when the next * display frame begins. */@UnsupportedAppUsagepublic void scheduleVsync() {    if (mReceiverPtr == 0) {        Log.w(TAG, "Attempted to schedule a vertical sync pulse but the display event "                + "receiver has already been disposed.");    } else {        nativeScheduleVsync(mReceiverPtr);    }}
复制代码


系统底层会固定每 16.6ms 生成一次 Vsync(垂直同步)信号,以保障屏幕刷新稳定,信号生成后,会回调 DisplayEventReceiver#onVsync 方法。


Choreographer 的内部实现类 FrameDisplayEventReceiver 收到 onSync 回调后,会在 UI 线程的消息队列中发出异步消息,调用 Choreographer#doFrame 方法。

// android.view.Choreographer private final class FrameDisplayEventReceiver extends DisplayEventReceiver        implements Runnable {     // ...     @Override    public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {        // ...        // Post the vsync event to the Handler.        Message msg = Message.obtain(mHandler, this);        msg.setAsynchronous(true);        mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);    }     @Override    public void run() {        mHavePendingVsync = false;        doFrame(mTimestampNanos, mFrame);    } }
复制代码


Choreographer#doFrame 方法执行时会接着调用到 doCallbacks(Choreographer.CALLBACK_TRAVERSAL, ...)方法执行 ViewRootImpl 注册的 mTraversalRunnable,也即 ViewRootImpl#doTraversal 方法。

// android.view.Choreographer void doFrame(long frameTimeNanos, int frame) {    // ...    try {        // ...        doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);        // ...    } finally {        // ...    }}
复制代码

ViewRootImpl#doTraversal 继而移除同步信号屏障,继续执行 ViewRootImpl#performTraversals 方法,最终调用到 View#measure、View#layout、View#draw 方法,执行绘制。

// ViewRootImpl.java void doTraversal() {    // ...    mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);    // ...                                                              performTraversals();                                                                // ...} private void performTraversals() {    // ...    performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);    // ...    performLayout(lp, desiredWindowWidth, desiredWindowHeight);    // ...    performDraw();}
复制代码


那么整个绘制流程中的 UI 线程是否一致呢?绘制过程中是否有强行取用主线程(mainThread)的情况?


纵观整个绘制流程,期间涉 ViewRootImpl、Choreographer 均使用了 Handler 对象,我们观察一下他们的 Handler 及其中的 Looper 都是怎样来的:


首先 ViewRootImpl 中的 Handler 是其内部继承自 Handler 对象实现的,并未重载 Handler 的构造函数,或明示传入的 Looper。

// ViewRootImpl.java final class ViewRootHandler extends Handler {    @Override    public String getMessageName(Message message) {        // ...    }                                                                                                   @Override    public boolean sendMessageAtTime(Message msg, long uptimeMillis) {        // ...    }                                                                                                   @Override    public void handleMessage(Message msg) {        // ...    }}                                                                                               final ViewRootHandler mHandler = new ViewRootHandler();
复制代码

我们观察一下 Handler 对象的构造函数,在未明示 Looper 的情况下,默认使用的是 Looper.myLooper(),myLooper 是从 ThreadLocal 中获取当前线程的 looper 对象使用。


结合我们之前讨论的 ViewRootImpl 对象的 mThread 是其实例化时所在的线程,由此,我们知道 ViewRootImpl 的 mHandler 线程与实例化线程是同一个线程。

// andriod.os.Handlerpublic Handler(@Nullable Callback callback, boolean async) {    // ...    mLooper = Looper.myLooper();    // ...    mQueue = mLooper.mQueue;    // ...} // andriod.os.Looper/** * Return the Looper object associated with the current thread.  Returns * null if the calling thread is not associated with a Looper. */public static @Nullable Looper myLooper() {    return sThreadLocal.get();}
复制代码

我们再观察一下 ViewRootImpl 内部持有的 mChoreographer 对象中的 Handler 线程是哪一个线程。


mChoreographer 实例化是在 ViewRootImpl 对象实例化时,通过 Choreographer#getInstance 方法获得。

// ViewRootImpl.java public ViewRootImpl(Context context, Display display) {    // ...    mThread = Thread.currentThread();    // ...    mChoreographer = Choreographer.getInstance();}
复制代码

观察 Choreographer 代码,可以看出,getInsatance 方法返回的也是通过 ThreadLocal 获取到的当前线程实例;


当前线程实例同样使用的是当前线程的 looper(Looper#myLooper),而非强制指定主线程 Looper(Looper#getMainLooper)。


由此,我们得出结论,整个绘制过程中,

自 View#invalidate 方法触发,至注册垂直同步信号监听(DisplayEventReceiver#nativeScheduleVsync),以及垂直同步信号回调(DisplayEventReceiver#onVsync)至 View 的 measue/layout/draw 方法调用,均在同一个线程(UI 线程),而系统并未限制该现场必须为主线程。


// andriod.view.Choreographer // Thread local storage for the choreographer.private static final ThreadLocal<Choreographer> sThreadInstance =        new ThreadLocal<Choreographer>() {    @Override    protected Choreographer initialValue() {        Looper looper = Looper.myLooper();        // ...        Choreographer choreographer = new Choreographer(looper, VSYNC_SOURCE_APP);        if (looper == Looper.getMainLooper()) {            mMainInstance = choreographer;        }        return choreographer;    }}; /** * Gets the choreographer for the calling thread.  Must be called from * a thread that already has a {@link android.os.Looper} associated with it. * * @return The choreographer for this thread. * @throws IllegalStateException if the thread does not have a looper. */public static Choreographer getInstance() {    return sThreadInstance.get();}
复制代码


上文分析的 Android 绘制流程和 UI 线程控制,可以总结为下图:



至此我们可以得到一个推论:拥有窗口(Window)展示的 View,其 UI 线程可以独立于 App 主线程


下面我们编码实践验证一下。


四、编码验证与实践


其实实际中屏幕内容的绘制从来都不是完全在一个线程中完成的,最常见的场景比如:


  1. 视频播放时,视频画面的绘制并不是 App 的主线程及 UI 线程。

  2. 系统 Toast 的弹出等绘制,是由系统层面统一控制,也并非 App 自身的主线程或 UI 线程绘制。


结合工作案例,我们尝试将 SDK 的整个 PopupWindow 弹窗整体置于子线程,即为 SDK 的 PopupWindow 指定一个独立的 UI 线程。


我们使用 PopupWindow 实现一个定制的可交互的 Snackbar 弹窗,在弹窗的管理类中,定义并实例化好自定义的 UI 线程及 Handler;


注意 PopupWindow 的 showAtLocation 方法执行,会抛至自定义 UI 线程中(dismiss 同理)。理论上,弹窗的 UI 线程会变为我们的自定义线程。

// Snackbar弹窗管理类public class SnackBarPopWinManager {     private static SnackBarPopWinManager instance;     private final Handler h; // 弹窗的UI线程Handler     // ...     private SnackBarPopWinManager() {        // 弹窗的UI线程        HandlerThread ht = new HandlerThread("snackbar-ui-thread");        ht.start();        h = new Handler(ht.getLooper());    }     public Handler getSnackbarWorkHandler() {        return h;    }     public void presentPopWin(final SnackBarPopWin snackBarPopWin) {        // UI操作抛至自定义的UI线程        h.postDelayed(new SafeRunnable() {            @Override            public void safeRun() {                // ..                // 展示弹窗                snackBarPopWin.getPopWin().showAtLocation(dependentView, Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL, 0, y);                // 定时自动关闭                snackBarPopWin.dismissAfter(5000);                // ...        });    }     public void dismissPopWin(final SnackBarPopWin snackBarPopWin) {        // UI操作抛至自定义的UI线程        h.postDelayed(new SafeRunnable() {            @Override            public void safeRun() {                // ...                // dismiss弹窗                snackBarPopWin.getPopWin().dismiss();                // ...        });    }     // ...}
复制代码

之后,我们定义好弹窗本身,其弹出、消失等方法均通过管理类实现执行。

// Snackbar弹窗本身(通过PopupWindow实现)public class SnackBarPopWin extends PointSnackBar implements View.OnClickListener {     private PopupWindow mPopWin;     public static SnackBarPopWin make(String alertText, long points, String actionId) {        SnackBarPopWin instance = new SnackBarPopWin();        init(instance, alertText, actionId, points);        return instance;    }     private SnackBarPopWin() {        // infalte等耗时操作        // ...        View popView = LayoutInflater.from(context).inflate(R.layout.popwin_layout, null);        // ...        mPopWin = new PopupWindow(popView, ...);        // ...    }     // 用户的UI操作,回调应该也在UI线程    public void onClick(View v) {        int id = v.getId();        if (id == R.id.tv_popwin_action_btn) {            onAction();        } else if (id == R.id.btn_popwin_cross) {            onClose();        }    }     public void show(int delay) {        // ...        SnackBarPopWinManager.getInstance().presentPopWin(SnackBarPopWin.this);    }     public void dismissAfter(long delay) {        // ...        SnackBarPopWinManager.getInstance().dismissPopWin(SnackBarPopWin.this);    }     // ... }
复制代码

此时,我们在子线程中实例化弹窗,并在 2s 后,同样在子线程中改变 TextView 内容。

// MainActivity.java public void snackBarSubShowSubMod(View view) {     WorkThreadHandler.getInstance().post(new SafeRunnable() {        @Override        public void safeRun() {            String htmlMsg = "已读新闻<font color=#ff1e02>5</font>篇,剩余<font color=#00af57>10</font>次,延迟0.3s";            final PointSnackBar snackbar = PointSnackBar.make(htmlMsg, 20, "");            if (null != snackbar) {                snackbar.snackBarBackgroundColor(mToastColor)                        .buttonBackgroundColor(mButtonColor)                        .callback(new PointSnackBar.Callback() {                    @Override                    public void onActionClick() {                        snackbar.onCollectSuccess();                    }                }).show();            }             // 在自定义UI线程中更新视图            SnackBarPopWinManager.getInstance().getSnackbarWorkHandler().postDelayed(new SafeRunnable() {                @Override                public void safeRun() {                    try {                        snackbar.alertText("恭喜完成<font color='#ff00ff'>“UI更新”</font>任务,请领取积分");                    } catch (Exception e) {                        DemoLogUtils.e(TAG, "error: ", e);                    }                }            }, 2000);        }    });}
复制代码

展示效果,UI 正常展示交互,并在由于在不同的线程中绘制 UI,也并不会影响到 App 主线程操作及动效:


观察点击事件的响应线程为自定义 UI 线程,而非主线程:


(注:实践中的代码并未真实上线。SDK 线上版本中 PopupWindow 的 UI 线程仍然与 App 一致,使用主线程)。

五、总结


对于 Android 子线程不能操作 UI 的更深入理解:控制 View 绘制的线程和通知 View 更新的线程必须是同一线程,也即 UI 线程一致。


对于弹窗等与 App 其他业务相对独立的场景,可以考虑多 UI 线程优化。


后续工作中,清晰辨析 UI 线程、主线程、子线程的概念,尽量不要混用。


当然,多 UI 线程也有一些不适用的场景,如以下逻辑:


  1. Webview 的所有方法调用必须在主线程,因为其代码中强制做了主线程校验,如 PopupWindow 中内置 Webview,则不适用多 UI 线程。

  2. Activity 的使用必须在主线程,因为其创建等操作中使用的 Handler 也被强制指定为 mainThreadHandler。


参考:

  1. Android 屏幕刷新机制

  2. 为什么Android必须在主线程更新UI

发布于: 刚刚阅读数: 3
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
Android 子线程 UI 操作真的不可以?_android_vivo互联网技术_InfoQ写作社区