背景
代码经历过多代人的开发维护,风格和思路 比较丰富多样,再加上版本高速迭代,为保证进度,也欠下不少技术债...多样性和不好的设计给后续维护者带来了困扰和不便,要解决这个问题需要一个漫长的过程,而且这个过程中需要一些默契和约定。(下文的约定大多来源于现有代码中的问题)
约定
MVVM
代码中有一部分使用了MVVM,但用得似乎不太对。如果合适,可以继续沿用MVVM模式。
如果页面很简单,很静态,只需要一个VC就可以了,那就可以不要VM。
如果页面不简单,尤其有状态变化,建议引入VM。
如果有多个页面,排列在一起,分步完成一个业务模块,建议为这个模块引入Service。
(现有代码中,Service基本等同于MVVM中的Model)
View
现有代码中 存在大量视图宽高相关的 “魔法数字”,可读性很差,可以的话,尽量避免“数字”宽高。
如果合适,建议使用XIB(它有缺点,但优点 > 缺点),并尽可能使 XIB预览图 和 美术设计稿 一致。
View中尽量不做逻辑处理,建议将事件抛出,由外层做具体逻辑处理。即使多层View嵌套,也建议向外逐级将事件抛出。
VC
如果合适,建议使用Storyboard,以减少VC中UI元素的创建与布局相关的代码,为VC瘦身。
如果存在对应的VM,逻辑处理应该交给VM,让VC回归它的本职:视图控制器,根据VM的状态控制视图的显示,将视图的事件转接到VM。(充当View和VM的媒介)
VC可以修改View,但不可以修改VM,VC应该把VM当成“只读”的。
VM
一个VC只应该引用一个VM。(现有代码中 存在一个VC引用多个VM的情况...)
VM只应该被引用,不应该反向引用VC。(现有代码中 存在VM 使用delegate指向VC,导致逻辑流程零碎散落)
一个 VM 可以被多个VC 引用。(后文会解释)
具备数据驱动(或称绑定)特征,才算“正宗地道”的VM。
如果确实不需要数据驱动,很有可能不需要VM,VC直接引用Service即可。
Service
完成一个业务流程,往往需要有多个IO操作(HTTP请求,本地存储等),把相关性紧密的一组IO操作集中在一起,形成一个Service。如果模块仅有一个或两个IO操作,可以省略Service。
Service为VM或VC提供接口封装,被不同的VM或VC引用、复用。
对于满足特定条件才会进入的模块,建议使用Service实例,而不是静态 或 全局单例。实例可以更方便地保存依赖数据,缓存返回结果,可以随页面的销毁而销毁。做测试时,方便传入测试专用的Sevice实例。
(现有代码中 使用空协议扩展实现service,是一种不好的实现方式)
Model
这里的Model,特指类型定义。接口返回类型不是必须要定义,如果字段简单,或业务场景简单,可不用定义。
如果定义,建议为字段指定默认值。(类型 和 接口 定义在同一文件中,有利于查找和理解)
如果要强化类型,比如希望用枚举取代字符串型字段,以获得更好的类型推断,应该为类型增加只读属性或方法,而不是增加新字段。
如果要支持UI展示,建议在VC或VM级别扩展Model类型。如果确实需要增加界面相关字段,建议添加 ui_ 前缀 或 下划线命名法。
方法参数个数不宜超过5个,再多的话建议定义专用数据类型,并添加Dto后缀。
Navigator
对于页面导航,推荐使用(新引入的)页面导航器:URLNavigator实例,参考Router/NavigationMap。
导航不是一定要在VC中进行,完全可根据业务逻辑在VM 或 Service中使用导航器导航。
和VC页面没有关系的弹窗(Alert)和提示(Toast),可根据业务逻辑在VM或Service中调用。
协议和扩展
现有代码中,协议扩展 有被滥用的嫌疑。比如Protocol目录和Network/Service目录,使用了大量的 空协议扩展。
无约束的空协议扩展,过于自由,使任何对象都可以轻易具备任何能力,从而变得无所不能,这违反单一职责原则,也容易困扰后续维护者。
应该尽量避免 空协议扩展。
如果确实要用到空协议扩展,应为协议或扩展添加类型约束,同时为协议名称后缀Mixin,以区别于普通协议。
// 全屏半透明遮罩
class FullScreenMaskView: UIView { ... }
// 为了使自定义弹框/菜单 更容易使用全屏遮罩,这里使用空协议扩展。
// - 通过where约束 限制只能是UIView及其子类才可以使用
protocol FullScreenMaskViewMixin where Self: UIView {}
extension FullScreenMaskViewMixin {
func showOuterMask() { ... }
func dismissOuterMask() { ... }
}
// 示例视图
class MyCtrlView: UIView { ... }
// 扩展:借助 FullScreenMaskView 支持弹出形式展示
extension MyCtrlView: FullScreenMaskViewMixin {
func show() {
FullScreenMaskView(self).show() // 把自身嵌入全屏遮罩,显示
}
func hide() {
self.dismissOuterMask() // 关闭外层遮罩
}
}
扩展(extension)
- 对内建类型(Swift 或 Cocoa)的扩展,不应该和具体业务相关。比如对UIView、UIViewController的扩展中不应该存在电池或定单相关的逻辑。有个简单的判断依据:这个扩展 拿到另一个项目中还能用吗?如果不能,那么您大概率应该换种实现方式。
- 扩展属于额外增强,即使没有额外增强,核心功能也应完整可用。如果移除扩展后,核心功能变得不可用,或编译错误,那基本可以认定存在对扩展的误用。
// 以下两个示例,删除extension代码块后,编译错误 // 误用示例1: class PendingViewController { override func viewDidLoad() { ... setupUI() rxBind() } } extension PendingViewController { func rxBind() { ... } } extension PendingViewController { func setupUI() { ... } } // 误用示例2: class BatteryLeaseController { var batteryTable: UITableView = UITableView() override func viewDidLoad() { ... batteryTable.delegate = self batteryTable.dataSource = self } } extension BatteryLeaseController: UITableViewDelegate, UITableViewDataSource { ... }
Swift
- fileprivate 关键字 应该尽量少用,甚至不用。 如果您经常大面积用,大概率是存在误用。
- guard 关键字 只应该用在方法体的开头, 不应该用在方法体的中间或末尾。 guard不同于if not,guard的意思是警卫,不满足条件者不得入内!在方法体的开头 声明要执行方法体 需要满足的前提条件。
guard else { ... }
else块中不应该包含任何业务逻辑,只可以包含 toast,alert,pop等“善后”动作。 业务相关的否定判断,应该使用 if not,而不是guard。
目录结构
原来的目录是 Model,View,Controller,ViewModel,随着不断的迭代,目录中的文件越来越多,平铺开来,一整屏都展示不全一个目录,想要从目录入手了解代码结构和功能模块,几乎不可能。
为更好地理解和维护,改为 按功能模块分组 的方式组织文件,将Controller目录改名为Features,做为功能模块的父目录,每个功能模块内含自己的View,VC,VM, Service。 如下图:
随着持续迭代和对业务的理解,模块数目可能会变化。
延伸
Reactor
ReactorKit,一个受Flux启发的库,熟悉Redux的人会感觉亲切。如果您感兴趣,也可以在项目中使用。
它和VM 扮演相同的功能角色,二者可以互换。
https://github.com/ReactorKit/ReactorKit
RxSwift
VM 和 Reactor依赖 RxSwift,如果不了解RxSwift,可能会碰壁。
对于初学者,它的门槛略高,但 不必心急,慢慢积累,总有一天您会发现它物超所值。