【译】iOS MVVM 指南 - 重构 MVC

820 阅读14分钟

原文地址

资料下载

在本 iOS 教程中,您将学习如何将 MVC 应用程序转换为 MVVM。此外,您将了解使用 MVVM 的组件和优点。作者:Chuck Krutinger。

MVVM(Model-View-ViewModel) 是一种设计模式,最近几年在iOS开发社区中获得了吸引力。它涉及一个新概念:ViewModel。在 iOS 应用程序中,ViewModel 是 ViewController 的配套对象。

MVVM-Diagram-228x500.png

如上所示,MVVM 模式包含三层:

  • Model:应用操作的数据;
  • View:用户界面,在 iOS 中 ViewController 与 View 密不可分;
  • ViewModel:View(s) 输入数据时更新 Model,Model 输出时更新 View(s)

MVC(Model-View-Controller) 相比,MVVM 具有以下优势:

  • 降低复杂度:MVVM 模式下移除了 ViewController 很多业务逻辑,使其更简洁;
  • 表达性:ViewModel 更好表达 View 的业务逻辑;
  • 可测试性:ViewModel 比 ViewController 更容易测试,你只需要测试业务逻辑而无需关心 View 的实现;

在这篇指南中,你会重构一个天气类 App,将其架构从 MVC 迁移至 MVVM。首先你将 viewController 中所有 weather 和 location 相关的逻辑迁移至 viewModel,然后通过为 viewModel 编写单元测试,你会了解如何轻而易举地将测试集成到新的 viewModel。

指南最后,你的 App 可以通过名称选择任意地点查看该地点的天气概况。

开始

点击指南开始或最后的资料下载,下载项目资料,然后打开工程。

该 App 从 eatherbit.io 请求最新的天气信息,并显示天气摘要。

你需要注册 Weatherbit 账户后,通过免费的 API Key 来使用 Weatherbit API,将申请的 Weatherbit API Key 添加到工程中便可运行项目。前往 www.weatherbit.io/account/cre… 注册。

获取到 API Key 后,回到 Xcode。

打开 Services 目录下的 WeatherbitService.swift,将 apiKey 的值替换。

Screen-Shot-2020-04-01-at-6.44.14-PM.png

编译运行:

Simulator-Screen-Shot-iPhone-8-2019-12-21-at-22.36.20-281x500.png

可以看到 McGaheysville, VA 的天气以及今天的日期。

MVVM 角色和职责介绍

重构之前,你必须了解 MVVM 模式下 ViewModel 和 View 的作用。

ViewController 只负责更改 View(s) 并将 View 的输入传给 ViewModel,所以你需要把其他逻辑从 ViewController 中移除并放到 ViewModel 中。

相反,ViewModel 有如下职责:

  • 数据输入:拿到 View 的输入并更新 Model;
  • 数据输出:将数据输出到 ViewController;
  • 格式化:格式化数据以便 ViewController 展示。

熟悉现有 App 架构

注意:本章节是可选的,主要是回顾 App 现有架构,如果你已经对 MVC 的 ViewController 比较熟悉,想直接开始重构,可以跳到通过 Box 进行数据绑定

熟悉该 App 当前的 MVC 架构设计,首先打开项目导航,如下所示:

Screen-Shot-2020-04-01-at-7.24.52-PM.png

Controllers 目录下可以找到 WeatherviewController.swift,这是你将要移除所有数据和服务用途而进行重构的 ViewController。

Models 目录下,可以看到两个不同的数据对象:WeatherbitDataLocationWeatherbitData 是一个结构体,用来表示 Weatherbit API 返回的数据。Location 是 Apple 的 CLLocation 服务返回的位置数据的简化结构。

Services 目录包含 WeatherbitService.swiftLocationGeocoder.swift,见名知意,WeatherbitService 从 Weatherbit API 请求天气数据,LocationGeocoder 将字符串转为 Location。

Storyboard 包含 LaunchScreenWeather 故事板。

UtilitiesView Models 都是空的,你将在重构时添加文件。

WeatherViewController

重构主要集中在 WeatherViewController,要了解 WeatherViewController,先查看其私有属性。

