纯Swift项目-Xib | StoryBoard 多人协作技巧

8,150 阅读12分钟

不同于国外,StoryBoard从面世到如今饱受国内开发者的质疑,质疑的理由很多,什么不利于多人协作啊,隐藏了UI细节啊,出问题不容易测试,降低执行效率啊等等。此文就是针对这些问题的举例和剖析。

StoryBoardXib 有什么区别?

StoryBoardXib 都是用来分离UI样式代码,改善视图代码重用率,增加所见即所得,降低视图测试繁复度的视图系列化工具,

  1. 其中Xib以视图View为主,
  2. StoryBoard 以控制器Controller及其之间的关系,以及和视图View的关系为主。

实际使用例子参见《纯Swift项目-Xib | StoryBoard 设备适配技巧》或其他StoryBoard文章

StoryBoardXib 不利于多人协作,git合并代码容易冲突,且难以处理?

这个是诋毁StoryBoard最多的理由,也是看上去最充分的理由。最显著的就是下图这种失败的例子。

Storyboard不利图片

在一个Storyboard中,大量的Controller控制器和Segue连线彰显着错综复杂的UI关系,使人望而生畏或者难以维护。

但这并不应该是Storyboard的锅,仅仅是使用者对工具的滥用!

没错,就是滥用,无论是Storyboard也好,纯代码也罢,它们的本质都是工具,工具本身没有正义或邪恶,影响工具的是使用者。哪怕是用纯代码开发,如果没有命名规范,肆意的嵌套if,不遵守MVC或者MVVM等开发模式,不区分开发环境与生产环境,这样写出来的代码又何谈可维护性,和多人协作呢?

那么反过来说,如何使用Storyboard才不算滥用?

避免滥用,最好的方法就是定制规范,就好像代码中的诸多规范一样。每个团队可能有自己不同的喜好,我在此抛砖引玉,列出我们团队使用Storyboard的规范,供大家参考。

每个模块独立Storyboard 每个Storyboard只应该有一个主VC和同页的子VC,主VC不应存在2个以上
  1. 一个项目中,Storyboard不该是孤立存在的,应该像MVP模式那样,每个页面都有独立的Storyboard,每个Storyboard只应该有一个主VC和同页的子VC,主VC不应存在2个以上。(绝大多数情况下,一个Storyboard上只应该有一个VC
  2. 页面间的Segue连线应该使用Stroyboard Reference SceneUITabBarController的子页因为复杂度应该当成主VC处置
  3. 视图的初始样式应尽量在Storyboard上属性面板中设置,非极特殊情况,布局也应在Storyboard上使用各种约束配合完成。这样有利于视图样式和视图代码分离,有利于视图代码重用性和兼容性提高。
  4. 对于逻辑复杂的VC,应添加Object对象,并绑定相应的类来分离逻辑代码。
  5. 对于圆角,背景色,阴影等CALayer的样式,应该使用扩展或子类化实例的形式,使用@IBInspectable属性关键字,在Storyboard属性面板中设定初始样式。
  6. 对于自定义视图,应使用@IBDesignable关键字保障在在Storyboard上所见即所得!

使用以上原则,只要任务分工合理,基本上不存在多人同时修改同一个Storyboard的情况,就算配合失误偶然发生,精简的Storyboard其代码量也不大,借助文件比较工具很容易就能处理git冲突。

说到底,臃肿的Storyboard和臃肿的ViewController一样,都是难以维护且容易git冲突的。唯一的解决方案就是有节制的使用工具。

StoryBoardXib 隐藏了UI细节,且容易导致ViewController臃肿?

与其说StoryBoardXib 隐藏了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的代码放到这第一个子控制器上,CollectionViewDelegateCollectionViewDataSource等代码也由子控制器实现

同理,优惠专区可以再添加一个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.parentnil,这时不能拿到主ViewController实例。如果需要在初始化的时候拿到主ViewController的实例,则应该在主ViewController``viewDidLoad方法中,调用ChildViewController的特定方法,把 self 当参数传过去。


  • 除此之外还可以使用Object对象

将它添加到控制器之上。

它的本质是一个继承自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 设置 DelegateDataSource 等的连线

在主ViewController中如需调用这个模块的方法或者传参

class HomeController: UIViewController {
    
    @IBOutlet weak var featuresController:FeaturesController!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        featuresController.datas = [....]
        featuresController.collectionView.reloadData()
    }
    
}

完成连线,同理,如果一个页面需要多个子模块,可以在Storyboard上拖入多个Object,并绑定不同的模块控制类,相对于占位的Container ViewChildViewController方法,Object方法在传参或互相调用方面,更加简便。缺点是没有ChildViewController的生命周期方法,如需使用viewWillAppear等,需要在主ViewControllerviewWillAppear中,调用Object的自定义方法。

通过上面的2种方法不难看出,并非是Storyboard造成ViewController代码臃肿,而是因为设计不当导致,就算你不用Storyboard,把所有功能都写在一个ViewController里一样臃肿。这都是使用者决定的,并非Storyboard的责任!

StoryBoardXib 出了问题不容易测试?

这个问题其实问的很模糊,我也是咨询了很多人才知道,他们所谓的问题不容易测试,是指如下两种情况:

  1. 修改或删除 @IBOutlet 的变量名时,对应的Storyboard上未做处理,导致运行时崩溃,崩溃内容看不懂!
  2. 绑定的类名改变时,对应的Storyboard上未做处理,导致运行时崩溃,崩溃内容看不懂!

