背景
最近在做一个iOS项目,客户要求交付的时候要有相对好的测试覆盖率,可维护性。这让我想到了之前开发的时候View Controller测试痛苦的经历,而且到了项目后期庞大的数千行代码的View Controller非常难维护,即使是用了不同的extension来分割代码,由于基本上90%的逻辑都会写在ViewController中,导致代码非常混乱。 通过改变MVC模式来达到更好的可测试和维护性有很多解决方案,比如VIPER,借鉴于Redux的 单向数据流动函数式,MVVM等等。我从对已有模式的兼容性和维护测试性的两个方面考虑,选择了MVVM作为整个App的前端架构模式。
MVC vs MVVM in iOS
MVC简介
由于iOS项目创建时会默认使用Model-View-Controller的标准模式,开发者一般都会将他用作自己的默认开发模式。我们来看一张经典的MVC的架构图。

在MVC中,Model代表数据,View代表用户界面,Controller充当二者之间的媒介。在实际应用中,View和ViewController会经常深度耦合在一起,以至于在Xcode中当你选择新建一个COCOAPods类时,他会询问你是否要新建一个与之相对应的xib view,如下图

很多人会称MVC为 Massive View Controller, 因为在实现一个App的过程中很多开发者会把网络,数据,状态的处理方法都写在ViewController里,而造成厚VC薄Model的情况。也正是由于这种厚VC的情况,会让Unit test非常难写,因为VC做了太多的事情,混杂了对UI组件的处理,对View状态的维护和对数据的处理。但是也不能说这是一种错误,因为VC本来就是View和Model之间的胶水代码。时间长了这里就变成了意大利面代码,永远没人想知道里面发生了什么。
MVVM简介
MVVM本质上是一种MVC的变种.,相当于把以前的VC拆分成View Controller和View Model两部分。 View Controller只关心View的信息比如IB Outlet,View的生命周期等等,View Model只关心现在的数据和状态信息。
这样的改变会让代码很好被测试,利用Unit test很容易测试View Model的数据和状态信息,而Xcode的Automatic UI testing又可以很好的帮我们处理VC这一层的测试。而且由于我们只是分离了View Controller为两层,这也不会对我们现有的代码有太多的侵入。尤其是如果我们在分离一部分VM层的数据状态处理逻辑代码给其他团队复用的时候,这种方式极大地方便了提取和移植。
总结
总结一下MVVM相对MVC的三大优势:
- 更好的可测试性
- 方便的代码移植
- 良好的MVC兼容性
当然世界上没有绝对完美的东西,MVVM也有他自己的缺点:
- 学习成本高,尤其是团队里有新成员加入的时候,会比较难上手
- MVVM需要绑定机制来触发ViewModel到ViewController的响应,iOS没有很好的绑定机制可以用,这也造成了我现在团队里的内存泄漏问题
- 数据和网络相关的代码会使得VM这层变得很臃肿,大量的Async代码会让程序难维护,所以我们采用了DataManager layer和Async Await in Swift来解决这个问题
IBM团队MVVM在iOS的实现方法
我们团队在具体实现上面参照了IBM奥斯丁团队的Bluepic项目,这个项目曾经在WWDC演讲过Going Server-side with Swift Open Source。 其中也介绍了Swift在server side的可能性,值得一看哦。
基本架构
我们受到React组件思维的影响,对Bluepic的架构做了调整,用组件来组织不同的UI item,这一点也确实省了我们切换ViewController,ViewModel和View文件夹的时间。 而且我们团队在Sprint中分任务的时候也是根据组件负责来分的,这也让团队成员更专注于他们的任务

| 文件 | 解释 |
|---|---|
| AppDelegate.swift | 处理App生命周期 |
| TestingAppDelegate.swift | 处理测试环境下App的生命周期 |
| Localizable.strings | 多语言支持 |
| Assets.xcassets/ | 图片资源 |
| Components/ | UI组件,每个组件都由一个View Controller, View Model和Xib view构成 |
| Configurations/ | 存放info.plist等配置文件 |
| DataManagers/ | 本地数据库,网络相关 |
| Extensions/ | 对Swift原生库的扩展方法 |
| Models/ | 数据结构定义 |
| Storyboards/ | 存放不同环境下的Storyboards |
| Utilities/ | 工具类文件夹 |
基本实现

