从 Auto Layout 的布局算法谈性能

这是使用 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: http://draveness.me/layout-performance

Draveness

iOS Developer / Rails / Elixir

Maine, USA draveness.me
blog comments powered by Disqus