其实只要知道,苹果是如何把Storyboardxml解析成视图,崩溃的错误内容也就容易看懂了 之前提到过,视图构造使用的是下面这个方法

    required init?(coder aDecoder: NSCoder) {
    
    }

如果绑定的类名改变输出错误:

  1. Unknown class _TtC11ProjectName14HomeController in Interface Builder file. // Swift
  2. Unknown class HomeController in Interface Builder file. // Objective C

通过上面的错误提示Interface Builder file就是指通过Storyboard或者Xib构建视图或者控制器,但找不到名为HomeController的控制器,看到这里就应该明白,我们某个Storyboard上绑定了名为HomeController的控制器,但代码中找不到,可能是改名或者删除了。这时可以全局搜素一下

在搜出来的结果中可以看到,是在Main.storyboard上绑定了HomeControllerTest.swift文件中定义了该类,但是因为改名所以无法找到。

这样的问题不用Storyboard就可以避免么?答案是否定的,因为重构代码的时候,改了一处忽略它处的例子比比皆是。哪怕纯代码也是一样,因此,如果需要修改类名或者变量名,应该善用Xcode的重构功能,而不是简单的直接修改。

这样修改类名或者变量名是,Storyboard或者Xib上绑定或连线的内容也会同步改变。就不会出错了。

同理,@IBOutlet 连线的属性通过下面的方法给视图赋值

    func setValue(_ value: Any?, forUndefinedKey key: String) {
        ......
    }

如果变量名改变的时候,会出现如下错误:

  1. *** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[<HomeController 0x7fbd0ce20c40> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key featuresController.'

这个方法找不到对应的属性时,就会抛出异常, 这里就是指找不到featuresController属性,通过全局搜索可以发现,代码中改了名字,

解决的方法同样是删掉对应的连线或者修改变量名时使用重构

由此可见,所谓的不容易测试,完全是因为重构不谨慎且对构造过程不理解,否则还是很容易定位问题且修改的。而且重构代码时利用Xcode重构功能的话,连问题都不会出现

StoryBoardXib 降低执行效率?

这个问题看起来好像是那么回事,StoryBoardXib本质上是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万次,结果输出

  1. Storyboard创建30000次用时 8.648092089919373
  2. 纯代码创建30000次用时 27.226440161000937

我们看到了什么?从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在这种情况下确实比纯代码创建更快。

  1. Storyboard创建30000次用时 8.513293381780386
  2. 纯代码创建30000次用时 27.19225306995213
  3. Storyboard创建30000次用时 25.9916725079529

这个结果是如何出现的,不妨大胆猜测一下,可能是由于苹果在对象多次创建的情况下,Storyboard可能存在缓存复刻机制,来提升效率,而纯代码并没有这样的优化。为了验证猜测,我们逐渐降低数量级。

  1. Storyboard创建3000次用时 0.20833597797900438
  2. 纯代码创建3000次用时 0.2654381438624114
  3. Storyboard创建3000次用时 0.34943647705949843
  1. Storyboard创建300次用时 0.010981905972585082
  2. 纯代码创建300次用时 0.005475352052599192
  3. Storyboard创建300次用时 0.014193600043654442
  1. Storyboard创建30次用时 0.0016030301339924335
  2. 纯代码创建30次用时 0.00031192018650472164
  3. Storyboard创建30次用时 0.001034758985042572
  1. Storyboard创建10次用时 0.0009886820334941149
  2. 纯代码创建10次用时 0.0001325791236013174
  3. Storyboard创建10次用时 0.0014422889798879623

上述结果果然验证了我们的猜测,随着次数的减少,Storyboard创建的速度逐渐低于存代码创建,但单次耗时仍然低于万分之一秒,这种效率是不会让用户有任何感知的,何况重复创建比纯代码还有优势,因此,这一条也不算StoryBoardXib的缺点

StoryBoardXib 拖动和设置约束布局很难精确?不易修改?

我想,这种言论可能是因为不太熟悉Interface Builder的功能和操作造成的,仅仅实验了几次不得其门而入就放弃了。

实际上约束布局是一个很强大的功能,可以解决绝大多数(98%)布局适配问题,98%这个数并不是随便给出的,很多人觉得达不到这个比例是因为对约束理解较少,还是按照以前的autolayoutMask的方式使用约束,因此很多布局问题还在用代码计算,可实际上约束功能十分强大,目前无法通过约束直接解决,必须代码辅助的问题微乎其微。

但与之相对的是约束的概念较多,依赖人脑思考很容易产生遗漏,这样在运行的时候就会各种报错或显示异常,因此用纯代码写约束,反复运行调试视图样式尺寸十分常见,而且有些页面较深,测试起来十分麻烦。

而使用StoryBoardXib就不同了,缺少约束或者约束冲突直接就有错误提示,适配不同设备可以直接在Interface Builder上切换测试,效率不知高了多少倍,准确性也高了很多

如果需要详细了解在StoryBoardXib上使用约束的技巧,可以参考文章《纯Swift项目-Xib | StoryBoard 设备适配技巧》及 《纯Swift项目-Xib | StoryBoard 约束使用技巧》或其他相关文章。

总结,StoryBoardXib虽然不是毫无缺点,但优势远大于付出,值得学习研究!