从 Auto Layout 的布局算法谈性能

6,529 阅读14分钟

这是使用 ASDK 性能调优系列的第二篇文章,前一篇文章中讲到了如何提升 iOS 应用的渲染性能,你可以点击 这里 了解这部分的内容。

在上一篇文章中,我们提到了 iOS 界面的渲染过程以及如何对渲染过程进行优化。ASDK 的做法是将渲染绘制的工作抛到后台线程进行,并在每次 Runloop 结束时,将绘制结果交给 CALayer 进行展示。

而这篇文章就要从 iOS 中影响性能的另一大杀手,也就是万恶之源 Auto Layout(自动布局)来分析如何对 iOS 应用的性能进行优化以及 Auto Layout 到底为什么会影响性能?

box-layout

把 Auto Layout 批判一番

由于在 2012 年苹果发布了 4.0 寸的 iPhone5,在 iOS 平台上出现了不同尺寸的移动设备,使得原有的 frame 布局方式无法很好地适配不同尺寸的屏幕,所以,为了解决这一问题 Auto Layout 就诞生了。

Auto Layout 的诞生并没有如同苹果的其它框架一样收到开发者的好评,它自诞生的第一天起就饱受 iOS 开发者的批评,其蹩脚、冗长的语法使得它在刚刚面世就被无数开发者吐槽,写了几个屏幕的代码都不能完成一个简单的布局,哪怕是 VFL(Visual Format Language)也拯救不了它。

真正使 Auto Layout 大规模投入使用的应该还是 Masonry,它使用了链式的语法对 Auto Layout 进行了很好的封装,使得 Auto Layout 更加简单易用;时至今日,开发者也在日常使用中发现了 Masonry 的各种问题,于是出现了各种各样的布局框架,不过这都是后话了。

masonry

Auto Layout 的原理和 Cassowary

Auto Layout 的原理其实非常简单,在这里通过一个例子先简单的解释一下:

view-demonstrate

iOS 中视图所需要的布局信息只有两个,分别是 origin/centersize,在这里我们以 origin & size 为例,也就是 frame 时代下布局的需要的两个信息;这两个信息由四部分组成:

  • x & y
  • width & height

以左上角的 (0, 0) 为坐标的原点,找到坐标 (x, y),然后绘制一个大小为 (width, height) 的矩形,这样就完成了一个最简单的布局。而 Auto Layout 的布局方式与上面所说的 frame 有些不同,frame 表示与父视图之间的绝对距离,但是 Auto Layout 中大部分的约束都是描述性的,表示视图间相对距离,以上图为例:

A.left = Superview.left + 50
A.top  = Superview.top + 30
A.width  = 100
A.height = 100

B.left = (A.left + A.width)/(A.right) + 30
B.top  = A.top
B.width  = A.width
B.height = A.height

虽然上面的约束很好的表示了各个视图之间的关系,但是 Auto Layout 实际上并没有改变原有的 Hard-Coded 形式的布局方式,只是将原有没有太多意义的 (x, y) 值,变成了描述性的代码。

我们仍然需要知道布局信息所需要的四部分 xywidth 以及 height。换句话说,我们要求解上述的八元一次方程组,将每个视图所需要的信息解出来;Cocoa 会在运行时求解上述的方程组,最终使用 frame 来绘制视图。

layout-phase

Cassowary 算法

在上世纪 90 年代,一个名叫 Cassowary) 的布局算法解决了用户界面的布局问题,它通过将布局问题抽象成线性等式和不等式约束来进行求解。

Auto Layout 其实就是对 Cassowary 算法的一种实现,但是这里并不会对它展开介绍,有兴趣的读者可以在文章最后的 Reference 中了解一下 Cassowary 算法相关的文章。

Auto Layout 的原理就是对线性方程组或者不等式的求解。

Auto Layout 的性能

