1. 关于 Auto Layout 的历史渊源
上世纪 90 年代,名叫 Cassowary的布局算法,通过将布局问题抽象成线性不等式,并分解成多个位置间的约束,解决了用户界面的布局问题。
Apple 自从 iOS 6 引入了 Auto Layout 的布局概念,其实就是对 Cassowary布局算法的一种实现。在使用 Auto Layout 进行布局时,可以指定一系列的约束,比如视图的高度、宽度等等。而每一个约束其实都是一个简单的线性等式或不等式,整个界面上的所有约束在一起就明确地(没有冲突)定义了整个系统的布局。
对于 Auto Layout 算法部分,本文不做展开。在这里我们仅仅需要知道,Auto Layout 的原理,就是在对 Layout 问题抽象的方程组求解,就可以继续向下阅读。
以下就是 WWDC 220 Session - 高性能 Auto Layout 高度脱水版。
2. iOS 上的性能表现
下图是 Ken Ferry 在 Session 现场的演示,可以比较清晰的看出,左图自使用布局的 CollectionView 上下滑动较右图而言更加流畅,Ken 在描述中也说到 iOS 12 在该例中的所有滑动事件是满帧状态。(左 iOS 12,右 iOS 11)
下图是官方测试后得到的 iOS 12 和 iOS 11 在特定场景下时间开销的对比图。可以明显的看到 iOS 12 具有很大的优势。
那么究竟是如何做到这个优化的呢?
3. 内部实现和感观体验
我们首先来通过一个例子整体的了解一下。分析一下这个简单的 Layout 场景:
下面我们在 updateConstraints()
方法中来描述这个 Layout:
// Don’t do this! Removes and re-adds constraints potentially at 120 frames per second
override func updateConstraints() {
// 首先移除约束
NSLayoutConstraint.deactivate(myConstraints)
// 然后对约束重新规则
myConstraints.removeAll()
// 构造一个 view 字典便于visual format使用
let views = ["text1":text1, "text2":text2]
// 为约束增加规则
myConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|-[text1]-[text2]",
options: [.alignAllFirstBaseline],
metrics: nil,
views: views)
myConstraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|-[text1]-|",
options: [],
metrics: nil,
views: views)
// 添加约束,与 deactivate 方法对应
NSLayoutConstraint.activate(myConstraints)
// 调用父类的 updateConstraints()
super.updateConstraints()
}
复制代码
至此我们就实现了这个简单的 Layout 方案。为了继续探究这个 Topic,在这之前先要了解一些预备知识。
3.1 updateConstraints 原理 - Render Loop
Render Loop 这个过程是用来确保所有的 UI 视图在每秒的所有帧中都表现出对应表现,正常情况下每秒会运行 120 次。这个工程分成三步:
- 更新约束:从子视图向外层逐级更新约束;
- Layout 调整:从外部向内,逐级视图获得自身的 Layout;
- 渲染与展示:与 Layout 相同,呈现顺序从外向内,使得视图呈现出来;
当然,这么叙述还是有些抽象。其实这三个过程在我们日常开发中也是经常接触的三类方法:
/// Render Loop 过程
/// 过程一:更新约束
func updateConstraints();
func setNeedsUpdateConstraints();
func updateConstraintsIfNeeded();
/// 过程二:Layout 调整
func layoutSubviews();
func setNeedsLayout();
func layoutIfNeeded();
/// 过程三:渲染与展示
func draw(_:);
func setNeedsDisplay();
复制代码
每一次调整都会运行这么一个 Render Loop 步骤。这是一套很精确的 API,目的为了让各个环节中的工作不重不漏,从而除去了很多重复操作。如上例中,如果一个 UILabel
需要有一个约束来描述其大小,但是其中的很多属性例如字条、字号等又会影响这个视图的大小,这套 API 就是这样,每次修改都会根据不同的属性来确定其尺寸。开发者可以在其方法内部来指明在渲染前最后的属性值,从而排除了多次设置的重复操作。
了解了 Render Loop 我们再来完善之前的代码。会发现在每次在 updateConstraints
的时候,都会重新解除和增加一次约束,这显然会使得性能变差。修改一下代码:
// This is ok! Doesn’t do anything unless self.myConstraints has been nil’d out
override func updateConstraints() {
if self.myConstraints == nil {
var constraints = [NSLayoutConstraint]()
let views = ["text1":text1, "text2":text2]
constraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|-[text1]-[text2]",
options: [.alignAllFirstBaseline],
metrics: nil,
views: views)
constraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|-[text1]-|",
options: [],
metrics: nil,
views: views)
NSLayoutConstraint.activate(constraints)
self.myConstraints = constraints
}
super.updateConstraints()
}
复制代码
这个 nil
的判断意思是如果我们增加了约束,那么就不用对其再次设置。这个错误也是开发者在客户端开发中较常见的错误,这种无变化的约束设置我们称之为 规则搅动 (Churning the Constraints),这种操作毫无意义且影响性能。
虽然 Render Loop 过程具有明确的目的性,但是这套 API 也是高危的,因为它经常会被调用。
下面我们来深究一下这个过程的原理。
3.2 增加约束的内部实现
当我们为空间增加一个约束 Constraint的时候,通过这些约束会组成一个多元一次方程组,这个方程组的解可以定位那些通过约束可间接计算出的定量。而这个计算过程是 Auto Layout 引擎来完成处理的。求出的解集在 UIView
渲染过程中,当做其 frame
属性中的值来使用。下图就是反应了这么一个过程。
在计算引擎计算出解集后,计算引擎还有他最后的一个工作,就是发送通知,使得对应的 UIView
调用其父视图的 setNeedsLayout()
方法。这也就是我们之前提到的更新约束这个步骤,通过向外层调用 setNeedsLayouts()
方法,我们可以验证这个由内向外的步骤。
在约束更新完成之后,进入了第二个步骤,也就是 Layout 调整阶段。每个视图会从计算引擎中获取到其子视图所需的所有数据,获取到之后重新为子视图赋值。从这点看出,Layout 调整阶段是自外向内的。
我们再来思考一下上文提及到的 规则搅动问题,如果我们每次将约束规则删除、重新添加,则每一次刷新视图都会从新经历一遍引擎的解集重计算、由内向外的 setNeedsLayout()
、自外向内的 Layout 调整。而这些其实是不需要的。
对于一次约束的增加过程至此也就大体讲完了。我们来总结一下这里我提到的一些主要内容:
- 不要由于自身的问题从而带来 规则搅动的错误;
- Auto Layout 的数学原理,就是基本的代数运算。
- Auto Layout 计算引擎是一个布局缓存和关系依赖的跟踪器;
- 需要什么就对什么做出约束,不要增加额外的约束,避免造成不必要的开销;
4. 建立一个有效的 Layout
4.1 使用 Instrument 来捕捉规则搅动
在使用 Auto Layout 布局来实现 UITableView
,我们经常会发现滑动卡顿的问题。这些问题在开发的时候很难查出原因所在。为了方便的解决并排查问题,新版的 Xcode 增加了一个新的工具 - Instrument for Layout。
这个工具的第一行 Layout Time 反应了 CPU 的使用情况,通过运算时间可以和后面的异常值进行比对。
第二行用于检测我们上文提到的 规则搅动的问题,当代码中出现大量的重复添加相同约束的错误时,会以直方图时间复杂度的形式呈现出来,便于我们做进一步的代码排查。
第三行来显示约束的增、删、改的操作。
最后一行,我们会对 UILabel
这个控件的 Layout 占中单独展示出来。因为我们的示例 App 中只有 UILabel
,当然如果你的应用中有其他的视图,也会按照类型来分行呈现。
其实纵观这个工具,他能够帮助我们的仅仅是查看约束的计算耗时以及是否出现了 规则搅动。但是这些都是我们在代码中可以直接避免的。这里有几个关于避免 规则搅动的 Tips 告诉大家:
- 尽量不要删除所有的约束(Avoid removing all constraints);
- 若是一个静态约束,仅做一次添加操作即可;
- 仅改变需要改变的约束;
- 尽量不要做删除视图的操作,反之用
hide()
方法替代;
一般做到这四点,可以避免绝大多数的 规则搅动代码层面的错误。
某些控件是十分特殊的,例如 UIImageView
、UILabel
这种,他们都有一个自适应的尺寸,这里我们称之为固有尺寸(Intrinsic Content Size),当我们不对其作出特殊化的 height 和 width 限制时,UIView
会直接用他们的固有尺寸(UIImageView
即图片尺寸,UILabel
即文本尺寸)来当做约束条件。
4.2 Override intrinsicContentSize 来调整 UILabel
约束性能
在很多控件组成的页面中,UILabel
的 Size 计算会在所有的计算开销中占很大的比重。这时候追求极致,我们可以 Override UILabel
的 intrinsicContentSize
来告诉计算引擎,如何抉择 UILabel
的 Size 问题。如果已知一个 UILabel
的展示 Size,直接 Override 其属性即可,否则对其设置成 UIView.noIntrinsicMetric
。
override var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
复制代码
4.3 不要过度使用 systemLayoutSizeFitting()
systemLayoutSizeFitting()
虽然能帮助我们根据 Layout 来自动计算其约束,但是纵观整个 Layout 过程,其计算的时间开销是十分大的。这个方法调用,其目的是从计算引擎中重新获得调用方法对应视图的 Size。然而这个过程较为复杂。
也许整个流程并不复杂,但是对于我们 Render Loop 过程,相当于作出了一次重复步骤。在 iOS 12 中,Apple 再次对自适应 Cell 作出了优化,所以在大多数情况下,减少 systemLayoutSizeFitting()
的调用可以使得时间开销再次削减。
5 总述
以上便是笔者对于这个 Session 的所有记录和脱水叙述。如同 Ken 所说,也许简单的对于 Auto Layout 中约束的 Tips 并不能满足于你,这里还有一些资料可以供你去继续学习。
- 可以前往 WWDC 2015 查看 Session 219 - Mysteries of Auto Layout, Part 2,为你带来 Auto Layout 实现及原理。
- https://techblog.toutiao.com/2018/06/19/untitled-46/