1. 事故现场:一个诡异的弹窗错位 Bug
1.1 问题描述
-
配置变更(如屏幕旋转)后,Fragment 重建,弹窗出现在屏幕最左侧。
-
正常点击 :和Title 中轴对齐
-
日志定位:
btnSelectRect.left = 0, right = 0。
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 的测量/布局/绘制三部曲
measure→layout→draw,缺一不可。
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.moveToState中container.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 直接为 true,action(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 分支,其执行步骤如下:
- 注册单次监听:
doOnNextLayout内部调用View.addOnLayoutChangeListener,为该titleView注册一个匿名的OnLayoutChangeListener。 - 布局就绪触发:待系统主线程消息队列处理到布局任务,即
ViewRootImpl.performTraversals()内的performLayout()阶段,titleView及其父视图会递归执行layout()方法,至此其宽高与屏幕坐标确定。 - 回调执行业务:布局完成后,该
OnLayoutChangeListener被触发。在onLayoutChange回调中,会首先调用removeOnLayoutChangeListener(this)移除自身,确保该监听器是一次性的。然后,执行传入的action,即您的业务代码块,此时btnSelect.getGlobalVisibleRect(btnSelectRect)能获取到正确的坐标,弹窗得以在正确位置弹出。
6. 延伸:事件分发与布局,两大体系的协同
-
问:
OnGlobalLayoutListener和OnLayoutChangeListener有什么区别? -
答:前者监听整棵树,后者监听单个 View;前者在
onLayout全部完成后回调,后者在指定 View 的onLayout后回调。