View.requestLayout() 不生效的問題

View 的 requestLayout() 方法顧名思義用來觸發一次 layout 行爲,通常是當咱們改變一些影響 View 佈局的參數後調用,刷新 View 的佈局。常見的使用方式以下:java

view.layoutParams.apply{
    width = 100
    height = 200
}
view.requestLayout()
複製代碼

要分析調用失效的緣由,首先咱們須要搞清楚 requestLayout() 流程。android

requestLayout 調用流程

調用 requestLayout() 以後是如何開始一次 layout 的呢?咱們看一下 requestLayout() 的源碼:bash

public void requestLayout() {
    if (mMeasureCache != null) mMeasureCache.clear();

    if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == null) {
        // Only trigger request-during-layout logic if this is the view requesting it,
        // not the views in its parent hierarchy
        ViewRootImpl viewRoot = getViewRootImpl();
        if (viewRoot != null && viewRoot.isInLayout()) {
            if (!viewRoot.requestLayoutDuringLayout(this)) {
                return;
            }
        }
        mAttachInfo.mViewRequestingLayout = this;
    }

    mPrivateFlags |= PFLAG_FORCE_LAYOUT;
    mPrivateFlags |= PFLAG_INVALIDATED;

    if (mParent != null && !mParent.isLayoutRequested()) {
        mParent.requestLayout();
    }
    if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
        mAttachInfo.mViewRequestingLayout = null;
    }
}
複製代碼

這個方法邏輯比較簡單,首先是將 MeasureCache 清掉,爲即將開始的 layout 作準備。接下來一段代碼根據註釋應該是 request-during-layout 的邏輯,這部分咱們先略事後面再講。再下來的代碼是這個方法的核心:設置繪製狀態位和調用 parent 的 requestLayout。PFLAG_FORCE_LAYOUT 表示當前 View 須要 layout,能夠理解爲 View 當前的佈局數據已通過期,須要下一次 layout pass 從新佈局,PFLAG_INVALIDATED 和 PFLAG_FORCE_LAYOUT 相似,只不過表示的是繪製數據。爲何要調用 parent 的 requestLayout() 這裏稍微解釋下,由於父 View 是包含着子 View 的,子 View 的佈局通常決定着父 View 的佈局,因此當子 View 佈局發生改變時也要通知父 View 刷新本身的佈局。經過一級一級向上調用最終調用到 ViewRootImpl 的 requestLayout() 方法,這個方法代碼以下:app

if (!mHandlingLayoutInLayoutRequest) {
    checkThread();
    mLayoutRequested = true;
    scheduleTraversals();
}
複製代碼

邏輯很簡單,佈局工做應該在 scheduleTraversals() 方法中完成,經過繼續跟進調用關係,最終調到了 performLayout() 方法,在這個方法中調用的是 DecorView 的 layout() 方法,開始了咱們熟悉的佈局流程,自頂向下調用子 View 的 layout() 方法,和 requestLayout() 的調用方向恰好相反,以下圖:佈局

request-during-layout 處理

如今咱們來看一下 View.requestLayout() 中剛纔跳過的部分,這裏經過 mAttachInfo.mViewRequestingLayout 變量來肯定發起 requestLayout() 的 View,由於只有發起 View 能觸發 request-during-layout 邏輯,它的祖先 Views 不能夠,至於緣由後面會講到。post

request-during-layout 從字面上看是在進行一次 layout pass 時,當前界面中有 View 調用 requestLayout()。代碼中能夠看到經過 viewRoot.isInLayout() 判斷當前是否在 layout,而後調用 ViewRootImpl.requestLayoutDuringLayout 方法,咱們繼續看看這個方法:ui

...
if (!mLayoutRequesters.contains(view)) {
    mLayoutRequesters.add(view);
}
...
複製代碼

核心邏輯就上面這一句,將發起 View 添加在 ViewRootImpl 的 mLayoutRequesters 列表中。繼續看看何時使用這個列表,經過查看代碼發現使用的地方在 performLayout() 中,前一句代碼是 mInLayout = false 說明是在上一次 layout pass 結束後處理這個列表。處理的邏輯也比較簡單,先對這個列表進行過濾拿到有效的 View,而後再依次調用 requestLayout() 的方法。this

因此 request-during-layout 的處理能夠簡單理解爲將在 layout 過程當中的 requestLayout() 調用延遲到當前 layout pass 結束後再調用,這樣也就理解了爲何只有發起 View 須要觸發 request-during-layout 邏輯。spa

requestLayout() 調用失效緣由

