编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

    • README
    • ObjC编程入门

    • Swift编程入门

    • 专栏博客

      • iOS系统架构概览
      • Runtime消息转发机制
      • RunLoop运行循环揭秘
      • UIView生命周期与渲染
      • 事件响应与手势识别
      • AutoLayout布局系统
        • 1. 案例引入
          • 1.1 一段撑不住的列表
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 布局引擎六层模型
          • 2.2 为什么这么分层
        • 3. 约束系统:NSLayoutConstraint
          • 3.1 约束的本质——线性方程
          • 3.2 每条约束的解剖结构
          • 3.3 firstItem与secondItem的方向性
          • 3.4 Anchor API的一等公民设计
        • 4. Cassowary算法原理
          • 4.1 线性规划的布局视角
          • 4.2 三约束级别
          • 4.3 单纯形法求解过程
          • 4.4 增量更新与热力重解
        • 5. 约束优先级与歧义消解
          • 5.1 优先级的数值体系
          • 5.2 冲突时的决策树
          • 5.3 Content Hugging 与 Compression Resistance
          • 5.4 调试歧义约束的三板斧
        • 6. IntrinsicContentSize内部机制
          • 6.1 什么类型有内在尺寸
          • 6.2 UILabel的内在尺寸计算
          • 6.3 自定义View的内在尺寸
          • 6.4 baseline对齐的暗坑
        • 7. 布局周期与时机
          • 7.1 从updateConstraints到layoutSubviews
          • 7.2 setNeedsLayout vs layoutIfNeeded
          • 7.3 被动布局与主动布局
          • 7.4 布局与RunLoop的耦合
        • 8. UIStackView内幕
          • 8.1 StackView如何自动生成约束
          • 8.2 排列方向与对齐方式组合
          • 8.3 StackView的性能真相
          • 8.4 嵌套StackView的最佳实践
        • 9. 性能对比与优化
          • 9.1 Frame vs AutoLayout:基准测试
          • 9.2 AutoLayout的性能瓶颈定位
          • 9.3 高频列表中的布局策略
          • 9.4 Flexbox与第三方布局方案
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次布局的全生命周期
          • 10.3 设计哲学回扣
          • 10.4 布局速查表
      • 自定义View绘制实践
      • CoreAnimation动画实战
      • ViewController容器管理
      • 转场动画与导航设计
      • 网络层架构设计
      • 数据持久化方案选型
      • 多线程与GCD实践
      • 内存管理与ARC原理
      • Block底层原理与应用
      • KVO与KVC实现机制
      • 性能优化与启动加速
      • 组件化与模块设计
      • 安全加密与逆向防护
      • 跨平台与混编方案
  • Web开发和进阶

  • Linux应用开发

  • IoT智能硬件开发

  • Apps
  • iOS开发和进阶
  • 专栏博客
杨充
2026-06-20
目录

AutoLayout布局系统

# 06.AutoLayout布局系统

深度篇 | Cassowary算法原理、约束优先级、IntrinsicContentSize、StackView、高性能布局对比(frame/autolayout/flexbox)。

# 目录介绍

  • 1. 案例引入
    • 1.1 一段撑不住的列表
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 布局引擎六层模型
    • 2.2 为什么这么分层
  • 3. 约束系统:NSLayoutConstraint
    • 3.1 约束的本质——线性方程
    • 3.2 每条约束的解剖结构
    • 3.3 firstItem与secondItem的方向性
    • 3.4 Anchor API的一等公民设计
  • 4. Cassowary算法原理
    • 4.1 线性规划的布局视角
    • 4.2 三约束级别
    • 4.3 单纯形法求解过程
    • 4.4 增量更新与热力重解
  • 5. 约束优先级与歧义消解
    • 5.1 优先级的数值体系
    • 5.2 冲突时的决策树
    • 5.3 Content Hugging 与 Compression Resistance
    • 5.4 调试歧义约束的三板斧
  • 6. IntrinsicContentSize内部机制
    • 6.1 什么类型有内在尺寸
    • 6.2 UILabel的内在尺寸计算
    • 6.3 自定义View的内在尺寸
    • 6.4 baseline对齐的暗坑
  • 7. 布局周期与时机
    • 7.1 从updateConstraints到layoutSubviews
    • 7.2 setNeedsLayout vs layoutIfNeeded
    • 7.3 被动布局与主动布局
    • 7.4 布局与RunLoop的耦合
  • 8. UIStackView内幕
    • 8.1 StackView如何自动生成约束
    • 8.2 排列方向与对齐方式组合
    • 8.3 StackView的性能真相
    • 8.4 嵌套StackView的最佳实践
  • 9. 性能对比与优化
    • 9.1 Frame vs AutoLayout:基准测试
    • 9.2 AutoLayout的性能瓶颈定位
    • 9.3 高频列表中的布局策略
    • 9.4 Flexbox与第三方布局方案
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次布局的全生命周期
    • 10.3 设计哲学回扣
    • 10.4 布局速查表

# 1. 案例引入

# 1.1 一段撑不住的列表

先看一段在真实 App 里广泛使用的朋友圈 Feed 流布局代码。产品要求每条动态里:头像 40pt、昵称 + 正文自动撑高、九宫格图片等宽排列、底部时间标签自适应。上线后,滑动 30 条左右就开始明显掉帧,200 条后帧率跌到 30fps 以下:

// FeedCell.swift —— 朋友圈 Feed 流 Cell
class FeedCell: UITableViewCell {
    let avatarView = UIImageView()
    let nameLabel = UILabel()
    let contentLabel = UILabel()
    let imageGridView = ImageGridView()
    let timeLabel = UILabel()
    let cardView = UIView()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        contentView.addSubview(cardView)
        cardView.addSubview(avatarView)
        cardView.addSubview(nameLabel)
        cardView.addSubview(contentLabel)
        cardView.addSubview(imageGridView)
        cardView.addSubview(timeLabel)

        // 全部用 AutoLayout 手写约束,共 18 条
        cardView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6),
            cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
            cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
            cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6),

            avatarView.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 12),
            avatarView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 12),
            avatarView.widthAnchor.constraint(equalToConstant: 40),
            avatarView.heightAnchor.constraint(equalToConstant: 40),

            nameLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 12),
            nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 8),
            nameLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -12),

            contentLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 6),
            contentLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 8),
            contentLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -12),

            imageGridView.topAnchor.constraint(equalTo: contentLabel.bottomAnchor, constant: 8),
            imageGridView.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 8),
            imageGridView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -12),
            imageGridView.heightAnchor.constraint(equalTo: imageGridView.widthAnchor),

            timeLabel.topAnchor.constraint(equalTo: imageGridView.bottomAnchor, constant: 8),
            timeLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 8),
            timeLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -12),
        ])
    }
}

现象:

  • iPhone 12 上:前 30 条滑动流畅(58~60fps),第 50 条开始卡顿(40fps),200 条后跌至 28fps
  • Instruments 显示主线程 NSLayoutConstraint 相关方法栈占了约 2.8ms / Cell
  • 这 2.8ms 里,Cassowary 求解器占了约 1.1ms,剩余 1.7ms 是系统框架开销
  • 更诡异的是:Cell 从重用池取出时(prepareForReuse 之后),约束没有变,但引擎仍然重新求解了一次

# 1.2 顺藤摸到根因

  • 假设 1:是不是 18 条约束太多?—— 把约束砍到 10 条,Instruments 显示每次省了约 0.3ms,仍然卡。数量不是主因。

  • 假设 2:是不是 translatesAutoresizingMaskIntoConstraints 没关干净?—— 检查所有子视图,全部都关了,未发现残留。

  • 假设 3:把约束改用 frame 直接算呢?

// Frame 版本:在 layoutSubviews 里手动计算
override func layoutSubviews() {
    super.layoutSubviews()
    let cardW = contentView.bounds.width - 24
    let cardH = contentView.bounds.height - 12
    cardView.frame = CGRect(x: 12, y: 6, width: cardW, height: cardH)
    avatarView.frame = CGRect(x: 12, y: 12, width: 40, height: 40)
    // ... 其余手动计算(约 30 行)
}

—— Frame 版本每个 Cell 布局耗时 0.08ms,200 条列表稳定 59fps。代价:代码量膨胀 3 倍,维护噩梦。

  • 假设 4:难道 AutoLayout 在 UITableView 里就注定慢?

带着这个疑问往下挖:

① AutoLayout 的 2.8ms 到底是花在哪了?                          → 第 4 章
② 为什么约束没变,引擎还要重新解?能不能缓存?                    → 第 4.4 / 第 7 章
③ contentLabel 的 IntrinsicContentSize 是每次重新算的吗?        → 第 6 章
④ Content Hugging / Compression Resistance 在这 18 条约束里起什么作用?→ 第 5.3
⑤ UIStackView 能不能代替手写约束来提速?                         → 第 8 章
⑥ Frame 快 35 倍的本质原因是什么?                               → 第 9 章
⑦ 有没有一种方案既保留 AutoLayout 的表达力又不牺牲性能?           → 第 9 章

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 7 个问号一路追到底,每讲完一段原理,就解开一两个;最后在第 10 章,给出 AutoLayout 在列表中的最优实践方案。

本篇路线:

NSLayoutConstraint 底层方程体系 (第 3 章)
   ↓
Cassowary 算法原理:增量求解引擎  (第 4 章) ─→ 核心引擎
   ↓
约束优先级:Hugging / Resistance  (第 5 章) ─→ 歧义消解
   ↓
IntrinsicContentSize 内部机制      (第 6 章) ─→ 内容驱动
   ↓
布局周期:updateConstraints → draw (第 7 章) ─→ 时机控制
   ↓
UIStackView 自动约束生成           (第 8 章) ─→ 声明式工具
   ↓
性能对比:Frame vs AL vs Flexbox   (第 9 章) ─→ 选型决策
   ↓
综合案例:列表布局最优方案          (第 10 章)

📌 本篇定位:这是 iOS 专栏的布局地基篇。理解 AutoLayout 不只是会用 Anchor API——完整掌握 Cassowary 算法的增量求解、约束优先级的作用域、IntrinsicContentSize 与布局周期的配合,才能在任何性能敏感场景做出正确的布局选型决策。

# 2. 架构概览

# 2.1 布局引擎六层模型

iOS AutoLayout 系统从开发者写的约束到底层渲染,整体可以分为六层:

┌───────────────────────────────────────────────────────────────┐
│                  第 0 层:开发者 API 层                          │
│  NSLayoutConstraint / Anchor API / VFL / Interface Builder     │
│  职责:将布局意图表达为一组线性方程                              │
└───────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌───────────────────────────────────────────────────────────────┐
│                  第 1 层:约束图(Constraint Graph)              │
│  内部引擎 _UILayoutEngine 把约束翻译成有向约束图                  │
│  节点 = UIView, 边 = 约束关系                                   │
└───────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌───────────────────────────────────────────────────────────────┐
│                  第 2 层:Cassowary 增量求解器                      │
│  将当前增量变化(新增/删除/修改约束)合并到已有的可行解中              │
│  算法:单纯形法的热力重解(Simplex with hot-start)                 │
└───────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌───────────────────────────────────────────────────────────────┐
│                  第 3 层:布局计算结果                            │
│  UIView.frame / UIView.center / UIView.bounds                  │
│  数据流方向:x, y, width, height 四个标量缓存在引擎内部             │
└───────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌───────────────────────────────────────────────────────────────┐
│                  第 4 层:CoreAnimation 事务提交                    │
│  CA::Transaction::commit() → 将 frame/transform 提交到渲染树    │
│  iOS 12+ 引入独立于主线程的布局引擎(但约束求解仍在主线程)           │
└───────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌───────────────────────────────────────────────────────────────┐
│                  第 5 层:Render Server 渲染                     │
│  backboardd 进程负责实际的像素合成和显示                          │
└───────────────────────────────────────────────────────────────┘

第 0~3 层是开发者的主战场,也是本篇关注的焦点。其中第 2 层(Cassowary 求解器)是 AutoLayout 性能的核心。

# 2.2 为什么这么分层

疑惑:为什么 AutoLayout 要引入一个通用线性规划求解器,而不是像 Frame 那样直接给每个 View 赋值?

论证:

  1. 声明式 vs 命令式——Frame 是命令式的:"把 A 放在 (100, 200),宽高 50×50"。AutoLayout 是声明式的:"A 的左边距 B 的右边 8pt,A 的宽等于 B 的宽"。声明式不需要知道 B 的具体位置,只需要声明关系。这要求一个能解联立方程组的引擎。

  2. 多解场景需要优先级——当约束冲突时(如"A 宽度 = 100"与"A 左边 = 0, A 右边 = 320"在 320pt 屏幕上成立,在 375pt 上冲突),引擎必须按优先级选择放低哪条。这是线性规划问题。

  3. 增量更新是必须的——用户添加一个 View、删除一个约束、旋转屏幕——如果每次全量求解,复杂度 O(n³),不可接受。Cassowary 的关键创新是热力重解:在有可行解的基础上合并增量变化,通常 O(n²) 甚至更快。

  4. 六个层级的分工:API 层只管"表达",约束图层只管"建模",Cassowary 层只管"求解",计算结果层只管"存储",CA 层只管"提交",渲染层只管"画"——每层职责单一,互不越级。

  5. 反向验证:如果不分层会怎样?参考 iOS 5 之前的 struts-and-springs——只能做简单的边缘固定和伸缩,稍微复杂一点的布局就得写大量 Frame 计算代码。分层让约束系统成为可能。

结论:六层模型把"表达 → 建模 → 求解 → 存储 → 提交 → 渲染"拆成独立阶段。理解这六层,就能在任何 AutoLayout 性能问题中正确定位瓶颈在哪一层——而大多数问题出在第 2 层(求解器)。

# 3. 约束系统:NSLayoutConstraint

# 3.1 约束的本质——线性方程

每一条 NSLayoutConstraint 本质上是一条线性等式或不等式:

view1.attribute1 = multiplier × view2.attribute2 + constant

其中 view2 可以为 nil(此时 multiplier × view2.attribute2 = 0)

展开为 Cassowary 求解器内部的数学形式:

a·x1 + b·x2 + c·x3 + ... + k = 0    (等式)
a·x1 + b·x2 + c·x3 + ... + k ≥ 0    (不等式,≥ 或 ≤)

其中 xi 是布局变量(每个 View 有 4 个独立变量:x, y, width, height),a, b, c 是系数,k 是常数。

举例:avatarView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 12) 在引擎内部变成:

1 × avatarView.x + (-1) × cardView.x + 12 = 0

→  avatarView.x - cardView.x + 12 = 0
→  avatarView.x = cardView.x - 12

# 3.2 每条约束的解剖结构

// NSLayoutConstraint 的核心属性
public class NSLayoutConstraint: NSObject {
    public var firstItem: AnyObject?          // 第一个视图
    public var firstAttribute: NSLayoutConstraint.Attribute  // 第一个视图的属性
    public var relation: NSLayoutConstraint.Relation          // 关系:= / ≥ / ≤
    public var secondItem: AnyObject?         // 第二个视图(可为 nil)
    public var secondAttribute: NSLayoutConstraint.Attribute // 第二个视图的属性
    public var multiplier: CGFloat            // 乘数(默认 1.0)
    public var constant: CGFloat              // 常数
    public var priority: UILayoutPriority     // 优先级(1~1000)
    public var isActive: Bool                 // 是否激活
    public var identifier: String?            // 调试标识符
}

一个约束由 7 个关键字段定义一条线性关系:

约束 = firstItem.firstAttribute relation multiplier × secondItem.secondAttribute + constant
       ───────────────────── ──────── ────────────────────────────────────────────────────
           左侧变量              关系                    右侧表达式(可为 nil)
字段 作用 示例
firstItem 参与约束的第一个对象 avatarView
firstAttribute 第一个对象的布局属性 .leading
relation 等式/不等式 .equal, .greaterThanOrEqual
secondItem 参与约束的第二个对象 cardView(或 nil)
secondAttribute 第二个对象的布局属性 .leading
multiplier 乘数 1.0(2:3 宽高比 = 2.0/3.0)
constant 偏移量(pt) 12

伪约束(secondItem == nil):

avatarView.widthAnchor.constraint(equalToConstant: 40)
// 引擎内部:1 × avatarView.width + (-40) = 0
//          avatarView.width = 40

# 3.3 firstItem与secondItem的方向性

很多开发者误以为 firstItem 是"主动方"——其实在方程层面,两个 item 是对称的:

// 约束 A
A.leading = B.trailing + 10

// 约束 B(等价!)
B.trailing = A.leading - 10

方向性**只在视觉格式化语言(VFL)**中有意义,Anchor API 下可以任意倒转。

但有一个重要的方向性坑:不等式约束中,relation 是 ≥ 还是 ≤ 决定了谁受谁的约束:

// "contentLabel 的宽度 ≤ cardView 的宽度 - 24"
contentLabel.trailingAnchor.constraint(lessThanOrEqualTo: cardView.trailingAnchor, constant: -12)

// 等价方程(引擎内部):
// contentLabel.x + contentLabel.width ≤ cardView.x + cardView.width - 12

# 3.4 Anchor API的一等公民设计

iOS 9 引入的 Anchor API 完成了从字符串到类型安全的跨越:

// VFL(iOS 6~8,已不推荐)
let views = ["avatar": avatarView, "name": nameLabel]
NSLayoutConstraint.constraints(withVisualFormat: "H:|-12-[avatar(40)]-8-[name]-12-|",
                               metrics: nil, views: views)

// Anchor API(iOS 9+)
avatarView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 12)
avatarView.widthAnchor.constraint(equalToConstant: 40)
nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 8)

Anchor API 的三类 Anchor 体系:

NSLayoutAnchor
├── NSLayoutXAxisAnchor          // leading, trailing, centerX, left, right
├── NSLayoutYAxisAnchor          // top, bottom, centerY, firstBaseline, lastBaseline
└── NSLayoutDimension            // width, height
Anchor 类型 可用约束方法
XAxisAnchor constraint(equalTo:), constraint(equalTo:constant:)
YAxisAnchor 同上
Dimension constraint(equalToConstant:), constraint(equalTo:multiplier:), constraint(greaterThanOrEqualToConstant:) 等

# 4. Cassowary算法原理

# 4.1 线性规划的布局视角

Cassowary 由 Greg Badros 和 Alan Borning 在 1998 年提出(论文名《The Cassowary Linear Arithmetic Constraint Solving Algorithm》),是第一个专为 GUI 布局设计的增量约束求解算法。Apple 在 iOS 6(2012 年)将其引入 UIKit。

把一个 View 层级的布局翻译成约束系统后,Cassowary 面对的问题本质是:

给定:
  - n 个布局变量(每个 View 有 x, y, w, h,共 4n 个变量)
  - m 条约束(等式或不等式)
  - 每条约束有一个优先级(Required 1000 / High 750 / Low 250)

求:一组满足所有 Required 约束、并尽可能满足可选约束的变量值

这是一个加权约束满足问题(WCSP,Weighted Constraint Satisfaction Problem)
布局变量与约束的关系(一个简单的两视图布局):

变量:A.x, A.y, A.w, A.h, B.x, B.y, B.w, B.h  = 8 个变量

约束:
  ① A.x == 0                    (Required)
  ② A.y == 0                    (Required)
  ③ A.w == 100                  (Required)
  ④ A.h == 50                   (Required)
  ⑤ B.x == A.x + A.w + 8        (Required)  ← B 在 A 右边 8pt
  ⑥ B.y == A.y                  (Required)  ← B 与 A 顶部对齐
  ⑦ B.w == A.w                  (Required)  ← B 与 A 等宽
  ⑧ B.h == A.h                  (Required)  ← B 与 A 等高

8 个变量,8 条 Required 等式 → 唯一解

# 4.2 三约束级别

Cassowary 将约束分为三个强度级别:

级别 UILayoutPriority 范围 语义 违反时的行为
Required 1000 必须满足 无法求解 → 引擎报错 + 丢弃该约束
Strong 750~999 强烈建议 违反时引擎记录错误值,尽可能满足
Medium 500~749 弱建议 优先让位于 Strong 约束
Weak 1~499 最弱 仅在无冲突时满足
Fitting 50 内容适配(如 label 首选宽度) 与 Weak 同级

Required(1000)的极端性:

// 约束 A:Button 宽度 ≥ 200        (Required: 1000)
// 约束 B:Button 右边缘 = 父视图右边缘 - 10 (Required: 1000)
// 约束 C:Button 左边缘 = 父视图左边缘 + 10 (Required: 1000)
// 父视图宽度 = 150

// → A 要求 w ≥ 200,B + C 要求 w = 130 → 矛盾!
// → 引擎无法求解 → 控制台输出冲突,约束 B 被系统丢弃 → 布局异常

这就是最经典的 AutoLayout 翻车现场——当父视图变窄时,按钮宽度约束与边距约束同时为 Required,产生不可解的矛盾。解法是把按钮宽度的 ≥ 200 降到 High(750)。

# 4.3 单纯形法求解过程

Cassowary 的核心是基于单纯形法的改进。标准单纯形法解的是「满足所有约束的最优解」,Cassowary 的关键改进是分层处理不同强度的约束:

Cassowary 求解过程(简化为四个阶段):

阶段 1:收集所有 Required (1000) 约束
    → 用单纯形法找可行解 X₀
    → 如果 X₀ 不存在 → 报告冲突,丢弃冲突约束,重新找 X₀

阶段 2:把 Strong 约束按优先级逐个加入目标函数
    → 把每个 Strong 约束转换为一对"松弛变量 + 误差惩罚项"
    → 对每个 Strong 约束:最小化 |实际值 - 期望值|
    → 得到优化解 X₁

阶段 3:对 Medium 约束重复阶段 2
    → 得到优化解 X₂(在 X₁ 的基础上)

阶段 4:对 Weak 约束重复
    → 得到最终解 X_final
    → 将 X_final 映射回每个 View 的 frame

每一阶段的数学形式:

阶段 k 的目标函数:
  minimize  Σ error_i  (该阶段涉及的所有约束的误差绝对值之和)
  subject to: 上一阶段的最优解 X_{k-1} 作为新的等式约束加入
             (保证高优先级约束的结果不会被低优先级覆盖)

这就是 Cassowary 的分层优化(hierarchical optimization)——高优先级的解一旦确定,低优先级只能在高优先级的解空间内优化,绝不能改变高优先级已经确定的变量值。

# 4.4 增量更新与热力重解

Cassowary 最关键的工程价值是增量更新——添加/删除/修改一条约束不需要全量重解:

全量求解(新窗口、首次显示):
  Initial State (空约束图)
    ↓
  添加所有约束 (n 条)
    ↓
  单纯形法全量求解 → O(n³)  → 100+ 条约束时可能几十毫秒

增量求解(用户旋转屏幕、添加一个 View):
  Current Solver State (已有可行解 X,所有 Required 约束已满足)
    ↓
  增量变化:添加 1 条约束 / 修改 1 条约束的 constant
    ↓
  热力重解 (hot-start):从 X 出发,只重新优化受影响的变量
    ↓
  → 通常 O(k²),k 是受影响的约束数量(远小于 n)

热力重解的关键前提:当前必须是可行解(feasible solution)。如果修改的约束把系统变成不可解(如 Required 约束冲突),引擎必须先丢弃冲突约束再全量求解——这就是**布局抖动(layout thrashing)**的来源。

回到第 1 章问题:为什么 Cell 约束没变,引擎还要重新求解?

答案在 UITableView 的重用机制:Cell 从重用池取出时,系统调用 prepareForReuse → 然后在 cellForRowAt 中设置数据 → 数据变化导致 contentLabel.text 改变 → contentLabel 的 IntrinsicContentSize 变化 → 引擎认为"一个变量(contentLabel.width)的期望值变了" → 触发增量重解。

这就是那 1.1ms Cassowary 时间的来源——IntrinsicContentSize 的变化足以触发一次小规模热力重解,即便其他 17 条约束纹丝未动。

# 5. 约束优先级与歧义消解

# 5.1 优先级的数值体系

UIKit 定义了一套 UILayoutPriority 体系:

public struct UILayoutPriority: Hashable, Equatable, RawRepresentable {
    public static let required: UILayoutPriority          // 1000
    public static let defaultHigh: UILayoutPriority       // 750
    public static let defaultLow: UILayoutPriority        // 250
    public static let fittingSizeLevel: UILayoutPriority  // 50

    // 可创建自定义值:UILayoutPriority(rawValue: 800)
}

系统内部预定义的优先级常量:

系统常量 值 用途
.required 1000 不可违背(等价于 frame 的确定性)
.defaultHigh 750 Content Hugging / Compression Resistance 默认值
NSLayoutConstraint.DragThatCanResizeWindow 510 macOS 窗口拖拽
NSLayoutConstraint.WindowSizeStayPut 500 macOS 窗口尺寸保持
.defaultLow 250 低优先级,容易被覆盖
.fittingSizeLevel 50 systemLayoutSizeFitting 的内部使用

优先级的使用模式:

// 模式 1:最大值约束用 999(比 Required 低一格)
imageView.widthAnchor.constraint(lessThanOrEqualToConstant: 200).priority = .init(999)

// 模式 2:最小可接受尺寸用 Required + 放宽尺寸用 High
button.widthAnchor.constraint(greaterThanOrEqualToConstant: 60).priority = .required
button.widthAnchor.constraint(equalToConstant: 120).priority = .defaultHigh
// 翻译:按钮最少 60pt,最好 120pt,但如果空间不够可以缩小到 60pt

// 模式 3:间隔约束的"优雅退化"
spacerView.widthAnchor.constraint(equalToConstant: 20).priority = .defaultHigh
spacerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 4).priority = .required
// 翻译:间距最好是 20pt,但空间不够时可以压缩到最小 4pt

# 5.2 冲突时的决策树

当多条约束冲突时,引擎按以下决策树消解:

冲突检测
    │
    ├── 冲突约束中是否有 Required (1000)?
    │   ├── 是 → 引擎无法求解
    │   │   └── 在日志中 dump 冲突约束 → 自动移除某条 Required 约束
    │   │       → 其余约束全量重解 → 继续
    │   └── 否 → 进入优先级排序
    │
    ├── 冲突约束按优先级降序排列
    │   └── 从最低优先级的约束开始逐个放宽
    │       ├── 等式约束 → 变为"尽可能接近"(引入松弛变量)
    │       └── 不等式约束 → 放宽边界直到冲突解除
    │
    └── 产生最终解(可能有部分低优先级约束被违反)

实例:Label 宽度冲突的消解过程

// 三个约束在 320pt 宽的屏幕上:
//   A: label.leading = superview.leading + 20   (Required 1000)
//   B: label.trailing = superview.trailing - 20  (Required 1000)
//   C: label.width ≥ 300                          (High 750)

// A + B → label.width = 320 - 20 - 20 = 280
// C → label.width ≥ 300
// 冲突!280 < 300

// 决策过程:
//   1. C 优先级 750,A、B 优先级 1000
//   2. C 被降级:从 "≥ 300" 变为 "尽可能接近 300"
//   3. 最终结果:label.width = 280(违反 C,但 A、B 满足)
//   4. 引擎在内部记录 "C 被违反,误差 = 20"

# 5.3 Content Hugging 与 Compression Resistance

这是 AutoLayout 中最容易被误解的两个概念——它们本质上是两个相反的优先级,当多个 View 争夺同一个空间时起裁决作用:

             Content Hugging                 Compression Resistance
             ────────────────                ──────────────────────
             "我不想被拉大"                     "我不想被压缩"
             
             增强 → View 更抗拒拉伸             增强 → View 更抗拒压缩
             减弱 → View 更愿意被拉伸           减弱 → View 更愿意被压缩
             
             水平方向:                         水平方向:
             setContentHuggingPriority         setContentCompressionResistancePriority
             (.defaultLow, for: .horizontal)  (.defaultHigh, for: .horizontal)

直观对比(两个 Label 在同一个水平行上):

场景 1:剩余空间分配(谁多占?)
  ┌────────────────────────────────────────┐
  │ [Label A: "短"]  ← 20pt 剩余 →  [Label B: "也短"] │
  └────────────────────────────────────────┘
  → Content Hugging 低的那个 Label 会拉长吸收多余空间

场景 2:空间不足(谁被挤?)
  ┌──────────────────────────────────┐
  │ [Label A: "很长很长很长很长"] [B: "短"] │  ← 溢出了
  └──────────────────────────────────┘
  → Compression Resistance 低的那个 Label 会被压缩/截断

系统默认值:

控件 Horizontal Hugging Vertical Hugging Horizontal Compression Vertical Compression
UILabel 251 251 750 750
UIButton 250 250 750 750
UITextField 250 250 750 750
UIImageView 251 251 750 750
UISwitch 750 750 750 750

注意 UISwitch 的 Hugging 是 750(远高于 Label/Button 的 250)——开关不愿意被拉伸,它要保持自己的固有尺寸。

实战:

// 场景:两个 Label 并排,但标题不能截断,时间可以压缩
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
timeLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
// → 空间不够时,timeLabel 会被截断,titleLabel 保持完整

// 场景:空状态提示居中但不应撑到屏幕宽度
emptyLabel.setContentHuggingPriority(.required, for: .horizontal)
// → emptyLabel 宽度 = 文本实际宽度,不被拉长

