從繪製時機深入淺出View.post

山有木xi發表於2024-01-14

最近新接手了一個專案的 B ug 修復任務 ,其中有一個Bug 是在一個頁面有一個 ImageView,ImageView 根據傳入的圖片設定寬度拉滿或者高度拉滿,然後根據 imageView 的大小透過程式碼繪製一個新的 View ,在 onCreate 中繪製總是位置錯亂,放入 onResume 後, 從後臺 重新進入 應用 就可以正常繪製。


1) 最開始以為是 繪製的時機問題

因為放在 onResume 然後重新進入是正常的,因為這個頁面比較頻繁的設定 view LayoutParams ,懷疑是某個 LayoutParams 設定的時機導致繪製的 view 的位置錯亂(這裡其實思路是接近的,但是沒有繼續深入,導致後面走了一些彎路),但是隻放入 onResume 後還是錯誤的位置


2) view 的繪製 演演算法 有問題

但是第二次繪製可以正常執行 甚至後面在重新建立的乾淨的類中也是正常的繪製

 

3) 思考兩次繪製過程中發生了什麼

因為這個類頻繁的設定 LayoutParams 所以開始把注意力放在這上面 看了下 LayoutParams 的原始碼

public void setLayoutParams(ViewGroup.LayoutParams params) {
        if (params == null) {
            throw new NullPointerException("params == null");
        }
        mLayoutParams = params;
        requestLayout();
}

看到了requestLayout() ,突然想起來,對啊,修改了 View 大小、位置等資訊會重新繪製,檢視的測量( measure )和佈局( layout )過程需要重新執行才能更新檢視的尺寸。

佈局過程的執行是非同步的,系統會在下一個 UI 幀中才會更新檢視的尺寸。因此,如果在修改 LayoutParams 後立即嘗試獲取檢視的新尺寸,很可能會得到舊的尺寸值。 而我們繪製的 View 是建立出來的 他的舊尺寸的寬高就是 0 所以導致我們的演演算法計算的有問題

 

為了驗證 寫了個測試的步驟

 view.postDelayed({
                        // 重新操作
                    },500)

當所有佈局設定完成後 延遲 500ms 重新繪製 這個時候就是正常的 所以問題就是我發現的那樣 但是真正的解決方案肯定不應該是這樣 太不優雅了

解決方案有兩個

1 、在所有操作執行完成後,執行頂層 View.post ,將繪製 view 的操作加入頂層 View 的最後一個操作,在頂層 View 繪製到最後一步時會自動幫我們執行繪製的步驟

binding.root.post {
            //操作
        }

2、 在所有操作執行完成後,監聽頂層佈局的繪製,當頂層View 繪製完成後,也就意味著真實的寬 / 高出現了

val observer: ViewTreeObserver = binding.root.viewTreeObserver
observer.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
   override fun onGlobalLayout() {
       // 移除監聽器,避免重複呼叫
       observer.removeOnGlobalLayoutListener(this)
       // 獲取檢視的新尺寸
       // 在這裡可以使用新的尺寸值
   }
})

我推薦 view .post 的解決方案 ,那麼view.post 到底幹了什麼, 為什麼會執行我們需要的操作呢 先來看看原始碼

 public boolean post(Runnable action) {
        final AttachInfo attachInfo = mAttachInfo;
        if (attachInfo != null) {
            return attachInfo.mHandler.post(action);
        }
        // Postpone the runnable until we know on which thread it needs to run.
        // Assume that the runnable will be successfully placed after attach.
        getRunQueue().post(action);
        return true;
    }
 
    private HandlerActionQueue getRunQueue() {
        if (mRunQueue == null) {
            mRunQueue = new HandlerActionQueue();
        }
        return mRunQueue;
}

view.post 中第一個邏輯是 AttachInfo AttachInfo View 內部的一個靜態類,其內部持有一個 Handler 物件,從註釋 可以知道 它是由 ViewRootImpl 提供的

inal static class AttachInfo {
    
        /**
         * A Handler supplied by a view's {@link android.view.ViewRootImpl}. This
         * handler can be used to pump events in the UI events queue.
         */
        @UnsupportedAppUsage
        final Handler mHandler;
 
     AttachInfo(IWindowSession session, IWindow window, Display display,
                ViewRootImpl viewRootImpl, Handler handler, Callbacks effectPlayer,
                Context context) {
            ···
            mHandler = handler;
            ···
     }
    
     ···
}

查詢 mAttachInfo 的賦值時機可以追蹤到 View dispatchAttachedToWindow 方法,該方法被呼叫就意味著 View 已經 Attach Window 上了

@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
    void dispatchAttachedToWindow(AttachInfo info, int visibility) {
        mAttachInfo = info;
        ···
    }

dispatchAttachedToWindow 方法的呼叫時機, 就到了  ViewRootImpl 類。 ViewRootImpl 內就包含一個 Handler 物件 mHandler ,並在建構函式中以 mHandler 作為構造引數之一來初始化 mAttachInfo

ViewRootImpl performTraversals() 方法就會呼叫 DecorView dispatchAttachedToWindow 方法並傳入 mAttachInfo ,從而層層呼叫整個檢視樹中所有 View dispatchAttachedToWindow 方法,使得所有 childView 都能獲取到 mAttachInfo 物件

除此之外 performTraversals() 方法也負責啟動整個檢視樹的 Measure Layout Draw 流程,只有當 performLayout 被呼叫後 View 才能確定自己的寬高資訊。而 performTraversals() 本身也是交由 ViewRootHandler 來呼叫的

