- 原文地址:How not to get desperate with MVVM implementation
- 原文做者:S.T.Huang
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:JayZhaoBoy
- 校对者:swants,ryouaki
让咱们想象一下,你有一个小项目,一般在短短两天内你就能够提供新的功能。而后你的项目变得愈来愈大。完成日期开始变得没法控制,从2天到1周,而后是2周。它会把你逼疯!你会不断抱怨:一件好产品不该该那么复杂!然而这正是我所面对过的,对我来讲那确实是一段糟糕的经历。如今,在这个领域工做了几年,与许多优秀的工程师合做过,让我真正意识到使代码变得如此复杂的并非产品设计,而是我。前端
咱们都有过由于编写面条式代码而损害咱们项目的经历。问题是咱们该如何去修复它?一个好的架构模式可能会帮到你。在这篇文章中,咱们将要谈论一个好的架构模式:Model-View-ViewModel (MVVM)。MVVM 是一种专一于将用户界面开发与业务逻辑开发实现分离的 iOS 架构趋势。android
「好架构」这个词听起来太抽象了。你会感到无从下手。这里有一点建议:不要把重点放在体系结构的定义上,咱们能够把重点放在如何提升代码的可测试性上。现现在有不少软件架构,好比 MVC、MVP、MVVM、VIPER。很明显,咱们可能没法掌握全部这些架构。可是,咱们要记住一个简单的原则:无论咱们决定使用什么样的架构,最终的目标都是使测试变得更简单。所以写代码以前咱们要根据这一原则进行思考。咱们强调如何直观的进行责任分离。此外,保持这种思惟模式,架构的设计就会变得很清晰、合理,咱们就不会再陷入琐碎的细节。ios
在这篇文章中,你将学到:git
你不会看到:github
全部这些架构都有优势和缺点,但都是为了使代码变得更简单更清晰。因此咱们决定把重点放在为何咱们选择 MVVM 而不是 MVC,以及咱们如何从 MVC 转到 MVVM。若是您对 MVVM 的缺点有什么观点,请参阅本文最后的讨论。编程
让咱们开始吧!swift
MVC (Model-View-Controller) 是苹果推荐的架构模式。定义以及 MVC 中对象之间的交互以下图所示:后端
在 iOS/MacOS 的开发中,因为引入了 ViewController,一般会变成:api
ViewController 包含 View 和 Model。问题是咱们一般都会在 ViewController 中编写控制器代码和视图层代码。它使 ViewController 变得太复杂。这就是为何咱们把它称为 Massive View Controller(臃肿的视图控制)。在为 ViewController 编写测试的同时,你须要模拟视图及其生命周期。但视图很难被模拟。若是咱们只想测试控制器逻辑,咱们实际上并不想模拟视图。全部这些都使得编写测试变得如此复杂。数组
因此 MVVM 来拯救你了。
MVVM 是由 John Gossman 在 2005 年提出的。MVVM 的主要目的是将数据状态从 View 移动到 ViewModel。MVVM 中的数据传递以下图所示:
根据定义,View 只包含视觉元素。在视图中,咱们只作布局、动画、初始化 UI 组件等等。View 和 Model 之间有一个称为 ViewModel 的特殊层。ViewModel 是 View 的标准表示。也就是说,ViewModel 提供了一组接口,每一个接口表明 View 中的 UI 组件。咱们使用一种称为「绑定」的技术将 UI 组件链接到 ViewModel 接口。所以,在 MVVM 中,咱们不直接操做 View,而是经过处理 ViewModel 中的业务逻辑从而使视图也相应地改变。咱们会在 ViewModel 而不是 View 中编写一些显示性的东西,例如将 Date 转换为 String。所以,没必要知道 View 的实现就能够为显示的逻辑编写一个简单的测试。
让咱们回过头再看看上面的图。一般状况下,ViewModel 从 View 接收用户交互,从 Model 中提取数据,而后将数据处理为一组即将显示的相关属性。在 ViewModel 变化后,View 就会自动更新。这就是 MVVM 的所有内容。
具体来讲,对于 iOS 开发中的 MVVM,UIView/UIViewController 表示 View。咱们只作:
另外一方面,在 ViewModel 中,咱们作:
你可能会注意到这样 ViewModel 会变得有点复杂。在本文的最后,咱们将讨论 MVVM 的缺点。但不管如何,对于一个中等规模的项目来讲,想一点一点完成目标,MVVM 仍然是一个很棒的选择。
在接下来的部分,咱们将使用 MVC 模式编写一个简单的应用程序,而后描述如何将应用程序重构为 MVVM 模式。带有单元测试的示例项目能够在个人 GitHub 上找到:
让咱们开始吧!
咱们将编写一个简单的应用程序,其中:
在这个应用程序中,咱们有一个名为 Photo 的结构,它表明一张照片。下面是咱们的 Photo 类:
struct Photo {
let id: Int
let name: String
let description: String?
let created_at: Date
let image_url: String
let for_sale: Bool
let camera: String?
}
复制代码
该应用程序的初始视图控制器是一个包含名为 PhotoListViewController 的 tableView 的 UIViewController。咱们经过 PhotoListViewController 中的 APIService获取Photo 对象,并在获取照片后从新载入 tableView:
self?.activityIndicator.startAnimating()
self.tableView.alpha = 0.0
apiService.fetchPopularPhoto { [weak self] (success, photos, error) in
DispatchQueue.main.async {
self?.photos = photos
self?.activityIndicator.stopAnimating()
self?.tableView.alpha = 1.0
self?.tableView.reloadData()
}
}
复制代码
PhotoListViewController 也是 tableView 的一个数据源:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// ....................
let photo = self.photos[indexPath.row]
//Wrap the date
let dateFormateer = DateFormatter()
dateFormateer.dateFormat = "yyyy-MM-dd"
cell.dateLabel.text = dateFormateer.string(from: photo.created_at)
//.....................
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.photos.count
}
复制代码
在 func tableView(_ tableView:UITableView,cellForRowAt indexPath:IndexPath) - > UITableViewCell 中,咱们选择相应的 Photo 对象并将标题、描述和日期分配给一个 cell。因为 Photo.date 是一个 Date 对象,咱们必须使用 DateFormatter 将其转换为一个 String。
如下代码是 tableView 委托的实现:
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
let photo = self.photos[indexPath.row]
if photo.for_sale { // If item is for sale
self.selectedIndexPath = indexPath
return indexPath
}else { // If item is not for sale
let alert = UIAlertController(title: "Not for sale", message: "This item is not for sale", preferredStyle: .alert)
alert.addAction( UIAlertAction(title: "Ok", style: .cancel, handler: nil))
self.present(alert, animated: true, completion: nil)
return nil
}
}
复制代码
咱们在 func tableView(_ tableView:UITableView,willSelectRowAt indexPath:IndexPath) - > IndexPath 中选择相应的 Photo 对象,检查 for_sale 属性。若是是 ture,就保存到 selectedIndexPath。若是是 false,则显示错误消息并返回 nil。
PhotoListViewController 的源码在这里,请参考标签「MVC」。
那么上面的代码有什么问题呢?在 PhotoListViewController 中,咱们能够找到显示的逻辑,如将 Date 转换为 String 以及什么时候启动/中止活动指示符。咱们也有 Veiw 层代码,如显示/隐藏 tableView。另外,在视图控制器中还有另外一个依赖项 ,API 服务。若是你打算为PhotoListViewController编写测试,你会发现你被卡住了,由于它太复杂了。咱们必须模拟 APIService,模拟 tableView 以及 cell 来测试整个 PhotoListViewController。唷!
记住,咱们想让测试变得更容易?让咱们试试 MVVM 的方法!
为了解决这个问题,咱们的首要任务是整理视图控制器,将视图控制器分红两部分:View 和 ViewModel。具体来讲,咱们要:
首先,咱们来看看 View 中的 UI 组件:
因此咱们能够将 UI 组件抽象为一组规范化的表示:
每一个 UI 组件在 ViewModel 中都有相应的属性。能够说咱们在 View 中看到的应该和咱们在 ViewModel 中看到的同样。
可是咱们该如何绑定呢?
在 Swift 中,有不少方式来实现「绑定」:
使用 KVO 模式是个不错的注意, 但它可能会建立大量的委托方法,咱们必须当心 addObserver/removeObserver,这可能会成为 View 的一个负担。理想的方法是使用 FRP 中的绑定方案。若是你熟悉函数式响应编程,那就放手去作吧!若是不熟悉的话,那么我不建议使用 FRP 来实现绑定,这样子就太大材小用了。Here 是一个很好的文章,谈论使用装饰模式来本身实现绑定。在这篇文章中,咱们将把事情简单化。咱们使用闭包来实现绑定。实际上,在 ViewModel 中,绑定接口/属性以下所示:
var prop: T {
didSet {
self.propChanged?()
}
}
复制代码
另外一方面,在 View 中,咱们为 propChanged 指定一个做为值更新回调的闭包。
// When Prop changed, do something in the closure
viewModel.propChanged = { in
DispatchQueue.main.async {
// Do something to update view
}
}
复制代码
每次属性 prop 更新时,都会调用 propChanged。因此咱们就能够根据 ViewModel 的变化来更新 View。很简单,对吗?
如今,让咱们开始设计咱们的 ViewModel,PhotoListViewModel。给定如下三个UI组件:
咱们在 PhotoListViewModel 中建立绑定的接口/属性:
private var cellViewModels: [PhotoListCellViewModel] = [PhotoListCellViewModel]() {
didSet {
self.reloadTableViewClosure?()
}
}
var numberOfCells: Int {
return cellViewModels.count
}
func getCellViewModel( at indexPath: IndexPath ) -> PhotoListCellViewModel
var isLoading: Bool = false {
didSet {
self.updateLoadingStatus?()
}
}
复制代码
每一个 PhotoListCellViewModel 对象在 tableView 中造成一个规范显示的 cell。它提供了用于渲染 UITableView cell 的数据接口。咱们把全部的 PhotoListCellViewModel 对象放入一个数组 cellViewModels 中,cell 的数量刚好是该数组中的项目数。咱们能够说数组 cellViewModels 表示 tableView。一旦咱们更新 ViewModel 中的 cellViewModels,闭包 reloadTableViewClosure 将被调用而且 View 将进行相应地更新。
一个简单的 PhotoListCellViewModel 以下所示:
struct PhotoListCellViewModel {
let titleText: String
let descText: String
let imageUrl: String
let dateText: String
}
复制代码
正如你所看到的,PhotoListCellViewModel 提供了绑定到 View 中的 UI 组件接口的属性。
有了绑定的接口,如今咱们将重点放在 View 部分。首先,在 PhotoListViewController 中,咱们初始化 viewDidLoad 中的回调闭包:
viewModel.updateLoadingStatus = { [weak self] () in
DispatchQueue.main.async {
let isLoading = self?.viewModel.isLoading ?? false
if isLoading {
self?.activityIndicator.startAnimating()
self?.tableView.alpha = 0.0
}else {
self?.activityIndicator.stopAnimating()
self?.tableView.alpha = 1.0
}
}
}
viewModel.reloadTableViewClosure = { [weak self] () in
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
复制代码
而后咱们要重构数据源。在 MVC 模式中,咱们在 func tableView(_ tableView:UITableView,cellForRowAt indexPath:IndexPath) - > UITableViewCell 中设置了显示逻辑,如今咱们必须将显示逻辑移动到 ViewModel。重构的数据源以下所示:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "photoCellIdentifier", for: indexPath) as? PhotoListTableViewCell else { fatalError("Cell not exists in storyboard")}
let cellVM = viewModel.getCellViewModel( at: indexPath )
cell.nameLabel.text = cellVM.titleText
cell.descriptionLabel.text = cellVM.descText
cell.mainImageView?.sd_setImage(with: URL( string: cellVM.imageUrl ), completed: nil)
cell.dateLabel.text = cellVM.dateText
return cell
}
复制代码
数据流如今变成:
以下图所示:
咱们来看看用户交互。在 PhotoListViewModel 中,咱们建立一个函数:
func userPressed( at indexPath: IndexPath )
复制代码
当用户点击单个 cell 时,PhotoListViewController 使用此函数通知 PhotoListViewModel。因此咱们能够在 PhotoListViewController 中重构委托方法:
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
self.viewModel.userPressed(at: indexPath)
if viewModel.isAllowSegue {
return indexPath
}else {
return nil
}
}
复制代码
这意味着一旦 func tableView(_ tableView:UITableView,willSelectRowAt indexPath:IndexPath) - > IndexPath 被调用,则该操做将被传递给 PhotoListViewModel。委托函数根据由 PhotoListViewModel 提供的 isAllowSegue 属性决定是否继续。咱们就成功地从视图中删除了状态。🍻
这是一个漫长的过程,对吧?耐心点,咱们已经触及到了 MVVM 的核心! 在 PhotoListViewModel 中,咱们有一个名为 cellViewModels 的数组,它表示 View 中的 tableView。
private var cellViewModels: [PhotoListCellViewModel] = [PhotoListCellViewModel]()
复制代码
咱们如何获取并排列数据呢?实际上咱们在 ViewModel 的初始化中作了两件事:
init( apiService: APIServiceProtocol ) {
self.apiService = apiService
initFetch()
}
func initFetch() {
self.isLoading = true
apiService.fetchPopularPhoto { [weak self] (success, photos, error) in
self?.processFetchedPhoto(photos: photos)
self?.isLoading = false
}
}
复制代码
在上面的代码片断中,咱们将属性 isLoading 设置为 true,而后开始从 APIService 中获取数据。因为咱们以前所作的绑定,将 isLoading 设置为 true 意味着视图将切换活动指示器。在 APIService 的回调闭包中,咱们处理提取的照片 models 并将 isLoading 设置为 false。咱们不须要直接操做 UI 组件,但很显然,当咱们改变 ViewModel 的这些属性时,UI 组件就会像咱们所指望的那样工做。
这里是 processFetchedPhoto( photos: [Photo] ) 的实现:
private func processFetchedPhoto( photos: [Photo] ) {
self.photos = photos // Cache
var vms = [PhotoListCellViewModel]()
for photo in photos {
vms.append( createCellViewModel(photo: photo) )
}
self.cellViewModels = vms
}
复制代码
它作了一个简单的工做,将照片 models 装成一个 PhotoListCellViewModel 数组。当更新 cellViewModels 属性时,View 中的 tableView 会相应的更新。
耶,咱们完成了 MVVM 🎉
示例应用程序能够在个人 GitHub 上找到:
若是你想查看 MVC 版本(标签:MVC),而后 MVVM(最新的提交)
在本文中,咱们成功地将一个简单的应用程序从 MVC 模式转换为 MVVM 模式。咱们:
正如我上面提到的,架构都有优势和缺点。在阅读个人文章以后,若是你对 MVVM 的缺点有一些见解。这里有不少关于 MVVM 缺点的文章,好比:
MVVM is Not Very Good — Soroush Khanlou The Problems with MVVM on iOS — Daniel Hall
我最关心的是 MVVM 中 ViewModel 作了太多的事情。正如我在本文中提到的,咱们在 ViewModel 中有控制器和演示器。此外,MVVM 模式中不包括构建器和路由器。咱们一般把构建器和路由器放在 ViewController 中。若是你对更清晰的解决方案感兴趣,能够了解 MVVM + FlowController (Improve your iOS Architecture with FlowControllers) 和两个着名的架构,VIPER 和 Clean by Uncle Bob.
总会存在更好的解决方案。做为专业的工程师,咱们一直在学习如何提升代码质量。许多像我同样的开发者曾经被这么多架构所淹没,不知道如何开始编写单元测试。因此 MVVM 是一个很好的开始。很简单,可测试性仍是很不错的。在另外一篇 Soroush Khanlou 的文章中,8 Patterns to Help You Destroy Massive View Controller,这里有有不少好的模式,其中一些也被MVVM所采用。与其受一个巨大的架构所阻碍,咱们何不开始用小而强大的 MVVM 模式开始编写测试呢?
“The secret to getting ahead is getting started.” — Mark Twain
在下一篇文章中,我将继续谈谈如何为咱们简单的画廊应用程序编写单元测试。敬请关注!
若是你有任何问题,留下评论。欢迎任何形式的讨论!感谢您的关注。
Introduction to Model/View/ViewModel pattern for building WPF apps — John Gossman Introduction to MVVM — objc iOS Architecture Patterns — Bohdan Orlov Model-View-ViewModel with swift — SwiftyJimmy Swift Tutorial: An Introduction to the MVVM Design Pattern — DINO BARTOŠAK MVVM — Writing a Testable Presentation Layer with MVVM — Brent Edwards Bindings, Generics, Swift and MVVM — Srdan Rasic
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。