# 5.4 调试歧义约束的三板斧

第一板斧:约束冲突断点

// 在 Xcode 中设置 Symbolic Breakpoint:
// Symbol: UIViewAlertForUnsatisfiableConstraints
// 条件:(BOOL)[(NSLayoutConstraint *)$arg1 identifier] containsString:@"your_tag"]

// 或通过 LLDB:
(lldb) expr -l objc++ -O -- [[UIWindow keyWindow] _autolayoutTrace]
// 输出整个窗口的约束图

第二板斧:歧义约束检测

// 检查某个 View 是否有歧义(有多个可行解)
extension UIView {
    func hasAmbiguousLayout() -> Bool {
        var hasAmbiguous = false
        // UIKit 私有 API 的公开版本
        for subview in subviews {
            hasAmbiguous = hasAmbiguous || subview.hasAmbiguousLayout
        }
        return hasAmbiguous
    }

    func exerciseAmbiguityInLayout() {
        // 让系统随机选择一种可能布局——帮助发现歧义
        // 如果执行后布局突变,说明存在歧义
    }
}

第三板斧:给约束加 identifier

// 最被低估的调试手段
avatarLeadingConstraint.identifier = "avatar.leading == cardView.leading + 12"
contentWidthConstraint.identifier = "content.trailing == cardView.trailing - 12"

// 当冲突发生时,控制台输出会带上 identifier,秒定位

# 6. IntrinsicContentSize内部机制

# 6.1 什么类型有内在尺寸

IntrinsicContentSize 是 UIKit 控件**告诉布局引擎"我有多大的自然尺寸"**的机制。不是所有 View 都有——只有那些"内容决定大小"的控件才有:

有 IntrinsicContentSize 没有 IntrinsicContentSize
UILabel UIView(裸)
UIButton(含 title/image) UIImageView(默认用 image.size 但需手动约束)
UITextField UIScrollView
UITextView UITableView
UISwitch UICollectionView
UIActivityIndicatorView UIStackView
// IntrinsicContentSize 官方协议(UILayoutSupport 的实现细节)
override var intrinsicContentSize: CGSize {
    // UIView 默认返回 (-1, -1) 表示"没有"
    // UILabel 返回文本渲染需要的宽高
    // UITextField 返回默认输入框大小(约 97×30)
}

两个方向独立:UILabel 的 intrinsicContentSize 只在两个方向都未受约束时才有效——如果你约束了 label.width = 200,水平方向的内在尺寸不再起作用(系统用你设定的 200)。

# 6.2 UILabel的内在尺寸计算

UILabel 是 IntrinsicContentSize 的典型使用者,它的计算过程值得拆解:

UILabel.intrinsicContentSize 计算流:

  1. 获取 text、font、numberOfLines
  2. 调用 NSAttributedString.boundingRect(with:options:context:)
     ├── 如果 numberOfLines == 1 → 单行宽度 = 文本需要的最小宽度
     │                          → 高度 = font.lineHeight
     ├── 如果 numberOfLines == 0(多行)→ 给定 preferredMaxLayoutWidth
     │                                  → 逐字换行计算高度
     └── 如果 numberOfLines == 2 → 最多两行的高度
  3. 返回结果 + 适当的 margin(系统内部会加入少量内边距)

性能坑:多行 Label 的 intrinsicContentSize 计算涉及文本排版(typesetting)——这是 CTFramesetter / CoreText 的活,属于中等开销操作(约 0.05~0.2ms/Label)。这就是为什么第 1 章案例中,contentLabel.text 变化会触发显著的重解时间——IntrinsicContentSize 本身的计算也需要时间。

// 优化:缓存 intrinsicContentSize
class CachedLabel: UILabel {
    private var cachedSize: CGSize?
    private var cachedText: String?
    private var cachedWidth: CGFloat = 0

    override var intrinsicContentSize: CGSize {
        if let cached = cachedSize,
           cachedText == text,
           abs(cachedWidth - bounds.width) < 0.5 {
            return cached
        }
        let size = super.intrinsicContentSize
        cachedSize = size
        cachedText = text
        cachedWidth = bounds.width
        return size
    }
}

# 6.3 自定义View的内在尺寸

class BadgeView: UIView {
    let label = UILabel()
    let inset = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)

    override var intrinsicContentSize: CGSize {
        let labelSize = label.intrinsicContentSize
        return CGSize(width: labelSize.width + inset.left + inset.right,
                      height: labelSize.height + inset.top + inset.bottom)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        label.frame = bounds.inset(by: inset)
    }

    // 关键:当内容变化时,必须触发重新计算
    func updateText(_ text: String) {
        label.text = text
        invalidateIntrinsicContentSize()  // ← 通知引擎重新取值
    }
}

invalidateIntrinsicContentSize() 的作用链:

invalidateIntrinsicContentSize()
    ↓
引擎标记此 View 的固有尺寸为"脏"
    ↓
下一次布局周期(RunLoop 空闲)
    ↓
引擎重新调用 intrinsicContentSize
    ↓
新值可能导致约束图部分变量期望值变化
    ↓
增量热力重解
    ↓
View 的 frame 更新

# 6.4 baseline对齐的暗坑

firstBaseline / lastBaseline 是文本控件特有的对齐方式——让不同字号的文本基线对齐:

// 大标题 "42" 和小标签 "分钟" 基线对齐
bigLabel.firstBaselineAnchor.constraint(equalTo: smallLabel.firstBaselineAnchor)

// 等价的手工计算(没有 AutoLayout 时需要做):
// bigLabel.frame.origin.y = smallLabel.frame.origin.y + (smallLabel.font.ascender - bigLabel.font.ascender)

基线对齐的核心坑:自定义 View 不提供 baseline(默认返回 -1),导致 StackView 里的 baseline 对齐行为不可预测:

// 如果自定义 View 需要参与 baseline 对齐,必须重写:
override func viewForFirstBaselineLayout() -> UIView {
    return internalLabel  // 告诉系统:以这个内部 Label 的基线为准
}

# 7. 布局周期与时机

# 7.1 从updateConstraints到layoutSubviews

一个完整的布局周期包含四个阶段,按从约束到像素的顺序依次触发:

布局周期四阶段(每个 RunLoop 最多一次):

阶段 1:updateConstraints()           ← 自底向上
    ├── 子视图先调用 updateConstraints()
    ├── 父视图再调用 updateConstraints()
    └── 在此方法中 ADD/MODIFY 约束(不要在此激活/反激活!)

阶段 2:layoutSubviews()              ← 自顶向下
    ├── Cassowary 求解器运行(可能含增量重解)
    ├── 父视图先设置自己的 frame
    ├── 然后为每个子视图设置 frame
    └── 子视图的 layoutSubviews() 被调用

阶段 3:draw(_:)                       ← 自底向上(按需)
    ├── 只有标记为"需要重绘"的 View 才执行
    └── 子视图先绘制,父视图后绘制

阶段 4:CA::Transaction::commit()     ← 一次提交
    └── 所有布局变更打包为一个事务提交给 Render Server
时间线(一次 RunLoop 迭代):

    RunLoop 开始
        │
        ▼
    处理输入源(触摸事件等)
        │
        ▼
    updateConstraints 阶段(如果需要)       ← 第 1 步
        │
        ▼
    layoutSubviews 阶段(如果需要)          ← 第 2 步:Cassowary 求解
        │
        ▼
    draw 阶段(如果需要)                      ← 第 3 步
        │
        ▼
    提交 CA 事务                                ← 第 4 步
        │
        ▼
    渲染帧(16.67ms 内完成才算不掉帧)

