Flex-box layout on iOS

对于布局的类库中,官方的Auto Layout是被大多数开发者接受的一种,但是由于其冗长的语法,开发者会选择对其封装过的Masonry来使用。既然选择使用Auto Layout来进行布局,就是不希望使用最原始的手动布局来解决屏幕适配问题(屏幕旋转、不同设备尺寸、iPad中的多任务等),Auto Layout是Cassowary)的一种实现,是使用描述性语言来实现一个/组线性方程或者不等式,由于是根据用户的方程式进行的布局约束,在进行实际视图层次布局的时候,就需要大量的解方程的计算,这个计算的消耗量是很大的,并且当视图的层级有变动的时候,这种计算还会重新进行一次,并没有对已有的计算进行缓存。

基于系统布局引擎的这种问题,很多另类布局引擎(Masonry不算)就慢慢被开发了出来,基本上的实现都摒弃了Auto Layout这种布局方式,改为底层使用手动布局,表现层使用一套新的布局约束体系。诚然这些布局引擎在性能上大大的优于Auto Layout,甚至堪比手动布局,但是学习曲线却是很陡的,甚至比当初接触Auto Layout要学习的语法还要多,不过使用时间长了之后就会发现如同使用Masonry一样顺手。其中根据接受程度来说,采用Flex-Box布局是受众面很广的一种实现方式,其余的或者结合安卓、web前端的几大布局实现的功能更强大的布局自己实现一种布局的方式结合Flex-Box实现的异步计算并且缓存布局的全家桶ASDK同样具有异步计算布局并且缓存的布局引擎LayoutKit等等,这些新布局引擎的学习成本就又是一个新的挑战了。

可以发现,在Web前端广受欢迎的Flex-Box布局在iOS上的实现还是很广泛的,毕竟性能的提升是很明显的,那么这篇文章就来作为学习Flex-Box在iOS系统上实现的一个记录。

1 在开发中使用Auto Layout的痛点

在介绍Flex-Box布局之前说一个在实际开发中经常会遇到的问题:有这样的一个需求,3-5个数目不定的itemView,他们的size一样,间距一样,但是要在父视图中整体居中显示。比如:

1
2
3
4
5
6
7
8
9
10
11
itemViewSize = {50,50} space = 10
superViewWidth = 300

itemView 的个数为3
|65101065|

itemView 的个数为4
|3510101035|

itemView 的个数为5
|5101010105|

如果使用手动计算frame的方法,会很方便的完成需求中的布局,如果使用Auto Layout来进行itemView之间的约束也是可以实现,但是很麻烦,即使使用Masonry也不是很好进行布局,这其中还需要知道superViewWidth这个变量,上面的例子中的superViewWidth是为了便于理解才设置为一个固定值的,实际开发中,使用Auto Layout有可能这个值会为0。

Masonry中为NSArray提供了一些便于组合布局的API:

1
2
3
4
5
// 提供可以根据数组中的元素进行固定大小的整体前后等间距布局
- (void)mas_distributeViewsAlongAxis:(MASAxisType)axisType withFixedItemLength:(CGFloat)fixedItemLength leadSpacing:(CGFloat)leadSpacing tailSpacing:(CGFloat)tailSpacing;

// 提供可以根据数组中的元素进行可变化大小的等间距布局
- (void)mas_distributeViewsAlongAxis:(MASAxisType)axisType withFixedSpacing:(CGFloat)fixedSpacing leadSpacing:(CGFloat)leadSpacing tailSpacing:(CGFloat)tailSpacing;

对应的效果如下

但是很明显,这两个方法不能够满足我们的需求,我们的需求应该类似于这样的实现

指定好itemView的大小、itemViews的对齐方式、itemView之间的间距。为了便于拓展,添加了一个对齐方式的参数,支持居左、居中、居右三种对齐方式。

首先如果要让itemViews整体按照对齐方式进行排列,需要将他们作为一个整体,或者说添加一个虚拟的view—virtualItemsSuperView,然后添加到superView上,而itemViews都添加到这个虚拟的view上。思路是这样,但是会添加一个不显示的占位视图,对于视图层次来说,多了一些渲染开支。

