面向协议的MVVM

苹果在WWDC2015上介绍了面向协议的编程思想,以及Swift 2.0中可以支持这一编程思想的新特性。
参见WWDC2015 Session 408 Protocol-Oriented Programming in Swift
然后女中豪杰NatashaTheRobot将其和MVVM结合起来,写了一篇文章介绍面向协议的MVVM(POMVVM),并在2015年底旧金山Swift峰会上作为一个主题分享。
本文主要基于以上两个资料,并参考其它一些资料,总结出一套最适合的自己项目的 POMVVM 架构方法。

Protocol-Oriented Programming(POP)

关于面向协议编程的具体内容可以去看Session 408,这里只引用其中一句话:

Don't start with a class.  
Start with a protocol.

Protocol Extentions

Swift 2.0增加了Protocol Extentions这个强大的特性,也是这个特性让POP成为可能。
看看这个特性是怎么运作的:

首先定义一个Animal Protocol和其中的属性:

1
2
3
4
5
protocol Animal {
var name: String { get }
var canFly: Bool { get }
var canSwim: Bool { get }
}

并定义三种具体的动物:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Parrot: Animal {
let name: String
let canFly = true
let canSwim = false
}

struct Penguin: Animal {
let name: String
let canFly = true
let canSwim = true
}

struct Goldfish: Animal {
let name: String
let canFly = false
let canSwim = true
}

这里对canFly和canSwim两个属性,每个具体的动物中都要实现一次,非常的不优雅,但利用Swift 2.0我们有了更好的实现方式。

定义Flyable、Swimable两个Protocol

1
2
3
4
5
6
7
protocol Flyable {

}

protocol Swimable {

}

利用Protocol Extensions给protocol增加默认实现

1
2
3
4
5
6
7
8
9
10
11
12
extension Animal {
var canFly: Bool { return false }
var canSwim: Bool { return false }
}

extension Animal where Self: Flyable {
var canFly: Bool { return true }
}

extension Animal where Self: Swimable {
var canSwim: Bool { return true }
}

这里对于符合Flyable协议的Animal,就把canFly返回true
对于符合Swimable的Animal,就把canSwim返回true

改造三个具体动物的实现

1
2
3
4
5
6
7
8
9
10
11
struct Parrot: Animal, Flyable {
let name: String
}

struct Penguin: Animal, Flyable, Swimable {
let name: String
}

struct Goldfish: Animal, Swimable {
let name: String
}

这么写是不是看上去优雅多了。
如果想让某个动物可以飞并且具有飞的相关行为,只要让这个动物类去符合Flyable协议就行了,不需要往这个类里写任何代码,如果将来需求变动又不需要飞了,只要把Flyable协议去掉。
可能你说,我把Animal作为一个基类,把默认实现写在基类里,子类中覆盖基类的属性和方法来提供不同的实现。恩,这样写也行,这是面向对象编程的基本思路,但这样显然没有POP的方式简洁优雅,其次,如果在基类中增加了一个fly()的方法,那不会飞的动物也不得不获得了这个方法,显得有些冗余,所以对这些会飞的动物再写一个基类,增加一层继承,好,这个问题解决了,那么如果一个动物又有fly()方法又有swim()方法呢?
而使用Swift2.0的POP思路就能完美解决这些问题。

用Protocol Extentions来提供默认实现相比基类或抽象类有这些优势:

  1. 类只能继承一个类,而一个类型可以符合多个Protocol,可以同时被多个Protocol装饰上多种默认行为。
  2. Protocol可以被应用到类、结构体和枚举,而类继承只能在类中使用。或者说,Protocol Extensions能为值类型提供默认行为而不仅仅是类。
  3. Protocol Extentions不会给类型引进任何额外的状态,它是高度解耦的。

MVVM


关于MVVM这里就不赘述了,可以参考MVVM核心概念

面向协议的MVVM

我们用一个小DEMO来讲述如何实现面向协议的MVVM架构,这个小DEMO就是Xcode提供的Master-Detail模板,里面默认实现了一个TableView列表,点击右上角的加号可以向列表中添加当前日期,我们在这个项目模板的基础上删除无用的代码,来实践我们的POMVVM。