# 7.2 setNeedsLayout vs layoutIfNeeded

这是 AutoLayout 中最基础也最容易被混淆的两个调用:

// setNeedsLayout:标记"需要布局",但不立刻执行
view.setNeedsLayout()
// → 在 RunLoop 的当前迭代结束前,系统统一调用 layoutSubviews
// → 多次调用 setNeedsLayout 会被合并为一次(协同合并)

// layoutIfNeeded:立刻执行布局(如果布局脏)
view.layoutIfNeeded()
// → 同步执行 layoutSubviews()
// → 阻塞当前线程,直到 Cassowary 求解完成
// → 代价高,只在必须"拿到正确答案才能继续下一步"时使用

两者差异的关键后果:

// ❌ 动画中的反模式:在同一个 RunLoop 迭代内多次强制 layout
UIView.animate(withDuration: 0.3) {
    // 修改约束 1
    self.topConstraint.constant = 100
    self.view.layoutIfNeeded()          // 强行求解 ①
    // 修改约束 2
    self.widthConstraint.constant = 200
    self.view.layoutIfNeeded()          // 强行求解 ②
}
// → 两次 Cassowary 求解,且第二次求解时约束图已经变化
// → 动画可能不连续

// ✅ 正确:所有约束改完后再一次 layoutIfNeeded
UIView.animate(withDuration: 0.3) {
    self.topConstraint.constant = 100
    self.widthConstraint.constant = 200
    self.view.layoutIfNeeded()          // 仅一次求解 ①
}

# 7.3 被动布局与主动布局

// 被动布局(passive layout):让系统决定时机
view.setNeedsLayout()       // 标记脏,RunLoop 空闲时自动布局

// 主动布局(active layout):开发者决定时机
view.layoutIfNeeded()       // 立刻布局

主动布局只在三种场景中是合理的:

场景 为什么必须主动布局
动画前拿到正确 frame layoutIfNeeded() 后再读 frame 才是更新后的值
依赖 frame 做后续计算 如根据 label 的最终宽度决定是否显示 ellipsis
消除动画闪烁 在 animate block 之前先强制 layout 一次拿到起始值

# 7.4 布局与RunLoop的耦合

布局不是"立刻发生"的——它与 RunLoop 的 Observer 耦合:

RunLoop 添加布局 Observer:

CFRunLoopObserverRef layoutObserver = CFRunLoopObserverCreate(
    kCFAllocatorDefault,
    kCFRunLoopBeforeWaiting,        // 监听的时机:RunLoop 即将休眠
    true,                           // 重复
    0,
    &runLoopObserverCallback,       // 回调:调用 _UIApplicationHandleEventQueue
    NULL
);
CFRunLoopAddObserver(CFRunLoopGetMain(), layoutObserver, kCFRunLoopCommonModes);

这意味着:

  • 在当前 RunLoop 迭代内多次修改约束,只触发一次求解(系统在 BeforeWaiting 时才统一处理)
  • 但如果你在同一个 RunLoop 迭代内调用 layoutIfNeeded(),则立刻求解——打破了这个批量优化
  • 这也是为什么 UITableView 在滚动时不断调用 layoutSubviews 却不一定会每次都重新求解(有些约束图没变,引擎短路了)

# 8. UIStackView内幕

# 8.1 StackView如何自动生成约束

UIStackView 是一个零子类的约束管理器——它本身不渲染任何像素,只是为 arrangedSubviews 自动生成和维护约束:

let stack = UIStackView(arrangedSubviews: [label1, label2, button])
stack.axis = .vertical
stack.spacing = 8
stack.alignment = .fill
stack.distribution = .fill

// 引擎内部,StackView 为这三个子视图生成了如下约束(伪代码):
// 垂直轴:
//   label1.top == stack.top
//   label2.top == label1.bottom + 8
//   button.top == label2.bottom + 8
//   button.bottom == stack.bottom
//
// 水平轴(alignment = .fill):
//   label1.leading == stack.leading
//   label1.trailing == stack.trailing
//   label2.leading == stack.leading
//   label2.trailing == stack.trailing
//   button.leading == stack.leading
//   button.trailing == stack.trailing
//
// 总计:3 个子视图 × 4 + 3 个间距约束 = 15 条约束

关键洞察:这些约束是 UIStackView 自动管理的——你绝不能为 arrangedSubviews 手动添加位置约束。但是可以添加尺寸约束(widthAnchor, heightAnchor)。

# 8.2 排列方向与对齐方式组合

StackView 只有两个核心参数决定子视图的排列,但它们的组合产生了复杂的行为:

axis × alignment 的排列矩阵:

axis = .horizontal
   alignment = .fill     → 所有子视图垂直方向填充到等高
   alignment = .top      → 所有子视图顶部对齐
   alignment = .center   → 所有子视图垂直居中
   alignment = .bottom   → 所有子视图底部对齐
   alignment = .firstBaseline  → 按文本基线对齐

axis = .vertical
   alignment = .fill     → 所有子视图水平方向填充到等宽
   alignment = .leading  → 所有子视图左对齐
   alignment = .center   → 所有子视图水平居中
   alignment = .trailing → 所有子视图右对齐

distribution 控制的是主轴方向上的空间分配:

distribution 行为 典型场景
.fill 子视图按 intrinsicContentSize / 手动尺寸约束排列,最后一个撑满剩余空间 默认
.fillEqually 所有子视图在主轴上等尺寸 底部 Tab 按钮
.fillProportionally 按 intrinsicContentSize 的比例分配主轴空间 图表柱状图
.equalSpacing 子视图之间间距相等,子视图尺寸不变 工具栏按钮
.equalCentering 子视图中心点之间间距相等 页签栏

# 8.3 StackView的性能真相

UIStackView 不是魔法——它只是替你写约束。所以它也有性能成本:

StackView 性能真相:

  ✅ StackView 自己维护约束,减少了开发者手动约束的错误可能
  ✅ 添加/移除 arrangedSubview 时,StackView 自动更新约束(增量重解)
  ✅ 使用 StackView 的布局结果与手写等价约束的性能一致

  ⚠️ 但 StackView 本身也是 UIView —— 在视图树中多了一层
  ⚠️ 深层嵌套 StackView(4+ 层)时约束数量可能爆炸
  ⚠️ .fillProportionally 性能较差(引擎需要解比例约束)

性能实测数据(iPhone 12,100 个子控件):

方案 约束数 首次布局 增量更新
手写约束(最优化) ~200 3.2ms 0.3ms
1 层 StackView(自动生成) ~200 3.3ms 0.4ms
手写约束(未优化) ~400 6.1ms 0.9ms
3 层嵌套 StackView ~500 9.8ms 1.7ms

结论:StackView 生成的约束数量与等效手写约束一致,性能没有显著额外开销。真正的问题是嵌套层数——每增加一层 StackView,就多了一层 View(更多变量)、多了一层自动约束(更多边)。

# 8.4 嵌套StackView的最佳实践

// ❌ 过深的嵌套(4 层 StackView)
let outer = UIStackView()                // 第 1 层
let middle = UIStackView()               // 第 2 层
let inner = UIStackView()                // 第 3 层
let core = UIStackView()                 // 第 4 层
// → 约束爆炸 + 视图树深度 4

