Android 子執行緒 UI 操作真的不可以?
作者: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.java void 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被賦值的一系列資訊。
其中可以看到有一些關鍵內容:
-
視窗(Window)相關的類、資訊及IPC類。
-
ViewRootImpl物件:這個類就是會觸發CalledFromWrongThreadException的來源。
-
其他資訊。
其實透過上面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,有兩種途徑:
-
由一個已attachWindow的父View呼叫其addView方法,將子view也attach至同一個window,從而擁有viewRootImpl。(material Snackbar方式)
-
透過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檢視樹繪製任務:
-
首先會在UI執行緒的訊息佇列中新增同步訊息屏障,保障後續的繪製非同步訊息的優先執行;
-
之後會向Choreographer註冊一個Runnable物件,由前者決定何時呼叫Runnable的run方法;
-
而該Runnable物件就是doTraversal方法,即真正執行檢視樹遍歷繪製的方法。
// ViewRootImpl.java final 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. */ @UnsupportedAppUsage public 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.Handler public 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主執行緒。
下面我們編碼實踐驗證一下。
四、編碼驗證與實踐
其實實際中螢幕內容的繪製從來都不是完全在一個執行緒中完成的,最常見的場景比如:
-
影片播放時,影片畫面的繪製並不是App的主執行緒及UI執行緒。
-
系統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執行緒也有一些不適用的場景,如以下邏輯:
-
Webview的所有方法呼叫必須在主執行緒,因為其程式碼中強制做了主執行緒校驗,如PopupWindow中內建Webview,則不適用多UI執行緒。
-
Activity的使用必須在主執行緒,因為其建立等操作中使用的Handler也被強制指定為mainThreadHandler。
參考:
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912579/viewspace-2895656/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Android子執行緒真的不能更新UI麼Android執行緒UI
- Android中子執行緒真的不能更新UI嗎?Android執行緒UI
- Android 中子執行緒真的不能更新UI嗎?Android執行緒UI
- Android複習之旅--子執行緒更新UIAndroid執行緒UI
- Android的Activity啟動與子執行緒更新UIAndroid執行緒UI
- 子執行緒與UI執行緒的通訊(委託)執行緒UI
- 如何在子執行緒中更新UI執行緒UI
- 子執行緒 UI 問題捉蟲執行緒UI
- pyqt5 子執行緒如何操作主執行緒GUIQT執行緒GUI
- Android 不能在子執行緒中更新 UI 的討論和分析Android執行緒UI
- 老問題:Android子執行緒中更新UI的3種方法Android執行緒UI
- Android中子執行緒更新主執行緒UI和ProgressBar的應用Android執行緒UI
- android 關於關於子執行緒更新UI的一些事Android執行緒UI
- MFC UI執行緒UI執行緒
- Android JNI 中的執行緒操作Android執行緒
- [原] Android performClick無效,UI執行緒理解AndroidORMUI執行緒
- 執行緒操作執行緒
- iOS 在主執行緒操作UI不能保證安全iOS執行緒UI
- Android新執行緒中更新主執行緒UI中的View方法彙總Android執行緒UIView
- android 主執行緒和子執行緒之間的訊息傳遞Android執行緒
- 關於“UI執行緒”UI執行緒
- 主執行緒中也不絕對安全的 UI 操作執行緒UI
- QT 主執行緒子執行緒互相傳值QT執行緒
- 多執行緒操作執行緒
- Android中UI執行緒與後臺執行緒互動設計的5種方法AndroidUI執行緒
- Android多執行緒之執行緒池Android執行緒
- Android的執行緒和執行緒池Android執行緒
- Java 執行緒常用操作Java執行緒
- 執行緒安全操作HashMap執行緒HashMap
- Android程式框架:執行緒與執行緒池Android框架執行緒
- Android執行緒篇(二)Java執行緒池Android執行緒Java
- Android執行緒管理之ExecutorService執行緒池Android執行緒
- Android 進階 ———— Handler系列之建立子執行緒HandlerAndroid執行緒
- 執行緒的基本操作:新建和終止執行緒執行緒
- 模擬主執行緒等待子執行緒的過程執行緒
- iOS拾遺——為什麼必須在主執行緒操作UIiOS執行緒UI
- android程式與執行緒詳解二:執行緒Android執行緒
- Android執行緒池Android執行緒