拾人牙慧而已,並非创见。

什么是 Layout?

Layout 的职责是回答这两个问题:

  • What to draw?
  • Where to draw?

第一个问题很好回答:Layout 是负责布局视图(UIView)及其子类。
关于第二个问题,Layout 要确定子视图(Subview)的 frame。根据它们的 frame,把子视图布局在屏幕上。

那怎么确定子视图的 frame 呢?

  1. 手动设置子视图的 frame 大小。
  2. 利用约束来描述子视图之间 frame 的关系。

随着 iPhone 的屏幕尺寸种类越来越多,第一种方法的用武之地已经很有限了。
所以我们常用第二种方法:使用线性方程组来描述 Constraint Anchor 之间的关系。Auto Layout 引擎则会根据这个方程组来计算出子视图的 frame。线性方程组中的等式格式如下图所示:

约束方程的组成:RedView.leading 等于 BlueView.trailing 乘系数 1 再加常量 8

width = trailing - leading
height = bottom - top
centerX = (leading + trailing) / 2
centerY = (top + bottom) / 2

在已知 leading、top、trailing 和 bottom 四种 anchor 的情况下,Auto Layout 引擎通过以上简单推算就能算出子视图的 frame。

不难得出一个结论:如果一个视图知道自己的 frame,就能通过线性方程组计算出子视图的 frame。那么开始递归,只要知道了根视图的 frame,这样子视图的 frame 都能通过线性方程组算出来。而根视图(即 UIWindow)在运行时是能确定 frame(即屏幕尺寸)的。

计算好的 frame 数据会通知给对应视图的父视图,父视图会根据此数据对子视图进行布局,即调用 layoutSubviews 方法。layoutSubviews 方法会在一个 Layout Pass 出现时被调用。

The Layout Cycle

布局周期:约束变化触发延迟布局过程,完成后回到应用运行循环

Constraints Change

视图的各个约束在 Auto Layout 的世界里是一堆线性等式(不等式)组。视图约束的改动即影响这些线性等式(不等式)组。

一般的约束改动有以下几类:

  1. 启用(activate)某项约束
  2. 停用(deactivate)某项约束
  3. 修改约束的优先级
  4. 修改约束对应的值
  5. 在视图层级上添加或移除某个视图

第五点很容易被忽略。

Deferred Layout Pass

Deferred Layout Pass 可以分为两个部分:

  1. 更新约束(update constraints)
  2. 对视图进行重新布局(view repositioning)

第一步是为了确保如果有未生效的约束改动,让这些约束生效。第二步是从 Auto Layout 引擎 读取布局数据,然后遍历整个视图层级并更新布局,此操作相对来说是比较耗时的。

假设如果没有第一步,就可能会导致「对视图进行重新布局」这一步多次进行,产生了多余的计算。

updateConstraints 方法使用小结

一般来说,在调用 setNeedsUpdateConstraints 方法之后的某个时机,iOS 系统会在布局前调用我们自己 updateConstraints 方法的实现。

那么所有自定义约束更新的代码都得放在 updateConstraints 方法里吗?
摘录部分 官方文档:

It is almost always cleaner and easier to update a constraint immediately after the affecting change has occurred. For example, if you want to change a constraint in response to a button tap, make that change directly in the button’s action method.
You should only override this method when changing constraints in place is too slow, or when a view is producing a number of redundant changes.

先上结论:在 updateConstraints 方法中修改视图的约束往往是比在别处更快。
但一般来说,只需在视图约束需要改变的时机去更新它们,这样做的好处是增强代码的易读性和可维护性。

但有些时候我们需要同时修改很多视图约束时,沿用上述的方法,可能会觉得很慢。此时就可以选择去覆盖 updateConstraints 方法。Auto Layout 引擎就可以在同一时刻批量处理这些视图约束的改动,从而提升性能。

当视图的约束发生改动后,Auto Layout 引擎需要重新计算视图的 frame,然后视图从 Auto Layout 引擎读取 frame 的数据,并调用 superview.setNeedsLayout(),最终更新界面。这一过程称之为 Deferred Layout Pass。Deferred(推迟出现的)一词表明:视图的约束发生改动后,视图的 frame 是不会立即发生对应的更改的。