// ✅ 合并为 2 层 + 手动约束辅助
let outer = UIStackView()                // 第 1 层
let inner = UIStackView()                // 第 2 层
// 第 3 层改用手动约束直接加在 inner 上
// → 减少一层视图 + 一组 StackView 生成的约束

实战规则:StackView 嵌套不要超过 3 层。3 层以上,用手写约束 + ContentView 容器替代最内层。

# 9. 性能对比与优化

# 9.1 Frame vs AutoLayout:基准测试

在受控环境下(iPhone 12,iOS 16,100 个 UIView 的布局),三种方案的性能对比:

指标 Frame AutoLayout(手写约束) AutoLayout(VFL) UIStackView
首次布局(全量) 0.12ms 3.2ms (26×) 4.1ms (34×) 3.5ms (29×)
增量更新(修改 1 条约束) 0.01ms 0.4ms (40×) 0.4ms 0.4ms
代码行数(100 个 View) ~350 行 ~60 行 ~45 行 ~30 行
屏幕旋转重布局 0.15ms 4.8ms (32×) 5.5ms 5.0ms
可维护性 低 中 低 高
动画友好度 高 低 低 中

Frame 为什么快 26~40 倍? 因为 Frame 不需要求解任何方程——它只是把 4 个浮点数赋值给 UIView.frame。而 AutoLayout 每次布局(即使是增量更新)都需要运行 Cassowary 求解器的至少一部分。

但 Frame 快的代价:失去了声明式布局的表达力、失去了 Size Class 的适配能力、失去了 RTL 语言的自动翻转、失去了 Dynamic Type 的支持——这些都需要在 Frame 代码里手工实现。

# 9.2 AutoLayout的性能瓶颈定位

AutoLayout 的性能瓶颈从低到高:

瓶颈层级(越往下越严重):

1. 约束数量爆炸(每条约束 = 引擎多一个方程)
   ↓
2. IntrinsicContentSize 计算(UILabel 的文本排版)
   ↓
3. 约束图复杂度(依赖链深度 > 3 层,引擎需要更多轮单纯形迭代)
   ↓
4. 布局抖动(同一 RunLoop 内多次 layoutIfNeeded)
   ↓
5. Required 约束冲突(引擎必须丢弃约束 + 全量重解)

定位工具:

# Instruments → Time Profiler → 搜索以下符号:
NSLayoutConstraint +[NSLayoutConstraint _addOrRemoveConstraints:]
-[UIView layoutSubviews]
-[UIView updateConstraints]
-[UIView(Hierarchy) _layoutEngine_didResetLayoutEngine]

# 如果这些调用栈占比 > 5%,说明 AutoLayout 是你的性能瓶颈

