配置变更后,弹窗为什么飞到了最左边?

4 阅读5分钟

1. 事故现场:一个诡异的弹窗错位 Bug

1.1 问题描述

  • 配置变更(如屏幕旋转)后,Fragment 重建,弹窗出现在屏幕最左侧。

  • 正常点击 :和Title 中轴对齐

  • 日志定位:btnSelectRect.left = 0, right = 0

52e6d80a-38fc-4380-8858-38e55714a2cc.jpeg

1.2 调用链还原

配置变更
  → Fragment 销毁重建
  → onCreateView → onViewCreated
  → doObserver() → LiveData 首次绑定推送
  → pop()  ← 此时拿到的 View 坐标全为 0
private fun pop() {
    Log.d(TAG, " screeningPop: ")
    val context = context ?: return
    //计算筛选按钮的中轴位置
    val titleView = mBinding().Title
    val btnSelect = titleView.getButton()
    val btnSelectRect = android.graphics.Rect()
    btnSelect.getGlobalVisibleRect(btnSelectRect)
     
    //获得筛选按钮的中轴线坐标
    val left = btnSelectRect.left
    val right = btnSelectRect.right
    val centerX = (left + right) / 2
    //

  mDialog = MyDialog.Builder(context)
        .setAnchorViewCenter(centerX)
        .create()
mDialog !!.show()
}

1.3 为什么正常点击没问题?

  • 正常点击发生在页面完全布局之后,而 onViewCreated + LiveData 的首次推送发生在 performTraversals 之前。

1.4 错误解法

常见错误解法:view.postDelayed 写死一个时长 :View 执行完 performTraversals 时间不确定


2. 根因挖掘:View 布局的异步本质

2.1 Android View 的测量/布局/绘制三部曲

  • measurelayoutdraw,缺一不可。

2.2 setContentView 只构建树,不启动绘制

  • 你的 onCreateView 里 inflate 出来的 View 只是存在于内存中,尚无尺寸。

2.3 真正启动点: ViewRootImpl.setView() → 异步触发 performTraversals()

  • 流程图精简呈现:
handleResumeActivity()
  ├─ 1. performResumeActivity()
  │     ├─ Activity.onResume()
  │     └─ Fragment.onResume()
  └─ 2. wm.addView(decor, params)
        └─ ViewRootImpl.setView()
             └─ requestLayout()  → 下一帧 performTraversals()

从流程图中明显可知 在 activity 和 Fragment 执行完 onResume 方法后才执行 performTraversals 因此在 onresume 方法 时view 还没有完成 测量布局和绘制


3. 生命周期时序:onViewCreated 比布局“跑得快”

3.1 Fragment 的生命周期 vs. 布局周期

  • 用时间线对比图
[主线程,按时间顺序]

ActivityThread.performLaunchActivity()
  ├── Activity.attach()             // PhoneWindow 创建
  └── Instrumentation.callActivityOnCreate()
       └── Activity.onCreate()
            └── setContentView(R.layout.xxx)   // DecorView 及 View 树构建(包括静态Fragment)

...(如果在 onCreate 中有动态添加 Fragment)
FragmentManager.executePendingTransactions()    // 执行事务
  └── Fragment.onCreateView()
       ├── inflater.inflate(...)               // Fragment 的 View 对象创建
       └── return view
  └── Fragment.onViewCreated(view, ...)        // ★ 此时 View 存在,但未布局

...(Activity 继续走生命周期)
Activity.onStart()
FragmentManager.dispatchStart()
  └── Fragment.onStart()

...(可能有其他消息)

AMS 跨进程触发:
ActivityThread.handleResumeActivity()
  ├── performResumeActivity()
  │    ├── Activity.performResume()
  │    │    └── Activity.onResume()
  │    └── FragmentManager.dispatchResume()
  │         └── Fragment.onResume()            // ★ 仍在布局之前
  │
  └── wm.addView(decor, params)                // 创建 ViewRootImpl
       └── ViewRootImpl.setView(decorView, ...)
            └── requestLayout()
                 └── scheduleTraversals()      // 发送消息到主线程消息队列

... (当前消息执行完毕,主线程处理下一个消息)

[下一帧,或者下一次 VSYNC 信号到达]
ViewRootImpl.performTraversals()
  ├── performMeasure()
  ├── performLayout()                          // ★ 布局完成,宽高确定
  └── performDraw()                            //此时才能获得正确的宽高 

小结:常见误解是 认为 onViewCreated /onresume是“View 都创建好了,可以拿尺寸了”。

实际上是只是,View 实例存在,可以通过 findById 获得控件但还没有被 performTraversals 调度执行过 measure layout ,因此宽高还不固定

+专栏 《Fragment 生命周期全解:从 onAttach 到 onResume,源码视角的调用链与 View 创建》

+专栏 《Fragment 的 View 到底是谁的?——动态/静态添加的 View 归属源码溯源》

3.2 Fragment 的 View 就是 Activity 的 View 树的一个分支

  • FragmentManager.moveToStatecontainer.addView(fragment.mView) 将其挂到 DecorView 树下。

  • 因此,Fragment 的 View 的布局完全依赖 Activity 的 ViewRootImpl 执行的 performTraversals

    •   +专栏《Fragment 的 View 到底是谁的?——动态/静态添加的 View 归属源码溯源》