// 1
private let geocoder = LocationGeocoder()
// 2
private let defaultAddress = "McGaheysville, VA"
// 3
private let dateFormatter: DateFormatter = {
  let dateFormatter = DateFormatter()
  dateFormatter.dateFormat = "EEEE, MMM d"
  return dateFormatter
}()
// 4
private let tempFormatter: NumberFormatter = {
  let tempFormatter = NumberFormatter()
  tempFormatter.numberStyle = .none
  return tempFormatter
}()
  1. geocoder 获取一个 String 参数比如 Washington DC,将其转换为经纬度,并发送至 weather 服务。
  2. defaultAddress 设置一组默认地址。
  3. DateFormatter 格式化日期以显示。
  4. NumberFormatter 将温度处理为整数并显示。

现在看一下 viewDidLoad()

override func viewDidLoad() {
  geocoder.geocode(addressString: defaultAddress) { [weak self] locations in
    guard 
      let self = self,
      let location = locations.first 
      else { 
        return 
      }
    self.cityLabel.text = location.name
    self.fetchWeatherForLocation(location)
  }
}

viewDidLoad() 调用 geocoderdefaultAdress 转换为 Location,回调使用返回的位置信息填充 cityLabel 的文本,接着将位置信息传递到 fetchWeatherForLocation(_:)

WeatherViewController 的最后一部分是 fetchWeatherForLocation(_:)

func fetchWeatherForLocation(_ location: Location) {
  //1
  WeatherbitService.weatherDataForLocation(
    latitude: location.latitude,
    longitude: location.longitude) { [weak self] (weatherData, error) in
    //2
    guard 
      let self = self,
      let weatherData = weatherData 
      else { 
        return 
      }
    self.dateLabel.text =
      self.dateFormatter.string(from: weatherData.date)
    self.currentIcon.image = UIImage(named: weatherData.iconName)
    let temp = self.tempFormatter.string(
      from: weatherData.currentTemp as NSNumber) ?? ""
    self.currentSummaryLabel.text =
      "(weatherData.description) - (temp)℉"
    self.forecastSummary.text = "\nSummary: (weatherData.description)"
  }
}

这个方法只做两件事:

  1. 调用 weather 服务传递经纬度;
  2. 使用 weather 服务回调的天气数据更新界面。

现在你已经对现有 App 结构有了一定了解,是时候开始重构了。

通过 Box 进行数据绑定

在 MVVM 里,你需要一个绑定 view model 输出数据到 views 的方式,为此,你需要一个工具类,来提供视图和视图模型绑定的简单机制。

有几种方式可以达到这样的绑定目的:

  • KVO(Key-Value Observing):一个使用 key paths 观察一个属性,当属性值发生变化时可以收到通知的机制;
  • FRP(Functional Reactive Programming):将事件和数据作为流处理的范例。苹果新的Combine框架是其FRP的方法。RxSwift和ReactiveSwift是FRP的两个流行框架。
  • Delegation:当值改变时使用代理方法传递通知。
  • Boxing:使用属性观察者通知观察者值的变化。

在本篇教程中,你将使用 boxing,对一个简单的 App 来说,boxing 的自定义实现已经足够用了。 在 Utilities,创建一个 Box.swift,然后添加如下代码:

final class Box<T> {
  //1
  typealias Listener = (T) -> Void
  var listener: Listener?
  //2
  var value: T {
    didSet {
      listener?(value)
    }
  }
  //3
  init(_ value: T) {
    self.value = value
  }
  //4
  func bind(listener: Listener?) {
    self.listener = listener
    listener?(value)
  }
}

上面的代码做了如下事情:

  1. 每个 Box 持有一个 Listener,值发生变化时进行通知;
  2. Box 有一个通用类型,didSet 属性观察者检测任何变化并通知 Listener 更新数据;
  3. init 方法初始化 Box 值;
  4. 当一个 BoxListener 调用 bind(listener:) 时,它变为 Listener 并立即收到 Box 当前值的通知。

创建 WeatherViewModel

现在你已经设置好了 View 和 ViewModel 之间进行数据绑定的机制,可以开始构建实际的 ViewModel。在 MVVM 中,viewController 不会调用任何服务,或者操作任何模型类型,这些都交由 ViewModel 来做。

你将通过迁移 WeatherViewController 中与 geocoder 和 WeatherBit 服务相关的代码到 WeatherViewModel 中来开始你的重构,然后你会在 WeatherViewController 中将视图绑定到视图模型的属性。

首先,在 View Models 目录下创建 WeatherViewModel.swift,然后添加如下代码:

// 1
import UIKit.UIImage
// 2
public class WeatherViewModel {
}