在使用 Auto Layout 进行布局时,可以指定一系列的约束,比如视图的高度、宽度等等。而每一个约束其实都是一个简单的线性等式或不等式,整个界面上的所有约束在一起就明确地(没有冲突)定义了整个系统的布局。

在涉及冲突发生时,Auto Layout 会尝试 break 一些优先级低的约束,尽量满足最多并且优先级最高的约束。

因为布局系统在最后仍然需要通过 frame 来进行,所以 Auto Layout 虽然为开发者在描述布局时带来了一些好处,不过它相比原有的布局系统加入了从约束计算 frame 的过程,而在这里,我们需要了解 Auto Layout 的布局性能如何。

performance-loss

因为使用 Cassowary 算法解决约束问题就是对线性等式或不等式求解,所以其时间复杂度就是多项式时间的,不难推测出,在处理极其复杂的 UI 界面时,会造成性能上的巨大损失。

在这里我们会对 Auto Layout 的性能进行测试,为了更明显的展示 Auto Layout 的性能,我们通过 frame 的性能建立一条基准线以消除对象的创建和销毁、视图的渲染、视图层级的改变带来的影响

你可以在 这里 找到这次对 Layout 性能测量使用的代码。

代码分别使用 Auto Layout 和 frame 对 N 个视图进行布局,测算其运行时间。

使用 AutoLayout 时,每个视图会随机选择两个视图对它的 topleft 进行约束,随机生成一个数字作为 offset;同时,还会用几个优先级高的约束保证视图的布局不会超出整个 keyWindow

而下图就是对 100~1000 个视图布局所需要的时间的折线图。

这里的数据是在 OS X EL Captain,Macbook Air (13-inch Mid 2013)上的 iPhone 6s Plus 模拟器上采集的, Xcode 版本为 7.3.1。在其他设备上可能不会获得一致的信息,由于笔者的 iPhone 升级到了 iOS 10,所以没有办法真机测试,最后的结果可能会有一定的偏差。

performance-chart-100-1000

从图中可以看到,使用 Auto Layout 进行布局的时间会是只使用 frame16 倍左右,虽然这里的测试结果可能受外界条件影响差异比较大,不过 Auto Layout 的性能相比 frame 确实差很多,如果去掉设置 frame 的过程消耗的时间,Auto Layout 过程进行的计算量也是非常巨大的。

在上一篇文章中,我们曾经提到,想要让 iOS 应用的视图保持 60 FPS 的刷新频率,我们必须在 1/60 = 16.67 ms 之内完成包括布局、绘制以及渲染等操作。

也就是说如果当前界面上的视图大于 100 的话,使用 Auto Layout 是很难达到绝对流畅的要求的;而在使用 frame 时,同一个界面下哪怕有 500 个视图,也是可以在 16.67 ms 之内完成布局的。不过在一般情况下,在 iOS 的整个 UIWindow 中也不会一次性出现如此多的视图。

我们更关心的是,在日常开发中难免会使用 Auto Layout 进行布局,既然有 16.67 ms 这个限制,那么在界面上出现了多少个视图时,我才需要考虑其它的布局方式呢?在这里,我们将需要布局的视图数量减少一个量级,重新绘制一个图表:

performance-layout-10-90

从图中可以看出,当对 30 个左右视图使用 Auto Layout 进行布局时,所需要的时间就会在 16.67 ms 左右,当然这里不排除一些其它因素的影响;到目前为止,会得出一个大致的结论,使用 Auto Layout 对复杂的 UI 界面进行布局时(大于 30 个视图)就会对性能有严重的影响(同时与设备有关,文章中不会考虑设备性能的差异性)。

上述对 Auto Layout 的使用还是比较简单的,而在日常使用中,使用嵌套的视图层级又非常正常。

在笔者对嵌套视图层级中使用 Auto Layout 进行布局时,当视图的数量超过了 500 时,模拟器直接就 crash 了,所以这里没有超过 500 个视图的数据。

