翻译地址
模型-视图-视图模型(MVVM)是近年来在iOS开发社区中得到广泛应用的一种设计模式。它涉及一个新概念,称为视图模型。在iOS应用程序中,视图模型是视图控制器的伴生对象。

如上所述,MVVM模式由三个层组成:
- 模型应用程序操作的应用程序数据。
- 视点用户界面的可视元素。在IOS中,视图控制器与视图的概念密不可分。
- 视图模型从视图输入更新模型,从模型输出更新视图。
MVVM比模型-视图-控制器,或MVC,这是监督办的实际做法:
- 降低复杂度MVVM通过将许多业务逻辑移出视图控制器,使视图控制器变得更简单。
- 表现力视图模型更好地表达了视图的业务逻辑。
- 可测性视图模型比视图控制器更容易测试。最后,您无需担心视图实现,就可以测试业务逻辑。
在本教程中,您将通过将天气应用程序的体系结构从MVC转换为MVVM来重构天气应用程序。首先,将所有与天气和位置相关的逻辑从视图控制器移到视图模型中。然后,您将为视图模型编写单元测试,以了解如何轻松地将测试集成到新的视图模型中。
在本教程的末尾,您的应用程序应该允许您按名称选择任何位置,并查看该位置的天气摘要。
开始
从下载项目材料开始,使用下载材料按钮在本教程的顶部或底部。然后,打开开始项目。
该应用程序获取天气信息,并提供当前天气的摘要。
要使用Weatherbit API,您需要注册一个免费的API键。在您添加自己的Weatherbit API密钥之前,该应用程序将无法工作。去https://www.weatherbit.io/account/create注册你的钥匙。
获得API密钥后,返回到Xcode。
在……下面服务,开WeatherbitService.swift。然后替换每个关键用你的新钥匙。

建造和运行。

你应该看看弗吉尼亚州麦盖希斯维尔的天气和今天的日子。
介绍MVVM的角色和责任
在深入到重构之前,您必须了解视图模型和视图控制器在MVVM模式中所做的事情。
视图控制器只负责更改视图和将视图输入传递到视图模型。因此,您将从视图控制器中删除任何其他逻辑,并将其移动到视图模型。
相反,视图模型负责以下方面:
- 模型输入 接受视图输入并更新模型。
- 模型输出将模型输出传递给视图控制器。
- 格式化::格式化模型数据,供视图控制器显示。
熟悉现有的应用程序结构
注本节是对应用程序结构的可选审查。如果您已经对mvc视图控制器感到满意,并且希望开始重构,您可以跳到使用Box进行数据绑定. 熟悉当前MVC设计中的应用程序。首先,打开ProjectNavigator,如下所示:

在……下面控制器,你会发现WeatherViewController.swift。这是视图控制器,您将重构以删除模型和服务类型的任何使用。
在……下面模型,您将发现两个不同的模型对象:WeatherbitData
和Location
. WeatherbitData
表示WeatherbitAPI返回的数据的结构。Location
是苹果的位置数据的简化结构CLLocation
服务返回。
服务含WeatherbitService.swift和LocationGeocoder.swift。顾名思义,WeatherbitService
从Weatherbit API获取天气数据。LocationGeocoder
将字符串转换为Location
.
故事板含LaunchScreen和天气故事板。
公用事业和视图模型都是空的。您将在重构期间为这些组创建文件。
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
输入,例如华盛顿特区并将其转换为它发送给气象服务的纬度和经度。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
转换defaultAddress
变成Location
。回调使用返回位置来填充cityLabel
短信。然后,它就过去了location
进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)"
}
}
这种方法只做两件事:
- 调用气象服务并将位置的纬度和经度传递给它。
- 使用天气服务回调提供的天气数据更新视图。
现在您已经对现有的应用程序结构有了很好的了解,现在是开始重构的时候了。
使用Box进行数据绑定
在MVVM中,需要一种将视图模型输出绑定到视图的方法。为此,您需要一个实用程序,它提供了将视图绑定到视图模型的输出值的简单机制。有几种方法可以实现这样的绑定:
- 键值观测或kVO*使用关键路径观察属性并在该属性更改时获取通知的机制。
- 功能反应编程将事件和数据作为流处理的范例。苹果新的联合框架是其玻璃钢的方法。RxSWIFT和ReactiveSWIFT是两种流行的FRP框架。
- 代表团*使用委托方法在值更改时传递通知。
- 拳击*使用财产观察员通知观察者某个值已更改。
在本教程中,您将使用拳击。对于简单的应用程序,一个定制的装箱实现就足够了。
在……下面公用事业,创建一个新的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
当值更改时通知。 - Box具有泛型类型值。这个
didSet
属性观察者检测到任何更改并通知Listener
任何价值更新。 - 初始化集
Box
的初始值。 - 当
Listener
打电话bind(listener:)
在……上面Box
,它变成Listener
并立即收到通知Box现值。
创建WeatherViewModel
现在您已经建立了在视图和视图模型之间进行数据绑定的机制,现在可以开始构建实际的视图模型了。在MVVM中,视图控制器不调用任何服务或操作任何模型类型。这一责任完全属于视图模型。
通过将与Geo编码器和Weatherbit服务相关的代码从WeatherViewController
进入WeatherViewModel
。然后,将视图绑定到视图模型属性WeatherViewController
.
首先,在视图模型,创建一个新的swift特文件名WeatherViewModel。然后,添加以下代码:
// 1
import UIKit.UIImage
// 2
public class WeatherViewModel {
}
这是代码分解:
- 首先,为
UIKit.UIImage
。没有其他UIKit视图模型中需要允许类型。一般的经验法则是永远不要进口。UIKit
在你看来模特们。 - 然后,设置
WeatherViewModel
的类修饰符public
。为了便于测试,您可以公开它。
现在,打开WeatherViewController.swift。添加以下属性:
private let viewModel = WeatherViewModel()
在这里,您可以在控制器内初始化视图模型。
接下来,你要搬家WeatherViewController
氏LocationGeocoder
逻辑到WeatherViewModel
。在完成以下所有步骤之前,应用程序不会再次编译:
第一次切割defaultAddress
离开WeatherViewController
然后粘贴到WeatherViewModel
。然后,添加一个静态属性的修饰符。
接下来,切geocoder
走出WeatherViewController
并将其粘贴到WeatherViewModel
.
在……里面WeatherViewModel
,添加一个新属性:
let locationName = Box("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
}
}
}
此代码更改locationName.value
到“装载…”在抓取之前geocoder
。什么时候geocoder
完成查找,您将更新位置名称并获取该位置的天气信息。
取代WeatherViewController.viewDidLoad()
守则如下:
override func viewDidLoad() {
viewModel.locationName.bind { [weak self] locationName in
self?.cityLabel.text = locationName
}
}
此代码绑定cityLabel.text
到viewModel.locationName
.
接下来,在里面WeatherViewController.swift删除fetchWeatherForLocation(_:)
由于您仍然需要一种获取某个位置的天气数据的方法,所以添加一个重构的fetchWeatherForLocation(_:)
在……里面WeatherViewModel.swift:
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)
}
视图模型首先将位置设置为默认地址。
哎呀!那是很多重构。您刚刚将所有服务和地理编码逻辑从视图控制器移到视图模型。注意视图控制器是如何显著缩小的,同时也变得更简单了。
若要查看操作中的更改,请更改defaultAddress
你现在的位置。
建造和运行。

确保城市名称现在显示您的当前位置。但是天气和日期不对。该应用程序正在显示故事板中的示例信息。
下一个你会修好的。
MVVM中的数据格式化
在MVVM中,视图控制器只负责视图。视图模型总是负责格式化来自视图中的服务和模型类型的数据。
在下一次重构中,您将将数据格式移出WeatherViewController
并进入WeatherViewModel
。在进行此操作时,您将添加所有剩余的数据绑定,以便天气数据根据位置的变化进行更新。
从处理日期格式开始。首先,切dateFormatter
从…WeatherViewController
。将属性粘贴到WeatherViewModel
.
下一个,在WeatherViewModel
,添加以下内容locationName
:
let date = Box(" ")
它最初是一个空字符串,当天气数据从Weatherbit API到达时更新。
现在,在里面添加以下内容WeatherViewModel.fetchWeatherForLocation(_:)
就在API获取闭包结束之前:
self.date.value = self.dateFormatter.string(from: weatherData.date)
以上代码更新date
每当天气数据到的时候。
最后,将以下代码粘贴到WeatherViewController.viewDidLoad()
:
viewModel.date.bind { [weak self] date in
self?.dateLabel.text = date
}
建造和运行。

现在的日期反映了今天的日期,而不是11月13日的故事板。你在进步!
该完成重构了。按照这些最后步骤完成其余天气字段所需的数据绑定。
首先,切tempFormatter
从…WeatherViewController
。将属性粘贴到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
物品。在……里面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)"
此代码将不同的天气项格式化为视图来表示它们。
最后,将以下代码添加到changeLocation(to:)
在API结束之前,获取闭包:
self.locationName.value = "Not found"
self.date.value = ""
self.icon.value = nil
self.summary.value = ""
self.forecastSummary.value = ""
如果地理代码调用没有返回位置,则此代码确保不显示天气数据。
建造和运行。

