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

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

1.1 问题描述

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

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

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

1.2 调用链还原

scss 复制代码
配置变更
  → Fragment 销毁重建
  → onCreateView → onViewCreated
  → doObserver() → LiveData 首次绑定推送
  → pop()  ← 此时拿到的 View 坐标全为 0
kotlin 复制代码
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()

  • 流程图精简呈现:
scss 复制代码
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. 布局周期

  • 用时间线对比图
scss 复制代码
[主线程,按时间顺序]

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. 解法:

kotlin 复制代码
 @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

kotlin 复制代码
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 的条件判断为 会走第二条路径。下面贴出源码:

kotlin 复制代码
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)
        }
    })
}
ini 复制代码
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 添加一个监听器,在执行完布局后,

ini 复制代码
@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 是什么,为什么要这么设计》

流程图:

scss 复制代码
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 后回调。

相关推荐
zhangphil7 小时前
Android Page 3 Flow读sql数据库媒体文件,Kotlin
android·kotlin
小书房7 小时前
Kotlin使用体验及理解1
android·开发语言·kotlin
Kapaseker8 小时前
我想让同事知道我很懂 Compose 怎么办?
android·kotlin
jinanwuhuaguo1 天前
OpenClaw工程解剖——RAG、向量织构与“记忆宫殿”的索引拓扑学(第十三篇)
android·开发语言·人工智能·kotlin·拓扑学·openclaw
jinanwuhuaguo1 天前
OpenClaw协议霸权——从 MCP 标准到意图封建化的政治经济学(第十八篇)
android·人工智能·kotlin·拓扑学·openclaw
zhangphil1 天前
Android sql查媒体数据封装room Dao构造AndroidViewModel,RecyclerView宫格展示,Kotlin
android·kotlin
jinanwuhuaguo1 天前
反熵共同体——OpenClaw的宇宙热力学本体论(第十七篇)
大数据·人工智能·安全·架构·kotlin·openclaw
pengyu1 天前
【Kotlin 协程修仙录 · 筑基境 · 中阶】 | 身份证与通行证:CoroutineContext 的深度解剖
android·kotlin
夏沫琅琊1 天前
android 短信读取与导出技术
android·kotlin