根據 requestLayout() 的調用流程能夠發現,若是由下到上的調用中斷沒法調到 ViewRootImpl.requestLayout() 的話就會致使沒法刷新佈局。經過查看源碼咱們發現調用父 View 的 requestLayout() 以前須要知足兩個條件 parent != null 和 !parent.isLayoutRequested(),若是 parent 爲空說明當前 View 不在界面上,那也不須要刷新佈局,這個條件是合理的。設計

另一個條件表示 parent 已經調用過 requestLayout(),這個判斷爲了防止正在進行的佈局沒有結束時開始下一次佈局。但若是咱們確實須要刷新當前界面的佈局該怎麼辦呢?沒事,View 的設計者想到了這種狀況,對應的解決方案就是上面的 request-during-layout 處理。

不過 request-during-layout 處理並非萬無一失的,它有兩個漏洞仍是會形成 requestLayout() 調用失效:

  1. request-during-layout 的處理必須是在 View.isInLayout == true 時才能奏效,若是當前不在 layout pass 中並且 requestLayout() 調用鏈沒法做用到 ViewRootImpl.requestLayout() 時調用仍是會失效。咱們前面有提到祖先 View.isLayoutRequested() == true 的狀況就是當前界面在進行 layout,但這裏卻說 isInLayout == false,是否是和前面說的自相矛盾了?固然不是。首先咱們先看看 View.isInLayout() 的代碼:

    public boolean isInLayout() {
        ViewRootImpl viewRoot = getViewRootImpl();
        return (viewRoot != null && viewRoot.isInLayout());
    }
    複製代碼

    能夠看到 isInLayout() 依賴於 ViewRootImpl.isInLayout(),繼續看看這個方法:

    boolean isInLayout() {
       return mInLayout;
    }
    複製代碼

    mInLayout = true 僅在 ViewRootImpl.performLayout() 中存在,換句話說只有這個方法觸發的佈局刷新纔會令 View.isInLayout() == true,若是經過別的途徑觸發佈局刷新就會致使這種 requestLayout() 調用失效。具體會有什麼佈局刷新調用不是經過 ViewRootImpl.performLayout() 發起的呢?目前遇到的一種狀況是在 RecyclerView 中滑動頁面引發的 itemView 佈局刷新,具體來講是將界面外的 itemView 滑動到界面內時。一個調用棧例子以下:

    ...
    at android.view.View.layout(View.java:22254)
    at android.view.ViewGroup.layout(ViewGroup.java:6310)
    at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1829)
    at android.widget.LinearLayout.layoutHorizontal(LinearLayout.java:1818)
    at android.widget.LinearLayout.onLayout(LinearLayout.java:1584)
    at android.view.View.layout(View.java:22254)
    at android.view.ViewGroup.layout(ViewGroup.java:6310)
    at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1829)
    at android.widget.LinearLayout.layoutVertical(LinearLayout.java:1673)
    at android.widget.LinearLayout.onLayout(LinearLayout.java:1582)
    at android.view.View.layout(View.java:22254)
    at android.view.ViewGroup.layout(ViewGroup.java:6310)
    at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1829)
    at android.widget.LinearLayout.layoutVertical(LinearLayout.java:1673)
    at android.widget.LinearLayout.onLayout(LinearLayout.java:1582)
    at android.view.View.layout(View.java:22254)
    at android.view.ViewGroup.layout(ViewGroup.java:6310)
    at androidx.recyclerview.widget.RecyclerView$LayoutManager.layoutDecoratedWithMargins(RecyclerView.java:9322)
    at androidx.recyclerview.widget.LinearLayoutManager.layoutChunk(LinearLayoutManager.java:1615)
    at androidx.recyclerview.widget.LinearLayoutManager.fill(LinearLayoutManager.java:1517)
    at androidx.recyclerview.widget.LinearLayoutManager.scrollBy(LinearLayoutManager.java:1331)
    at androidx.recyclerview.widget.LinearLayoutManager.scrollVerticallyBy(LinearLayoutManager.java:1075)
    at androidx.recyclerview.widget.RecyclerView.scrollStep(RecyclerView.java:1832)
    at androidx.recyclerview.widget.RecyclerView.scrollByInternal(RecyclerView.java:1927)
    at androidx.recyclerview.widget.RecyclerView.onTouchEvent(RecyclerView.java:3187)
    ...
    at com.android.internal.policy.DecorView.superDispatchTouchEvent(DecorView.java:448)
    at com.android.internal.policy.PhoneWindow.superDispatchTouchEvent(PhoneWindow.java:1840)
    at android.app.Activity.dispatchTouchEvent(Activity.java:3873)
    at androidx.appcompat.view.WindowCallbackWrapper.dispatchTouchEvent(WindowCallbackWrapper.java:69)
    at androidx.appcompat.view.WindowCallbackWrapper.dispatchTouchEvent(WindowCallbackWrapper.java:69)
    at com.android.internal.policy.DecorView.dispatchTouchEvent(DecorView.java:406)
    at android.view.View.dispatchPointerEvent(View.java:14056)
    ...
    at android.view.ViewRootImpl.deliverInputEvent(ViewRootImpl.java:7621)
    ...
    at android.view.InputEventReceiver.dispatchInputEvent(InputEventReceiver.java:188)
    ...
    複製代碼

    從上面的調用棧能夠清楚的看到 InputEvent -> TouchEvent -> RecyclerView.scroll*() -> LinearLayoutManager.scroll*() -> LinearLayoutManager.layoutChunk() -> itemView.layout() 這樣一個調用流程,這裏的 itemView 確實處於 layout 過程當中,但不是 ViewRootImpl.performLayout 發起的,因此 View.isInLayout() == false,就會觸發咱們這條調用失效。這個漏洞是 View 的設計者的責任嗎?我認爲不是的,由滾動觸發 layout 的行爲是 RecyclerView 的特殊處理,而對這種特殊處理致使的 requestLayout() 調用失效就應該由觸發者 RecyclerView 解決,顯然它沒有。

  2. 即便 request-during-layout 可以被觸發,在延遲調用 requestLayout() 前還會對發起 View 進行一次過濾,該 View 和它的祖先 View 的 visibility 必須不是 GONE,而且被設置了 View.PFLAG_FORCE_LAYOUT 狀態,對應代碼在 ViewRootImpl.getValidLayoutRequesters()可見。第一個過濾條件能夠理解,不可見的 View 不須要佈局。第二個可能會形成調用失效,該狀態表示是否須要被從新佈局,調用 requestLayout() 時該狀態被啓用,layout 完成後被清掉。好比在一次 layout 中剛經過調用 requestLayout() 設置了 View.PFLAG_FORCE_LAYOUT,而後還沒等到 request-during-layout 處理,這個標誌位就被清掉了。有這種可能麼?有的,代碼以下:

    view.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> 
    	v.layoutParams.width = 100
    	v.requestLayout()
    }
    複製代碼

    上面代碼中的 requestLayout() 不會起做用,爲何呢?咱們看看 onLayoutChangeListener 在哪裏被調用:

    public void layout(int l, int t, int r, int b) {
        ...
        
    	if (li != null && li.mOnLayoutChangeListeners != null) {
    		ArrayList<OnLayoutChangeListener> listenersCopy =
                        (ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
    		int numListeners = listenersCopy.size();
    		for (int i = 0; i < numListeners; ++i) {
    			listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
            }
        }
    
        final boolean wasLayoutValid = isLayoutValid();
    
        mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;
        mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;
    
        ...
    }
    複製代碼

    從上面的代碼能夠看到 onLayoutChangeListener 被調用後 PFLAG_FORCE_LAYOUT 就被清掉了。

解決方案

既然知道了 requestLayout() 失效的緣由,那如何才能解決這個問題呢?具體代碼以下:

fun View.isSafeToRequestDirectly():Boolean {
	return if (isInLayout) {
        // when isInLayout == true and isLayoutRequested == true,
        // means that this layout pass will layout current view which will
        // make currentView.isLayoutRequested == false, and this will let currentView
        // ignored in process handling requests called during last layout pass.
        isLayoutRequested.not()
    } else {
        var ancestorLayoutRequested = false
        var p: ViewParent? = parent
        while (p != null) {
            if (p.isLayoutRequested) {
                ancestorLayoutRequested = true
                break
            }
            p = p.parent
        }
        ancestorLayoutRequested.not()
    }
}

fun View.safeRequestLayout() {
    if (isSafeToRequestDirectly()) {
    	requestLayout()
    } else {
    	post { requestLayout() }
    }
}
複製代碼

經過 isSafeToRequestDirectly() 來判斷調用 requestLayout() 是否奏效,這個方法裏面分別從 isInLayout == true/false 兩種狀況判斷,對應上面分析的兩種失效狀況。若是是的話就直接調用不然經過 post() 方法等當前 layout 結束後再延遲調用。

相關文章
相關標籤/搜索