抛开上面的虚拟父视图,根据Masonry已经提供的两个方法去实现这个功能。写着写着就会发现里面需要一定数量的计算来对视图进行布局:不仅要考虑排列方向还需要考虑对齐方式,还需要根据不同的需求进行视图的约束设置。当做完这一套之后会发现,实现代码已经很长了,里面大多数都是不可以重用的判断逻辑代码。

另外一种可行的方法就是分别计算这些itemView与其他itemView之间的约束,不使用NSArray分类中的方法,具体做法可以看这个

这些效果实现起来也不是很难,首先需要理解需求,进行针对性的计算,复用性不是太好,说白了,就是让程序员操心的太多了,毕竟他们都很懒。这就是开发中使用Auto Layout进行布局的一个缩影,还会有很多的类似需求是使用Auto Layout不容易解决的,但如果转换思路,使用Flex-Box布局就可以很方便的解决这样的问题。

2 Flex-Box布局

Flex-Box布局是将视图作为盒子模型下进行弹性布局的一种布局方案,具体的使用语法可以参考阮一峰的博文。Flex-Box将每一个视图作为一个盒子,盒子拥有内外边距,虽然同样是采用描述性的语言进行布局,却没有Auto Layout中的视图依赖关系,这里主要说一下Flex-Box布局中的几个重要概念:axiscontaineritem

container作为容器决定内部的item的整体布局,每一个采用Flex-Box布局的元素都称为容器,每一个容器有两个axis,axis用来决定内部item的布局方向,而对于和视图一一对应的item则就决定其具体的布局。关于Flex-Box的语法不是很难,参考博文结合实际展示就可以明白,主要是接触一种新思想需要花费一定的时间去学习适应。

2.1 Yoga&YogaKit

根据Flex-Box的实现时间以及平台普及程度来看,其对于布局的思想还是很先进的,但是对于已经支持Flex-Box的Web端来说,移动端目前还是有一定的使用困难,不过好在Facebook基于 Flex-Box使用C语言实现了一个跨平台的移动端实现—-Yoga,并且仅仅是提取了Flex-Box中关于布局的功能,没有实现针对于设置颜色这种非布局的特点。

这里不讨论Yoga,仅仅对在iOS上封装的YogaKit进行学习讨论。YogaKit可以用于Objective-C项目以及swift项目,整个使用过程中是通过一个YGLayout对象来进行布局的配置,YGLayout包括所有Flex-Box布局中的所有特性,并且YogaKit为UIView添加了一些属性和方法:一个YGLayout类型的属性、一个决定是否使用Yoga布局的布尔值、一个用来设置布局的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef void (^YGLayoutConfigurationBlock)(YGLayout *layout);

@interface UIView (Yoga)

/**
The YGLayout that is attached to this view. It is lazily created.
*/
@property (nonatomic, readonly, strong) YGLayout *yoga;
/**
Indicates whether or not Yoga is enabled
*/
@property (nonatomic, readonly, assign) BOOL isYogaEnabled;

/**
In ObjC land, every time you access `view.yoga.*` you are adding another `objc_msgSend`
to your code. If you plan on making multiple changes to YGLayout, it's more performant
to use this method, which uses a single objc_msgSend call.
*/
- (void)configureLayoutWithBlock:(YGLayoutConfigurationBlock)block
NS_SWIFT_NAME(configureLayout(block:));

@end

所有要做的布局操作就是使用这个一个block对YGLayout对象对视图进行设置,并且在完成布局之后,可以调用根视图的yoga属性的-applyLayoutPreservingOrigin:方法来将布局应用到根视图以及其子视图中。一个例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
// in viewDidLoad()
let contentView = UIView()
contentView.backgroundColor = .lightGray
contentView.configureLayout {(layout)in
layout.isEnabled = true // 重要的实现,用来决定是否使用Yoga布局
layout.width = 40
layout.height = 40
layout.marginTop = 10
layout.marginLeft = 10
}
view.addSubview(contentView)
contentView.yoga.applyLayout(preservingOrigin:true

在熟悉了Flex-Box语法之后,就会很容易的对视图进行布局,但是如果当视图的层级嵌套多了以后,就会出现很多的这样的代码块,引发一些问题:

  • 写起来没有那么优雅(没有Masonry中的链式调用)
  • 布局代码占用太多编辑器空间(主要还是写法不优雅)
  • 没有支持iOS11中的safeArea(可以使用padding属性达到类是的目的)

对于项目中的屏幕旋转问题,还需要专门在func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)中处理一下:

1
2
3
4
5
6
7
8
9
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)

view.configureLayout { (layout) in
layout.width = YGValue(size.width)
layout.height = YGValue(size.height)
}
view.yoga.applyLayout(preservingOrigin: true)
}

但是有一个问题,虽然子视图是按照需求正确展示了,但是视图控制器的view却没有了正确的frame。

另外对于实际开发中遇到的视图高度自适应(动态UITableViewCell)问题,YogaKit也没有很好的解决方案,根据以上的这些YogaKit的不足,可以根据需求在Yoga的基础上进行进一步的封装,以实现更多更强大的功能。

2.2 FlexLayout

FlexLayout adds a nice Swift interface to the highly optimized facebook/yoga flexbox implementation. Concise, intuitive & chainable syntax.

FlexLayout是基于Yoga实现的一个语法简洁、性能高效、书写优雅的布局框架。

由于Yoga的布局是所有的布局操作都是在YGLayout类上进行的,因此FlexLayout的封装操作是在YGLayout上增加一个表现层—–Flex类,Flex类对设置Flex-Box布局需要的操作的进行封装,内部通过返回Flex对象以及swift的闭包特性为框架增加了链式调用:

1
2
3
4
5
@discardableResult
public func wrap(_ value: Wrap) -> Flex {
view.yoga.flexWrap = value.yogaValue
return self
}

同时新增define:函数,根据闭包可以将布局代码集中到一个地方,将视图的布局在编辑器中可以有很明确的层次展现出来,这对于设置以及修改布局来说都很方便:

1
2
3
4
5
@discardableResult
public func define(_ closure: (_ flex: Flex) -> Void) -> Flex {
closure(self)
return self
}

除了以上这些特点,针对于Flex-Box布局中的container和item的表现形式也比Yoga更加直观,内部通过添加辅助UIView实例来实现这一特性:

1
2
3
4
5
6
7
8
9
10
11
@discardableResult
public func addItem() -> Flex {
let view = UIView()
return addItem(view)
}

@discardableResult
public func addItem(_ view: UIView) -> Flex {
self.view.addSubview(view)
return view.flex
}

除了其内部的封装方式,加上简洁的语法,在使用上对开发者来说是很友好,可以说是提供了一种封装Yoga的思路。

2.3 AsyncDisplayKit

ASDK也是Facebook团队开发的,

3 其他布局框架

3.1 MyLayout&TangramKit

MyLayout是一套iOS界面视图布局框架,TangramKit是它的swift实现版本。MyLayout的内核是基于对UIView的layoutSubviews方法的重载以及对子视图的bounds和center属性的设置而实现的。在摒弃Auto Layout的同时,使用一套新的约束体系来完成手动计算布局,并且吸取其他平台上的优秀布局经验增加了一些新的布局体系,同时针对于iOS开发中的一些常见布局问题,开发出了诸如视图隐藏和显示时会自动激发布局、布局视图的高度自适应(UITableviewCell动态高度)、标签云实现、左右内容宽度自适应、按比例分配尺寸和间距等等这样的功能。具体的布局原理以及使用规范可以参考作者的一系列博文。

MyLayout将开发中经常遇到的情况使用合适的布局形式进行抽象,整体的框架如下:

作者对框架的维护很频繁,最新版本中也添加了iOS11以后的safeArea概念,以及支持xib创建的视图进行布局,基本上是开发中的布局问题都想到了,不知道是不是由于是国内开发者的项目,文档写的很详细,针对使用过程中的示例以及注意事项都有备注。

3.1.1 评估尺寸和缓存