所有的天气信息现在都会更新defaultAddress
。如果您已经使用了当前的位置,那么从窗口向外看并确认数据是正确的。]接下来,您将看到MVVM如何扩展应用程序的功能。
在MVVM中添加功能
到目前为止,您可以检查默认位置的天气。但如果你想知道其他地方的天气呢?您可以使用MVVM添加一个按钮来检查其他位置的天气。
您可能已经注意到左上角的位置符号➤。这个按钮还不能用。接下来,您将将其连接到一个提示输入新位置的警报,然后获取该新位置的天气。
第一,打开Weather.storyboard。然后,打开WeatherViewController.swift助理编辑。
下一步,控制拖动更改位置按钮到最后WeatherViewController
。命名方法提示形式定位.

现在将以下代码添加到promptForLocation(_:)
:
//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)
以下是此方法的详细说明:
- 创建一个
UIAlertController
有一个文本字段。 - 添加一个动作按钮提交。该操作将新的位置字符串传递给
viewModel.changeLocation(to:)
. - 呈现
alert
.
建造和运行。

放一些不同的地方。你可以试试巴黎、法国或德克萨斯州的巴黎。你甚至可以加入一些无稽之谈,比如格格查看应用程序的响应情况。
花点时间思考一下,在视图控制器中添加这个新功能所需的代码是多么少。对视图模型的单个调用将触发更新该位置的天气数据的流程。聪明对吧?
接下来,您将了解如何使用MVVM创建单元测试。
用MVVM进行单元测试
MVVM的最大优点之一是它使创建自动化测试变得更加容易。
要用mvc测试视图控制器,必须使用UIKit
实例化视图控制器。然后,您必须在视图层次结构中搜索以触发操作并验证结果。
使用MVVM,您可以编写更多的常规测试。您可能仍然需要等待一些异步事件,但是大多数事情都很容易触发和验证。
为了了解MVVM使测试视图模型更加简单,您将创建一个测试WeatherViewModel更改位置,然后确认locationName
将更新绑定到预期位置。
首先,在MVVMFromMVCT组,创建一个新的单元测试用例类文件名WeatherViewModel测试.
您必须导入应用程序来发短信。就在下面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
并且只有当该值与预期结果匹配时,才能满足期望。忽略任何位置名称值,例如“装载…”或者默认地址。只有预期的结果才能满足测试期望。 通过更改位置开始测试。在进行更改之前等待几秒钟是很重要的,这样任何挂起的地44. 理编码活动都会首先完成。当应用程序启动时,它会触发一个geocoder
查一下。
当它创建视图模型的测试实例时,它还会触发geocoder
查一下。等待几秒钟后,这些其他查找就可以在触发测试查找之前完成。
苹果的文档明确警告CLLocation
如果请求的速率太高,则会引发错误。
- 等待长达8秒的期望才能实现。只有当预期的结果在超时之前到达时,测试才会成功。
单击旁边的钻石testChangeLocationUpdatesLocationName()
去做测试。当测试通过后,钻石将变成绿色的标记。

在这里,您可以按照下面的示例创建确认其他值的测试。WeatherViewModel
。理想情况下,您可以插入一个模拟天气服务,以删除测试中对风化位.io的依赖。
回顾对MVVM的重构
干得好!在回顾这些更改时,您可以看到重构所带来的MVVM的一些好处:
- 降低复杂度: WeatherViewController现在简单多了。
- 专门性: WeatherViewController不再依赖于任何模型类型,只关注视图。
- 分离: WeatherViewController只与WeatherViewModel通过发送输入,例如changeLocation(to:),或绑定到其输出。
- 表现力: WeatherViewModel将业务逻辑与低级视图逻辑分离。
- 可维护*向WeatherViewController.
- 可测*WeatherViewModel相对容易测试。
但是,mvvm有一些折衷之处,您应该考虑:
- **额外类型//::MVVM在应用程序的结构中引入了一个额外的视图模型类型。
- 束缚机制*它需要一些数据绑定方法,在这种情况下,Box类型。
- 样板:您需要一些额外的样板来实现MVVM。
- 记忆当将视图模型引入混合时,您必须意识到内存管理和内存保留周期。
推荐👇:
如果你想一起进阶,不妨添加一下交流群1012951431
面试题资料或者相关学习资料都在群文件中 进群即可下载!