代码分解:

  1. 首先,引入 UIKit.UIImage,ViewModel 中不允许使用 UIKit 的其他类型,一般规则是绝不在你的 ViewModel 中引入 UIKit
  2. 然后,将 WeatherViewModel 类修饰为 Public,这样方便后续测试。

现在打开 WeatherViewController,添加如下属性:

private let viewModel = WeatherViewModel()

在这里初始化控制器的 ViewModel。

下一步,你将迁移 WeatherViewControllerLocationGeocoder 逻辑到 WeatherViewModel,该应用在你做完如下步骤前不会再次编译:

  1. 首先剪切 defaultAddress,粘贴到 WeatherViewModel,然后添加 static 修饰符;
  2. 接下来,剪切 geocoder,粘贴到 WeatherViewModel

WeatherViewModel 添加一个新的属性:

let locationName = Box("Loading...")

上面这段代码确保该 App 启动后在请求到位置前展示"Loading..."。

接下来在 WeatherViewModel 中添加如下方法:

func changeLocation(to newLocation: String) {
  locationName.value = "Loading..."
  geocoder.geocode(addressString: newLocation) { [weak self] locations in
    guard let self = self else { return }
    if let location = locations.first {
      self.locationName.value = location.name
      self.fetchWeatherForLocation(location)
      return
    }
  }
}

在通过 geocoder 获取之前,这段代码将 locationName.value 更新为“Loading...”,当 geocoder 完成请求,你将更新地理名称并请求该位置的天气信息。

WeatherViewController.viewDidLoad() 方法实现替换为如下代码:

override func viewDidLoad() {
  viewModel.locationName.bind { [weak self] locationName in
    self?.cityLabel.text = locationName
  }
}

这段代码将 cityLabel.text 绑定到 viewModel.locationName

接下来,删除 WeatherViewController 中的 fetchWeatherForLocation(_:)

由于仍需要请求一个位置天气数据的方法,在 WeatherViewModel.swift 中添加重构的 fetchWeatherForLocation(_:) 方法:

private func fetchWeatherForLocation(_ location: Location) {
  WeatherbitService.weatherDataForLocation(
    latitude: location.latitude, 
    longitude: location.longitude) { [weak self] (weatherData, error) in
      guard 
        let self = self,
        let weatherData = weatherData 
        else { 
          return 
        }
  }
}

现在这个回调不会做任何事情,但是你会在下一章节完成这个方法。

最后,在 WeatherViewModel 中添加一个初始化方法:

init() {
  changeLocation(to: Self.defaultAddress)
}

ViewModel首先将 location 设置为默认地址。

重构量很大,刚刚你已将所有服务和 geocoder 逻辑从 viewController 迁移到 viewModel,注意 viewController 是如何显著缩小变得更加简单。

defaultAddress 赋值为你当前的地点看看效果,编译运行:

Simulator-Screen-Shot-iPhone-8-2019-12-21-at-23.27.09-281x500.png

现在已经显示你当前位置的城市名称了,但是天气和日期还不正确,该 App 正从 StoryBoard 展示示例信息。接下来修复这个问题。

在 MVVM 中格式化数据

在 MVVM 中,viewController 只负责 views,viewModel 负责格式化服务和模型类型的数据并展示到 views。

接下来的重构,你会将 WeatherViewController 中数据格式化的逻辑迁移到 WeatherViewModel 中,完成后添加所有其余的数据绑定,以便天气数据在位置变化时更新。

首先处理日期格式。第一步,剪切 WeatherViewController 中的 dataFormatter 属性,粘贴到 WeatherViewModel

接下来,在 WeatherViewModel locationName 之后添加如下代码:

let date = Box(" ")

date 初始化为一个空白字符串,从 Weatherbit API 获取到天气数据时更新。

在 API 获取闭包结束之前,在 WeatherViewModel.fetchWeatherForLoacation(_:) 添加如下代码:

self.date.value = self.dateFormatter.string(from: weatherData.date)

无论何时,只要天气数据获取到,以上代码都会更新 date

最后,在 WeatherViewController.viewDidLoad() 的最后粘贴如下代码:

viewModel.date.bind { [weak self] date in
  self?.dateLabel.text = date
}

编译运行:

Simulator-Screen-Shot-iPhone-8-2019-12-21-at-23.36.28-281x500.png

现在已经显示了今天的日期,而不是 storyboard 中的 11 月 13 日,进步中!

是时候完成重构了。按照下面的步骤完成剩余天气字段所需的数据绑定。

首先,从 WeatherViewController 中剪切 tempFormatter 属性,粘贴到 WeatherViewModel

然后,在 WeatherViewModel 中添加如下代码,对其余属性进行绑定:

let icon: Box<UIImage?> = Box(nil)  //no image initially
let summary = Box(" ") 
let forecastSummary = Box(" ")

WeatherViewController.viewDidLoad() 最后添加如下代码:

viewModel.icon.bind { [weak self] image in
  self?.currentIcon.image = image
}
    
viewModel.summary.bind { [weak self] summary in
  self?.currentSummaryLabel.text = summary
}
    
viewModel.forecastSummary.bind { [weak self] forecast in
  self?.forecastSummary.text = forecast
}

这样你已经为图标、天气摘要和预测摘要创建了绑定,每当 box 中的值发生变化,viewController 会自动收到通知。

接下来,是时候实际更改 Box 中这些对象的值了,在 WeatherViewModel.swift fetchWeatherForLocation(_:) 闭包的最后:加如下代码:

self.icon.value = UIImage(named: weatherData.iconName)
let temp = self.tempFormatter
  .string(from: weatherData.currentTemp as NSNumber) ?? ""
self.summary.value = "(weatherData.description) - (temp)℉"
self.forecastSummary.value = "\nSummary: (weatherData.description)"

这段代码格式化不同的天气以便于 view 展示。

changeLocation(to:) 的最后,并且 API 获取闭包结束之前,添加如下代码:

self.locationName.value = "Not found"
self.date.value = ""
self.icon.value = nil
self.summary.value = ""
self.forecastSummary.value = ""

如果没有 geocode 没有返回任何位置,这段代码确保不会显示天气数据。

编译运行:

Simulator-Screen-Shot-iPhone-8-2019-12-21-at-23.40.18-281x500.png

所有天气信息现在都会更新到您的 defaultAddress。如果您使用了当前位置,请查看窗口并确认数据正确。:]接下来将了解 MVVM 如何扩展 App 功能。

在 MVVM 中添加扩展功能

至此,你已经可以查看默认位置的天气,但是如果你想知道其他地方的天气呢?你可以使用 MVVM 添加一个按钮来查看其他位置的天气。

你可能已经注意到左上角的位置符号➤,它是一个还不起作用的按钮,接下来,你将把它与一个提示新位置的弹窗衔接,然后获取新位置的天气。

首先打开 Weather.storyboard,然后打开编辑器中的 WeatherViewController.swift

接着按住 control 键,使用鼠标左键拖拽 Change Location ButtonWeatherViewController,方法取名为 promptForLocation

assistanteditor.gif

promptForWeather(_:) 方法中添加如下代码:

//1
let alert = UIAlertController(
  title: "Choose location",
  message: nil,
  preferredStyle: .alert)
alert.addTextField()
//2
let submitAction = UIAlertAction(
  title: "Submit", 
  style: .default) { [unowned alert, weak self] _ in
    guard let newLocation = alert.textFields?.first?.text else { return }
    self?.viewModel.changeLocation(to: newLocation)
}
alert.addAction(submitAction)
//3
present(alert, animated: true)

方法分解:

  1. 创建一个带有 textField 的 UIAlertController
  2. 添加一个 Submit 动作按钮,这个动作传入一个新的位置字符串到 viewModel.ChangeLocation(to:)
  3. 弹出 alert

编译运行:

locations.gif

输入一些不同的位置,你可以尝试 Paris(巴黎)、Texas(得克萨斯州)等等,甚至可以输入一些无意义的内容比如 ggggg 看看 App 怎么响应。

思考一下在 viewController 中添加这个新功能需要多少代码,对 viewModel 的单个调用会触发更新该位置的天气数据的流程,很聪明吧?

接下来,你将学到如何使用 MVVM 进行单元测试。

使用 MVVM 进行单元测试

MVVM 的一个重要优势是轻而易举地创建自动化测试。

在 MVC 模式下测试一个 viewController,你必须使用 UIKit 来实例化 viewController,然后,你必须搜索视图层次结构以触发操作并验证结果。

在 MVVM 中你可以编写更多传统的测试,你可能仍需要等待一些异步事件,但更多事情可以很容易触发并验证。

要了解 MVVM 使得测试一个 viewModel 有多简单,你将创建一个使 WeatherViewModel 变更位置的测试,然后确认 locationName 绑定更新到期望的位置。

首先,在 MVVMFromMVCTests 组下,创建的一个新的 Unit Test Case Class 类型的文件,取名 WeatherViewModelTests

为了测试你必须引入这个 App,在 import XCTest 下方添加如下代码:

@testable import Grados

现在,在 WeatherViewModelTests 中添加如下方法:

func testChangeLocationUpdatesLocationName() {
  // 1
  let expectation = self.expectation(
    description: "Find location using geocoder")
  // 2
  let viewModel = WeatherViewModel()
  // 3
  viewModel.locationName.bind {
    if $0.caseInsensitiveCompare("Richmond, VA") == .orderedSame {
      expectation.fulfill()
    }
  }
  // 4
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    viewModel.changeLocation(to: "Richmond, VA")
  }
  // 5
  waitForExpectations(timeout: 8, handler: nil)
}

对以上的测试代码的解释:

  1. locationName 的绑定是异步的,使用 expectation 来等待异步事件;
  2. 创建一个 viewModel 实例来测试;
  3. 绑定到 locationName,并且仅在值与预期结果匹配时满足期望,忽略任意位置或默认位置的值,比如"Loading...",只有预期结果才能满足测试预期;
  4. 改变位置开始测试,在更改位置之前等待几秒钟很重要,确保所有处理中的地理编码活动完成,当 App 启动时会触发 geocoder 进行查找。在创建这个 viewModel 测试实例时也会触发 geocoder 进行查找,在触发该测试查找前等待几秒钟以允许那些其他的查找完成。苹果官方文档 明确提示,如果请求频率太高, CLLocation 可能会抛出错误;
  5. 最多等待 8 秒实现预期,只有在超时之前获取到期望的结果才算成功。

点击 testChangeLocationUpdatesLocationName() 边上的菱形按钮运行测试,当测试通过时,菱形按钮会标记为绿色的选中状态。

runtest.gif

在这里你可以按照该示例创建测试,确认 WeatherViewModel 的其他值。理想情况下你会注入模拟天气服务,以移除测试对 weatherbit.io 的依赖。

回顾

干得好!当你回顾这些变化时,从重构的结果可以看到 MVVM 的一些好处:

  • 降低复杂度WeatherViewController 更简洁了;
  • 专业性WeatherViewController 不再依赖任何模型类型,只专注于 view;
  • 解耦WeatherViewController 只通过发送输入与 WeatherViewModel 交互,比如 changeLocation(to:),或者绑定到它的输出;
  • 表达性WeatherViewModel 从更低一级的 view 逻辑中分离了业务逻辑;
  • 可维护性:轻易地以最小的更改在 WeatherViewController 中添加一个新的特性;
  • 可测试性WeatherViewModel 相对容易测试;
  • 额外的类型:MVVM 为 App 的结构引入了一个额外的 ViewModel 类型;
  • 绑定机制:MVVM 需要一些数据绑定方式,比如本示例中的 Box 类型;
  • 样板:你需要一些额外的样板来实现 MVVM;
  • 内存:当引入 viewModel 到当前工程中时,你必须注意内存管理和内存生命周期。

接下来?

你可以点击该教程顶部或底部的资料下载按钮,下载该项目的完整版本。

MVVM 已成为专业 iOS 开发者的核心竞争力,在很多专业环境中,你应该熟悉 MVVM,并有能力实现它。鉴于 Apple 引入了 Combine 框架以支持响应式编程,这一点尤其如此。

Design Patterns By Tutorials》一书中有更多 MVVM 模式的信息。

cover-design-patterns.png

如果你想要学习更多关于 Combine 框架,以及如何使用 Combine 实现 MVVM,请在 MVVM with CombineCombine: Asynchronous Programming With Swift 一书中查看本教程相关内容。

关于 KVO(Key-Value Observing) 的更多信息,请查看 What's New in Foundation: Key-Value Observing。

希望你喜欢本教程,如果有任何问题或意见,请加入下面的论坛讨论!

43871e3b3650512c4bbaefc78ebe7d9d.png

Chuck Krutsinger

资料下载

贡献者

Chuck Krutsinger(Author)

Jayven Nhan(Tech Editor)

April Rames(Editor)

Julia Zinchenko(Illustrator)

Morten Faarkrog(Final Pass Editor)

Richard Critz(Team Lead)

Ray Fix(Topics Master)

超过 300 个内容创作者。加入我们的队伍