MyLayout支持评估布局视图的尺寸——-sizeThatFits:

1
2
3
4
5
6
// Objective-C
-(CGSize)sizeThatFits:(CGSize)size;
-(CGSize)sizeThatFits:(CGSize)size inSizeClass:(MySizeClass)sizeClass;

// swift
public func tg_sizeThatFits(_ size:CGSize = .zero, inSizeClass type:TGSizeClassType = .default) -> CGSize

这个方法不会让布局视图进行真正的布局,仅仅是对布局的size进行一个评估,通过这个方法可以在没有进行布局的时候(一般都是进行布局前)动态的算出布局的位置和大小,有了这个尺寸,就可以对其进行缓存,在真正进行布局的时候直接使用缓存的值进行布局,从而提升性能。

对于通过评估的尺寸可以通过tg_cacheEstimatedRect属性来决定是否进行缓存,默认为不缓存。当用-sizeThatFits方法评估布局视图的尺寸后,所有子视图都会生成评估的位置和尺寸,因为此时并没有执行布局所以子视图并没有真实的更新frame值。而当布局视图要进行真实布局时又会重新计算所有子视图的位置和尺寸,因此为了优化性能当我们对布局进行评估后在下次真实布局时(这个点怎么知道呢?)可以不再重新计算子视图的位置和尺寸而是用前面评估的值来设置位置和尺寸。这个属性设置为YES时则每次评估后到下一次布局时不会再重新计算子视图的布局了,而是用评估值来布局子视图的位置和尺寸。而当这个属性设置为NO时则每次布局都会重新计算子视图的位置和布局。

这个针对于缓存评估尺寸的属性对于那些动态高度UITableviewCell中会有很大的用处,一般将某一个布局视图作为UITableviewCell的contentView的子视图,然后决定缓存计算结果:

1
2
3
4
5
6
7
MyXXXLayout *rootLayout= [MyXXXLayout new];
rootLayout.cacheEstimatedRect = YES; //设置缓存评估的rect,如果您的cell是高度自适应的话,强烈建立打开这个属性,这会大大的增强您的tableview的性能!!
rootLayout.myHorzMargin = 0; //宽度和父视图相等
rootLayout.wrapContentHeight = YES; //高度动态包裹。
[self.contentView addSubview:rootLayout];
self.rootLayout = rootLayout;
//在rootLayout添加子视图。。。

然后在UITableView的代理方法-heightForRowAtIndexPath中按如下格式进行高度的评估的计算:

1
2
3
4
5
6
-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{

UITableViewCellXXX *cell = (UITableViewCellXXX*)[self tableView:tableView cellForRowAtIndexPath:indexPath];
CGSize size = [cell.rootLayout sizeThatFits:CGSizeMake(tableView.frame.size.width, 0)];
return size.height;
}

计算评估尺寸并进行缓存的核心方法是-(CGSize)myEstimateLayoutRect:(CGSize)size inSizeClass:(MySizeClass)sizeClass sbs:(NSMutableArray*)sbs,这个方法内部做了以下事情:

  • 为自己以及子类视图设置对应的sizeClass,这个是这个框架中的一个新特性
  • 根据传入的尺寸评估计算要展示的尺寸,不同的布局(框架中有7种布局)根据各自特点内部决定如何布局
  • 根据不同布局计算出来的尺寸缓存到为UIView添加的一个MyFrame实例中
  • 计算完成之后还将自己以及子类视图的sizeClass重置为默认的类型

针对于不同的布局决定如何布局的核心方法为:

1
-(CGSize)calcLayoutRect:(CGSize)size isEstimate:(BOOL)isEstimate pHasSubLayout:(BOOL*)pHasSubLayout sizeClass:(MySizeClass)sizeClass sbs:(NSMutableArray*)sbs

通过源码可以发现,这些frame的计算都是在主线程上进行的,作者认为

https://www.jianshu.com/p/603fd470bbb0

好问题,因为计算并不耗时,一个视图计算大概耗时零点零几毫秒。所以在主线程中计算没有任何压力。如果多线程的话还要切换上下文,同步,异步处理 反而开销增大。

