本文翻译自 Mastering MVVM on iOS
网络上有许多关于 iOS 开发中所使用的 app 架构的讨论文章。今天我会展示一些在开发 iOS app 时使用 MVVM 架构的技巧。本文不会介绍除 MVVM 之外的其它架构,如果你有兴趣,可以参考 这篇文章。Apple MVC 架构的主要问题在于某些组件需要承担多种责任,这会导致出现类似 Massive-View-Controller 之类的一些问题。
为什么要使用 MVVM?
在 Apple 的 iOS SDK 中,UIViewController 是一个主要的组件,所有的动作都是由他启动和构建的。实际上它有点名不副实,它更像是 MVC 中的 View 而不是 Controller。在 UIViewController 里包含了许多类似于 viewDidLoad, viewWillLayoutSubviews 之类的 View 相关的回调函数,这就是为什么我们不应该把它当做是 Controller 而应该把它认为是 View 的原因,而 UIViewController 中的 Controller 的部分,其实是一个 ViewController。
ViewModel 是 View 的完整数据表示。每个 View 都应该持有 仅一个 ViewModel 实例。通常来说 ViewModel 会通过一个 manager 来拉取信息,随后将其转换为必要的格式以供展示。请看以下示例:
import Foundation
class ItemsViewModel {
var items: [Item] = []
var error: Error?
var refreshing = false
private let dataManager: DataManager
init(dataManager: DataManager) {
self.dataManager = dataManager
}
func fetch(completion: @escaping () -> Void) {
refreshing = true
dataManager.fetchItems { [weak self] (items, error) in
self?.items = items ?? []
self?.error = error
self?.refreshing = false
completion()
}
}
}
以上例子中我们的 ItemsViewModel 通过 DataManager 来拉去数据,并将其保存到内部变量当中。它里面也包含了一个错误码,以及刷新状态,我们可以根据这些条件来构建出合适的 UI 界面。
import UIKit
class ItemsViewController: UIViewController {
@IBOutlet private weak var tableView: UITableView!
private var viewModel: ItemsViewModel
init(viewModel: ItemsViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
viewModel.fetch { [weak self] in
self?.tableView.reloadData()
}
}
}
extension ItemsViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.items.count
}
}
以上的代码中,我们使用 ItemsViewController 中展示 UITableView 中的一个 item 列表。它持有一个 ViewModel 实例,并在 viewDidLoad 回调中通过这个实例来拉取数据。同时我们向 ItemsViewModel.fetch 中传入了一个 closure,拉取数据结束后会在这个 closure 中刷新界面。此外 ItemsViewController 还实现了 UITableViewDataSource 协议,它会从 ViewModel 中获取数量。
响应式绑定(Reactive Bindings)
如何绑定 View 和 ViewModel 是 MVVM 架构的主要思想,在 MVVM 架构下,开发者聚焦于实现 ViewModel,而设计师可以在 Interface Designer 中实现 View。在之前的例子中,我们使用 closures 来完成这种绑定,因为 iOS SDK 中没有提供开箱即用的绑定能力。在实际的 app 开发中,你可以使用许多流行的 FRP 扩展,比如 ReactiveCocoa, RxSwift 或者 Bond,我一般使用 Bond,因为它很简单:
import Bond
class ItemsViewModel {
let items = Observable<[Item]>([])
let error = Observable<Error?>(nil)
let refreshing = Observable<Bool>(false)
private let dataManager: DataManager
init(dataManager: DataManager) {
self.dataManager = dataManager
}
func fetch() {
refreshing.value = false
dataManager.fetchItems { [weak self] (items, error) in
self?.items.value = items ?? []
self?.error.value = error
self?.refreshing.value = false
}
}
}
以上是我们使用 Bond 重新编写的 ItemsViewModel。它使用了响应式编程的思路来监听变化。每当数据发生变化,items, error, refreshing 这些 Observable 对象就会发出事件告知外部。
class ItemsViewController: UIViewController {
@IBOutlet private weak var tableView: UITableView!
private let activityIndicator = ActivityIndicatorView()
private var viewModel: ItemsViewModel
init(viewModel: ItemsViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bindViewModel()
viewModel.fetch()
}
func bindViewModel() {
viewModel.refreshing.bind(to: activityIndicator.reactive.isAnimating)
viewModel.items.bind(to: self) { strongSelf, _ in
strongSelf.tableView.reloadData()
}
}
func setupUI() {
view.addSubview(activityIndicator)
}
}
extension ItemsViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.items.value.count
}
}
以上是重写后的 ItemsViewController,重点关注其中的 bindViewModel 方法,在这个方法中我们 ViewModel 和 View 绑定了起来。一旦 refreshing, items 等 Observable 对象发生了变化,他就会设置 ActivityIndicatorView 的 isAnimationg 属性,或者触发 tableView 的刷新。这比使用 closure 来绑定而言更加易读。
ViewModel 的组合
有时候我们的 View 很复杂,需要对多个数据源进行响应。比如 Instagram 的用户中心界面里,即需要知道用户的信息,也需要知道这个用户所关联的所有图片。对于这种业务而言,比较好的处理方式是把这两种逻辑拆分到两个 ViewModel 上。但我们有一个规则:每个 View 都只能有一个 ViewModel。对于这种情况,最好的选择是把 ViewModel 组合起来:
import Bond
import ReactiveKit
class UserProfileViewModel {
let refreshing = Observable<Bool>(false)
let username = Observable<String>("")
let photos = Observable<[Photos]>([])
private let userViewModel: UserViewModel
private let photosViewModel: PhotosViewModel
init(userManager: UserManager, photoManager: PhotoManager) {
userViewModel = UserViewModel(manager: userManager)
photosViewModel = PhotosViewModel(manager: photoManager)
userViewModel.username.bind(to: username)
photosViewModel.photos.bind(to: photos)
combineLatest(userViewModel.refreshing, photosViewModel.refreshing)
.map { $0 || $1 }
.bind(to: refreshing)
}
func fetch() {
userViewModel.fetch()
photosViewModel.fetch()
}
}
class UserViewModel {
let refreshing = Observable<Bool>(false)
let username = Observable<String>("")
func fetch() {
refreshing.value = true
manager.fetch(user: id) { [weak self] (user, error) in
self?.username.value = "@" + user.username
self?.refreshing.value = false
}
}
}
class PhotosViewModel {
let refreshing = Observable<Bool>(false)
let photos = Observable<[Photo]>([])
func fetch() {
refreshing.value = true
manager.fetch(for user: id) { [weak self] (photos, error) in
self?.photos.value = photos ?? []
self?.refreshing.value = false
}
}
}
以上示例中,我们使用 UserProfileViewModel 来持有另外两个 ViewModel,并由它来统一拉取数据。此外,UserProfileViewModel 会根据两个 ViewModel 的刷新状态来给出总体的刷新状态。
总结
ViewModel 可以很方便地把展示逻辑拆分到不同的实体当中,从而避免 Massive-View-Controller 之类的问题,同时让代码变得可控、容易被单元测试覆盖。