MVVM的数据绑定部分我们采用了GitHub上一个开源库SwiftBond,这个库的优势用法简单方便,支持iOS7,维护者更新较为频繁,目前已经是v4.2.0版本。另外RxSwift这个库也是一个很好的选择。

DEMO源码下载地址:
https://github.com/liuduoios/POMVVMDemo

建议大家阅读后面的内容之前先下载源码跑一下。

POP,一切从Protocol开始思考,用Protocol来提供最高级别的抽象。

MVVMBase

首先我们来抽象MVVM架构层面的东西

抽象出ViewModel

1
2
3
protocol ViewModel {

}

抽象出可绑定的视图,这里用了泛型协议

1
2
3
4
5
protocol BindableView {
typealias ViewModelType
var viewModel: ViewModelType! { get }
func bindViewModel(viewModel: ViewModelType)
}

抽象出可绑定的TableViewCell,简单继承于BindableView,为了增强抽象性和描述性

1
2
3
protocol BindableTableCell: BindableView {

}

业务抽象

到每个具体界面,首先考虑不是界面的具体实现,而是先抽象出业务逻辑。

对于主界面:
我们首先抽象出两个Protocol
数据源Protocol:

1
2
3
4
protocol MasterViewControllerDataSource {
var items: ObservableArray<Item> { get }
var openSwitchCount: Observable<Int> { get }
}

业务逻辑Protocol:

1
2
3
4
5
6
protocol MasterViewControllerBusinessDelegate {
/// 插入当前日期
func insertNowDate()
/// 更新打开开关的个数
func updateOpenSwitchCount()
}

对于主界面上的Cell:
抽象出它的数据源,并通过Protocol Extension给文字颜色提供一个默认的共享实现:

1
2
3
4
5
6
7
8
9
10
11
12
protocol DateCellDataSource {
var text: Observable<String?> { get }
var on: Observable<Bool> { get set }
var textColor: Observable<UIColor> { get }
}


extension DateCellDataSource {
var textColor: Observable<UIColor> {
return Observable(.greenColor())
}
}

业务逻辑Protocol:

1
2
3
4
protocol DateCellBusinessDelegate {
mutating func openSwitch()
mutating func closeSwitch()
}

对于Detail界面:

数据源Protocol:

1
2
3
4
protocol DetailViewControllerDataSource {
var on: Observable<Bool> { get set }
var text: Observable<String?> { get set }
}

业务逻辑Protocol:

1
2
3
4
protocol DetailViewControllerBusinessDelegate {
func openSwitch()
func closeSwitch()
}

可以发现Cell和Detail拥有相同的业务逻辑,我们后面会说这个问题。

ViewModel

实现主界面的ViewModel:

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
struct MasterViewModel: MasterViewControllerDataSource {
var items: ObservableArray<Item> = ObservableArray([Item]())
var openSwitchCount: Observable<Int> = Observable(0)
}

extension MasterViewModel: MasterViewControllerBusinessDelegate {
/// 插入当前日期
func insertNowDate() {
let item = Item(text: NSDate().description, on: false)
items.insert(item, atIndex: 0)
}

/// 更新打开开关的个数
func updateOpenSwitchCount() {
openSwitchCount.next(currentOpenSwitchCount())
}

/// 获取当前打开开关的个数
private func currentOpenSwitchCount() -> Int {
print(items.array)
let count = items.array.filter { $0.on.value }.count
return count
}
}

extension MasterViewModel: ViewModel {}

实现Cell的ViewModel:
这里我们为每一个Cell都创建一个ViewModel,把cell中的业务逻辑放到cell的ViewModel中,有效的减少了ViewController中的代码数量。

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
struct DateCellViewModel: DateCellDataSource {

// -------------------------
// MARK: - Public Properties
// -------------------------

private(set) var text: Observable<String?> = Observable(nil)
var on: Observable<Bool> = Observable(false)

// ------------------------
// MARK: - Underlying Model
// ------------------------

private var item: Item {
didSet {
configureBinding()
}
}

private func configureBinding() {
item.text.bidirectionalBindTo(text)
item.on.bidirectionalBindTo(on)
}

// -----------------
// MARK: - Lifecycle
// -----------------

init(item: Item) {
self.item = item
configureBinding()
}

}