以Category Component为例子,他有自己的ViewController,ViewModel和Xib view。 这里Xib就不过多解释了,里面有autoLayout和基本的UI组件。
ViewController的实现如下,其中的CategoryViewModelNotification是View Model给View Controller传信息的消息约定. 在ViewController中会有一个ViewModel的reference,以及一个用于初始化ViewModel的notifyVC方法
class CategoryViewController: UIViewController {
var viewModel: CategoryViewModel!
// IB Outlet
@IBOutlet weak var textA: UILabel!
@IBOutlet weak var textB: UILabel!
override func viewDidLoad() {
self.viewModel = CategoryViewModel(notifyVC: notifyVC)
self.viewModel.updateData()
}
func doA() {
// Update UI using View Model Data
debugPrint('doA')
textA.text = self.viewModel.getText(for: 0)
}
func doB() {
// Update UI using ViewModel Data
debugPrint('doB')
textA.text = self.viewModel.getText(for: 1)
}
}
// ViewModel -> View controller Communication
extension CategoryViewController {
func notifyVC(_ notification: CategoryViewModelNotification) {
switch notification {
case .A:
self.doA()
break
case .B:
self.doB()
break
}
}
}
ViewModel的实现如下,ViewModel会直接与Data Manager联系来更新当前这个Component的状态或者数据,然后成功后会通过初始化时ViewController传来的notifyVC来通知View Controller更新UI
enum CategoryViewModelNotification {
case A
case B
}
class CategoryViewModel: NSObject {
fileprivate var notifyVC: ((_ viewModelNotification: CategoryViewModelNotification) -> Void)!
private var textList = [String]()
init(notifyVC : @escaping (_ viewModelNotification: CategoryViewModelNotification) -> Void) {
super.init()
self.notifyVC = notifyVC
}
func setupData() {
textList = DataManager.getData()
self.notifyVC(.A)
self.notifyVC(.B)
}
func getText(for index: Int) -> String {
if(index >= textList.count) {
return 'Error'
} else {
return textList[index]
}
}
}
内存泄漏问题,三角关系泛滥
以上这种模式是BluePic里所采用的,使用名为notifyVC的@escaping函数来作为绑定媒介。在初始化的时候把定义在ViewController的notifyVC绑定到ViewModel里面,当ViewModel中的数据发生变化时,用这个绑定好的函数来进行对ViewController的通知。
但是这种方法,由于在绑定方法的时候,会将这个notifyVC函数当成参数传入。由于不像Javascript,Swift中没有函数对象,这里的函数会被转换为closure。 而在这个closure中又保持了一个对于ViewController self的strong reference,这里就会产生循环依赖关系。如下图

这样每一个Component的VC,VM,View的组合都是一个个闭合的三角循环依赖关系。由于iOS没有垃圾回收机制而是使用的ARC,这种循环引用会导致每次初始化一个Component都会导致一次内存泄漏,这在我团队的项目中很严重,每次切换语言重新渲染的时候都会有50MB左右的内存泄漏。
解决方法
我们要解决这个问题只需要打破三角关系中的一环,由于外部初始化component会从ViewController开始,销毁也一般会调用VC的removeFromParentVC方法。所以我们选择让他们的关系变成如下这样(虚线代表弱引用)

具体解决方法有两种
使用unowned或者weak关键字修饰notifyVC里的self,强制closure使用弱引用
在Viewcontroller中新加一个变量
lazy var weakNotifyVC: (_ viewModelNotification: ProductListingPageViewModelNotification) -> Void ={ [unowned self] in
return self.notifyVC
}()
在绑定的时候把这个weakNotifyVC传进ViewModel来做初始化,这样直接就把closure里的self reference变成weak类型,也就解决了这个问题
使用delegate模式,把一个变量传给ViewModel来初始化而不是传一个函数进去
先定义一个专门用来做VC和VM绑定的protocol
protocol NotifyVCDelegate: class {
func notifyVC(notification: Any)
}
然后ViewController实现这个protocol
extension CategoryViewController: NotifyVCDelegate {
func notifyVC(notification: Any) {
if let notifyVC = notification as? CategoryViewModelNotification {
self.notifyVC(notifyVC)
}
}
func notifyVC(_ notification: CategoryViewModelNotification) {
switch notification {
case .A:
self.doA()
break
case .B:
self.doB()
break
}
}
}
并且修改ViewModel的初始化函数,并且在ViewModel中介入一个notifyVCDelegate,这里的notifyVC函数复用了之前的实现,就不载多做描述了
weak var notifyVCDelegate: NotifyVCDelegate!
init(notifyVC : NotifyVCProtocol) {
super.init()
self.notifyVCDelegate = notifyVC
}
并且改变ViewController中对ViewModel的初始化代码
self.viewModel = CategoryViewModel(notifyVC: self)
由于self是一个object,Swift会按引用传值,在ViewModel内部我们给他绑定了一个weak的引用,这样的话也就解决了之前closure到ViewController的强引用问题
小结
由于没有现成的绑定机制,所以MVVM在iOS中的实现要稍微复杂些。又由于Swift中函数并不是一等公民,在以函数为值传递到另一个函数中时,他会变成一个closure,而closure的内存泄漏已经是自从OC一来的一个臭名昭著的问题了。由于我在Javascript中的书写习惯,总喜欢把函数当作一个对象来处理,导致了这个bug。Swift个人认为更适合于面向协议的编程方式,这种方式也更符合ARC的工作机制,更方便也更直观计算每个对象的reference数目。所以我个人比较推荐第二种解决方案。第一种解决方案更像是OC留下的一个语法糖,这种非Swift风格的代码也很有可能在之后的Swift更新中被重构掉。