如何使用 ConstraintSet 动态修改约束条件?其底层实现原理是什么?

解读

国内面试中,这道题既考察“会不会用”,也考察“懂不懂原理”。
“会用”层面,面试官希望你给出一段可在主线程安全执行的模板代码,并说明 applyTo 与 clone 的调用顺序、动画化切入方式、以及 ConstraintLayout 版本差异(2.x 之后支持 MotionLayout 部分能力)。
“懂原理”层面,希望你把“Java 层对象 → XML 解析缓存 → 扁平化约束表 → 线性方程组 → 重新请求布局”这一整条链路讲清楚,并能解释为什么 ConstraintSet 可以脱离视图树先“离线拼装”,再一次性刷新;同时能指出在哪些场景下会触发 requestLayout 而哪些只会触发 redraw,从而体现性能意识。

知识点

  1. ConstraintSet 的创建方式:clone(ConstraintLayout)、clone(Context, xmlResId)、new ConstraintSet() 后手动 create*Connection
  2. 核心 API:connect/connectRtl、center、setMargin/setGoneMargin、setVisibility、setVerticalBias/setHorizontalBias、constrainWidth/constrainHeight、constrainDefaultWidth/Height、setBarrierType、createGuideline、applyTo
  3. 动画化:TransitionManager.beginDelayedTransition(constraintLayout, ChangeBounds()) + applyTo
  4. 版本差异:1.1 之前仅支持静态替换;2.0 之后支持 Flow、Layer、MockView、ImageFilterView 等新控件约束;MotionLayout 场景下可配合 KeyFrame 实现插值
  5. 性能注意:
    • 避免在每一帧都 clone,应缓存复用
    • 若仅改 margin/bias,可调用 ConstraintLayout.LayoutParams 直接修改,再 requestLayout,比 ConstraintSet 全程替换更轻
    • 在 RecyclerView 中,应在 onBindViewHolder 里操作 LayoutParams,而非 ConstraintSet,防止频繁创建对象
  6. 底层实现:
    • ConstraintLayout 在 onMeasure 中会把所有子 View 的约束扁平化为线性方程组(Cassowary 算法),求解后得到绝对坐标
    • ConstraintSet 内部持有 Constraint 数组,每个 Constraint 记录 start/end/top/bottom/baseline 连接信息、margin、bias、dimension 行为等,序列化后仅保存 int/float 基本类型,不持有 View 引用
    • applyTo 阶段:
      a. 遍历 ConstraintSet 数组,把对应 id 的 LayoutParams 逐项填充
      b. 若发现任何“结构性差异”(如从 match_parent 变为 0dp、或新增/删除约束),则标记强制 requestLayout
      c. 如果只是纯 margin/bias 数值变化,则仅调用 view.setLayoutParams 触发重新测量
    • 由于 ConstraintLayout 的测量结果会被缓存,若同一帧多次修改 ConstraintSet,仅最后一次生效,减少重复测量
    • 2.x 引入 Optimizer 标志位,允许开发者通过 layout_optimizationLevel 关闭某些约束检查,进一步降低求解耗时

答案

使用步骤(Kotlin 示例,可直接在 Activity 中演示):

// 1. 缓存原始约束,避免重复 clone
val originSet = ConstraintSet().apply { clone(rootConstraintLayout) }
val newSet = ConstraintSet().apply { clone(rootConstraintLayout) }

// 2. 离线修改
newSet.connect(R.id.targetView, ConstraintSet.TOP, R.id.barrier, ConstraintSet.BOTTOM, 16.dp)
newSet.setHorizontalBias(R.id.targetView, 0.3f)
newSet.constrainWidth(R.id.targetView, ConstraintSet.MATCH_CONSTRAINT)
newSet.constrainHeight(R.id.targetView, 80.dp)

// 3. 动画化切入
TransitionManager.beginDelayedTransition(rootConstraintLayout, ChangeBounds().apply { duration = 280 })
newSet.applyTo(rootConstraintLayout)

底层实现要点:

  1. ConstraintSet 内部维护一个 SparseArray<Constraint>,Constraint 是纯数据类,记录每个控件的四条边连接关系、margin、bias、dimensionRatio 等 40 余项属性
  2. applyTo 时,ConstraintLayout 会遍历 SparseArray,通过 id 找到对应子 View,把 Constraint 数据写入 LayoutParams;若发现与当前 LayoutParams 不一致,则调用 view.setLayoutParams 触发重新测量
  3. ConstraintLayout.onMeasure 使用 Cassowary 线性求解器,将所有约束转化为 |Ax = b| 形式,求解后得到 left/top/right/bottom;若同一帧多次调用 applyTo,ConstraintLayout 通过标志位 mDirtyHierarchy 保证只求解一次
  4. 由于 ConstraintSet 不持有 View 引用,仅持有 id 与基本类型,因此可以安全地在后台线程拼装,再切回主线程 applyTo;这也是官方推荐“离线拼装、一次性提交”的原因

拓展思考

  1. 折叠屏旋转场景:当屏幕宽度从 full 变为 589dp 时,如何利用 ConstraintSet 实现“侧边栏自动隐藏 + 主内容区由 0dp 变为 match_parent”?请给出判定条件与动画策略
  2. 与 MotionLayout 对比:MotionLayout 在 ConstraintSet 基础上增加了 KeyPosition、KeyAttribute、KeyCycle,其底层同样复用 Cassowary,但额外维护了一个 FloatLayout 插值器;请分析在 60 FPS 下,MotionLayout 的求解耗时与纯 ConstraintSet 的差异,并给出可量化的优化建议(如 optimizerLevel、constraintReEvaluator)
  3. 国内厂商 ROM 定制:部分厂商把 ConstraintLayout 的 requestLayout 拦截到异步线程,导致动画掉帧;如何通过反射关闭其“异步测量”开关,或降级为 FrameLayout+手动布局作为兜底?请给出兼容方案与灰度策略