Android开发:如何检测View约束完全解析计算完成的时机
Android ConstraintLayout 视图尺寸获取时机异常解决方案
问题场景
开发像素绘画应用时,需要获取顶部视图与底部视图之间的可用间距,用来设置透明背景视图、像素艺术画板的宽高。
最初的实现方案是在约束布局中放置id为activityCanvas_distanceContainer的FrameLayout,将其约束在顶部、底部视图之间占满中间区域,计划读取该视图的measuredHeight属性获取可用间距,实际运行时出现尺寸获取异常:
- 使用
OneShotPreDrawListener绑定根视图触发尺寸计算时,拿到的高度值错误 - 注册
OnGlobalLayoutListener监听时,前4-5次回调返回的高度为错误值438,多次回调后才会跳到正确值1000 - 异常仅在从新建项目页经过参数校验、大画布警告弹窗后跳转CanvasActivity的场景必现,直接点击已有项目走简单Intent跳转时尺寸正常,异常场景下根布局的
measuredHeight会出现异常跳变
对应布局代码如下:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/fragment_background_color_daynight" tools:context=".activities.canvas.CanvasActivity"> <View android:id="@+id/activityCanvas_topView" android:layout_width="match_parent" android:layout_height="90dp" android:background="@color/fragment_background_color_daynight" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <com.therealbluepandabear.pixapencil.customviews.colorswitcherview.ColorSwitcherView android:id="@+id/activityCanvas_colorSwitcherView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" app:isPrimarySelected="false" app:layout_constraintBottom_toBottomOf="@+id/activityCanvas_colorPickerRecyclerView" app:layout_constraintEnd_toEndOf="@+id/activityCanvas_topView" app:layout_constraintTop_toTopOf="@+id/activityCanvas_colorPickerRecyclerView" app:primaryColor="@android:color/holo_green_dark" app:secondaryColor="@color/black" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/activityCanvas_colorPickerRecyclerView" android:layout_width="0dp" android:layout_height="50dp" android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:orientation="horizontal" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layout_constraintBottom_toBottomOf="@+id/activityCanvas_topView" app:layout_constraintEnd_toStartOf="@+id/activityCanvas_colorSwitcherView" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@+id/activityCanvas_primaryFragmentHost" tools:listitem="@layout/color_picker_layout" /> <FrameLayout android:id="@+id/activityCanvas_distanceContainer" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toTopOf="@+id/activityCanvas_tabLayout" app:layout_constraintEnd_toEndOf="@+id/activityCanvas_primaryFragmentHost" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/activityCanvas_topView" /> <com.google.android.material.card.MaterialCardView android:id="@+id/fragmentOuterCanvas_canvasFragmentHostCardViewParent" style="@style/activityCanvas_canvasFragmentHostCardViewParent_style" android:layout_width="wrap_content" android:layout_height="wrap_content" android:elevation="1dp" app:layout_constraintBottom_toTopOf="@+id/activityCanvas_tabLayout" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/activityCanvas_topView"> <!-- 运行时将动态计算视图宽高 --> <com.therealbluepandabear.pixapencil.customviews.transparentbackgroundview.TransparentBackgroundView android:id="@+id/activityCanvas_transparentBackgroundView" android:layout_width="0dp" android:layout_height="0dp" /> </com.google.android.material.card.MaterialCardView> <com.google.android.material.tabs.TabLayout android:id="@+id/activityCanvas_tabLayout" android:layout_width="0dp" android:layout_height="wrap_content" android:tabStripEnabled="false" app:layout_constraintBottom_toTopOf="@+id/activityCanvas_viewPager2" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> <com.google.android.material.tabs.TabItem android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/activityCanvas_tab_tools_str" /> <com.google.android.material.tabs.TabItem android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/activityCanvas_tab_filters_str" /> <com.google.android.material.tabs.TabItem android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/activityCanvas_tab_color_palettes_str" /> <com.google.android.material.tabs.TabItem android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/activityCanvas_tab_brushes_str" /> </com.google.android.material.tabs.TabLayout> <androidx.viewpager2.widget.ViewPager2 android:id="@+id/activityCanvas_viewPager2" android:layout_width="0dp" android:layout_height="110dp" app:layout_constraintBottom_toBottomOf="@+id/activityCanvas_primaryFragmentHost" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> <androidx.coordinatorlayout.widget.CoordinatorLayout android:id="@+id/activityCanvas_coordinatorLayout" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> <FrameLayout android:id="@+id/activityCanvas_primaryFragmentHost" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
最初使用的监听代码:
OneShotPreDrawListener.add(binding.root) { binding.activityCanvasTransparentBackgroundView!!.setViewWidth(binding.activityCanvasDistanceContainer!!.measuredHeight) binding.activityCanvasTransparentBackgroundView!!.setViewHeight(binding.activityCanvasDistanceContainer!!.measuredHeight) }
调试用的全局布局监听代码:
binding.activityCanvasDistanceContainer?.viewTreeObserver?.addOnGlobalLayoutListener( object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { Log.d("M_LOG", binding.activityCanvasDistanceContainer?.measuredHeight.toString()) } })
问题根因
- Activity启动前如果存在弹窗、页面过渡动画、系统栏insets异步适配逻辑,ConstraintLayout会触发多轮测量,前几轮测量基于临时窗口尺寸计算,返回的是中间态错误值
- 布局中
activityCanvas_primaryFragmentHost是全屏铺满的FrameLayout,distanceContainer、TabLayout、ViewPager2的约束都直接或间接依赖这个宿主,宿主尺寸在多轮测量中跳变时,所有关联子视图的尺寸都会同步变化 - 常规的
OneShotPreDrawListener、OnGlobalLayoutListener只会响应每一次布局完成事件,不会等待所有约束计算、窗口调整、动画全部执行完毕,因此无法拿到最终稳定值
解决方案
最优方案:用约束布局原生能力实现尺寸自适应,从根源避免时机问题
不需要手动写代码读取尺寸再设置给画板,直接修改XML约束即可实现自动适配:
- 将承载画板的
MaterialCardView宽高改为0dp(即match_constraint),保留原有的上下左右约束 - 给CardView添加
app:layout_constraintDimensionRatio="1:1"属性,约束布局会自动根据上下视图的可用间距,生成1:1比例的正方形画板区域 - 将内部的
TransparentBackgroundView宽高改为match_parent,自动填满父CardView - 冗余的
activityCanvas_distanceContainer可以直接删除,减少布局层级
修改后的CardView代码示例:
<com.google.android.material.card.MaterialCardView android:id="@+id/fragmentOuterCanvas_canvasFragmentHostCardViewParent" style="@style/activityCanvas_canvasFragmentHostCardViewParent_style" android:layout_width="0dp" android:layout_height="0dp" android:elevation="1dp" app:layout_constraintDimensionRatio="1:1" app:layout_constraintBottom_toTopOf="@+id/activityCanvas_tabLayout" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/activityCanvas_topView"> <com.therealbluepandabear.pixapencil.customviews.transparentbackgroundview.TransparentBackgroundView android:id="@+id/activityCanvas_transparentBackgroundView" android:layout_width="match_parent" android:layout_height="match_parent" /> </com.google.android.material.card.MaterialCardView>
该方案完全由布局系统自动计算尺寸,不存在时机问题,适配所有跳转场景。
备选方案1:注册布局变化监听,过滤中间态值
如果必须手动计算尺寸,不要用一次性监听,给目标视图注册布局变化监听,只有当连续两次测量的宽高一致、且值大于0时,才执行尺寸设置,执行完立刻移除监听:
val distanceContainer = binding.activityCanvasDistanceContainer var lastValidHeight = -1 val layoutChangeListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> val currentHeight = distanceContainer.measuredHeight if (currentHeight > 0 && currentHeight == lastValidHeight) { // 尺寸已稳定,执行画板设置 binding.activityCanvasTransparentBackgroundView.apply { setViewWidth(currentHeight) setViewHeight(currentHeight) } // 移除监听避免重复触发 distanceContainer.removeOnLayoutChangeListener(layoutChangeListener) } lastValidHeight = currentHeight } distanceContainer.addOnLayoutChangeListener(layoutChangeListener)
备选方案2:在窗口焦点回调中执行计算
将尺寸计算逻辑放到onWindowFocusChanged回调中,当Activity拿到窗口焦点时,所有窗口级别的尺寸调整、过渡动画都已执行完成,此时拿到的尺寸是稳定值:
private var hasSetCanvasSize = false override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (hasFocus && !hasSetCanvasSize) { val availableHeight = binding.activityCanvasDistanceContainer.measuredHeight if (availableHeight > 0) { binding.activityCanvasTransparentBackgroundView.apply { setViewWidth(availableHeight) setViewHeight(availableHeight) } hasSetCanvasSize = true } } }
注意需要加布尔标记位,避免切后台、弹窗导致焦点变化时重复执行逻辑。
避坑提示
- 不要在
onCreate/onStart/onResume生命周期中直接读取视图尺寸,这几个阶段视图还未完成测量,拿到的宽高基本都是0 OneShotPreDrawListener仅适合做单次绘制前的修改,不适合依赖最终布局尺寸的逻辑,该回调触发时可能还有未完成的布局请求- 约束布局中如果多个视图存在依赖链,尽量减少冗余的中间锚点视图,避免多轮测量时出现级联的尺寸跳变
内容的提问来源于stack exchange,提问作者thebluepandabear
相关产品推荐
相关产品推荐

