不同于国外,StoryBoard
从面世到如今饱受国内开发者的质疑,质疑的理由很多,什么不利于多人协作啊,隐藏了UI细节啊,出问题不容易测试,降低执行效率啊等等。此文就是针对这些问题的举例和剖析。
StoryBoard
和 Xib
有什么区别?StoryBoard
和 Xib
都是用来分离UI样式代码,改善视图代码重用率,增加所见即所得,降低视图测试繁复度的视图系列化工具,
Xib
以视图View
为主,StoryBoard
以控制器Controller
及其之间的关系,以及和视图View
的关系为主。实际使用例子参见《纯Swift项目-Xib | StoryBoard 设备适配技巧》[1]或其他StoryBoard
文章
StoryBoard
和 Xib
不利于多人协作,git
合并代码容易冲突,且难以处理?这个是诋毁StoryBoard
最多的理由,也是看上去
最充分的理由。最显著的就是下图这种失败的例子。
Storyboard不利图片
在一个Storyboard
中,大量的Controller
控制器和Segue
连线彰显着错综复杂的UI关系,使人望而生畏或者难以维护。
Storyboard
的锅,仅仅是使用者对工具的滥用!没错,就是滥用
,无论是Storyboard
也好,纯代码也罢,它们的本质都是工具,工具本身没有正义或邪恶,影响工具的是使用者。哪怕是用纯代码开发,如果没有命名规范,肆意的嵌套if
,不遵守MVC或者MVVM等开发模式,不区分开发环境与生产环境,这样写出来的代码又何谈可维护性,和多人协作呢?
Storyboard
才不算滥用?避免滥用,最好的方法就是定制规范,就好像代码中的诸多规范一样。每个团队可能有自己不同的喜好,我在此抛砖引玉,列出我们团队使用Storyboard
的规范,供大家参考。
每个模块独立Storyboard
每个Storyboard只应该有一个主VC和同页的子VC,主VC不应存在2个以上
MVP
模式那样,每个页面都有独立的Storyboard,每个Storyboard只应该有一个主VC和同页的子VC,主VC不应存在2个以上。(绝大多数情况下,一个Storyboard上只应该有一个VC)Segue
连线应该使用Stroyboard Reference Scene
,UITabBarController
的子页因为复杂度应该当成主VC处置CALayer
的样式,应该使用扩展或子类化实例的形式,使用@IBInspectable
属性关键字,在Storyboard属性面板中设定初始样式。@IBDesignable
关键字保障在在Storyboard上所见即所得!使用以上原则,只要任务分工合理,基本上不存在多人同时修改同一个Storyboard
的情况,就算配合失误偶然发生,精简的Storyboard其代码量也不大,借助文件比较工具很容易就能处理git冲突。
说到底,臃肿的
Storyboard
和臃肿的ViewController
一样,都是难以维护且容易git冲突的。唯一的解决方案就是有节制的使用工具。
StoryBoard
和 Xib
隐藏了UI细节,且容易导致ViewController
臃肿?与其说StoryBoard
和 Xib
隐藏了UI细节,倒不如说苹果是希望通过他们来引导开发者正确的使用 视图 和 控制器 ,他们创建视图实例的时候都是通过
required init?(coder aDecoder: NSCoder) {
}
构造方法创建视图实例。所有初始样式都是在属性面板中设置的值,通过
func setValue(_ value: Any?, forUndefinedKey key: String) {
......
}
来赋值给视图对应的属性。
至于说导致
ViewController
臃肿,更是荒谬,StoryBoard
提供了多种方案来分离代码,只不过很多人不知道而已。
拿美团的主页UI举例
这样的首页较为复杂,正常布局的话需要多个CollectionView
和一个UITableView
如果这些视图的Delegate
都由ViewController
来实现,自然显得臃肿且混乱。
一般手写派会分出3个ChildViewController
来解决臃肿问题,难道Storyboard
就做不到么?
答案是否定的,很早的版本,苹果就给出了上图中的解决方案。一个占位的容器视图指向子控制器的Embed Segue
按住Control
键连线到想要包含的子控制器,占位视图的实例==子控制器的view
(子控制器根视图)
选择Embed
连线方式后,子控制器 的尺寸变化成跟占位视图一样的尺寸
这样我们可以将功能图标的CollectionView
的代码放到这第一个子控制器上,CollectionViewDelegate
、CollectionViewDataSource
等代码也由子控制器实现
同理,优惠专区可以再添加一个Container View
,指向第二个子控制器。
Container View
创建的ChildViewController
如何与主ViewController
传参或互相调用?
ChildViewController
可以通过 self.parent(Swift)|| self.parentViewController(OC)来拿到主ViewController
的实例。主ViewController
可以通过 self.chilren(Swift) || self.childViewControllers(OC)来拿到ChildViewController
的实例,它是一个数组,顺序等同于占位视图再视图层次中的顺序。
值得一提的是,通过此种方式创建的ChildViewController
,其构造方法晚于主ViewController
,但生命周期中的viewDidLoad
则早于主ViewController
, 因此在ChildViewController
中的viewDidLoad
方法中,self.parent 是nil
,这时不能拿到主ViewController
实例。如果需要在初始化的时候拿到主ViewController
的实例,则应该在主ViewController``viewDidLoad
方法中,调用ChildViewController
的特定方法,把 self 当参数传过去。
将它添加到控制器之上。
它的本质是一个继承自NSObject的子类,我们完全可以把它当成一个小功能模块的控制器。
class FeaturesController: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
@IBOutlet weak var collectionView:UICollectionView!
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
<#code#>
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
<#code#>
}
}
复制代码
在Storyboard
上选中这个Object
,绑定上面的类
右键这个Object
,在弹出的菜单中连线
右键CollectionView
设置 Delegate 和 DataSource 等的连线
在主ViewController
中如需调用这个模块的方法或者传参
class HomeController: UIViewController {
@IBOutlet weak var featuresController:FeaturesController!
override func viewDidLoad() {
super.viewDidLoad()
featuresController.datas = [....]
featuresController.collectionView.reloadData()
}
}
复制代码
完成连线,同理,如果一个页面需要多个子模块,可以在Storyboard
上拖入多个Object
,并绑定不同的模块控制类,相对于占位的Container View
和ChildViewController
方法,Object
方法在传参或互相调用方面,更加简便。缺点是没有ChildViewController
的生命周期方法,如需使用viewWillAppear
等,需要在主ViewController
的viewWillAppear
中,调用Object
的自定义方法。
通过上面的2种方法不难看出,并非是Storyboard
造成ViewController
代码臃肿,而是因为设计不当导致,就算你不用Storyboard
,把所有功能都写在一个ViewController
里一样臃肿。这都是使用者决定的,并非Storyboard
的责任!
StoryBoard
和 Xib
出了问题不容易测试?这个问题其实问的很模糊,我也是咨询了很多人才知道,他们所谓的问题不容易测试,是指如下两种情况:
Storyboard
上未做处理,导致运行时崩溃,崩溃内容看不懂!Storyboard
上未做处理,导致运行时崩溃,崩溃内容看不懂!其实只要知道,苹果是如何把Storyboard
的xml
解析成视图,崩溃的错误内容也就容易看懂了 之前提到过,视图构造使用的是下面这个方法
required init?(coder aDecoder: NSCoder) {
}
复制代码
如果绑定的类名改变输出错误:
通过上面的错误提示Interface Builder file
就是指通过Storyboard
或者Xib
构建视图或者控制器,但找不到名为HomeController
的控制器,看到这里就应该明白,我们某个Storyboard
上绑定了名为HomeController
的控制器,但代码中找不到,可能是改名或者删除了。这时可以全局搜素一下
在搜出来的结果中可以看到,是在Main.storyboard
上绑定了HomeController
,Test.swift
文件中定义了该类,但是因为改名所以无法找到。
这样的问题不用Storyboard
就可以避免么?答案是否定的,因为重构代码的时候,改了一处忽略它处的例子比比皆是。哪怕纯代码也是一样,因此,如果需要修改类名或者变量名,应该善用Xcode
的重构功能,而不是简单的直接修改。
这样修改类名或者变量名是,Storyboard
或者Xib
上绑定或连线的内容也会同步改变。就不会出错了。
同理,@IBOutlet 连线的属性通过下面的方法给视图赋值
func setValue(_ value: Any?, forUndefinedKey key: String) {
......
}
复制代码
如果变量名改变的时候,会出现如下错误:
这个方法找不到对应的属性时,就会抛出异常, 这里就是指找不到featuresController
属性,通过全局搜索可以发现,代码中改了名字,
解决的方法同样是删掉对应的连线或者修改变量名时使用重构
由此可见,所谓的不容易测试,完全是因为重构不谨慎且对构造过程不理解,否则还是很容易定位问题且修改的。而且重构代码时利用Xcode重构功能
的话,连问题都不会出现
StoryBoard
和 Xib
降低执行效率?这个问题看起来好像是那么回事,StoryBoard
和 Xib
本质上是XML
,要解析成视图就需要反序列化,必然没有直接代码创建速度高,但这只是感觉上,实际上有多少影响呢?我们来测试一下:
var controllers:[ViewController] = []
let count = 30000
controllers.reserveCapacity(count)
guard let sb = storyboard else { return }
var beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = sb.instantiateViewController(withIdentifier: "ViewController") as! ViewController
controllers.append(vc)
}
print("Storyboard创建(count)次用时", CACurrentMediaTime() - beginTime)
controllers.removeAll(keepingCapacity: true)
beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = ViewController()
controllers.append(vc)
}
print("纯代码创建(count)次用时", CACurrentMediaTime() - beginTime)
复制代码
第一次使用了3万次,结果输出
我们看到了什么?从Storyboard
创建竟然比纯代码更快?简直不敢相信自己的眼睛,而且差距这么大一定是有什么神奇的事情发生,为了验证我的想法,我又将Storyboard
创建复制了一次
var controllers:[ViewController] = []
let count = 30000
controllers.reserveCapacity(count)
guard let sb = storyboard else { return }
var beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = sb.instantiateViewController(withIdentifier: "ViewController") as! ViewController
controllers.append(vc)
}
print("Storyboard创建(count)次用时", CACurrentMediaTime() - beginTime)
controllers = []
controllers.reserveCapacity(count)
beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = ViewController()
controllers.append(vc)
}
print("纯代码创建(count)次用时", CACurrentMediaTime() - beginTime)
controllers = []
controllers.reserveCapacity(count)
beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = sb.instantiateViewController(withIdentifier: "ViewController") as! ViewController
controllers.append(vc)
}
print("Storyboard创建(count)次用时", CACurrentMediaTime() - beginTime)
复制代码
输出结果如下,而且多次运行结果相近,可能是因为随着内存使用率提高,电脑性能在降低,影响了结论,但不管怎么说,大量测试空的ViewController
在这种情况下确实比纯代码创建更快。
这个结果是如何出现的,不妨大胆猜测一下,可能是由于苹果在对象多次创建的情况下,Storyboard
可能存在缓存复刻机制,来提升效率,而纯代码并没有这样的优化。为了验证猜测,我们逐渐降低数量级。
上述结果果然验证了我们的猜测,随着次数的减少,Storyboard
创建的速度逐渐低于存代码创建,但单次耗时仍然低于万分之一秒,这种效率是不会让用户有任何感知的,何况重复创建比纯代码还有优势,因此,这一条也不算StoryBoard
和 Xib
的缺点
StoryBoard
和 Xib
拖动和设置约束布局很难精确?不易修改?我想,这种言论可能是因为不太熟悉Interface Builder
的功能和操作造成的,仅仅实验了几次不得其门而入就放弃了。
实际上约束布局是一个很强大的功能,可以解决绝大多数(98%)布局适配问题,98%
这个数并不是随便给出的,很多人觉得达不到这个比例是因为对约束理解较少,还是按照以前的autolayoutMask的方式使用约束,因此很多布局问题还在用代码计算,可实际上约束功能十分强大,目前无法通过约束直接解决,必须代码辅助的问题微乎其微。
但与之相对的是约束的概念较多,依赖人脑思考很容易产生遗漏,这样在运行的时候就会各种报错或显示异常,因此用纯代码写约束,反复运行调试视图样式尺寸十分常见,而且有些页面较深,测试起来十分麻烦。
而使用StoryBoard
或 Xib
就不同了,缺少约束或者约束冲突直接就有错误提示,适配不同设备可以直接在Interface Builder
上切换测试,效率不知高了多少倍,准确性也高了很多
如果需要详细了解在
StoryBoard
或Xib
上使用约束的技巧,可以参考文章《纯Swift项目-Xib | StoryBoard 设备适配技巧[2]》及 《纯Swift项目-Xib | StoryBoard 约束使用技巧[3]》或其他相关文章。
StoryBoard
和 Xib
虽然不是毫无缺点,但优势远大于付出,值得学习研究![1]
https://juejin.cn/post/6844903763526811655: https://juejin.cn/post/6844903763526811655
[2]
https://juejin.cn/post/6844903763526811655: https://juejin.cn/post/6844903763526811655
[3]
https://juejin.cn/post/6844903763526811655: https://juejin.cn/post/6844903763526811655