layoutSubviews 方法使用小结

摘录部分 官方文档:

You should override this method only if the autoresizing and constraint-based behaviors of the subviews do not offer the behavior you want. You can use your implementation to set the frame rectangles of your subviews directly.

通过覆盖 layoutSubviews 方法,我们可以添加自定义布局的代码。但需要注意的是:只有当某个布局不能通过设定约束来实现时,才真正需要这么做。

在 layoutSubviews 方法被调用时,此时正处于布局进行中,即:有些视图已经完成布局,而有些视图还没完成布局,或是将要完成布局。

当覆盖 layoutSubviews 方法时,不能调用 setNeedsUpdateConstraints 方法,这样会很容易导致布局反馈循环(Layout Feedback Loop)。这篇 文章 介绍了如何调试这一问题。

Auto Layout Debug 技巧

在使用 Auto Layout 时,常见的两种问题是:

  • Unsatisfiable Constraints -> 线性方程组无解
  • Ambiguous Layouts -> 线性方程组有多个解

Unsatisfiable Constraints

遇到这种情况,可以 Auto Layout 引擎主动选择打破的那条约束为突破点,去研究到底是哪里发生冲突,还可以对可疑的约束和视图各自加上 identifier 和 accessibilityIdentifier 来提高调试日志的可读性。

Ambiguous Layouts

导致 Ambiguous Layouts 的原因可能为:

  • 视图的约束太少,无法计算出确定的 frame。
  • 视图约束的优先级发生冲突。

当发生这种情况时,某个视图计算出来的 frame 是有多种可能的结果,Auto Layout 引擎选择其中一种来完成布局,但这种布局往往不会是理想的布局。

LLDB 调试输出:autolayoutTrace 标出布局不明确的 UILabel,并用 hasAmbiguousLayout 与 exerciseAmbiguityInLayout 验证
我们可以使用:po view.value(forKey: "_autolayoutTrace")! 打印出当前视图层级,可以很直观地看出是哪个子视图出现了 ambiguous layouts 的问题。
我们甚至还可以继续执行 e label.exerciseAmbiguityInLayout(),然后执行continue 命令,就可以看到其他可能的布局。这样,两种布局一对比就比较容易找到问题所在。

High Performance Auto Layout

我们来看这个简单例子:
简单布局:同一容器内 text1 与 text2 水平排列并受间距、边距和尺寸约束
这样一个简单布局是无须大费周章去覆盖 updateConstraints 方法的。这里举一个反例,以更好地理解 Auto Layout 这个黑盒。
性能反例:updateConstraints 每次停用、清空、重建并启用 text1 和 text2 的约束
在这个方法里:首先停用现有的约束,然后重新添加约束,最后再启用新添加的约束。从直觉上来看,这样做好像看不出有什么损耗性能的地方。

Render Loop

为了理解上述代码对性能的影响,我们需要更具体地了解 updateConstraints 方法,这里引入一个新概念:Render Loop。
渲染循环由 Update Constraints、Layout 和 Display 三个阶段组成
Render Loop 是每秒可能运行 120 次的进程,以确保每个子视图均恰当地显示在屏幕上。它包括三个阶段,分别是:Update Constraints、Layout 以及 Display。

渲染循环遍历方向:更新约束自底向上,布局与显示自顶向下
第一阶段:从视图层级的底部到顶部,每个视图都会调用 updateConstraints 方法。
第二阶段:从视图层级的顶部到底部,每个视图都会调用 layoutSubviews 方法。
第三阶段:从视图层级的顶部到底部,每个视图会判断自己是否需要显示在屏幕上。

三个渲染阶段的方法类比:updateConstraints、layoutSubviews、draw 及各自的 setNeeds 和 IfNeeded 方法
上图这样把这几种方法列在表格中是为了说明这些方法虽是在不同阶段,但却可以用来作类比。

整个 Render Loop 存在的目的就是:

  1. 避免无用的计算过程。
  2. 推迟一些计算过程(为的是在某个时刻进行集中计算)。
  3. 如果有可能,跳过这些计算过程。

回头看这段代码:
布局性能反例:layoutSubviews 每次移除并重新创建 text1、text2 子视图
上文提及过 updateConstraints 方法是可以同 layoutSubviews 方法类比的。
在 layoutSubviews 方法每次被调用时,所有的子视图都被销毁,然后再重新创建它们,并把它们添加到父视图上。绝大多数人看到上面这段代码时,肯定会有一种「这样做会影响到性能表现」的直觉。

同理,那段 updateConstraints 方法也是如此!

优化后的 updateConstraints:只在约束数组为 nil 时创建并启用约束
我们只需想方设法确保 updateConstraints 方法内的做法只执行一次就可以避免多余的计算。

通过上面的类比,我们逐渐明白之前那段 updateConstraints 方法内的代码是有待优化的代码。但我们的目的不是为了仅仅说明这段代码不好,而是要尽量抽丝剥茧,尝试理解背后的原理。

Activate constraints 背后发生了什么?

激活约束的内部关系:视图持有变量与约束,布局引擎用约束建立方程并求解变量
在对视图添加约束之后,Auto Layout 引擎会开始计算表示约束的方程组,然后再把方程组的解(该视图的 frame 数据,即 minX、minY、width 和 height)告知该视图。

text1 与 text2 的水平约束方程:两者宽度均为 100,text1 左距 20,间距 20
以上图这个简单布局为例(仅关注水平方向上的布局)。Auto Layout 引擎开始计算一个简单的方程组:
布局引擎缓存并求解 text1 的位置和宽度,以及 text2 对 text1 的依赖方程

计算完毕后,Auto Layout 引擎会通知视图,然后视图向它的父视图发送 setNeedsLayout 信息。

此时就进入 Render Loop 的第二阶段 Layout:
渲染循环进入 Layout 阶段,自顶向下遍历视图层级

在 layoutSubviews 方法中,视图从 Auto Layout 引擎中读取方程组的解(frame 数据)对子视图进行布局。
layoutSubviews 从布局引擎读取变量值,再通过 bounds 和 center 设置子视图

到此为止,我们循序渐进地走过一遍布局的过程。相信你现在对这个过程有了比较直观的了解,这样会有助于你对当前布局性能的好坏做出恰当的判断。

反复停用再启用相同约束会使布局引擎频繁重复求解

让我们回顾这段代码,在 Render Loop 可能以每秒 120 次调用 updateConstraints 方法的情况下,这段代码中先停用现有的约束,然后再启用新添加的约束。我们可以粗浅地理解为 Auto Layout 引擎一秒钟解 120 次同样的方程组!虽然 Auto Layout 引擎解一次方程组会很快,但是累计下来在这儿会产生很大的开销,会对性能表现有不小的影响!

两组独立局部布局:text1 与 text2、text3 与 text4 分属不同父视图
大多数情况下,我们都是在同一父视图内对子视图添加约束。

如上图所示:text1 和 text2 拥有同一个父视图,text3 和 text4 拥有同一个父视图。text1 和 text3 分别在两个不同的视图层级里。

无关联视图形成两组独立方程,布局引擎可分别求解
此时布局的时间复杂度是线性的,因为 Auto Layout 引擎实际上是在解两个独立无关的方程组。

而当需要 text1 和 text3 左对齐时:
跨父视图对齐 text1 与 text3 后,两组局部布局产生全局依赖
Auto Layout 引擎就需要解两个有依赖关系的方程组了。可想而知此时的时间复杂度就不再是线性的,计算量相较之前会增大。

通过上面的说明,我们可以逐渐领悟到这样一件事:

The layout engine is a layout cache and dependency tracker

光对 Auto Layout 这个黑盒有较为直观的认知还不够,还需要通过实际具体的例子来强化这份认知,强烈推荐看完 High Performance Auto Layout [26:15] 处的例子。

translatesAutoresizingMaskIntoConstraints

从 Interface Builder 中生成的 UIView 的 translatesAutoresizingMaskIntoConstraints(之后简称为 tAMIC)默认为 false,而代码生成的 UIView 的 tAMIC 默认为 true。