extension DateCellViewModel: ViewModel {}

View/ViewController

主界面中的实现:
主界面MasterViewController,我们让它符合协议BindableView,然后来实现BindableView中的属性和方法。
首先对于泛型类型ViewModelType,把它实现成

1
typealias ViewModelType = protocol <MasterViewControllerDataSource, MasterViewControllerBusinessDelegate, ViewModel>

这样就不用关心它具体是哪个类型,只要是符合这些协议的类型都可以。

借助SwiftBond强大的数据绑定功能,对于UITableView和UICollectionView,我们不用再去实现繁琐的UITableViewDataSource中的方法,只要做一下绑定操作后,只要修改了数据(插入、修改、删除),TableView的界面会立刻随之更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var viewModel: ViewModelType!

func bindViewModel(viewModel: ViewModelType) {
viewModel.dates.lift().bindTo(tableView) { indexPath, dataSource, tableView in
let cell = tableView.dequeueReusableCellWithIdentifier("DateCell", forIndexPath: indexPath) as! DateCell
if let cellViewModel = self.viewModel.cellViewModelAtIndex(indexPath.row) {
cell.bindViewModel(cellViewModel)
}
return cell
}
}

func insertNewObject(sender: AnyObject) {
viewModel.insertNowDate()
}

可以看到这里我们都是用ViewModelType这个类型来调用接口,并不关心具体实现。

Cell中的实现:

1
2
3
4
5
6
7
8
9
10
11
class DateCell: UITableViewCell, BindableTableCell {
typealias ViewModelType = DateCellViewModel

var viewModel: ViewModelType?

func bindViewModel(viewModel: ViewModelType) {
self.viewModel = viewModel
viewModel.date.bindTo(textLabel!.bnd_text)
textLabel?.textColor = viewModel.textColor.value
}
}

现在基本的实现已经写好了,我们点击主界面右上角的加号时,就会往主界面的MasterViewModel中的items数组中添加一条当前日期的数据,随之界面会自动更新来展现出这条数据。

这里我们会遇到一个问题,如果每个Cell中都有一个开关,在改变开关的时候会去更新CellViewModel,那么这个更新如何同步到整个列表的ViewModel的Items数组中呢?
如果是用Objective-C实现,那么可能这个问题不是一个问题,但是我们用的是Swift实现,总所周知,Swift中的数组是值类型的,就是说,如果我们这样操作:

1
2
3
let array = ["a", "b", "c"]
var item = array[1]
item = "new"

从数组里通过下标去取一个值,然后改变这个值,这个改变是不会作用到原数组中的。

我们可以借助SwiftBond库提供的双向绑定功能来解决这个问题

1
2
3
4
5
6
7
8
9
10
11
// 把数据绑定到TableView上
viewModel.items.lift().bindTo(tableView) { indexPath, dataSource, tableView in
let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as! DateCell
let item = dataSource[indexPath.section][indexPath.row]

// 为每个cell绑定cellViewModel
let cellViewModel = DateCellViewModel(item: item)
cell.bindViewModel(cellViewModel)

return cell
}

这里通过下标从dataSource中取出了item,按道理说对item的属性进行赋值是不会改变viewModel的items数组中对应的item的值的。但是Swift对值类型的拷贝时机是有优化的,一般来说,拷贝操作会尽量推迟到真正需要拷贝的时候。所以我们可以在真正的拷贝发生前对其进行双向绑定,就能解决这个问题了。

