WWDC 2018:高性能 Auto Layout

12,786 阅读9分钟

WWDC 2018 Session 220: High Performance Auto Layout
作者简介:@冬瓜争做全栈瓜,今日头条 iOS 工程师,Sepicat 作者。

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 次。这个工程分成三步:

  1. 更新约束:从子视图向外层逐级更新约束;
  2. Layout 调整:从外部向内,逐级视图获得自身的 Layout;
  3. 渲染与展示:与 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 调整。而这些其实是不需要的。

对于一次约束的增加过程至此也就大体讲完了。我们来总结一下这里我提到的一些主要内容:

  1. 不要由于自身的问题从而带来 规则搅动 的错误;
  2. Auto Layout 的数学原理,就是基本的代数运算。
  3. Auto Layout 计算引擎是一个布局缓存和关系依赖的跟踪器;
  4. 需要什么就对什么做出约束,不要增加额外的约束,避免造成不必要的开销;

4. 建立一个有效的 Layout

4.1 使用 Instrument 来捕捉规则搅动

在使用 Auto Layout 布局来实现 UITableView,我们经常会发现滑动卡顿的问题。这些问题在开发的时候很难查出原因所在。为了方便的解决并排查问题,新版的 Xcode 增加了一个新的工具 - Instrument for Layout

这个工具的第一行 Layout Time 反应了 CPU 的使用情况,通过运算时间可以和后面的异常值进行比对。

第二行用于检测我们上文提到的 规则搅动 的问题,当代码中出现大量的重复添加相同约束的错误时,会以直方图时间复杂度的形式呈现出来,便于我们做进一步的代码排查。

第三行来显示约束的增、删、改的操作。

最后一行,我们会对 UILabel 这个控件的 Layout 占中单独展示出来。因为我们的示例 App 中只有 UILabel,当然如果你的应用中有其他的视图,也会按照类型来分行呈现。

其实纵观这个工具,他能够帮助我们的仅仅是查看约束的计算耗时以及是否出现了 规则搅动。但是这些都是我们在代码中可以直接避免的。这里有几个关于避免 规则搅动 的 Tips 告诉大家:

  1. 尽量不要删除所有的约束(Avoid removing all constraints);
  2. 若是一个静态约束,仅做一次添加操作即可;
  3. 仅改变需要改变的约束;
  4. 尽量不要做删除视图的操作,反之用 hide() 方法替代;

一般做到这四点,可以避免绝大多数的 规则搅动 代码层面的错误。

某些控件是十分特殊的,例如 UIImageViewUILabel 这种,他们都有一个自适应的尺寸,这里我们称之为固有尺寸(Intrinsic Content Size),当我们不对其作出特殊化的 height 和 width 限制时,UIView 会直接用他们的固有尺寸(UIImageView 即图片尺寸,UILabel 即文本尺寸)来当做约束条件。

4.2 Override intrinsicContentSize 来调整 UILabel 约束性能

在很多控件组成的页面中,UILabel 的 Size 计算会在所有的计算开销中占很大的比重。这时候追求极致,我们可以 Override UILabelintrinsicContentSize 来告诉计算引擎,如何抉择 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 并不能满足于你,这里还有一些资料可以供你去继续学习。

  • 学习 Auto Layout 中的日志可以有效地帮助你 Debug;
  • 学习 Debug 的相关 Session;
  • 可以前往 WWDC 2015 查看 Session 219 - Mysteries of Auto Layout, Part 2,为你带来 Auto Layout 实现及原理。