在本 iOS 教程中,您将学习如何将 MVC 应用程序转换为 MVVM。此外,您将了解使用 MVVM 的组件和优点。作者:Chuck Krutinger。
MVVM(Model-View-ViewModel) 是一种设计模式,最近几年在iOS开发社区中获得了吸引力。它涉及一个新概念:ViewModel。在 iOS 应用程序中,ViewModel 是 ViewController 的配套对象。
如上所示,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 的值替换。
编译运行:
可以看到 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 架构设计,首先打开项目导航,如下所示:
在 Controllers 目录下可以找到 WeatherviewController.swift,这是你将要移除所有数据和服务用途而进行重构的 ViewController。
在 Models 目录下,可以看到两个不同的数据对象:WeatherbitData 和 Location。WeatherbitData 是一个结构体,用来表示 Weatherbit API 返回的数据。Location 是 Apple 的 CLLocation 服务返回的位置数据的简化结构。
Services 目录包含 WeatherbitService.swift 和 LocationGeocoder.swift,见名知意,WeatherbitService 从 Weatherbit API 请求天气数据,LocationGeocoder 将字符串转为 Location。
Storyboard 包含 LaunchScreen 和 Weather 故事板。
Utilities 和 View 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
}()
geocoder获取一个String参数比如 Washington DC,将其转换为经纬度,并发送至 weather 服务。defaultAddress设置一组默认地址。DateFormatter格式化日期以显示。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() 调用 geocoder 将 defaultAdress 转换为 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)"
}
}
这个方法只做两件事:
- 调用 weather 服务传递经纬度;
- 使用 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)
}
}
上面的代码做了如下事情:
- 每个
Box持有一个Listener,值发生变化时进行通知; Box有一个通用类型,didSet属性观察者检测任何变化并通知Listener更新数据;- init 方法初始化
Box值; - 当一个
Box的Listener调用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 {
}
代码分解:
- 首先,引入
UIKit.UIImage,ViewModel 中不允许使用UIKit的其他类型,一般规则是绝不在你的 ViewModel 中引入UIKit。 - 然后,将
WeatherViewModel类修饰为Public,这样方便后续测试。
现在打开 WeatherViewController,添加如下属性:
private let viewModel = WeatherViewModel()
在这里初始化控制器的 ViewModel。
下一步,你将迁移 WeatherViewController 的 LocationGeocoder 逻辑到 WeatherViewModel,该应用在你做完如下步骤前不会再次编译:
- 首先剪切
defaultAddress,粘贴到WeatherViewModel,然后添加 static 修饰符; - 接下来,剪切
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 赋值为你当前的地点看看效果,编译运行:
现在已经显示你当前位置的城市名称了,但是天气和日期还不正确,该 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
}
编译运行:
现在已经显示了今天的日期,而不是 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 没有返回任何位置,这段代码确保不会显示天气数据。
编译运行:
所有天气信息现在都会更新到您的 defaultAddress。如果您使用了当前位置,请查看窗口并确认数据正确。:]接下来将了解 MVVM 如何扩展 App 功能。
在 MVVM 中添加扩展功能
至此,你已经可以查看默认位置的天气,但是如果你想知道其他地方的天气呢?你可以使用 MVVM 添加一个按钮来查看其他位置的天气。
你可能已经注意到左上角的位置符号➤,它是一个还不起作用的按钮,接下来,你将把它与一个提示新位置的弹窗衔接,然后获取新位置的天气。
首先打开 Weather.storyboard,然后打开编辑器中的 WeatherViewController.swift。
接着按住 control 键,使用鼠标左键拖拽 Change Location Button 到 WeatherViewController,方法取名为 promptForLocation。
在 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)
方法分解:
- 创建一个带有 textField 的
UIAlertController; - 添加一个 Submit 动作按钮,这个动作传入一个新的位置字符串到
viewModel.ChangeLocation(to:); - 弹出
alert。
编译运行:
输入一些不同的位置,你可以尝试 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)
}
对以上的测试代码的解释:
locationName的绑定是异步的,使用expectation来等待异步事件;- 创建一个
viewModel实例来测试; - 绑定到
locationName,并且仅在值与预期结果匹配时满足期望,忽略任意位置或默认位置的值,比如"Loading...",只有预期结果才能满足测试预期; - 改变位置开始测试,在更改位置之前等待几秒钟很重要,确保所有处理中的地理编码活动完成,当 App 启动时会触发
geocoder进行查找。在创建这个 viewModel 测试实例时也会触发geocoder进行查找,在触发该测试查找前等待几秒钟以允许那些其他的查找完成。苹果官方文档 明确提示,如果请求频率太高,CLLocation可能会抛出错误; - 最多等待 8 秒实现预期,只有在超时之前获取到期望的结果才算成功。
点击 testChangeLocationUpdatesLocationName() 边上的菱形按钮运行测试,当测试通过时,菱形按钮会标记为绿色的选中状态。
在这里你可以按照该示例创建测试,确认 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 模式的信息。
如果你想要学习更多关于 Combine 框架,以及如何使用 Combine 实现 MVVM,请在 MVVM with Combine 或 Combine: Asynchronous Programming With Swift 一书中查看本教程相关内容。
关于 KVO(Key-Value Observing) 的更多信息,请查看 What's New in Foundation: Key-Value Observing。
希望你喜欢本教程,如果有任何问题或意见,请加入下面的论坛讨论!
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 个内容创作者。加入我们的队伍