我们对嵌套视图数量在 100~500 之间布局时间进行测量,并与 Auto Layout 进行比较:

performance-nested-autolayout-frame

在视图数量大于 200 之后,随着视图数量的增加,使用 Auto Layout 对嵌套视图进行布局的时间相比非嵌套的布局成倍增长。

虽然说 Auto Layout 为开发者在多尺寸布局上提供了遍历,而且支持跨越视图层级的约束,但是由于其实现原理导致其时间复杂度为多项式时间,其性能损耗是仅使用 frame 的十几倍,所以在处理庞大的 UI 界面时表现差强人意。

在三年以前,有一篇关于 Auto Layout 性能分析的文章,可以点击这里了解这篇文章的内容 Auto Layout Performance on iOS

ASDK 的布局引擎

Auto Layout 不止在复杂 UI 界面布局的表现不佳,它还会强制视图在主线程上布局;所以在 ASDK 中提供了另一种可以在后台线程中运行的布局引擎,它的结构大致是这样的:

layout-hierarchy

ASLayoutSpec 与下面的所有的 Spec 类都是继承关系,在视图需要布局时,会调用 ASLayoutSpec 或者它的子类的 - measureWithSizeRange: 方法返回一个用于布局的对象 ASLayout

ASLayoutable 是 ASDK 中一个协议,遵循该协议的类实现了一系列的布局方法。

当我们使用 ASDK 布局时,需要做下面四件事情中的一件:

  • 提供 layoutSpecBlock
  • 覆写 - layoutSpecThatFits: 方法
  • 覆写 - calculateSizeThatFits: 方法
  • 覆写 - calculateLayoutThatFits: 方法

只有做上面四件事情中的其中一件才能对 ASDK 中的视图或者说结点进行布局。

方法 - calculateSizeThatFits: 提供了手动布局的方式,通过在该方法内对 frame 进行计算,返回一个当前视图的 CGSize

- layoutSpecThatFits:layoutSpecBlock 其实没什么不同,只是前者通过覆写方法返回 ASLayoutSpec;后者通过 block 的形式提供一种不需要子类化就可以完成布局的方法,两者可以看做是完全等价的。

- calculateLayoutThatFits: 方法有一些不同,它把上面的两种布局方式:手动布局和 Spec 布局封装成了一个接口,这样,无论是 CGSize 还是 ASLayoutSpec 最后都会以 ASLayout 的形式返回给方法调用者。

手动布局

这里简单介绍一下手动布局使用的 -[ASDisplayNode calculatedSizeThatFits:] 方法,这个方法与 UIView 中的 -[UIView sizeThatFits:] 非常相似,其区别只是在 ASDK 中,所有的计算出的大小都会通过缓存来提升性能。

- (CGSize)calculateSizeThatFits:(CGSize)constrainedSize {
  return _preferredFrameSize;
}

子类可以在这个方法中进行计算,通过覆写这个方法返回一个合适的大小,不过一般情况下都不会使用手动布局的方式。

使用 ASLayoutSpec 布局

在 ASDK 中,更加常用的是使用 ASLayoutSpec 布局,在上面提到的 ASLayout 是一个保存布局信息的媒介,而真正计算视图布局的代码都在 ASLayoutSpec 中;所有 ASDK 中的布局(手动 / Spec)都是由 -[ASLayoutable measureWithSizeRange:] 方法触发的,在这里我们以 ASDisplayNode 的调用栈为例看一下方法的执行过程:

-[ASDisplayNode measureWithSizeRange:]
    -[ASDisplayNode shouldMeasureWithSizeRange:]
    -[ASDisplayNode calculateLayoutThatFits:]
        -[ASDisplayNode layoutSpecThatFits:]
        -[ASLayoutSpec measureWithSizeRange:]
        +[ASLayout layoutWithLayoutableObject:constrainedSizeRange:size:sublayouts:]
        -[ASLayout filteredNodeLayoutTree]

