闭包之术
背景
近期,公司的项目(iOS)需要在现有书城上添加许多新功能。 我发现书城架构因为设计问题,不能直接应用在一些新的页面上。
如果要接着使用,就需要对其功能进行拆分。 以便新的页面能仅复用自己需要的逻辑,不必背负无关逻辑的负担。
欲对其进行拆分,就要求先理清其逻辑。 但我发现,在长期的版本迭代过程中,现有书城框架已经变得臃肿不堪的一坨。 想看懂,需要投入大量的时间。 况且即使看懂,拆分也要冒着牵一发而动全身的风险。 后面可能引发明显的、暗藏的 bug,光是想想,就让我不寒而栗。 遂决定重新设计书城架构。
在这之前,我必须想清楚两个问题 ——
- 旧书城架构是怎么走到这一步的?
- 新书城架构该如何避免变得臃肿? 高速地迭代、野蛮地生长, 在这些外在因素不会改变的前提下, 该如何避免历史重演?
近看旧书城架构
旧书城架构的组成部分和职责如下:
- BookCityViewController
- 实现 UITableViewDelegate & UITableViewDataSource , 并转发给 「 BookCityBaseModule 及其诸多子类」
- 通过 「 UIResponder 链」 接收 BookCityEvent , 并转发给 「 BookCityBaseModule 及其诸多子类」 。
- 处于 MVC 架构中的 Controller 层。
- BookCityEvent
- 包含触发事件的 UITableViewCell (或其子 UIView )和对应的 NSIndexPath 等。
- 处于 MVC 架构中的 Controller 层。
- BookCityManager
- 「下拉刷新」和「上拉加载更多」等事件和网络请求。
- 分页状态的维护(当前已加载的分页,下个要加载的分页)。
- 根据返回的数据决定使用哪个业务的 BookCityBaseModule 子类。
- 处于 MVC 架构中的 Controller & Model 层。
- 「 BookCityBaseModule 及其诸多子类」
- 将 JSON 反序列化为具体业务的 BookCityBaseItem 子类
- 实例化具体业务的 UITableViewCell 子类和子视图。
- 处理被转发过来的 BookCityEvent 事件。
- 包含了一部分业务字段,包括「数据 ID」和一些「控制跳转」的字段。
- 处于 MVC 架构中的 Controller & Model 层。
- 「 BookCityBaseItem 及其诸多子类」
- 包含具体业务的字段,多为 ID 类型。
- 处于 MVC 架构中的 Model 层。
- 「 BookCityBaseCell 及其诸多子类」
- 具体业务的 GUI 布局和绘制。
- 处于 MVC 架构中的 View 层。
本次希望能够复用的逻辑包括:
- 「布局绘制」
- BookCityBaseCell 和其子类的 GUI 展示
- 「数据模型」
- BookCityBaseItem 和其子类的业务数据模型
- 「模块映射」
- BookCityManager 根据返回数据选择 BookCityBaseModule 的子类处理具体业务
- 「事件处理」
- BookCityBaseModule 和其子类的反序列化,实例化视图和事件处理
其中 「布局绘制」 和 「数据模型」 复用起来问题不大。 难以复用的是 「模块映射」 和 「事件处理」 。
「模块映射」 在 BookCityManager 中被实现。 想要复用它, 势必要将可复用的「精华」从不可复用的「负担」中通过重构剥离出来, 因为新的页面的 「分页逻辑」 和 「服务器接口」 与旧页面不同。
在反复阅读代码之后,我发现这个操作的工作量尚可负担。 只是改代码就有出 bug 的风险。 零修改的复用,才是好的复用。
最大的阻碍在于复用 「事件处理」 。
首先, 「 BookCityBaseModule 及其诸多子类」 虽然负责 「事件处理」 , 但大量的逻辑需要 「 UIResponder 链和其上的 UIViewController 」 的支持。 脱离了外部的 UIViewController 虽然能编译通过,但是点击事件等会没有反应。
再有, 所有用到旧书城框架页面的 UIViewController ,都要实现 UITableViewDataSource & UITableViewDelegate & BookCityEvent 相关的逻辑。 但很多业务都有自己的 UIViewController 基类, 不能直接继承 BookCityViewController ,只能选择复制粘贴。 截止成文时,在代码库中能搜索到 15 处相同或相似逻辑的复制粘贴。
然后, 直接劝退我的是 BookCityBaseModule 的基类膨胀问题。 因为具体的业务模块数量多达几十个, 很多业务都把自己模块子类的成员变量和方法堆积到基类 BookCityBaseModule 中。
举个例子。 有个方法名为 randomRefreshLocalData , 作用是将本地数据「洗牌」后再呈现(不要问我为啥会有这种需求)。 该方法以接口( protocol )方法的形式声明在基类 BookCityBaseModule 里。 我遍历了所有子类,其中有三个应用了基类里的实现。 其余 23 处尽是这种空实现:
1 | - (void)randomRefreshLocalData {}
|
也就是说,为了这三个子类能够复用「洗牌」逻辑, 其余 23 个子类都必须背负实现这个方法的负担。 我觉得负责前三个子类的同学应该给负责后面 23 个子类的同学买点儿护发素。 当然护发素也得有我的份。 因为我理清这里面具体逻辑的时候, 要极其小心前三个子类实现里调用的方法和变量。 我也不清楚它们会不会被别的业务调用和修改。 像这种为了「方便复用」就把逻辑堆到基类里的例子还有很多。 而且,可以预见的,基类 BookCityBaseModule 后面还会继续膨胀下去。
总结下,导致旧书城框架难以复用的问题有:
- BookCityManager 有一部分逻辑对于新页面是负担,复用前需要剥离。
- BookCityBaseModule 依赖外部 BookCityViewController 实现事件传递。 每复用一次,就要复制粘贴一次。
- BookCityBaseModule 作为基类,随着子类增多,逐渐膨胀。
抽象
为了解决上诉三个问题, 我们先把问题抽象一下。 有的时候, 面对具体的问题没有思路, 经过抽象可能会发现它是一个之前解决过的问题。 我们只是被表象蒙蔽了双眼。
问题 1 比较典型。 BookCityManager 承担了好几种职责。 要复用其中任意一种,其它的职责都会成为负担。 这违反了 「SOLID 原则」 中的 「单一职责原则」 。 过多的职责,使得这个类内聚性低,复用性差。
对于一般的业务模块, 要在实现之初就做到高内聚是十分考验编程经验的。 在迭代的过程中反复提炼、抽离出可复用的逻辑, 逐步提高内聚也是常规操作。 但 BookCityManager 定位在框架层, 理应「高瞻远瞩」, 考虑好后面要被高频率复用的情况, 从设计上保障高内聚。 毕竟设计架构的时候能看多远,决定了架构能走多远。 一旦框架出现难以支持业务高速发展的情况, 想要升级,工作量会极其庞大, 因为在已有代码库中框架会被业务代码大规模依赖。
问题 2 也比较典型。 「 BookCityBaseModule 及其诸多子类」 的事件处理依赖 「 UIResponder 链和其上的 UIViewController 」 。 但这种依赖并没有表达在它的接口上(构造函数或其它接口),属于隐式依赖。 而且它的隐式依赖是动态的,编译期检查不出来。 这会形成该类很好复用的假象,因为编译很容易通过。 但是运行起来会发现,所有的事件都不会得到响应。 这种错误需要靠 QA 同学才能发现,比编译器发现要危险且昂贵的多。 抽象来看,这个问题属于高耦合。 「 BookCityBaseModule 及其诸多子类」 要正常工作,需要耦合 「 UIResponder 链和其上的 UIViewController 」 。
耦合高的模块就像电影里那些有免疫缺陷疾病的可怜人一样, 需要呆在一个隔离箱中, 保持极其恒定的环境。 外部环境稍有改变,就挂了。
问题 3 的讨论似乎在网上并不多见。 我本来想通过查证 BookCityBaseModule 在设计之初的样子, 来推演它是如何变得臃肿的。 但由于我们的项目从 SVN 迁移到 GIT 的时候并没有全量迁移历史, 且 SVN 服务器不可访问了, 这个计划没能实施。 不过,根据我的印象,一开始它是没有这么臃肿的。
我推测变臃肿的过程大致如下:
- BookCityBaseModule 在某个版本下有 10 个子类,它尚且维持着较苗条的状态。
- 该版本的新需求使其中 5 个子类产生公用字段,为了节省时间该字段被推到了基类里,以便「复用」。
- N 个版本后,反复重复 1 和 2,导致基类膨胀到难以维护。
网上经常见到「面向对象编程 = 高复用」是否是个骗局的讨论。 更是有人继 「SOLID 原则」 之后,提出了 「组合复用原则」 。 我的观点是,面向对象编程的确实带来了方便的复用。 但是,「代价是什么呢」?
基类成为了子类的耦合。 一旦基类在版本迭代中变得臃肿,内聚性降低, 复用它和它的子类的代价都会变大。 此外,子类耦合度的上升, 使得对于基类逻辑的修改, 有时会意外影响该子类。
所以想通过面向对象编程的继承特性复用某些方法的代价 就是 整个继承树上的类的复用性、健壮性变差 。
回到具体的书城业务。 假设 BookCityBaseModule 是个业务层的普通基类, 它带来的问题其实没有这么大。 但是作为框架层的核心基类, 它的膨胀直接导致整个框架复用困难。
总结一下,我们抽象这三个问题为:
- 「 BookCityManager 内聚低」
- 「 BookCityBaseModule 及其诸多子类耦合高」
- 「 BookCityBaseModule OO 膨胀」
重新设计书城框架
针对 「 BookCityManager 内聚低」 的问题, 我将其内的「刷新」、「加载更多」、「分页」等逻辑 分别在类 Refresher 、 MoreLoader 、 Pager 中重新实现。 框架内,只保留 「模块映射」 逻辑。 如此一来,业务可根据自己页面的加载特点自由组合这些类, 而不必因为 BookCityManager 固有分页方式的限制,而无法复用 「模块映射」 的逻辑。
针对 「 BookCityBaseModule OO 膨胀」 的问题, 我将框架层的类和函数都迁移到一个独立的仓库里。 其内的类(或接口)在业务侧是「只读」的。 业务侧无法为了复用个别方法一时方便而改动框架层的基类(或接口), 进而也不会导致框架随着版本的迭代而变得臃肿、无法维护。
解决这两个问题固然需要技巧,展开来讲也有很多值得说的细节。 但本质上无非是把逻辑从一个类搬到多个类里,或者搬到独立的仓库里。 只要计划好,实施上属于「体力活」。
余下 「 BookCityBaseModule 及其诸多子类耦合高」 的问题是比较难以解决的。 我尝试结合实例 「热门标签模块」 推导出, 当初为什么会设计出 BookCityEvent 这种依赖 「 UIResponder 链和其上的 UIViewController 」 的事件传递方式。 「热门标签模块」 很简单,仅由一或多排按钮组成,大概长成这样:
1 2 3 4 | +---------------------------------+
| [ Hot ] [ New ] [ On Discount ] |
| [ Historical ] [ VIP ] |
+---------------------------------+
|
该模块的构造栈如下
- BookCityViewController (下简称 controller)
- BookCityManager (下简称 manager)
- HotLabelModule : BookCityBaseModule (下简称 child controller)
- HotLabelCell (下简称 cell)
- HotLabelListView (下简称 list view)
- HotLabelItemView (下简称 item view)
- UILabel (下简称 label)
- UITapGestureRecognizer (下简称 recognizer)
点击事件的第一响应者是栈顶的 label, 经 「 UIResponder 链」( item view -> list view -> cell -> UITableView ) 一路到达栈底的 controller。 再由 controller 通过 UITableView 提供的 index path 找到对应的 child controller, 并把事件发回给它。 最终 child controller 根据事件携带的信息,构造并跳转到对应的标签详情页。 栈顶的 label 由 child controller 构造(间接)而出, 事件最终的处理也是交由 child controller 负责, 明明整个事件处理的始末都距离 child controller 更近, 为何舍近求远绕道栈底的 controller 呢?
如果能找到某种方法, 在事件开始处直接处理页面跳转, 而不必经过中间的诸多层级, 保证处理链路最短,依赖最少,耦合最低, 就能最大层度上保证这个模块在重构或者移植后依然能正常工作, 而不是像我本次重构时面临的局面 —— 表面通过了编译, 实际却因为 「 UIResponder 链和其上的 UIViewController 」 的变化导致点击无反应。
我认为绕道栈底 controller 的原因是,次级页面的跳转需要三个要素:
- 「label 的点击事件响应」
- 「child controller 内部的业务数据」
- 「controller 包含的导航信息」 ( UINavigationController )
所以,事件的处理链路务必要经过这三个节点。 这也就造成目前 label -> child controller -> controller -> child controller 这种「大回环」的设计。
那如果砍掉最后事件传回 child controller 的过程, 直接在 controller 里处理跳转如何? 这样虽然缩短了处理链路, 但是 controller 可能会包含多达几十种与 「热门标签模块」 child controller 同级的其它业务的 child controller。 所有业务都交由 controller 处理会导致它的代码行数疯长, 也就是 iOS 圈里常说的 「MVC - Massive View Controller」问题。 所以这是一个「按下葫芦起了瓢」的解决方案。
那反过来呢?把 controller 和 child controller 传递到 label 里呢? 这样即实现了 「label 的点击事件响应」 、 「child controller 内部的业务数据」 、 「controller 包含的导航信息」 「兵合一处」的需求, 又不会导致 controller 膨胀。 也不太好。因为保证视图类的复用性是一个有追求的程序员的基本素养。 如果我们通过继承 UILabel 完成了在其内部处理「跳转标签详情页」的逻辑, 那么这个 label 在不需要「跳转标签详情页」的情境下,会因为过于具体和内聚低而无法复用。
栈顶不行栈底也不行,那就只剩栈中的 child controller 了。 我们需要一个能把 「label 的点击事件响应」 和 「controller 包含的导航信息」 都集中到 child controller 的方案。 先看如何在 child controller 内部获悉点击响应。 最直觉的办法就是把 label 的 recognizer 回调指向 child controller 。 这里最大的难点在于 label 并非由 child controller 直接构造, 而是经由 cell / list view / view 三层间接构造。 要把回调跨越三层指向 child controller 并非易事。
当然,我们可以通过成员变量在 child controller 里访问到 label:
1 2 3 4 5 6 7 | class ChildController {
func create() {
cell = Cell()
cell.listView.itemViews[0].label.tapRecognizer.addTarget(self, action: #selector(handle))
}
}
|
但是看看这长长的调用链,明显违反 「迪米特法则」 。 方法 create 想正常工作,需要依赖的条件太多。 首先,cell、list view、item view 的方法命名和类型都不能改变, 否则 child controller 就不能通过编译。 其次,cell、list view、item view 不能应用诸如延迟加载之类的技巧, 必须在被构造之后马上构造自己的属性。 否则 child controller 虽然能通过编译, 但会因为调用链上某个属性为空, 而导致赋值失败, 进而导致点击无响应。
换种方法,我们可以把 child controller 通过构造方法,一层一层传递到 label:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | class ChildController {
func create() {
cell = Cell(controller: self)
}
@objc func handle() {}
}
class Cell {
init(controller: ChildController) {
self.controller = controller
}
func create() {
listView = ListView(controller: self.controller)
}
}
class ListView {
init(controller: ChildController) {
self.controller = controller
}
func create() {
itemViews = (0..<6).map {
ItemView(controller: self.controller)
}
}
}
class ItemView {
init(controller: ChildController) {
self.controller = controller
}
func create() {
label = UILabel()
recognizer = UITapGestureRecognizer()
recognizer.addTarget(self.controller, action: #selector(handle))
label.addGetureRecognizer(recognizer)
}
}
|
上面的代码遵守了 「迪米特法则」 ,不会因为耦合太高而变得过于「脆弱」。 但是我们上文提到了, 为了保持视图类的复用性, 不能让 label 耦合进具体的 「热门标签模块」 。 这种实现无疑违反这一点。 整个视图类树都耦合进了具体的业务, 进而导致无法复用 (虽然实际上它们只坚持了半年就被「加个简单需求」下线了,但这不是放弃追求高复用的理由)。 那么,抽象出接口来规避耦合进具体业务怎么样?
通过定义接口:
1 2 3 4 5 6 7 8 9 10 11 | protocol CellTapHandling {
func handleTap(index: Int)
}
protocol ListViewTapHandling {
func handleTap(index: Int)
}
protocol ItemViewTapHandling {
func handleTap(itemView: ItemView)
}
|
和实现接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | extention ChildController : CellTapHandling {
func handleTap(index: Int) {
detail = DetailViewController(self.hotLabels[index])
self.navigator.showDetailViewController(detail)
}
}
extention Cell : ListViewTapHandling {
func handleTap(index: Int) {
self.delegate.handleTap(index)
}
}
extention ListView : ItemViewTapHandling {
func handleTap(itemView: ItemView) {
index = self.itemViews.firstIndex(of: itemView)
self.delegate.handleTap(index)
}
}
|
这样视图类耦合进具体业务的问题就解决了。 但是对比第一种解决方案, 我们大概多定义了三个 protocol 和三个 extension , 多写了二十多行代码(还是伪的)。 而且根据我在「加个简单需求」这个问题上的经验, 这种视图很可能会在几个版本后加个边框或者阴影之类的。 到时候少不了要再加一层 FrameView 或者 ShadowView 。 那就又要多出 protocol FrameViewTapHandling 和 extentsion ItemView : FrameViewTapHandling 。
「加个简单需求」 已经够让人头秃的了,我真的不想再写这么多代码。 所以我想出了 「闭包之术」 (终于要扣题了)!
用闭包降低耦合
用闭包解决上面问题的实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | class Module {
func create() {
cell = Cell {
ListView { index in
let view = ItemView(data: self.hotLabels[index])
view.onTap = {
let detail = DetailViewController(data: self.hotLabels[index])
self.navigator.showDetailViewController(detail, sender: nil)
}
return view
})
})
}
}
class ListView {
func init(create: (Int) -> UIView) {
itemViews = (0..<6).map(create) { index in
return create(index)
}
}
}
|
可以明显看出,代码量骤降。 而且这是在我为了让非 iOS 开发同学也能理解, 而没有充分使用 swift 语法特性的前提下。 即使加上边框和阴影,也无非就是多两行代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | class ChildController {
func create() {
cell = Cell {
FrameView {
ListView { (index) in
ShadowView {
let view = ItemView(data: self.hotLabels(index))
view.onTap = {
detail = DetailViewController(data: self.hotLabels[index])
self.navigator.showDetailViewController(detail)
}
return view
}
}
}
}
}
}
|
丝毫不会因为要传递点击事件而增加代码量。 应用闭包后, 因为它「惰性求值」的特性, 仍然可以进行「延迟加载」, 不必在构造时加载大量资源。 也不会出现属性没有初始化导致赋值失败的尴尬。
回到 「热门标签模块」 的业务逻辑。 我们已经借力闭包以很少的代码量做到了将 「label 的点击事件响应」 传递到了 child controller。 同理通过闭包传递 「controller 包含的导航信息」 也是轻而易举:
1 2 3 4 5 6 7 8 9 10 11 | class Controller {
func create() {
manager = Manager { type, data in
switch (type) {
case 42:
return ChildController(navigator: self, data: data)
}
}
}
}
|
获得了 「label 的点击事件响应」 和 「controller 包含的导航信息」 再结合自己 「child controller 内部的业务数据」 , 就可以不再借由 「 UIResponder 链和其上的 UIViewController 」 处理点击-跳转事件了。 只要构造时,按接口要求传入参数,编译通过即代表功能可以正常工作。 耦合降低带来的是模块健壮性的大大提升。
用闭包提升内聚
本次重构书城架构,聚焦的是 「 BookCityBaseModule 及其诸多子类耦合高」 的问题。 提升内聚并不是重点。 但无可置疑的是,闭包也是提升内聚,增强复用性的利器。
从代码设计的角度讲,使用闭包遵循了 「依赖倒置原则」 。 「依赖倒置原则」 强调具体细节应该依赖抽象,而不是反过来。 以 list view 为例,它的职责是布局,是将 item view 安规则排列成行。 这一职责并不要求被布局的对象是具体的 ItemView ,只需要是抽象的 UIView 即可。
传统的实现方式, 因为要传递点击(或其它具体的业务)事件的原因, 将构造 item view 的职责强加给 list view,导致其内聚性降低,复用性变差。 使用闭包后,list view 的布局逻辑可以复用到任何使用 UIView 的地方。
应用算法复杂度的思维来分析使用闭包前后一个模块的内聚程度。 假设一个模块的结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | root(a, b, c, d, e)
print(f)
foo(a, b, d)
print(d)
alpha(a)
print(a)
beta(b)
print(b)
bar(c, e)
print(e)
gamma(c)
print(c)
|
可见,如果使用传统方式透传参数,底部的 root 的 「内聚复杂度」 会随着:
- 模块结构层次的加深
- 每层子节点数量的增加
而变大。 因为层次越深, 每层子节点越多, root 要负责传递的参数也就越多。 每当要复用 root 就必须集齐这些参数。 这使得复用变得困难。
如果设层次深度为 D ,每层节点平均数量为 N 。 那么透传参数的情况下,一个模块的内聚程度为 O(D, N) = N^D 。
如果使用闭包构造模块,结构就会变成这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | root(foo, bar, e)
print(e)
foo()
bar()
foo(alpha, beta, d)
print(d)
alpha()
beta()
alpha(a)
print(a)
beta(b)
print(b)
bar(gamma, e)
print(e)
gamma()
gamma(c)
print(c)
|
可以观察到,模块深度被拍平成了 1 。 「内聚复杂度」 变成了 O(N) = N 。 由指数级变成了线性级。 提升非常大。
书城框架重构总结
旧书城框架,在设计上存在的天然缺陷包括:
- 「 BookCityManager 内聚低」 ,复用性不够强,不能满足新的业务。
- 「 BookCityBaseModule 及其诸多子类」 耦合高,不够健壮。 移植到新的业务中后,编译时、运行时问题频发。
我在重构中通过分离 BookCityManager 的诸多职责解决了前者, 使用闭包解决了后者。
至于 「 BookCityBaseModule OO 膨胀」 的问题, 并非当初框架设计之过, 而是在迭代过程中「日积月累」出来的。 我寄希望于通过将框架层的类和函数都迁移到独立的仓库中解决。
乐观地说一句, 如果能通过仓库的隔离, 终结框架在迭代中膨胀的轮回, 即使前两个问题再次出现, 也只局限于个别业务。 框架是安全的,大部分业务就是安全的。 程序猿的头发也就是(相对)安全的。
结语
闭包之所以能帮助降低耦合、提高内聚, 是因为它能够自动捕获上下文资源。 这种能力来自编程语言本身。 这是一种更「原初」的能力,无法通过优化代码设计来替代。 我也暂时未找到其它替代方案。 借助这种能力可以使代码的健壮性、复用性提高一个等级, 接口的设计可以保持「优雅」。 再也不用为了传递参数和事件在每一层接口上「打洞」。 使得「加个简单需求」变得从容许多,不再那么「秃然」。
当然,「闭包之术」 也不是 100% 完美。 在实践中我发现了一些问题:
- UITableViewCell 的复用机制导致回调闭包指向了不正确的 child controller。
- Objective-C 的闭包(block)语法十分反人类。
- 利用引用计数管理内存的语言在大量使用闭包的情况下易产生循环引用。
- 因为复用性大大幅提升,类和方法的数量变少,有些 crash 的堆栈信息会变得雷同。
- ……
还有一些细节包括:
- 闭包应该作为「构造方法的参数」还是「对象的实例变量」?
- 闭包的参数应不应该包活持有闭包的实例?
- ……
这些问题的解决方案和实现技巧, 只能再找机会总结分享了。 因为我还有很多「简单需求」要加。😭