手动设置视图的 frame 的布局方式背地里仍然是使用 Auto Layout 在布局。

举个例子,在 viewDidLoad 方法中添加如下代码:

label.translatesAutoresizingMaskIntoConstraints = true
view.addSubview(label)
label.frame = CGRect(x: 100, y: 100, width: 100, height: 100)

打断点进入调试状态,查看该 label 的约束:
LLDB 输出显示手动 frame 被转换为位置和宽高各为 100 的 NSAutoresizingMaskLayoutConstraint
可以看出:手动设置的 frame 在背地里转换成对应的NSAutoresizingMaskLayoutConstraint 来进行布局了。

当用代码来创建视图约束时,一定要记得把 tAMIC 设为 false,以避免约束冲突。

Alignment Rect

「Auto Layout 引擎计算出的结果是视图 frame 的数据」这往往是不少人的误区。
Auto Layout 引擎计算出的结果其实是视图 Alignment rectangle 的数据。换言之,Auto Layout 使用 Alignment rectangle 来布局而不是 frame。

但一般来说,视图 Alignment rectangle 和 frame 的数据是一致的。

黄色虚线表示图标与按钮的 Alignment Rect,仅包围核心视觉内容

上图黄色虚线所标识的范围为两个视图的 Alignment rectangle。
Alignment rectangle 与 frame 的区别在于:前者仅包含视图的核心视觉元素,而后者不光包含视图的核心视觉元素,还包含其他修饰性的元素。
值得一提的是:UIView.transform 并不会改变 Alignment rectangle 的大小。

举个例子,这里有一张图片,其宽高分别为:180 x 80,绿色矩形的大小为:150 x 50。
一张 180×80 图片中的绿色核心矩形为 150×50,且相对完整 frame 偏左上
如果想要使这张图片居中显示,对应的布局代码为:

imageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(imageView)

NSLayoutConstraint.activate([
    imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
    imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])

但如果想要以绿色矩形的中心为参照进行居中对齐的话,就得修改 UIImageView 的 Alignment rectangle。

private let dogImageView = UIImageView(named: "alignment_rect", top: 0, left: 0, bottom: 30, right: 30)!

...
...

extension UIImageView {
  convenience init?(named name: String, top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat) {
    guard let image = UIImage(named: name) else {
        return nil
    }
    let insets = UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)
    let insetImage = image.withAlignmentRectInsets(insets)
    self.init(image: insetImage)
  }
}

居中对比:按完整图片 frame 居中时绿色矩形偏左,按 Alignment Rect 居中时绿色矩形居中

至此,能证明「Auto Layout 使用 Alignment rectangle 来布局而不是 frame」。

Intrinsic Content Size

常见的 UI 控件,如 UILabel 和 UIImageView。在为它们设定完显示的文字和图片后,它们会有 intrinsicContentSize,Auto Layout 引擎利用这个intrinsicContentSize 生成 NSContentSizeLayoutConstraint 对它们进行布局。

当需要 Text measurement 时,选择性地去覆盖 intrinsicContentSize 可以提高布局性能。Text measurement 带来的性能开销是很大的,在那些显示大量文本的 app 中尤甚。

如果在无需 Text measurement 就可以确定文本显示的大小,就可以直接覆盖 intrinsicContentSize,返回这个确定的值。

override var intrinsicContentSize: CGSize {
    // 返回某个确定的 CGSize
    return CGSize(width: 100, height: 100)
}

如果是利用约束来确定文本显示的大小而不是文本本身的大小,可以这样:

override var intrinsicContentSize: CGSize {
    return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}

这样做是在告诉父视图:我已经可以通过约束来确定我自己的大小啦,没必要进行 Text measurement 了。

谈及 intrinsicContentSize,就不得不提一嘴 systemLayoutSizeFitting 方法,因为有时候会把它俩混为一谈,实际上它俩可以说是正反面。intrinsicContentSize 是传递给 Auto Layout 引擎的信息,而 systemLayoutSizeFitting 方法是 Auto Layout 引擎计算得出的信息。

参考信息