ASDK 的文档中推荐在子类中覆写 - layoutSpecThatFits: 方法,返回一个用于布局的 ASLayoutSpec 对象,然后使用 ASLayoutSpec 中的 - measureWithSizeRange: 方法对它指定的视图进行布局,不过通过覆写 ASDK 的布局引擎 一节中的其它方法也都是可以的。

如果我们使用 ASStackLayoutSpec 对视图进行布局的话,方法调用栈大概是这样的:

-[ASDisplayNode measureWithSizeRange:]
    -[ASDisplayNode shouldMeasureWithSizeRange:]
    -[ASDisplayNode calculateLayoutThatFits:]
        -[ASDisplayNode layoutSpecThatFits:]
        -[ASStackLayoutSpec measureWithSizeRange:]
            ASStackUnpositionedLayout::compute
            ASStackPositionedLayout::compute            ASStackBaselinePositionedLayout::compute        +[ASLayout layoutWithLayoutableObject:constrainedSizeRange:size:sublayouts:]
        -[ASLayout filteredNodeLayoutTree]

这里只是执行了 ASStackLayoutSpec 对应的 - measureWithSizeRange: 方法,对其中的视图进行布局。在 - measureWithSizeRange: 中调用了一些 C++ 方法 ASStackUnpositionedLayoutASStackPositionedLayout 以及 ASStackBaselinePositionedLayoutcompute 方法,这些方法完成了对 ASStackLayoutSpec 中视图的布局。

相比于 Auto Layout,ASDK 实现了一种完全不同的布局方式;比较类似与前端开发中的 Flexbox 模型,而 ASDK 其实就实现了 Flexbox 的一个子集。

在 ASDK 1.0 时代,很多开发者都表示希望 ASDK 中加入 ComponentKit 的布局引擎;而现在,ASDK 布局引擎的大部分代码都是从 ComponentKit 中移植过来的(ComponentKit 是另一个 Facebook 团队开发的用于布局的框架)。

ASLayout

ASLayout 表示当前的结点在布局树中的大小和位置;当然,它还有一些其它的奇怪的属性:

@interface ASLayout : NSObject

@property (nonatomic, weak, readonly) id<ASLayoutable> layoutableObject;
@property (nonatomic, readonly) CGSize size;
@property (nonatomic, readwrite) CGPoint position;
@property (nonatomic, readonly) NSArray<ASLayout *> *sublayouts;
@property (nonatomic, readonly) CGRect frame;

...

@end

代码中的 layoutableObject 表示当前的对象,sublayouts 表示当前视图的子布局 ASLayout 数组。

整个类的实现都没有什么值得多说的,除了大量的构造方法,唯一一个做了一些事情的就是 -[ASLayout filteredNodeLayoutTree] 方法了:

- (ASLayout *)filteredNodeLayoutTree {
  NSMutableArray *flattenedSublayouts = [NSMutableArray array];
  struct Context {
    ASLayout *layout;
    CGPoint absolutePosition;
  };
  std::queue<Context> queue;
  queue.push({self, CGPointMake(0, 0)});
  while (!queue.empty()) {
    Context context = queue.front();
    queue.pop();

    if (self != context.layout && context.layout.type == ASLayoutableTypeDisplayNode) {
      ASLayout *layout = [ASLayout layoutWithLayout:context.layout position:context.absolutePosition];
      layout.flattened = YES;
      [flattenedSublayouts addObject:layout];
    }

    for (ASLayout *sublayout in context.layout.sublayouts) {
      if (sublayout.isFlattened == NO) queue.push({sublayout, context.absolutePosition + sublayout.position});
  }

  return [ASLayout layoutWithLayoutableObject:_layoutableObject
                         constrainedSizeRange:_constrainedSizeRange
                                         size:_size
                                   sublayouts:flattenedSublayouts];
}

而这个方法也只是将 sublayouts 中的内容展平,然后实例化一个新的 ASLayout 对象。

ASLayoutSpec

ASLayoutSpec 的作用更像是一个抽象类,在真正使用 ASDK 的布局引擎时,都不会直接使用这个类,而是会用类似 ASStackLayoutSpecASRelativeLayoutSpecASOverlayLayoutSpec 以及 ASRatioLayoutSpec 等子类。

笔者不打算一行一行代码深入讲解其内容,简单介绍一下最重要的 ASStackLayoutSpec

stack

ASStackLayoutSpecFlexbox 中获得了非常多的灵感,比如说 justifyContentalignItems 等属性,它和苹果的 UIStackView 比较类似,不过底层并没有使用 Auto Layout 进行计算。如果没有接触过 ASStackLayoutSpec 的开发者,可以通过这个小游戏 Foggy-ASDK-Layout 快速学习 ASStackLayoutSpec 的使用。

关于缓存以及异步并发

因为计算视图的 CGRect 进行布局是一种非常昂贵的操作,所以 ASDK 在这里面加入了缓存机制,在每次执行 - measureWithSizeRange: 方法时,都会通过 -shouldMeasureWithSizeRange: 判断是否需要重新计算布局:

- (BOOL)shouldMeasureWithSizeRange:(ASSizeRange)constrainedSize {
  return [self _hasDirtyLayout] || !ASSizeRangeEqualToSizeRange(constrainedSize, _calculatedLayout.constrainedSizeRange);
}

- (BOOL)_hasDirtyLayout {
  return _calculatedLayout == nil || _calculatedLayout.isDirty;
}

在一般情况下,只有当前结点被标记为 dirty 或者这一次布局传入的 constrainedSize 不同时,才需要进行重新计算。在不需要重新计算布局的情况下,只需要直接返回 _calculatedLayout 布局对象就可以了。

因为 ASDK 实现的布局引擎其实只是对 frame 的计算,所以无论是在主线程还是后台的异步并发进程中都是可以执行的,也就是说,你可以在任意线程中调用 - measureWithSizeRange: 方法,ASDK 中的一些 ViewController 比如:ASDataViewController 就会在后台并发进程中执行该方法:

- (NSArray<ASCellNode *> *)_layoutNodesFromContexts:(NSArray<ASIndexedNodeContext *> *)contexts {
  ...

  dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
  dispatch_apply(nodeCount, queue, ^(size_t i) {
    ASIndexedNodeContext *context = contexts[i];
    ASCellNode *node = [context allocateNode];
    if (node == nil) node = [[ASCellNode alloc] init];

    CGRect frame = CGRectZero;
    frame.size = [node measureWithSizeRange:context.constrainedSize].size;
    node.frame = frame;

    [ASDataController _didLayoutNode];
  });

  ...

  return nodes;
}

上述代码做了比较大的修改,将原有一些方法调用放到了当前方法中,并省略了大量的代码。

关于性能的对比

由于 ASDK 的布局引擎的问题,其性能比较难以测试,在这里只对 ASDK 使用 ASStackLayoutSpec布局计算时间进行了测试,不包括视图的渲染以及其它时间:

async-node-calculate

测试结果表明 ASStackLayoutSpec 花费的布局时间与结点的数量成正比,哪怕计算 100 个视图的布局也只需要 8.89 ms,虽然这里没有包括视图的渲染时间,不过与 Auto Layout 相比性能还是有比较大的提升。

总结

其实 ASDK 的布局引擎大部分都是对 ComponentKit 的封装,不过由于摆脱了 Auto Layout 这一套低效但是通用的布局方式,ASDK 的布局计算不仅在后台并发线程中进行、而且通过引入 Flexbox 提升了布局的性能,但是 ASDK 的使用相对比较复杂,如果只想对布局性能进行优化,更推荐单独使用 ComponentKit 框架。

References

Github Repo:iOS-Source-Code-Analyze

Follow: Draveness · GitHub

Source: draveness.me/layout-perf…