作为一名iOS 开发者,大家都知道UIKit默认是MVC 架构的,Model,View,和Controller 。随着这几年App开发的普及,这三部分所使用的技术都越来越成熟。比如 Model 现在有很多 JSON-binding 像 Mantle,JSONModel;Controller所代表的控制层也出现很多思潮像MVVM,MV;,对于View,现在出来的UI控件更是数不胜数,让人眼花缭乱。在那么多变化中,有没有一些东西是始终不变的呢?是有的,有些核心的思想是一直没有变的,比如今天我们要谈的Layout技术。
Layout 是大家经常接触,但却很少去关注的话题。因为用起来太简单了,都是改大小,改位置。没觉得需要再做些什么了。
不就是 setFrame 吗?
确实只是setFrame,但你也别小看它。细看你也会发现一整片天地。Layout 技术看上去简单,它却是整个GUI框架的基础,上层技术的设计,是必须依赖它才能实现。
Code vs xib family
很早之前(有多早?没有 iOS 之前就有 nib 了) 关于view的Layout就有两种写法。
- 用纯code写,继承UIView,UIButton等,实现自定义的View
- 用nib,xib 以及后来的Storyboard,来拖控件来实现可视化的编辑
哪一种更好呢?
这个是没有固定答案的,哪一种都好,哪一种都不好,好坏是依据情境而定的,因为产品业务不同,技术栈不同,而且人还是有技术偏好的。所以不能从一而论。但是你用没有用好?是看的出来的。技术与熟悉它的人一起配合才会产生好的效果。所以与其说好坏,不如说一种风格,用code 写的人,觉得纯code,看起代码来更容易,方便继承,组合和代码管理。用xib的觉得xib 和 storyboard更直观,调样式更简单。不管选择怎样,对一名开发人员,你是需要明白两种的利弊来做恰当的选择,对一个iOS团队,是需要统一思想的,去发挥一种方式的优点,要建立规则规避缺点的影响,比如用xib之类的文件,很不利于merge,特别是在多人协作的情况下,这个时候有的团队,就是把xib分配到人,一个人维护一部分。哪怕是两者都用,你也需要去明白,既然你两者优点都想要,那你也会带来维护上复杂度的提升。
我是使用纯 code 来写 view 比较多的。但是代码写多了,总会不满足。总觉得不能老是 override layoutSubviews了,于是就会想:
- 能更简单点吗?能把布局逻辑抽象出来吗?
- 像Android 一样写在一个简单的可读的xml 里,还可以继承?
- 能像 CSS 之于 HTML 一样,完全分开吗?甚至异步加载布局?
有了Auto Layout
我相信不只有我一个人这么想?还有很多人也是的。但是怎么办呢?
随着iOS平台的发展和成熟,事情出现了转机。iOS 刚开始只有 320 480尺寸,后来有了iPad,然后又有了320 596尺寸,还有@2x @3x 等等。而且现在很多App还要适配多语言,不同语言下文字的长度差别可能很大。写死 size 的做法已经越来越不好用了。如何采用通用的方法来布局呢?苹果出了一个通用方案Auto Layout。
以前我们写死固定的 size,origin,现在不能再用固定的数字,否则就没办法统一了。那用什么呢?用关系,对于多种size的屏幕,存贮固定的大小,不如存储关系。因为对于同一个界面,很多情况下,子view 在不同的尺寸下的相对关系是不变的。我们存储关系,让程序在运行时去解析这些关系,根据当前的设备算出,该设定的尺寸,这样才能统一。
那我们怎么找到这些关系呢?怎么抽象出来呢?
来让我们看看我们在 layoutSubviews 里写的代码。一般写 layoutSubviews 里面的代码是这样的
1 | - (void)layoutSubviews { [super layoutSubviews]; CGSize textSize = [_centerLabel.text sizeWithAttributes:@{NSFontAttributeName: _centerLabel.font}]; _centerLabel.frame = CGRectMake((CGRectGetWidth(self.bounds) - textSize.width) / 2, (CGRectGetHeight(self.bounds) - textSize.height) / 2, textSize.width, textSize.height); } |
我们进一步分析,如果把一个view的frame中的origin和size都拆开成left, top, width, height
1 | CGFloat left = (1 / 2) * self.bounds.size.width - textSize.width / 2; CGFloat top = (1 / 2) * self.bounds.size.height - textSize.height / 2; CGFloat width = textSize.width; CGFloat height = textSize.height; _centerLabel.frame = CGRectMake(left, top, width, height); |
对 left top ,width, height进一步进行数学变换,分离变量和常量,
1 | CGFloat left = (1 / 2) * self.bounds.size.width - textSize.width / 2; |
对于没有变量的, 假设一个变量,并乘以0
1 | CGFloat width = 0 * self.bounds.size.width + textSize.width; |
所以呢,代码成了这个样子
1 | CGFloat left = (1 / 2) * self.bounds.size.width - textSize.width / 2; |
最后 left,top, width, height 就可以归一化到 如下形式
1 | aView.属性 = 乘数 * bView.属性 + 常量 |
接着,如果一个view 跟多个view有关呢?
假如像下面这样跟两个view有关
1 | aView.属性 = 乘数 * bView.属性 + 乘数 * cView.属性 + 常量 |
那怎么统一呢?
不急,我们先看下 CGRect 的结构
1 | struct CGRect { |
rect 有 origin 和 size,size 是 view 的大小,origin 呢?origin 是 view 左上角的位置,size在同一套单位下是绝对的,origin是相对的,但是它是相对于谁的?相对于自己的父view的,又因为所有的view 都在同一个view tree 上的,所以bView cView总是能找到相同的一个父节点的,假设是F节点 那么总会得到如下的形式
1 | FView.属性 = 乘数 * bView.属性 + 常量 |
所以就也能转化成
1 | aView.属性 = 乘数 * FView.属性 + 常量 |
即使,再加上 dView, eView 也没有关系,还是上面这个公式。
看到了吧,所有的布局计算都可以对应到这样一个 一次函数
然而推出这个公式有什么用呢?
我们看一下 Auto Layout 的 API
1 | /* Create constraints explicitly. Constraints are of the form "view1.attr1 = view2.attr2 * multiplier + constant" |
知道了吧,Auto Layout的API就是如此。就是基于这个最基本的函数运算来设计的。有了这个函数, 在 layoutSubviews 里的动态布局代码,才可以抽象出来写成声明式的关系了!
Auto Layout 介绍
Auto Layout是iOS 6之后,苹果推出的布局技术,主要是为了适配多屏幕而产生的。Auto Layout 本身的原理是基于constraint的,Auto Layout内部的实现是隐藏的,接口设计的非常简单和干净,而且直击要害。
Auto Layout 主要是一个类 NSLayoutConstraint
而NSLayoutConstraint主要是两个API。
- 一个是就上面推导出来的那个。
- 还有一个就 Visual Format Language,解析方法,而VFL也是为了表示这种函数关系而产生的。
1 | + (NSArray<__kindof NSLayoutConstraint *> *)constraintsWithVisualFormat:(NSString *)format options:(NSLayoutFormatOptions)opts metrics:(nullable NSDictionary<NSString *,id> *)metrics views:(NSDictionary<NSString *, id> *)views; |
除了方法之外,再就是常用的属性,如下
1 | typedef NS_ENUM(NSInteger, NSLayoutAttribute) { |
关于这个基本函数的运算和属性说完了,算式是说完了。但是还没有完。
你细看,会发现NSLayoutRelation有三种,不只是可以用等号来判定等式两边,还可以大于等于,小于等于
1 | typedef NS_ENUM(NSInteger, NSLayoutRelation) { |
怎么多了两种呢?以前计算都是相等关系,现在不等关系也是可以处理了。神奇吧?欲知详情,可以看完文章后,查看结尾的论文链接。
好,让我们来用用试试!但是你当你写完代码,你发现它长成了这个样子。
1 | CGSize textSize = [_centerLabel.text sizeWithAttributes:@{NSFontAttributeName: _centerLabel.font}]; |
恩,设计的很好,但这也太复杂了吧。
出什么问题了?
- API 和参数名字太长了
- 参数重复出现,只对两个 view 之间设置关系,但是 _centerLabel 出现数次
- 再一次阅读代码时,思维是跳跃,你试试,当你把目光放到代码上的时候,你先要定位到第一 item,我看到设置了_centerLabel, 又往后看我设置的是 centerX,那我相对的是什么? self,self的什么?self的centerX,就这样我的思维一直在跳动,看完之后的,我还要把我看到的参数套用到公式中去。
还有个VFL,再试试
1 | UIView *superview = self; |
好吧,看来出现了神奇的符号。V:[superview]-(<=1)-[_centerLabel],太不直观了。你要记住每个符号的意义。而且当你通过XCode,在引号内部输入一串这样的神奇符号时是没有自动补全的。反应慢的同学,囧,比如我,写之前得停顿一会儿,一次想好。
为什么还是不好用?
VFL 的设计初衷是好的,想通过符号的引入,让你看到,界面元素之间的位置关系,比如 横线 - 表示间隔,方括号包住的内容[] 表示view,。[superview]-[_centerLabel] 表示superview 和 _centerLabel挨着。(<=1)表示距离大于1,[superview]-(<=1)-[_centerLabel] 表示 两个superview和_centerLabel之间的距离大于1,V:表示Vertical,所以完整的意思是 superview 和_centerLabel在竖直方向上某个属性挨着,且距离大于1。解释一遍还是能懂的,但是这个设计还是没有逃脱,去解方程的思维,VFL的表达式用符号表示操作符,把变量通过views和 metrics两个参数传进来。API使用者始终在考虑怎么去处理这个方程。
Coder Interface(API)
作为一个 Coder,我们经常写 User Interface 让 User 更爽,那么我们自己面对的 Coder Interface呢,要怎么设计,用起来更爽呢?
设计接口也是和产品设计一样的,需要考虑使用者的思维习惯,之所以NSLayoutContraint 那么不好用就是因为不符合使用者的思维习惯。借用产品设计的一句话“Don’t make me think”,来做说明的话,NSLayoutConstraint API 一直 Make me think!
所以,该DSL上场了
DSL出场前,让我们先介绍下DSL,DSL是Domain Specific Language的缩写,基本的目的是处理某一领域的特定问题的语言。他不像通用语言,要去覆盖全部的问题域,而是处理某一特定的问题。设计它的目的就是为了转化通用语言,让他更适合使用者的知识模型,用起来顺畅。平常我们也会经常遇到的DSL,比如CSS就是针对编写网页布局的DSL,Podfile语法就是编写Cocoapods依赖规则的DSL。
那我们要设计怎样的DSL呢?
设计DSL,先要了解你面向的使用者。设计语法本身不是目的,语言是为了传播思想服务的,是为了思维的转换,把其他领域的知识内容转换成使用者熟悉的思维方式,适应使用者的思维才行。
历史证明程序员有多少中常用思维,就会有产生多少种DSL。其实机制有了,好的方案就会浮现的。Auto Layout发布后,AutoLayout的DSL如雨后春笋般的涌现.
我找出一些有代表性的,按照程序员偏好的语法风格分下类
- 陈述命令形式,像说一句话一样
- 还原数学公式,仍然是代入公式计算,但更直观
- Shortcut形式,创造了很多short cut的方法,覆盖了常用的布局需求
- 仿照 CSS 形式,用CSS的语法来做iOS的布局
大家都可以按照自己的口味选择。但我最终选择了Masonry。
理由是这样的,其实写代码时,我们要去设置什么,我们已经想好了,我们要的是把它写成代码,越直接,越快越好。
来看看 Masonry 是怎么做的,如果我们想 让_centeLabel 在它的 superView 里面居中,
如果思考地机械一点就是
1 | 让 _centerLabel的中心等于_centerLabel的superview 的中心 |
翻译成代码就是
1 | make.center.equalTo(_centerLabel.superview); |
这样直接陈述就可以了。每一句话,都是Masonry能听懂的命令,所以我们直接发送几条命令就能完成目的,接着把设置的清单塞到block里
1 | [_centerLabel mas_makeConstraints:^(MASConstraintMaker *make) { |
然后,这个_centerLabel就居中了,是不是很简单!
Masonry
Masonry 这种DSL 设计的优点在于把数学的函数,翻译成可读性极佳的代码,像读句子一样,人好读了,写起来思维流畅,就不容易出错,而且它并没有实现很重的语法,完全利用Objective-C的语法,”.” 操作符作为连接符,block作为设置声明的清单。这样做的好处是实现简单,而且不需要单独的 parser,因此对效率也不会有太大影响,这是很巧妙的地方,这样的设计,平衡各方面的需求,使用者,语言本身,实现复杂性,性能等等。
Masonry本身只是语法的转换,并没有在 Auto Layout 的基础之上添加新的功能。
所以主要的 API 也是一个
1 | - (NSArray *)mas_makeConstraints:(void(^)(MASConstraintMaker *make))block; |
紧接着就是可以操作的属性,和 Auto Layout一一对应。
MASViewAttribute | NSLayoutAttribute |
---|---|
view.mas_left | NSLayoutAttributeLeft |
view.mas_right | NSLayoutAttributeRight |
view.mas_top | NSLayoutAttributeTop |
view.mas_bottom | NSLayoutAttributeBottom |
view.mas_leading | NSLayoutAttributeLeading |
view.mas_trailing | NSLayoutAttributeTrailing |
view.mas_width | NSLayoutAttributeWidth |
view.mas_height | NSLayoutAttributeHeight |
view.mas_centerX | NSLayoutAttributeCenterX |
view.mas_centerY | NSLayoutAttributeCenterY |
view.mas_baseline | NSLayoutAttributeBaseline |
然后是 关系符 也对应者 Auto Layout 定义的三种关系符
.equalTo
equivalent to NSLayoutRelationEqual
.lessThanOrEqualTo
equivalent to NSLayoutRelationLessThanOrEqual
.greaterThanOrEqualTo
equivalent to NSLayoutRelationGreaterThanOrEqual
除此之外,他还有些 shortcut的属性,来方便设置相对布局
- edges
- size
- center
与Auto Layout不同之处
Masonry 多了两个API,一个是update
1 | - (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *make))block; |
原因是出于苹果关于对性能的建议,如果,你只更新 constraint 里面的 constant的值,那么你不需要再 make, 你可以update
1 | [_centerLabel mas_updateConstraints:^(MASConstraintMaker *make) { |
这个句子,只是更新了center,并没有改变该view与其他view之间的依赖关系。所以 Masonry 会去从已经在_centerLabel 里面找到相似的 contraints 去更新他,而不是再添加一个 新的constraint
还有一个是 remake
1 | - (NSArray *)mas_remakeConstraints:(void(^)(MASConstraintMaker *make))block; |
不同点就在 remake 会移除以前设定的contraints,这样调用Masonry的外部外部代码就不用为了以后能够移除contraints 而去keep contraints的reference了。
Masonry使用
讲Masonry使用之前,我们先来比较总结一下iOS的布局系统。布局系统是GUI框架的基本组成部分,我这里从三个基本维度来简单分析下
- 基本数据结构
- 元素之间的相对关系
- 常用的布局模型
还是回到CGFrame的frame,frame分为size和origin的,如果拿Android系统做对比的话size这个结构都有,但是 origin就不一样了,Android中有的是margin,margin和origin不一样,margin 是相对的是其他view,可以平级view,也可以不平级,origin却只是相对于父view,跟其他view无关的。所以如果拿Android几种常用的Layout做个解释的话,iOS的只原生支持Andorid的AbsoluteLayout 。
也正是因为这个基本设计的不同,iOS里面的布局计算都可以归一到刚才推出来的一对一函数。
1 | aView.属性 = 乘数 * bView.属性 + 常量 |
但是 Android的就不是这样的,天生的就是一对多的,margin有四个边,计算要考虑4个邻居元素的位置,所以 Android 的布局代码总是和LinearLayout等Layout一起用的,否则就总是要程序员去处理复杂的计算了。iOS里面却不需要去刻意突出这样的Layout模型,但也是需要这样的模型结构的。那 iOS 怎么实现LinearLayout之类的布局模型呢?通过控件比如UITtableView,UICollectionView,这些UI控件做了布局系统中复杂的计算。
总结如下表
布局 | iOS | Android | Web |
---|---|---|---|
数据结构 | origin, size | margin, size | margin, size |
相对关系 | 相对于父view | 现对于父view 或者同级 view 都可以 | 相对于父亲 view 或同级 view 都可以 |
布局模型 | UITableView, UICollectionView… | LinearLayout, RelativeLayout… | posistion attribute,float attribute,flexbox… |
Masonry Demo
既然 iOS 里面没有像Android这样原生的常用的布局模型,下面让我们实现 Android 的几种常见 Layout,来熟悉Masonry的用法
Android主要五个Layout,我们主要实现下面三个Layout,LinearLayout,FrameLayout,GridLayout。AbsoluteLayout本来就是iOS的默认方式,就不用实现了,RelativeLayout本身要解决是布局嵌套过深的问题,而不是位置关系。
LinearLayout 主要是列表的形式,
实现竖直列表可以mas_left , mas_right, 和 mas_height 都是固定的,不断的调整 mas_top的值。这里虽然也有 top left right 和 bottom,但是和 Android 里的的 margin 是不同的。
- 你使用的 top left right bottom,要指定相对于那个元素,margin的 top left right bottom 是不需要的。
- 这里的数字是有方向的,向下,向右为正,所以设置 right 的 offset 是从右往左的-20。
1 | UIView *lastCell = nil; |
实现水平列表可以mas_top mas_bottom 和 mas_width 固定,不断调整 mas_left的值。
1 | UIView *lastCell = nil; |
FrameLayout
FrameLayout 主要是实现层级堆叠的效果,通过layout_gravity, 设置堆叠的位置。
下面我们介绍其中的几种堆叠
左上角
当要设置的view的属性和相对的view属性相同时,相对的view的属性可以直接省略。
1 | make.left.equalTo(cell.superview.mas_left); |
可以写成
1 | make.left.equalTo(cell.superview); |
居中
很简单直接用 center 属性即可
1 | make.center.equalTo(cell.superview); |
上边对齐 左右居中
center可以分为centerX和centerY这里使用centerX, 加上top属性即可
1 | make.centerX.equalTo(cell.superview); |
GridLayout
Grid layout 实现起来就复杂一些了,需要我们去算处于哪一行和列, 不断的更新left 和top。
1 | CGFloat cellWidth = 70; |
另外如果你嫌总是要敲mas_ 这个prefix太烦的话,使用时定义一个宏就可以了。
1 | #define MAS_SHORTHAND |
好了,是不是意犹未尽?但是Masonry 的基本使用就介绍到这里了。至于更多内容,可以直接查看Masonry 项目。
SnapKit
如果是新开的的项目准备用Swift的话,可以用SnapKit, SnapKit 是 Masonry开发者开发的Swift版本。
另外在 Swift中使用Masonry 也会有些不便,比如下面的多出来的括号
1 | view3.mas_makeConstraints { make in |
结尾
本文回顾了iOS平台的布局技术的发展,讲述了Auto Layout的技术的由来,Auto Layout 技术的核心,以及相关DSL技术的产生,最后介绍了Masonry这个DSL的使用。至于文章开头提出的三个问题,部分已经有了答案,剩下要看以后的发展了。另外像我还没有介绍到的技术,比如 Size Class很适合不同大小的设备上使用不同设计的情况,比如同时有iPad和iPhone版本时,使用Size Class可以带来更好的交互体验,限于篇幅就没有介绍。
最后,本文中所有代码都在github上。
如果,大家对Layout相关话题还饶有兴趣,可以继续看下面的链接
- Cartography 另一种优秀 Auto Layout DSL
- Flexbox 优秀 CSS 3的布局模型
- Classy - Expressive, flexible, and powerful stylesheets for native iOS apps
- Auto Layout 内部实现的解释
- The Cassowary Linear Arithmetic Constraint Solving Algorithm, Auto Layout内部算法
以下是我的博客,我会定期更新 iOS客户端 和 Hybird App开发相关文章。欢迎订阅!
另外,如果发现本文中有任何谬误,请联系我yinjiaji110@gmail.com,我会及时更正。 谢谢!