我反而觉得是autolayout的多线程处理反而影响了性能。我github上有出性能报告的。

如同提问题的人的疑问一样,我一直也以为Auto Layout的主要消耗是计算出最后frame的操作,难道主要的性能问题是多线程之间的操作么??不过主要的性能影响是根据设置好的约束(线性等式或不等式)到计算出最终frame是肯定的,具体这其中是由于多线程操作还是由于解方程比较影响性能,就只能等Auto Layout开源之后才能知道了。

3.1.2 SizeClass

本身Size-Class是Apple中用于定义不同设备的各种屏幕尺寸的,可以根据经纬方向上确定AnyCompactRegular三种形式决定各种屏幕下的视图约束。这个功能的实现看起来还是很有难度的。

3.1.3 一些Layout

MyFlowLayout是框架中对于Flex-Box布局的实现,作者说是实现了CSS3中全部的Flex-Box功能,不过看其代码觉得更像是一个九宫格布局,而Flex-Box布局中的一些特性在MyLinearLayout中的实现倒是很多,但确实如作者所说,可以作为UICollectionView的替代品。另外真正实现了九宫格布局的是栅格布局—MyGridLayout,栅格布局运用更多的地方就是电商app的界面展示,布局有可能需要根据后台下发数据来渲染出最后结果,栅格

可以看出来,每一个Layout都是一个UIView的实例,这样很像是将布局操作作为一个开发者可触摸的视图提供出来进行布局。有的人会认为这样会添加一个UIView到视图层次中,增加不必要的渲染操作,不过结合视图封装的思想,这样做还是有好处的。我想他认为添加多余的视图是在整体布局内部的小布局处,比如给出的demo中的LLTest1ViewController的线性布局处,代码中针对于某些同水平/同垂直方向上的子视图添加了一个不显示的布局视图,这在视图层次中确实是多余的,个人也不喜欢为了某些显示效果而添加一些不显示的视图,多一个中间层视图多的却不只是一个计算操作。

3.2 PinLayout

3.3 LayoutKit

LayoutKit是Linkedin的开发团队在对app做性能测试的时候发现列表滚动有肉眼可见的卡顿开发的布局框架。他们发现主要的卡顿是在主线程中的自动布局,虽然知道原因,但是却不知道Auto Layout内部的什么操作导致了卡顿,毕竟Auto Layout是苹果的闭源框架。在遵循最佳实践、合理设置约束之后,改善效果还不是很明显,因此他们决定转换思路,使用另一种方案来解决布局问题。

在经过一番研究之后他们开发出了布局速度更快、准确性高、稳定性高的LayoutKit,它是基于手动计算布局的,性能肯定是比Auto Layout要高很多,另外它是在异步线程中进行布局计算,因此对用户的界面操作不会有任何影响,并且对于计算结果会进行缓存,它的断言机制更加方便调试、review,这对于开发者来说是更友好的一点。

需要说明的一点是,LayoutKit不是一个Flex-Box布局类型的iOS端实现,不过它里面却有很多Flex-Box中优秀的特性。以上这些听起来和Facebook出品的AsyncDisplayKit中的布局引擎很像,不过由于AsyncDisplayKit是一个比较庞大的类库,布局引擎不能单独拿出来使用,这一点上还是增加了一些门槛。基于独特的布局算法以及对布局时按需创建UIView的特性,LayoutKit让布局的计算不会影响到app的整体性能。

LayoutKit

整个框架提供五种基础布局:

  • LabelLayout,专门针对于UILabel的布局
  • ButtonLayout,专门针对于UIButton的布局
  • SizeLayout,对于那些具备特殊尺寸的布局,比如UIImageView
  • InsetLayout,支持插入子布局的一种嵌入式布局
  • StackLayout,很像UIStackView,为子布局提供水平和垂直方向布局的嵌套布局

在我们开发中的视图结构大多数都是那种具有水平或者垂直蜂窝状特征的嵌套视图,LayoutKit中的嵌套布局不会对性能产生负面影响,因为LayoutKit不会为不需要的布局创建视图(例如,StackLayout, InsetLayout),这一点上比MyLayout中布局即视图会好一点。

