MVVM + ReactiveCoocoa 5
三个月前我写了自己的第一个应用,Memori, 同时,我也开始使用MVC的架构模式,但是随着功能的增加,C层越来越臃肿。
MVC的缺陷
总得来说就是业务代码放在viewcontroller中,会导致C层太大,同时,view层有和C层太紧密,导致我们要考虑太多的视图相关的问题。Viewcontroller还要管理起自己的生命周期,有时还要管理其他的控制器的,所以,MVC又被戏称"Massive View Controller".
MVC 的另一个选择 MVVM
除了MVC外,其实还有很多的架构可以选择,包括MVVM。在MVVM里UIViewcontroller现在是View中的一部分,然后,又引入了一个概念叫做ViewModel来填补View和Model之间的空隙。
MVVM 和 ReactiveCoocoa配合会产生非常好的效果,当也有其他的选择,包括SwiftBond,RxSwift。他们都非常的类似。下面的代码用的是RAC:版本5.
下面主要通过一些简单的例子来讲MVVM
例子
在Memori的第一屏中有个label,负责显示用户当前的信息填写的完成程度,现在的需求是,当用户把collectionview中的某个item删除时,label的值也应该随之改变。
struct Book {
let name: String
let cardCount: Int
let progress: Float
...
}
class BookStore {
let books = MutableProperty([Book]())
...
}
MutableProperty
来自ReactiveSwift,你可以把它视为一个可以监听或者绑定其他属性的地方。
ViewModel
class ViewModel {
let currentProgress = MutableProperty("")
init(withBookStore bookStore: BookStore) {
// Each time 'books' is updated on the store, 'currentProgress' is updated with the computed value
currentProgress <~ bookStore.books.map { books in
let progress = computeCurrentProgress(fromBooks: books)
return "\(progress*100)% KNOWN"
}
}
...
}
<~
是RAC中的一个用来绑定target和signal的重载符。MutableProperty
既是target又是signal,所以我们可以把它绑定到BookStore.books
和ViewModel.currentProgress
。 map(transform)
方法可以帮我们把boos列表转化成字符串。
View
class ViewController: UIViewController {
// Injected
var viewModel: ViewModel!
// Injected
@IBOutlet var progressLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
// Uses ReactiveCocoa extensions to bind the text
// of the UILabel to the Property in the ViewModel
progressLabel.reactive.text <~ viewModel.currentProgress
}
...
}
RAC 在绝大数UIView中添加了reactive
部分,来让我们直接把文本绑定到UILabel
上去。
代码写完
就这样,通过更改在model中的books
属性,它就会自动更改view上的label了。
例如,用户删除了tableview中的某一行,view就会通知viewmodel,然后viewmodel通知model,修改了books的属性。
在用MVVM的时候,有几条规则:
- viewmodel绝不使用UIKit。这条规则是非常重要的,它把viewmodel从view中分离了出来。如果你谨遵这条规则,你甚至可以在iOS 和 macOS的应用里用相同的viewmodel。
- view也和model不直接产生关系。 viewmodel负责把model的数据暴露给view,view不应该关心数据怎样产生。
- model和app剩余的部分完全没有联系。
在写代码时,这些规则应该被强制实施,尤其是在大型的项目中。
Collections
按照上面思路使用UIViewController还好,但是使用UITableView
或者UICollectionVie
时就会产生问题了,该谁来负责做DataSource?由于cell是懒加载的,并且是复用的,那应该怎么把viewmodel绑定到cell上呢?
datasource应该是由ViewModel提供,在这种情况下的最好的处理方式是:由viewmodel给每一个cell提供一个单独的viemodel,这样的话,我们又有了两个新的规则:
- 只有view才可以创建view。
-
只有viewmodel才可以创建viewmodel(在app启动时除外)
这样的分离概念是非常好的:viewmodel部分没有关联到view,他只知道怎么创建一个cell的viemodel,你可以不改任何代码的在tableview和collectionview之间切换。
相关代码如下:
ViewModel
class ViewModel {
private var books: [Book]
func getBookCount() -> Int {
return books.count
}
func createCellViewModel(forIndex index: Int) -> CellViewModel {
return CellViewModel(withBook: books[index]!)
}
}
struct CellViewModel {
let name: String
let cardCount: String
init(withBook book: Book) {
self.name = book.name
self.cardCount = String(book.cardCount)
}
}
这里没有使用MutableProperty
,因为绑定会带来不必要的性能损耗。
View
class ViewController: UIViewController, UITableViewDataSource {
...
func tableView(...numberOfRowsInSection section: Int) -> Int {
return viewModel.getBookCount()
}
func tableView(...cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// The View creates the View
let cell = tableView.dequeueReusableCell(withIdentifier: "BookCell")
as! TableViewCell
// The ViewModel creates the ViewModel
cell.viewModel = viewModel.getBookViewModel(atIndex: indexPath.row)
return cell
}
}
class TableViewCell: UITableViewCell {
@IBOutlet var nameLabel: UILabel!
@IBOutlet var cardCountLabel: UILabel!
var viewModel: CellViewModel! {
didSet {
nameLabel.text = viewModel.name
nameLabel.cardCount = viewModel.cardCount
}
}
}
基本上完成了,现在还有一个问题,view怎么知道什么时候reload呢?最基本的方法就是在viewmodel中创建一个Signal
,当books 更新时,信号就会发出信息,然后view就知道要reload了。
然而,这样的信息并不足够,在MVVM里,view并不可以获取model,所以他亦不知道数据的增删改查,解决这个问题,我使用了一个第三方库Changeset,他可以算出两数据的差别,同时也做了对tableview和collectionview的扩展,这样可以直接使用。
理想的状态是无论何时viewmodel更改了books的属性,Changeset就会计算出数据的变化(edits),并发送到view上。然后可以产生合适的动画。
// ViewModel
class ViewModel {
let booksChangeset = MutableProperty([Edit]())
private var books: [Book] {
didSet {
booksChangeset.value = Changeset.edits(
from: oldValue,
to: books)
}
}
func deleteBook(at index: Int) {
books.remove(at: index)
}
}
// View
class ViewController: UIViewController {
override func viewDidLoad() {
...
viewModel.booksChangeset.producer
.startWithValues { edits in
self.tableView.update(with: edits)
}
}
}
}
如你所见,这很简单,并且是个非常强大的模式:viewmodel与view解耦,但是books属性变化时,tableview又可以自动通过合适的动画变化,你也可以轻松的进行单元测试。
导航
导航部分的话我们一般这么做:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "AddBook",
let viewController = segue.destination as? AddBookNameViewController {
viewController.viewModel = viewModel.getAddBookViewModel()
}
}
但是有个关于解耦view和viewmodel的有趣的地方。下面的例子是一个关于创建book的流程:
我们需要两个
UIViewController
来对应两个不同的场景。但是添加一个book
一个流程,所以我们可以把一个viewmodelAddBoolViewModel
用在两个地方。在第一个vc中设置viewmodel的name,然后把viewmodel传递到第二个vc。
来源: @JoanZap