即整個檢視樹的繪製任務也是先插入到 MessageQueue 中,後續再由主執行緒取出任務進行執行。由於插入到 MessageQueue 中的訊息是交由主執行緒來順序執行的,所以 attachInfo.mHandler.post(action) 就保證了 action 一定是在 performTraversals 執行完畢後才會被呼叫,因此我們就可以在 Runnable 中獲取到 View 的真實寬高了

 

再來看看第二個處理邏輯 getRunQueue().post(action);getRunQueue 實際上返回了一個 HandlerActionQueue ,所以本質上就是呼叫了 HandlerActionQueue.post 讓我們來看看 HandlerActionQueue

public class HandlerActionQueue {
    
    private HandlerAction[] mActions;
    private int mCount;
 
    public void post(Runnable action) {
        postDelayed(action, 0);
    }
 
    public void postDelayed(Runnable action, long delayMillis) {
        final HandlerAction handlerAction = new HandlerAction(action, delayMillis);
        synchronized (this) {
            if (mActions == null) {
                mActions = new HandlerAction[4];
            }
            mActions = GrowingArrayUtils.append(mActions, mCount, handlerAction);
            mCount++;
        }
    }
 
    public void executeActions(Handler handler) {
        synchronized (this) {
            final HandlerAction[] actions = mActions;
            for (int i = 0, count = mCount; i < count; i++) {
                final HandlerAction handlerAction = actions[i];
                handler.postDelayed(handlerAction.action, handlerAction.delay);
            }
 
            mActions = null;
            mCount = 0;
        }
    }
 
    private static class HandlerAction {
        final Runnable action;
        final long delay;
 
        public HandlerAction(Runnable action, long delay) {
            this.action = action;
            this.delay = delay;
        }
 
        public boolean matches(Runnable otherAction) {
            return otherAction == null && action == null
                    || action != null && action.equals(otherAction);
        }
    }
    
    ···
    
}

HandlerActionQueue 可以看做是一個專門用於儲存 Runnable 的任務佇列, mActions 就儲存了所有要執行的 Runnable 和相應的延時時間。兩個 post 方法就用於將要執行的 Runnable 物件儲存到 mActions 中, executeActions 就負責將 mActions 中的所有任務提交給 Handler 執行

 

所以說,getRunQueue().post(action) 只是將我們提交的 Runnable 物件儲存到了 mActions 中,還需要外部主動呼叫 executeActions 方法來執行任務

而這個主動執行任務的操作也是由 View dispatchAttachedToWindow 來完成的,從而使得 mActions 中的所有任務都會被插入到 mHandler MessageQueue 中,等到主執行緒執行完 performTraversals() 方法後就會來執行 mActions ,所以此時我們依然可以獲取到 View 的真實寬高

 

onCreate onResume 函式中為什麼無法也直接得到 View 的真實寬高呢?

從結果反推原因,這說明當 onCreate onResume 被回撥時 ViewRootImpl performTraversals() 方法還未執行,那麼 performTraversals() 方法的具體執行時機是什麼時候呢?

這可以從 ActivityThread -> WindowManagerImpl -> WindowManagerGlobal -> ViewRootImpl 這條呼叫鏈上找到答案

首先,ActivityThread handleResumeActivity 方法就負責來回撥 Activity onResume 方法,且如果當前 Activity 是第一次啟動,則會向 ViewManager wm )新增 DecorView

@Override
    public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward,
            String reason) {
        ···
        //Activity 的 onResume 方法
        final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason);
        ···
        if (r.window == null && !a.mFinished && willBeVisible) {
            ···
            ViewManager wm = a.getWindowManager();
            if (a.mVisibleFromClient) {
                if (!a.mWindowAdded) {
                    a.mWindowAdded = true;
                   
                    wm.addView(decor, l);
                } else {
                    a.onWindowAttributesChanged(l);
                }
            }
        } else if (!willBeVisible) {
            if (localLOGV) Slog.v(TAG, "Launch " + r + " mStartedActivity set");
            r.hideForNow = true;
        }
···
}


此處的 ViewManager 的具體實現類即 WindowManagerImpl WindowManagerImpl 會將操作轉交給 WindowManagerGlobal

@UnsupportedAppUsage
    private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();
 
    @Override
    public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
        applyDefaultToken(params);
        mGlobal.addView(view, params, mContext.getDisplayNoVerify(), mParentWindow,
                mContext.getUserId());
    }

WindowManagerGlobal 就會完成 ViewRootImpl 的初始化並且呼叫其 setView 方法,該方法內部就會再去呼叫 performTraversals 方法啟動檢視樹的繪製流程

public void addView(View view, ViewGroup.LayoutParams params,
            Display display, Window parentWindow, int userId) {
        ···
        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);
            // do this last because it fires off messages to start doing things
            try {
                root.setView(view, wparams, panelParentView, userId);
            } catch (RuntimeException e) {
                // BadTokenException or InvalidDisplayException, clean up.
                if (index >= 0) {
                    removeViewLocked(index, true);
                }
                throw e;
            }
        }
    }

所以說, performTraversals 方法的呼叫時機是在 onResume 方法之後,所以我們在 onCreate onResume 函式中都無法獲取到 View 的實際寬高。當然,當 Activity 在單次生命週期過程中第二次呼叫 onResume 方法時就可以獲取到 View 的寬高屬性 也就是文章開頭我遇到的 bug


來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/69917874/viewspace-3003770/,如需轉載,請註明出處,否則將追究法律責任。

相關文章