这里在cell.bindViewModel中去进行双向绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func bindViewModel(var viewModel: ViewModelType) {
self.viewModel = viewModel

bnd_bag.dispose()

// ViewModel中关于text的属性单向绑定到label的相关属性上
viewModel.text.bindTo(label.bnd_text)
viewModel.textColor.bindTo(label.bnd_textColor)

// ViewModel的on和UISwitch的on双向绑定
viewModel.on.bidirectionalBindTo(cellSwitch.bnd_on)

cellSwitch.bnd_on
.distinct()
.observeNew { switchOn in
if switchOn {
viewModel.openSwitch()
} else {
viewModel.closeSwitch()
}
}.disposeIn(bnd_bag)
}

我们可以在cell中的开关切换状态时,去读取一下items数组中on为true的项目的个数,并且显示在tableHeaderView中,经测试是没有问题的。

然后,用同样的方法,在点击cell后进入详情时,同样也是从items数组中取出一个item,并用其创建一个DetailViewModel,传给DetailViewController,代码如下:

1
2
3
4
5
6
7
8
9
10
11
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "showDetail" {
if let indexPath = self.tableView.indexPathForSelectedRow {
let controller = segue.destinationViewController as! DetailViewController

// 取出viewModel.items中对应的Item,创建一个DetailViewModel
let detailViewModel = DetailViewModel(item: viewModel.items[indexPath.row])
controller.viewModel = detailViewModel
}
}
}

在DetailViewController中进行绑定操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func bindViewModel(viewModel: ViewModelType) {
// ViewModel的text和TextField的text双向绑定
viewModel.text.bidirectionalBindTo(detailTextField.bnd_text)
// ViewModel的text单项绑定到Label上
viewModel.text.bindTo(detailDescriptionLabel.bnd_text)

// ViewModel的on和UISwitch的on双向绑定
viewModel.on.bidirectionalBindTo(detailSwitch.bnd_on)

detailSwitch.bnd_on.observeNew { switchOn in
if switchOn {
viewModel.openSwitch()
} else {
viewModel.closeSwitch()
}
}
}

进入详情界面后,会发现对Switch进行开关,或者修改TextField中的内容,这些变化都会反馈到主列表界面中。

抽取公共逻辑

现在我们开始用上Protocol Extentions的特性。
可以看到在列表的cell和详情界面中,都有Switch关闭的操作,我们用这个来代表某个相同的业务逻辑,因此他们有着相同的实现。
通过Protocol Extensions,我们就可以不必在每个ViewModel里都分别去实现这个逻辑,只要对符合某个协议的Protocol进行扩展即可。

首先创建Switchable协议,符合这个协议的结构就是可以支持开关切换的。
它的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protocol Switchable {
var on: Observable<Bool> { get set }
mutating func openSwitch()
mutating func closeSwitch()
}

extension ViewModel where Self: Switchable {
func openSwitch() {
print("I have opened the switch.")
}

func closeSwitch() {
print("I have closed the switch.")
}
}

我们对ViewModel这个Protocol进行了扩展,凡是实现了Protocol协议,并且也实现了Switchable协议的结构,会自动获得openSwitch()和closeSwitch()这两个默认的实现。
然后把DateCellViewModel和DetailViewModel都去符合一下Switchable协议,这两个ViewModel就都获得了默认实现,不需要分别各自实现了。

DateCellViewModel:

1
extension DateCellViewModel: DateCellBusinessDelegate, Switchable {}

DetailViewModel:

1
extension DetailViewModel: DetailViewControllerBusinessDelegate, Switchable {}

这样POMVVM的结构就基本完成了。

总结

可以看到它相比普通的MVVM:

  1. 能提供更高的抽象。
  2. 面向接口编程实现解耦。
  3. 更符合Swift的写法,更加优雅。
  4. 可以用Protocol Extentions抽取公共的业务逻辑。
  5. 利用Protocol Extentions给类型添加/去除功能而不引进额外状态。

参考资料

本文源代码下载地址:
https://github.com/liuduoios/POMVVMDemo

参考资料:

  1. 【WWDC2015 Session 408】Protocol-Oriented Programming in Swift
  2. 【WWDC2015 Session 414】Building Better Apps with Value Types in Swift
  3. Swift 2.0: Protocol-Oriented MVVM
  4. Protocol-Oriented MVVM Slides