English version also available: TinyVFL: A safer virtual format language for Auto Layout
Virtual Format Language(后面简称VFL)是设定Auto Layout约束的其中一种方法,但或许不是很多人认识,具体使用大家可以到官方文档进行了解,因为很多人觉得这种方式有点难理解,但实际上VFL对于多个view同时布局是一种相对方便的方法,代码一定程度简化而且代码带有一定的视觉提示。在Objective-C时代的VFL代码是这样的:
通常代码到了Swift后就会变得更简单,但VFL是一个例外,原因是Swift下面没有NSDictionaryOfVariableBindings
方法,因此Swift下面会变成这样:
或许看似没有复杂太多,但每次手动创建views字典的对应关系就会让编写的便利性降低很多。而且Swift是静态语言,字符串指向对象这种操作能免则免,安全性也低,因此我创建了TinyVFL库,作为VFL官方接口的替代,实现尽可能的安全和方便设置。使用TinyVFL编写同样的约束是这个样子的:
垂直的布局可以直接垂直来表示,方便理解。去除所有字符串内容,无需再害怕手抖导致运行时错误,特别是不需要手动管理views字典,增删view变得非常简单。重构时候rename也不用再手动处理字符串内容。
如果你仍然感兴趣,那让我们来看看TinyVFL是怎么达到这个效果。
动态视图字典的处理
TinyVFL底层实际也是直接调用原生的VFL方法,虽然可以直接创建单独的NSLayoutConstraint
,但两套不同方式的layout系统接在一起的话在测试的时候就需要大量工作确保每一个约束都设置正确,这显然是没有必要的,TinyVFL主要的工作也是替代原有的VFL,使用VFL作为基础方法显然是最好的选择。
但这里最大的挑战是实现一个NSDictionaryOfVariableBindings
类似的效果,但这个方法在Swift下本来就没有提供,我也不可能超越语言本身的限制。不过其实并不需要,NSDictionaryOfVariableBindings
的好处在于自动形成了便于使用的字符串和对象映射。但我们已经不需要手动使用字符串了,所以映射关系并不需要如此动态,最重要是能使底层创建的VFL字符串能对应上适合的view字典即可。实现方法很多,但最好的就是每个view有一个唯一的代表自身的string。"唯一"这个词提醒了我,Set
就是持有唯一对象的一个类型,而Set
判断对象唯一性用的就是hashValue
,所以我创建了这么一个extension
:
这样每个view都能输出一个唯一的view字符串,我们的NSDictionaryOfVariableBindings
就变成了类似下面这一种实现:
当然了,我们的VFL format字符串下不能直接使用view1
这样,需要输出view1.vflName
,但因为只是底层实现使用,所以这个实现已经能满足需求了。
类enum效果实现
TinyVFL的主要特性是能直接使用.view(view1)
这种方式设置layout对象,大部分人第一眼看上去可能都觉得是一个enum case
对象。Swift的enum
确实是很强大,但我在这里确没有使用enum
,而是使用struct
搭配静态方法实现,内部其实也是对应enum
去存储。为什么要这样设计,因为enum
有两个小缺点:
- 你没法使用默认参数。
- 你没法使用相同的名称进行声明case值,即使具有不同的参数。
对于enum限制更详细的说明和解决方法可以参照这篇文章:TinySolution:解决enum的限制
因此如果使用enum
的话,就没法同时实现.space(10)
和.space(20, priority: 250)
,只能.space(10, priority: nil)
,这显然是不可接受的。但如果使用struct的静态方法就可以做到类似效果,实现如下:
使用struct
还有另外一个好处,VFL有两个比较相似的格式,space的值跟view的size,两者都是数字加可选的优先级,所以基本格式是一致的,但初始化名称需要保持差异化,使用struct的话我们就可以在底层用相同的enum去实现,但外层struct使用不同的初始化方法。
但这里有一个需要注意的点,VFL的space可以是没有宽度值,系统会使用默认的间隔距离。但如果直接把space设成optional的话,我们没法限制使用者把space设成nil的同时给priority设一个值,当然我相信没什么人会这么做,只是我们能做得更好,就是再添加一个只有optional space参数的方法,这样直接在调用上就已经安全了,无需以约定去实现安全。
友好的异常提示
原生的VFL有个非常不友好的地方是当你进行了一些错误的配置,例如布局的字符串写错了,或者在options里面设置了错误的对齐信息,crash的时候就会定位到AppDelegate
里面,因此你需要自己找出到底在哪个位置出粗。但实际上我们是可以优化这个问题的。
虽然我们能利用初始化方法控制对象的创建,但没法限制用户错误地传入对象,例如把.superView
放到中间的位置,或者把两个.space
连续放在一起。对于这些问题,我们可以在初始化对象时候添加一个验证方法,验证出现问题的情况,给出对应的崩溃提示内容。一般情况我们不会强行崩溃一个方法,但错误的布局在最终执行时还是会崩溃,所以这里比较适合崩溃处理。判断方式如下:
对于options其实是更容易出问题的地方,因为options适用vertical和horizontal的名称不明显,部分错误设置会出现崩溃,部分错误设置不会崩溃但又不会有任何效果。但实际上options的验证很简单,在原生接口都可以直接加,我也不明白UIKit为什么不直接添加这部分判断。具体判断方式如下:
让代码更加简洁易读
纵向布局的代码效果已经可以得到优化,但水平布局有点过长就变得没那么清晰,用习惯了我们也不需要这么长的初始化方法,因此我们可以缩减一下。.view(...)
很简单就缩减成.v(...)
,.space(...)
缩减成.s(...)
,priority:
缩减成p:
,这样这些提示性的内容就减低干扰性同时保留了提示作用。而实现方式也很简单,我们只需要添加相应的静态方法,同时也保留原有的方法。
.superView()
我们没法缩减成.s()
,因为这就跟.space()
冲突了。想过用side代替,但side依然是缩写成s而且语义没那么明确。另外一个考虑是使用emoji,我觉得◀️▶️🔼🔽其实很适合表示边界,但可惜Swift支持大部分emoji作为方法名,但还是有少部分不支持,而这几个箭头就是其中之一。最终我决定用方向直接表示四边.left
、.right
、.top
、.bottom
,虽然底层实际还是.superView
,但语意上就更加清晰了。
让代码更通用
我们的代码相比原生代码没有增加什么限制,那还可以在什么地方提高通用性呢?答案就是平台。Auto Layout除了iOS上能用,还能用于macOS。我们使用到的UIView
是依赖于UIKit的,而macOS的依赖是AppKit,但Appkit没有UIView
,只有NSView
,不过我们实际上也没有用到UIView
特有的属性或者方法,我们只是需要确保这是个适用于Auto Layout的View而已,因此我们可以用typealias
处理这个问题,用一个统一的名称表示View。实现方式如下: