Sep 24, 2017

|

Storyboard 和 Xib 的“抉择”

近期开始进行一个新项目的原型制作及其结构设计,打算把一些心路历程记录下来,随便先给它取个名字叫做:P-OOP

比起手写 UI,“拖控件”的 Storyboard 和 Xib 似乎一直都更投我所好。不过即使是 Storyboard 和 Xib 之间,似乎也还是多多少少有一些纷争。

Storyboard & Xib

公司 (年久失修) 的 iOS Guidelines 中写着一句话:

进行源码管理时 Storyboard 极易导致冲突,团队开发时,各画面与各组件尽可能使用 Xib 进行实现。

对此我一直抱着赞否两论的观点。在实际工作时,同一 Storyboard 中存在大量 ViewController 十分容易冲突是一个不争的事实,掉进这个坑的人有可能还进行过 xml 修正。但是这个锅 Storyboard 不背。一部分人可能因此选择了弃 Storyboard 从 Xib 之路,我也一度徘徊是否这才是正道。但是很显然的是,Storyboard 从一开始就不是为了代替 Xib 而来。

除了 UI 设置的相似部分以外,Storyboard 更重视画面之间的关联和迁移,而 Xib 作为通用组件的模版应该是不二的选择。

在 P-OOP 中,将会存在大量的 dialog,尽管可以很容易的使用 Present Modally 来实现,不过为了保持系列产品的风格一致性,需要考虑如何以比较好的方式来实现共通的 header 和 footer 样式。考虑过很多方案,比如:

  1. 将 footer 和 header 集成在同一个 view 中,并添加一个 content view,最终在某 controlelr view 中将上述 view 与实际从另一个 xib 中载入的 content view 组合,完成组装。但是存在一个比较显著的缺点,实际可见的 controller view 所呈现的内容并不是很直观,果然还是必须看代码才能梳理清楚。
  2. 将 footer 和 header 以及一个 content view 集成在同一个 controller view 中。在代码中按照要求载入 content,代理方法之类变得容易管理了一些,但是更糟糕的是这个 controller 的代码终将成为垃圾场的。。。那加入继承呢?有些小题大做?

果然简洁才是最高的,将 footer 和 header 完全独立为两个 view,按需载入。结合 @IBInspectable 和 @IBDesignable 可以说是比较完美了,从画面设计到迁移等都很清晰。不足一提的小缺点是使用时候的 auto layout 的设置可能存在一些重复操作 (比如 Auto Layout 之类的),若考虑 Model 除了 form sheet 以外可以是 full screen,后者需要在顶部额外预留 20px,这样一来反而变得巧妙了。

也许过几天自己的想法又发生了细微变化,但简洁清晰无论何时都不会太坏。

心得

Storyboard Reference

Storyboard 容易引发冲突,这句话在 Storyboard Reference 面前是不成立的。

Storyboard Reference 第一次出现在 Xcode 7,可以从组件库中找到它,并自行进行配置和关联,十分简单,无需赘述。即使是一个已经完成且十分繁杂的 Storyboard,也可以选中想要分离的 Storyboard,通过 Editor -> Refactor to Storyboard 来实现。比如,使用了两个 Container View,默认情况下此时画面中存在三个 controller,对其进行分离之后,变成了这样:

Storyboard Reference

Loadable Nib

将 Xib 组件的载入协议化,其中一个目的是为了类型安全,另一个目的是为了减少重复代码。

protocol Loadable: class {
    static var nibName: String { get }
}

extension Loadable {
    static var nibName: String { return String(describing: Self.self) }
}

UIView 进行扩展,要求被载入的 view 遵循 Loadable 协议:

extension UIView {
    func instantiateFromNib<T: UIView>(_:T.Type) -> T where T: Loadable {
        if let nib = UINib(nibName: T.nibName, bundle: nil).instantiate(withOwner: nil, options: nil).first as? T {
            return nib
        } else {
            fatalError("Nib \(T.nibName) is not exist ?!")
        }
    }
    
    func instantiateFromNibOwner<T: UIView>(_:T.Type) where T: Loadable {
        let bundle = Bundle(for: type(of: self))
        if let nib = UINib(nibName: T.nibName, bundle: bundle).instantiate(withOwner: self, options: nil).first as? UIView {
            nib.frame = self.bounds
            nib.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            self.addSubview(nib)
        } else {
            fatalError("Nib \(T.nibName) is not exist ?!")
        }
    }
}

简洁的初始化:

let view:ClassName = self.instantiateFromNib(ClassName.self)
self.instantiateFromNibOwner(ClassName.self)

后来发现一个名为 Reusable 的库,其中除了这一部分的实现之外,还有对 Cell 甚至是 Storyboard 和 ViewController 的重用,十分强大。

回到这一部分的实现,略有区别的地方在于:

  1. Reusable 在初始化 nib 的时候选择了扩展协议。
  2. File's Owner 的情况下,Reusable 使用了 Auto Layout。由于我们的 P-OOP 项目对应的设备尺寸不多,所以像是部分弹出框就没有对应 Auto Layout,所以就直接从 frame 的尺寸下手了。。

追记:把这部分实现和例子提了出来放在了 Github 上~

@IBDesignable 和 @IBInspectable

@IBDesignable 可以用于视图的实时渲染,@IBInspectable 可以用于定义运行时属性。

举个例子来说:首先在定义一个 DialogHeaderView,标记为 @IBDesignable,将它的 headerTitle 属性设置为 @IBInspectable

@IBDesignable class DialogHeaderView: UIView {

    @IBInspectable var headerTitle: String = "" {
        didSet {
            navigationBar.topItem?.title = self.headerTitle
        }
    }
    ...   
}

然后向目标视图添加一个 UIView,并将类定义为 DialogHeaderView,此时在 Attribuite Inspector 中可以直接设置属性:

IB1

之后即会反映在运行时属性栏中:

IB2

不过构建失败的时候还是挺多的,不妨通过 Editor -> Debug Selected Views 来调试一下选中的视图。

类型安全

除了定义上面的 Loadable 协议,在类型安全这个问题上还可以进一步再做一些工作。

存在 Storyboard,Segue 的定义也就会有存在,由于 identifier 的定义是字符串,防不胜防,不匹配的情况还是会时而发生。这时候使用 R.swift 就能够完全解消这个担忧了。

R.swift 被广泛使用于解决类型安全的问题,图片、字体、本地化等等都受益于此。