掌握 iOS 中的 MVVM 模式

1,785 阅读4分钟

本文翻译自 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 之类的问题,同时让代码变得可控、容易被单元测试覆盖。