3.3.1 布局算法

文档上说布局算法中核心的是位于Layout协议中的这两个函数:

  1. func measurement(within maxSize: CGSize) -> LayoutMeasurement
  2. func arrangement(within rect: CGRect, measurement: LayoutMeasurement) -> LayoutArrangement

arrangement(origin:width:height:)是一个便利方法,可以结合上面两个函数的参数到一个中,内部还是调用的这两个核心方法,所以这个框架中需要重点注意的地方就是这两个方法了:先计算size,然后根据size计算出来frame。

整个布局过程可以分为三步:

  1. 初始化一个布局对象
  1. 计算布局中视图的frame
  1. 初始化视图并且根据#2中的frame设置为自己的frame

基于该框架的特性,上面两个计算函数以及布局过程中的前两步可以在在非主线程中进行,因此可以提高整体的效率,不过在使用的时候要保证线程安全。

3.3.2 异步计算

3.3.3 缓存计算结果

3.3.4 优化UITableView和UICollectionView

3.3.5 StackView

整个布局框架中仅有的一个UIVIew的子类,也可以说是一个有优化过的UIStackView,它让使用者不需要去关注如何布局以及线程之间的操作。虽然它是UIView的子类,由于内部使用的是LayoutKit,所以在将它结合Auto Layout使用的时候还需要一些额外的操作:需要在子类的尺寸有改变的时候主动调用StackView的invalidateIntrinsicContentSize函数、子类化StackView必须要实现sizeThatFits方法返回正确的尺寸。

3.4 Componenkit

基于对复杂的视图层级结构上使用Auto Layout性能表现很差的实时,Facebook使用Objective-C++开发出了使用虚拟DOM的概念抽象组件化了UIView层次结构的Componenkit,其布局系统的表现形式是根据Flex-Box布局规范来实现的,根据其实现思路可以发现根本就是React的另一个实现,虽然和React native还是有很大区别。

Componenkit使用一种声明式来构建组件代码,不同于以往的指令式代码,可以让使用者专注于需要做什么而不是如何去实现这个目标中的具体细节。

4 如何选取

对于新技术要保持有热度的去学习,从上面的一些布局引擎/框架的开发实现来看,单单对于布局的优化这几年就有很多优秀框架出现。另外更重要的一点是要有全局观的去学习,横向比较这些技术的优缺点,探究一下内部实现的原理,然后才能选取出合适的运用到实际项目中。

同样是新的布局引擎,以上进行了Flex-Box和非Flex-Box的区分。采用Flex-Box思路实现的布局在使用上的成本会比较小,毕竟Flex-Box在Web前端这么多年的发展还是提供了很多的材料可供学习;另一个非Flex-Box的实现思路则另辟蹊径,根据更加人性化的结构来完成调用的表现层构建,但是学习成本却比Flex-Box实现的要高许多。对于想要从布局来优化项目的团队来说,要考察比较不同布局引擎的社区是否活跃,这对于使用过程中的解决问题来说很重要,也许有的坑别人已经帮你踩过了,也许你提交了一个issue半年也没有作者修改回复。

借用YogaKit vs. ComponentKit vs. AsyncDisplayKit中说的,如果项目中要大范围的进行性能优化,使用AsyncDisplayKit是个不错的选择,简单的就直接YogaKit或者封装一层更友好的API来进行布局,而对于ComponentKit来说,上手难度高是一个,如果懂些C++或者说不排斥新的编程方式(js中的组件、包的形式)还是可以上手试一下的。

如果要对比各个布局框架的性能,可以使用这个库来进行性能比较。

这篇文章不是一棒子打死说不要使用Auto Layout进行布局,只是由于在视图个数视图的嵌套层级达到一定的数量级时其计算操作会很影响性能,或者想换种新方式来完成布局编码。如果软件本身就不是很复杂,Auto Layout还是很推荐使用的,毕竟Masonry没有那么难用不是么 :)

参考文章

有趣的AutoLayout示例—Masnory实现 github