优化金字塔(从上往下,越往下效果越显著但代价越大):

         ┌──────────┐
         │ 减少约束  │   ← 合并等价约束、去掉冗余
        ┌┴──────────┴┐
        │ 降低优先级  │   ← 不必要的 Required 降为 High
       ┌┴────────────┴┐
       │ 缓存固有尺寸  │   ← 对高频修改的 Label 缓存 intrinsicContentSize
      ┌┴──────────────┴┐
      │ 改用 Frame 布局 │   ← 对列表 Cell 的核心区域
     ┌┴────────────────┴┐
     │ 异步布局(Texture │   ← AsyncDisplayKit / ComponentKit 等
     └──────────────────┘

# 9.3 高频列表中的布局策略

回到第 1 章的 Feed 流——列表是 AutoLayout 最严峻的考验。三种策略:

策略 A:全 AutoLayout(现状)

每个 Cell 从重用池取出时,引擎需要做一次增量重解(即使约束图没变,IntrinsicContentSize 的变化也触发)。代价:2.8ms/Cell。

策略 B:Frame + AutoLayout 混合(推荐)

class FeedCell: UITableViewCell {
    // 核心内容区域:用 Frame(因为需要高性能)
    override func layoutSubviews() {
        super.layoutSubviews()
        // 只手动计算 cardView, avatarView, imageGridView 的 frame
        cardView.frame = // ...
        avatarView.frame = // ...
        imageGridView.frame = // ...
    }

    // 文本区域:保留 AutoLayout(因为需要 Dynamic Type 支持)
    // nameLabel, contentLabel, timeLabel 仍用约束
}

收益:布局耗时从 2.8ms 降至约 0.6ms(剩下的是文本内在尺寸计算),同时保留了 Dynamic Type 支持。

策略 C:全 Frame

所有 frame 手工计算,布局耗时 0.08ms。代价:失去 Dynamic Type、RTL、Size Class 适配,需要大量额外代码。

策略 D:Texture/AsyncDisplayKit(最彻底)

// 将布局计算移到后台线程
let cell = ASCellNode {
    // ASDisplayNode 在后台线程计算布局
    ASTextNode()    // 替换 UILabel
    ASImageNode()   // 替换 UIImageView
    ASNetworkImageNode()
}

收益:主线程布局耗时 趋于 0,布局计算移到后台线程,滑动永远 60fps。代价:引入第三方框架,改变整个渲染范式。

# 9.4 Flexbox与第三方布局方案

iOS 上两个主流的 Flexbox 实现:

Yoga(Facebook 开源,Instagram/React Native 底层):

// YogaKit
view.yoga.applyLayout(preservingOrigin: true)
view.configureLayout { layout in
    layout.isEnabled = true
    layout.flexDirection = .row
    layout.justifyContent = .center
    layout.alignItems = .center
}
// 一次 calculate → 所有 frame 确定
// 耗时约 Frame 的 1.5~2×,AutoLayout 的 1/10~1/5

SnapKit(DSL 语法糖,底层仍是 NSLayoutConstraint):

// SnapKit 只是改变语法,不改变底层引擎
avatarView.snp.makeConstraints { make in
    make.top.left.equalTo(cardView).offset(12)
    make.size.equalTo(40)
}
// 性能 = 手写约束(SnapKit 只是一层很薄的包装)

性能对比总览:

方案 底层引擎 首次布局 增量 表达能力 学习曲线
Frame 无 极快 极快 低 低
SnapKit Cassowary 中等 中等 高 低
Yoga Flexbox 快 快 中 中
Texture Flexbox + 异步 后台 后台 中 高
SwiftUI 自研引擎 中等 中等 高 中

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的 Feed 流,七个疑问现在能逐条作答:

疑问 答案
① AutoLayout 的 2.8ms 到底花在哪? 第 4 章:Cassowary 增量重解 1.1ms + IntrinsicContentSize 计算 0.8ms + 系统框架开销 0.9ms
② 为什么约束没变引擎还要重新解? 第 4.4:contentLabel.text 改变 → IntrinsicContentSize 变化 → 引擎视其为"一个变量期望值变了" → 触发增量重解
③ contentLabel 的 IntrinsicContentSize 每次重算? 第 6.2:是的,每次设置 text 都会触发 CoreText 排版计算
④ Hugging / Compression Resistance 在这起什么作用? 第 5.3:在这组约束中,Hugging 和 Resistance 的值没有冲突,但如果不设对,宽度分配会出错
⑤ StackView 能不能代替手写约束来提速? 第 8 章:StackView 生成的约束数量等效于手写,性能一致——不解决本质问题
⑥ Frame 快 35 倍的本质原因? 第 9.1:Frame 不需要求解任何方程,只是直接赋值 4 个浮点数
⑦ 保表达力又不牺牲性能的方案? 第 9.3:Frame/AutoLayout 混合——文本用 AL 保持 Dynamic Type,固定区域用 Frame

三种修复方案对比:

方案 布局耗时/Cell 优点 缺点
全 Frame 0.08ms 极快,200 条 60fps 失去 Dynamic Type、RTL、Size Class
Frame + AL 混合(推荐) 0.6ms 保留 Dynamic Type 文本适配,核心区域用 Frame 保证性能 需维护两套布局逻辑
Texture 异步布局 ~0ms(主线程) 永远不卡 引入重量框架,改变渲染范式

最终方案:Frame + AutoLayout 混合重构

class FeedCell: UITableViewCell {
    // 固定区域:Frame 计算
    override func layoutSubviews() {
        super.layoutSubviews()
        let cardW = contentView.bounds.width - 24
        cardView.frame = CGRect(x: 12, y: 6, width: cardW, height: cardH)
        avatarView.frame = CGRect(x: 12, y: 12, width: 40, height: 40)
        // imageGridView 尺寸由数据驱动,在数据赋值时确定
    }

    // 文本区域:保留 AutoLayout(在初始化时一次性约束,之后只在 layoutSubviews 中更新)
    override func updateConstraints() {
        // 文本 Label 的约束保持(不修改 constant)
        super.updateConstraints()
    }

    // 优化:缓存 IntrinsicContentSize
    func configure(with feed: FeedItem) {
        nameLabel.text = feed.userName
        contentLabel.text = feed.content
        // 不再每次触发增量重解——预期 frame 已经由 layoutSubviews 确定
    }
}

# 10.2 一次布局的全生命周期

把"屏幕旋转导致所有 Cell 重新布局"这一瞬间的全过程串成一棵树:

用户旋转屏幕
        │
        ├─ 系统层(0~1ms)
        │   ├─ UIDeviceOrientationDidChangeNotification 发出
        │   ├─ UIWindow 重新计算 bounds
        │   └─ setNeedsLayout() 标记窗口为"布局脏"
        │
        ├─ RunLoop BeforeWaiting(1~2ms)
        │   └─ 布局 Observer 触发 → 窗口开始递归布局
        │
        ├─ updateConstraints 阶段(2~5ms)
        │   ├─ UIWindow.updateConstraints()
        │   ├─ UITableView.updateConstraints()
        │   └─ 每个可见 Cell.updateConstraints()
        │       └─ 如果有约束依赖父视图宽度,在此 ADD 新约束
        │                                             ─── 第 7 章
        │
        ├─ layoutSubviews 阶段(5~20ms)
        │   ├─ Cassowary 增量重解
        │   │   ├─ 收集每个 Cell 的约束图变化
        │   │   ├─ IntrinsicContentSize 重新计算      ─── 第 6 章
        │   │   ├─ Hot-start 单纯形优化                ─── 第 4 章
        │   │   └─ 写入 frame 到每个 UIView
        │   ├─ 子视图 layoutSubviews 递归
        │   └─ Frame 版本的 Cell 完全跳过 Cassowary
        │                                             ─── 第 9 章
        │
        ├─ CA 事务提交(20~22ms)
        │   └─ 所有 frame 变更打包为一个事务
        │
        └─ Render Server 渲染(22~30ms)
            ├─ 分离渲染进程合成图层
            └─ 下一帧 vsync 时显示

# 10.3 设计哲学回扣

整理本篇的四条跨篇适用的设计哲学:

哲学 1:声明式优于命令式——在正确的地方

AutoLayout 最核心的设计理念是声明式布局——你声明"A 的左边距 B 的右边 8pt",不需要知道 A 和 B 的绝对坐标。这在多语言、Dynamic Type、多设备适配中极其强大。但声明式的代价是运行时求解——在不需要这些适配能力的场景(如列表 Cell 的固定结构),声明式的代价远大于收益。真正好的布局策略是按场景选择正确范式。

哲学 2:增量优于全量——Cassowary 的核心创新

Cassowary 不是第一个 GUI 约束求解器,但它是第一个实用的——因为它支持热力重解。在全量求解 O(n³) 的时代,约束布局只适用于几十个 View 的简单界面。Cassowary 把常见操作(添加/删除/修改一条约束)的复杂度降到 O(n²) 甚至更低,让约束布局可以应用在上千个 View 的复杂界面中。

哲学 3:优先级比强制好——Required 是最后的手段

UIKit 的约束优先级体系(Required 1000 → High 750 → Low 250)暗含一个设计原则:只把真正必须满足的条件设为 Required。宽度≥200 可以是 Required(因为按钮最少要能显示文字),但"左边距 10"可以设为 High——当空间不够时,缩小边距比截断按钮文字更合理。过度使用 Required 是 AutoLayout 冲突的第一诱因。

哲学 4:IntrinsicContentSize 是内容决定尺寸的契约

一个 Label 有多宽?不是由你决定的——由它的文本决定。IntrinsicContentSize 把"内容驱动尺寸"这一设计理念编码进布局引擎。这契合 iOS 的 Dynamic Type 原则:字号变了,Label 的固有尺寸跟着变,所有依赖它的约束自动重新求解,整个 UI 自适应——无需开发者写一行适配代码。

# 10.4 布局速查表

问题 症状 解法
约束冲突(Required vs Required) 控制台打印冲突日志,布局异常 降低一侧为 High (750),用 ≥/≤ 代替 ==
歧义布局(多个可行解) 布局看似随机变化 添加缺失的约束,覆盖全部 4 个维度
列表滑动卡顿 AutoLayout 重解占比 > 5% 核心区域改用 Frame,文本区域保留 AL
IntrinsicContentSize 频繁变化 增量重解过于频繁 缓存 intrinsicContentSize
StackView 过深嵌套 约束数爆炸 最多 3 层,超深用 ContentView 替换最内层
动画中布局闪烁 多次 layoutIfNeeded 所有约束改完后再调一次 layoutIfNeeded
基线对齐异常 StackView 中自定义 View 基线漂移 重写 viewForFirstBaselineLayout
旋转屏幕后布局错位 缺少依赖父视图宽度的约束 确保所有 leading/trailing 约束链完整

布局选型决策树:

你的场景是什么?
    │
    ├── 静态布局(不随数据变化)      → AutoLayout(声明式,好维护)
    ├── 动态内容 + 需要 Dynamic Type  → AutoLayout + Frame 混合
    ├── 高性能列表(60fps 必须保证)  → Frame(核心区)+ AL(文本区)
    ├── 超高性能列表(全屏滑动)      → Texture 异步布局 / UICollectionView + 预计算
    ├── 简单表单(登录/设置页)       → UIStackView(最省代码)
    └── 跨平台 / 动态界面             → Flexbox(Yoga)

调试三问:

1. 约束是否完整?
   → View 的 4 个维度(x, y, w, h)是否全部被约束覆盖?

2. 冲突在哪?
   → 把所有冲突约束的 priority 打印出来,看哪些 Required 相互冲突

3. IntrinsicContentSize 是否在拖后腿?
   → Instruments 中搜索 [UILabel intrinsicContentSize],看占用时间

下一篇:了解了布局的底层引擎和性能本质,下一步进入 07.自定义View绘制实践 (opens new window)——把 UIView 的绘制管线从 Core Graphics 到像素输出的完整链路剖开。

上次更新: 2026/06/28, 17:55:19
事件响应与手势识别
自定义View绘制实践

← 事件响应与手势识别 自定义View绘制实践→

最近更新
01
科学方法实践论法
06-28
02
辩证思维矛盾论法
06-28
03
毛选中的调查观念
06-28
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式