View.requestLayout() 不生效的问题

View 的 requestLayout() 方法顾名思义用来触发一次 layout 行为,一般是当我们改变一些影响 View 布局的参数后调用,刷新 View 的布局。常见的使用方式如下:

view.layoutParams.apply{
    width = 100
    height = 200
}
view.requestLayout()

要分析调用失效的原因,首先我们需要搞清楚 requestLayout() 流程。
requestLayout 调用流程
调用 requestLayout() 之后是如何开始一次 layout 的呢?我们看一下 requestLayout() 的源码:

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() 方法,这个方法代码如下:

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 不可以,至于原因后面会讲到。

request-during-layout 从字面上看是在进行一次 layout pass 时,当前界面中有 View 调用 requestLayout()。代码中可以看到通过 viewRoot.isInLayout() 判断当前是否在 layout,然后调用 ViewRootImpl.requestLayoutDuringLayout 方法,我们继续看看这个方法:

...
if (!mLayoutRequesters.contains(view)) {
    mLayoutRequesters.add(view);
}
...

核心逻辑就上面这一句,将发起 View 添加在 ViewRootImpl 的 mLayoutRequesters 列表中。继续看看什么时候使用这个列表,通过查看代码发现使用的地方在 performLayout() 中,前一句代码是 mInLayout = false 说明是在上一次 layout pass 结束后处理这个列表。处理的逻辑也比较简单,先对这个列表进行过滤拿到有效的 View,然后再依次调用 requestLayout() 的方法。

所以 request-during-layout 的处理可以简单理解为将在 layout 过程中的 requestLayout() 调用延迟到当前 layout pass 结束后再调用,这样也就理解了为什么只有发起 View 需要触发 request-during-layout 逻辑。

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 解决,显然它没有。

  1. 即使 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 结束后再延迟调用。

总结

学习技术是一条慢长而艰苦的道路,不能靠一时激情,也不是熬几天几夜就能学好的,必须养成平时努力学习的习惯。所以:贵在坚持!
最后,祝愿即将跳槽和已经开始求职的大家都能找到一份好的工作,这些面试题分享在我的Android 移动互联网群里,可以来群里下载,群里还有一些行业大牛,群里也会有不定时送书等活动,欢迎前来下载。

喜欢请点击+关注哦

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容