4. 解法:

 @SuppressLint("UseCompatLoadingForDrawables")
    private fun screeningPop() {
        val titleView = fragmentBinding().title
        val btnSelect = titleView.getButton()
        val context = context ?: return
        //等待onlayout 完毕 onlayout 过程中可能触发二次测量
        titleView.doOnLayout {
val btnSelectRect = android.graphics.Rect()
            btnSelect.getGlobalVisibleRect(btnSelectRect)
            //获得筛选按钮的中轴线坐标
            val left = btnSelectRect.left
            val right = btnSelectRect.right
            val centerX = (left + right) / 2
            Log.d(TAG, "screeningPop: left $left ,right $right ,centerX = $centerX")
            screeingDialog = ScreeingDialog.Builder(context)
                .setAnchorViewCenter(centerX)
                .create()
screeingDialog!!.show()
        }

为什么能用 doOnLayout

public inline fun View.doOnLayout(crossinline action: (view: View) -> Unit) {
    // ① 如果当前已经布局完成,并且没有触发新的布局请求,立即执行
    if (ViewCompat.isLaidOut(this) && !isLayoutRequested) {
        action(this)
    } else {
        // ② 否则,等到下一次布局完成再执行
        doOnNextLayout {
            action(it)
        }
    }
}

正常点击时的即时执行路径

在页面已稳定完成首次布局,且用户进行正常点击时,情况截然不同。此时 titleView 早已完成布局,且未请求新的布局。因此,doOnLayout 方法开头的条件 ViewCompat.isLaidOut(this) && !isLayoutRequested 直接为 trueaction(this) 被立即执行,弹窗直接在点击事件中同步创建并显示。

当配置变更导致 Fragment 重建时,此时新的 titleView 尚未完成布局screeningPop() 内调用 因此ViewCompat.isLaidOut(titleView) && !titleView.isLayoutRequested 的条件判断为 会走第二条路径。下面贴出源码:

public inline fun View.doOnNextLayout(crossinline action: (view: View) -> Unit) {
    addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
        override fun onLayoutChange(
            view: View,
            left: Int,
            top: Int,
            right: Int,
            bottom: Int,
            oldLeft: Int,
            oldTop: Int,
            oldRight: Int,
            oldBottom: Int
        ) {
            view.removeOnLayoutChangeListener(this)//移除老的监听器
            action(view)
        }
    })
}
public void addOnLayoutChangeListener(OnLayoutChangeListener listener) {
    ListenerInfo li = getListenerInfo();
    if (li.mOnLayoutChangeListeners == null) {
        li.mOnLayoutChangeListeners = new ArrayList<OnLayoutChangeListener>();
    }
    if (!li.mOnLayoutChangeListeners.contains(listener)) {
        li.mOnLayoutChangeListeners.add(listener);
    }
}

也就是 给titleView 添加一个监听器,在执行完布局后,

@SuppressWarnings({"unchecked"})
public void layout(int l, int t, int r, int b) {
    if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
        onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
        mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
    }

    int oldL = mLeft;
    int oldT = mTop;
    int oldB = mBottom;
    int oldR = mRight;

    boolean changed = isLayoutModeOptical(mParent) ?
            setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

    if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
        onLayout(changed, l, t, r, b);
        //略
        //已确定左右上下 位置后 触发 onLayoutChange 返回
        ListenerInfo li = mListenerInfo;
        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);
                //此处测量已经完毕,返回 onLayoutChange huidiao,此时执行 action(it) 
            }
        }
    }

    //略
}
  • 小专栏《clone 是什么,为什么要这么设计》

流程图:

doOnLayout(action)
    ↓ (如果还没布局)
doOnNextLayout(action)
    ↓
addOnLayoutChangeListener(oneShotListener)   ← 注册到 View 的 mOnLayoutChangeListeners 列表
    ↓
... 消息循环 ...
    ↓
ViewRootImpl.performTraversals()
    └→ performLayout()
        └→ View.layout(left, top, right, bottom)     ← 递归执行所有 View 的 layout
            └→ onLayout(changed, left, top, right, bottom)
            └→ notifyEnterOrExit()                   ← 内部触发 layout 监听器
                └→ 遍历 mOnLayoutChangeListeners
                    └→ oneShotListener.onLayoutChange(...)
                        ├→ removeOnLayoutChangeListener(this)   ← 自动移除,防止泄漏
                        └→ action(view)                         ← 你的业务代码,此时宽高就绪

小结:配置变更场景下 doOnLayout 的延迟执行逻辑

逻辑进入 doOnNextLayout 分支,其执行步骤如下:

  1. 注册单次监听:doOnNextLayout 内部调用 View.addOnLayoutChangeListener,为该 titleView 注册一个匿名的 OnLayoutChangeListener
  2. 布局就绪触发:待系统主线程消息队列处理到布局任务,即 ViewRootImpl.performTraversals() 内的 performLayout() 阶段,titleView 及其父视图会递归执行 layout() 方法,至此其宽高与屏幕坐标确定。
  3. 回调执行业务:布局完成后,该 OnLayoutChangeListener 被触发。在 onLayoutChange 回调中,会首先调用 removeOnLayoutChangeListener(this) 移除自身,确保该监听器是一次性的。然后,执行传入的 action,即您的业务代码块,此时 btnSelect.getGlobalVisibleRect(btnSelectRect) 能获取到正确的坐标,弹窗得以在正确位置弹出。

6. 延伸:事件分发与布局,两大体系的协同

  • OnGlobalLayoutListenerOnLayoutChangeListener 有什么区别?

  • :前者监听整棵树,后者监听单个 View;前者在 onLayout 全部完成后回调,后者在指定 View 的 onLayout 后回调。