翻译自 MVVM with Combine Tutorial for iOS
点击下载本教程素材/Demo
本教程将介绍如何使用 Combine + SwiftUI + MVVM架构模式 来构建一个简单的天气App。
Version:
Swift 5, iOS 13, Xcode 11
今年WWDC最大的亮点无疑是苹果新发布的 Combine 框架和 SwiftUI。Combine 是一个 函数式响应式编程框架(Functional Reactive Programming),它可以发送逻辑数据流,并以成功或失败告终。SwiftUI 是一个声明式的UI框架,它可以结合 Combine 来处理应用的状态。教程将演示如何使用 SwiftUI + Combine + MVVM架构模式 创建一个简单的天气App。读完本教程,您讲了解到:
- 使用 Combine 来管理状态
- 在 SwiftUI 中为 View 和 ViewModel 建立绑定关系
- 理解 SwiftUI, Combine, MVVM 这三个概念是怎样结合到一起的
界面最终效果如下:
Getting Started
- 首先 下载本教程所需素材/Demo,打开文件夹 CombineWeatherAppStarter。
- 为了能看到天气信息,您必须先花两分钟去 OpenWeatherMap 网站上注册并获取一个 API key,如图:
-
打开 WeatherFetcher.swift,更新 WeatherFetcher.OpenWeatherAPI.key,如下:
struct OpenWeatherAPI { ... static let key = "
" // Replace with your own API Key } -
build and run,主屏幕会展示一个可点击的按钮:
点击 "Best weather app" 将展示更多细节:
此时界面上还是空的,接下来让我们逐渐完善它。
An Introduction to the MVVM Pattern
Model-View-ViewModel 是一种 UI 设计模式,它也是 MV* 模式系列中的一员,包括 Model View Controller(MVC), Model View Presenter(MVP).
每种模式都是为了分离 界面逻辑和业务逻辑,以便更容易开发和测试App。
Note: Design Patterns by Tutorials 和 Advanced iOS App Architecture 这两本书介绍了更多关于设计模式和iOS架构相关的知识。
回顾 MVVM 的起源有助于更好的理解该模式。
MVC 是最早的 UI 设计模式,它的起源可以追溯到 Smalltalk language of the 1970s. 下图展示了 MVC 模式的主要组件:
这种模式将UI分成三部分:
- Model - 代表应用程序状态的模型
- View - 由UI控件组成
- Controller,处理用户交互,并相应的更新模型状态
这些概念看起来不错,但是通常当人们开始实现MVC时,上面说明的看似循环的关系导致Model,View 和 Controller 变得巨大而混乱。
后来,Martin Flowler 引入了称为 Presentation Model 的MVC模式的一种变体,该模型被 Microsoft 以 MVVM 的名义采用和普及。
MVVM 的核心是 ViewModel,ViewModel 是一种代表了 UI状态的 Model类型,它包含详细描述每个UI控件状态的属性。如 输入框当前的文本内容、某个按钮是否可用。它也对外公开了视图可以执行的操作,比如按钮点击或手势。
将 ViewModel 看作是 View的Model,也许会更容易理解。
MVVM模式遵循以下规则:
- View 引用了 ViewModel,反之不会。
- ViewModel 引用了 Model,反之不会。
- View 和 Model 之间无引用关系。
MVVM模式的几个优点是:
- 轻量级的 View:所有的UI逻辑都在 ViewModel 中,从而产生了非常轻量级的 View。
- 容易测试:可以在没有View的情况下运行整个应用程序,从而大大增强了其可测试性。
此时,您可能已经发现了一个问题。如果 View 引用了 ViewModel,但 ViewModel 没有引用 View,那 ViewModel 如何更新 View 呢?
这就是MVVM模式的秘诀所在。
MVVM and Data Binding
数据绑定(Data Binding) 可以在 View 和 ViewModel 之间建立一种关联。在今年的WWDC之前,不得不使用 RxSwift (via RxCocoa) 或者 ReactiveSwift (via ReactiveCocoa) 来做数据绑定。在本教程中,将使用 SwiftUI 和 Combine。
MVVM With Combine
虽然 Combine 并不是做绑定的唯一方式,但在 SwiftUI 和 ViewModel 中使用 Combine 是十分自然的选择。它使您可以清晰地定义从UI到网络调用的链,并轻松实现这些功能。使用其他通信模式(如代理、回调) 也可以达到效果,但这样做会将 SwiftUI 的声明式方法及其绑定 换成命令式的。
Building the App
先看Model层,再看UI层。
我们可以创建一个通用的方法,将 OpenWeatherMap 接口返回的数据解码成我们需要的对象,打开 Parsing.swift 并添加以下代码:
import Foundation
import Combine
func decode(_ data: Data) -> AnyPublisher {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
return Just(data)
.decode(type: T.self, decoder: decoder)
.mapError { error in
.parsing(description: error.localizedDescription)
}
.eraseToAnyPublisher()
}
它使用标准的 JSONDecoder
来解码 OpenWeatherMap API 返回的JSON数据。后面我们再说说 mapError(_:)
和 eraseToAnyPublisher()
。
打开 WeatherFetcher.swift, 该实体负责从 OpenWeatherMap API 中获取数据并提供给消费者。
从协议开始,添加如下代码:
protocol WeatherFetchable {
func weeklyWeatherForecast(
forCity city: String
) -> AnyPublisher
func currentWeatherForecast(
forCity city: String
) -> AnyPublisher
}
第一个方法,用来获取第一个界面要展示的后续5天的天气信息。第二个方法用来查看某天的天气信息详情。
AnyPublisher
将在被订阅后执行。第一个参数 WeeklyForecastResponse
指的是计算成功后返回的类型,第二个参数是计算失败时的错误类型。
通过在类声明下面添加以下代码来实现这两种方法:
// MARK: - WeatherFetchable
extension WeatherFetcher: WeatherFetchable {
func weeklyWeatherForecast(
forCity city: String
) -> AnyPublisher {
return forecast(with: makeWeeklyForecastComponents(withCity: city))
}
func currentWeatherForecast(
forCity city: String
) -> AnyPublisher {
return forecast(with: makeCurrentDayForecastComponents(withCity: city))
}
private func forecast(
with components: URLComponents
) -> AnyPublisher where T: Decodable {
// 1
guard let url = components.url else {
let error = WeatherError.network(description: "Couldn't create URL")
return Fail(error: error).eraseToAnyPublisher()
}
// 2
return session.dataTaskPublisher(for: URLRequest(url: url))
// 3
.mapError { error in
.network(description: error.localizedDescription)
}
// 4
.flatMap(maxPublishers: .max(1)) { pair in
decode(pair.data)
}
// 5
.eraseToAnyPublisher()
}
}
看看这段代码做了些什么:
- 尝试从
URLComponents
中拿到 URL。如果失败,返回一个包装在Fail
中的错误,并将Fail
类型擦除为方法的返回类型AnyPublisher
。 - 使用新的
URLSession
方法dataTaskPublisher(for:)
来获取数据,此方法使用URLRequest
的实例做请求,返回一个(Data, URLResponse)
类型的元组 或是 一个URLError
。 - 因为这个方法返回类型是
AnyPublisher
,因此需要将URLError
转换为WeatherError
。 - 在
flatMap
中使用decode(_:)
方法 将服务器返回的json数据转换为所需的对象,因为我们只需要取一次网络请求的值,所以设置.max(1)
。 - 由于这个函数可能返回
Publishers.FlatMap
或Publishers.MapError
。因此我们需要使用eraseToAnyPublisher()
来擦除它的类型,否则我们需要在返回值中包含所有可能出现的类型,如果将来又增加新的类型,就更难处理了。而且,作为API的使用者,我们并不关心这些返回值的类型细节。
Diving Into the ViewModels
接下来,我们将使用 ViewModel 来处理 每周天气预报页:
打开 WeeklyWeatherViewModel.swift 并且添加如下代码:
// 1
class WeeklyWeatherViewModel: ObservableObject, Identifiable {
// 2
@Published var city: String = ""
// 3
@Published var dataSource: [DailyWeatherRowViewModel] = []
private let weatherFetcher: WeatherFetchable
// 4
private var disposables = Set()
init(weatherFetcher: WeatherFetchable) {
self.weatherFetcher = weatherFetcher
}
}
看看这段代码做了些什么:
- 让
WeeklyWeatherViewModel
遵守ObservableObject
和Identifiable
协议,这使得WeeklyWeatherViewModel
的属性可以被用来做数据绑定。我们后续在 View 层会看到。 - 属性包装器
@Published
,使得 属性city
在发生改变时可以被其他对象观察到。 - 在 ViewModel 中引用一份 View 的数据源
dataSource
,这与我们在 MVC模式中的处理方式不同。由于这个属性被标记为@Published
,所以编译器会自动为 属性dataSource
合成 publisher 的相关方法。SwiftUI 将会订阅这个 publisher,当属性dataSource
发生改变时,SwiftUI 会自动刷新UI。 - 将
disposables
当做所有请求的集合,作为一个全局属性,它保留了所有请求的强引用。如果没有这些引用,您所做的请求出了作用域就立刻会被释放掉,也就无法接收后续从服务器返回的数据。
现在,在 WeeklyWeatherViewModel
的初始化器中初始化 weatherFetcher
,并添加以下代码:
func fetchWeather(forCity city: String) {
// 1
weatherFetcher.weeklyWeatherForecast(forCity: city)
.map { response in
// 2
response.list.map(DailyWeatherRowViewModel.init)
}
// 3
.map(Array.removeDuplicates)
// 4
.receive(on: DispatchQueue.main)
// 5
.sink(
receiveCompletion: { [weak self] value in
guard let self = self else { return }
switch value {
case .failure:
// 6
self.dataSource = []
case .finished:
break
}
},
receiveValue: { [weak self] forecast in
guard let self = self else { return }
// 7
self.dataSource = forecast
})
// 8
.store(in: &disposables)
}
- 通过 WeatherFetcher 从 OpenWeatherMapAPI 获取 city 的天气数据。
- 将 请求的回复对象
WeeklyForecastResponse
转换为界面所需的DailyWeatherRowViewModel
数组。一个DailyWeatherRowViewModel
表示列表中一行所展示的数据。您可以在 DailyWeatherRowViewModel.swfit 文件中查看它的具体实现。在MVVM模式中,在ViewModel层应该提供 View 所需展示的数据,而不是直接将服务器返回的数据交由 View 去处理。比如,不应该让 View 来格式化所要展示的数据。View 尽量只关心界面的展示渲染,而不处理额外的业务逻辑。 - OpenWeatherMap API 会根据一天中的时间返回同一天的多个温度,我们去掉其中重复的部分。关于
Array.removeDuplicates
方法的实现细节请看 Array+Filtering.swfit 文件。 - 尽管从服务器获取、解析JSON数据发生在后台线程,但是应该在主线程刷新UI。方法
receive(on: DispatchQueue.main)
可以确保接下来的步骤 5, 6, 7 在主线程执行。 - 通过
sink(receiveCompletion:receiveValue:)
方法来订阅 publisher 并接收事件。我们也相应的更新dataSource
。值得注意的是,接收到订阅的值values 和 订阅结束(成功 or 失败) 的事件,是分开处理的。 - 如果接收到的事件是 failure,设置
dataSource
为一个空数组。 - 每当接收到一个新值时就更新
dataSource。
- 最后,将返回的 cancellable 加入到
disposables
中。如前文提及到的,如果不保留对它的引用,发起网络请求的 publisher 就会立即终止。
现在,App应该可以成功 Build。接下来我们开始编写 View。
Weekly Weather View
首先打开 WeeklyWeatherView.swift,在这个 struct
中添加并初始化属性 viewModel
:
@ObservedObject var viewModel: WeeklyWeatherViewModel
init(viewModel: WeeklyWeatherViewModel) {
self.viewModel = viewModel
}
通过属性包装器 ObservedObject
,在 WeeklyWeatherView
和 WeeklyWeatherViewModel
之间建立连接。这意味着,当 WeeklyWeatherView
的属性 objectWillChange
发送一个值时,该 View 将被通知该数据源将要修改,并刷新界面。
现在,打开 SceneDelegate.swift,用下面的代码替换掉旧的属性 weeklyView
:
let fetcher = WeatherFetcher()
let viewModel = WeeklyWeatherViewModel(weatherFetcher: fetcher)
let weeklyView = WeeklyWeatherView(viewModel: viewModel)
再次 Build,确保可以编译通过。
返回 WeeklyWeatherView.swift,实现 body
中的代码:
var body: some View {
NavigationView {
List {
searchField
if viewModel.dataSource.isEmpty {
emptySection
} else {
cityHourlyWeatherSection
forecastSection
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle("Weather ⛅️")
}
}
当 dataSource
为空时,将显示一个空白部分。否则,将显示天气预报部分,并能够查看你搜索的城市的更多详细信息。在文件底部添加如下代码:
private extension WeeklyWeatherView {
var searchField: some View {
HStack(alignment: .center) {
// 1
TextField("e.g. Cupertino", text: $viewModel.city)
}
}
var forecastSection: some View {
Section {
// 2
ForEach(viewModel.dataSource, content: DailyWeatherRow.init(viewModel:))
}
}
var cityHourlyWeatherSection: some View {
Section {
NavigationLink(destination: CurrentWeatherView()) {
VStack(alignment: .leading) {
// 3
Text(viewModel.city)
Text("Weather today")
.font(.caption)
.foregroundColor(.gray)
}
}
}
}
var emptySection: some View {
Section {
Text("No results")
.foregroundColor(.gray)
}
}
}
这段代码主要分为三个部分:
- 第一个绑定对象,
$viewModel.city
在你在TextField
中输入的值和WeeklyWeatherViewModel
的city
属性之间建立起绑定关系。使用$
可以将city
属性转换为Binding
,这样做的前提是WeeklyWeatherViewModel
遵守了ObservableObject
协议,并且city
已被@ObservedObject
属性包装器所修饰。 - 使用各自的 ViewModel 初始化每日天气预报行,打开 DailyWeatherRow.swift 以查看其实现。
- 你依然可以像
Text(viewModel.city)
中一样使用WeeklyWeatherViewModel
中的属性,而不涉及任何绑定。
Build and Run the app,你应该可以看到如下界面:
然而,输入内容时界面却没有任何反应,这是由于我们还未将 city
与 真实的网络请求关联起来。
打开 WeeklyWeatherViewModel.swift,在初始化器中加入以下代码:
// 1
init(
weatherFetcher: WeatherFetchable,
scheduler: DispatchQueue = DispatchQueue(label: "WeatherViewModel")
) {
self.weatherFetcher = weatherFetcher
// 2
_ = $city
// 3
.dropFirst(1)
// 4
.debounce(for: .seconds(0.5), scheduler: scheduler)
// 5
.sink(receiveValue: fetchWeather(forCity:))
}
这段代码至关重要,它将 SwiftUI 和 Combine 两者结合了起来:
- 添加一个
scheduler
参数,你能指定 HTTP 请求在哪一个队列执行。 - 被属性包装器
@Published
修饰的 属性city
,便具备了Publisher
相同的功能。这意味着city
既可以被观察,也可以使用Publisher
的任何方法。 - 只要订阅了这个事件流,
$city
就会发送它的第一个值。由于第一个值是一个空字符串,我们需要跳过它以避免发送一次无意义的网络请求。 - 使用
debounce(for:scheduler:)
来提供更好的用户体验。如果没有debounce
,每输入一个字母时,fetchWeathe
都将发起一个新的 HTTP 请求。debounce(for: .seconds(0.5))
通过等待0.5秒,直到用户停止键入才会发送一个值。你可以在 RxMarbles 上找到该行为的可视化图解。你也可以传递一个scheduler
参数,表示发出的任何值都将在该特定队列上。经验法则:你应该在后台队列中处理值,在主队列中交付值。 - 最后,通过
sink(receiveValue:)
方法来观察这些事件,并使用已实现的fetchWeather(forCity:)
方法来处理它们。
编译并运行项目,效果如下:
Navigation and Current Weather Screen
MVVM 作为一种架构模式,并未涉及到所有的实现细节,有些部分可由开发者自行决定。例如,如何从一个界面导航到另一个界面,以及哪个实体应承担此责任。SwiftUI 暗示了 NavigationLink
的用法,这也是本教程将使用的做法。
如果你查看 NavigationLink
最基础的初始化器: public init
, 你会发现,它期待一个 View
作为参数。这实质上是将您当前的视图 与 目标视图 联系了起来。在较简单的App中,这样做没有问题。但是当有复杂的流程需要根据外部逻辑(例如服务器响应)导航到不同的目标视图时,就行不通了。
按照MVVM的常规做法,View 应该询问 ViewModel 下一步该怎么做,但 NavigationLink
期望的参数是 View,这就难以处理了。而且 ViewModel 应该与这些导航问题无关才对。这个问题可以 FlowControllers 或 Coordinator 来解决,它们由另一个与 ViewModel 一起管理应用程序路由 的实体表示。这种方法可以很好的扩展,但是会阻止我们使用 NavigationLink
之类的方法。
但是,这并不在本教程的讨论范围之内,因此,我们暂时会使用一个实用的混合方法。
在进入导航之前,先更新 CurrentWeatherView
和 CurrentWeatherViewModel
。打开 CurrentWeatherViewModel.swift
并添加以下代码:
import SwiftUI
import Combine
// 1
class CurrentWeatherViewModel: ObservableObject, Identifiable {
// 2
@Published var dataSource: CurrentWeatherRowViewModel?
let city: String
private let weatherFetcher: WeatherFetchable
private var disposables = Set()
init(city: String, weatherFetcher: WeatherFetchable) {
self.weatherFetcher = weatherFetcher
self.city = city
}
func refresh() {
weatherFetcher
.currentWeatherForecast(forCity: city)
// 3
.map(CurrentWeatherRowViewModel.init)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] value in
guard let self = self else { return }
switch value {
case .failure:
self.dataSource = nil
case .finished:
break
}
}, receiveValue: { [weak self] weather in
guard let self = self else { return }
self.dataSource = weather
})
.store(in: &disposables)
}
}
CurrentWeatherViewModel
模仿前面在 WeekliyWeatherViewModel
中所做的工作:
- 让
CurrentWeatherViewModel
遵守ObservableObject
和Identifiable
协议。 - 暴露一个可选的
CurrentWeatherRowViewModel
作为数据源。 - 当收到
CurrentWeatherForecastResponse
值时,将其转换为CurrentWeatherRowViewModel
格式。
现在,开始处理界面。打开 CurrentWeatherView.swift 并添加以下初始化代码:
@ObservedObject var viewModel: CurrentWeatherViewModel
init(viewModel: CurrentWeatherViewModel) {
self.viewModel = viewModel
}
这和在 WeeklyWeatherView
中的做法一样。您在自己的项目中很可能也会这样做:在 View 中注入 ViewModel 并访问其公共API。
现在,更新 body
:
var body: some View {
List(content: content)
.onAppear(perform: viewModel.refresh)
.navigationBarTitle(viewModel.city)
.listStyle(GroupedListStyle())
}
你会注意到 onAppear(perform:)
方法的使用,它具有 () -> Void
类型的参数,并在视图出现时执行。此时,你可以调用 viewModel.refresh
方法刷新数据源。
最后,在文件底部添加以下UI代码:
private extension CurrentWeatherView {
func content() -> some View {
if let viewModel = viewModel.dataSource {
return AnyView(details(for: viewModel))
} else {
return AnyView(loading)
}
}
func details(for viewModel: CurrentWeatherRowViewModel) -> some View {
CurrentWeatherRow(viewModel: viewModel)
}
var loading: some View {
Text("Loading \(viewModel.city)'s weather...")
.foregroundColor(.gray)
}
}
此时项目还不能编译通过,因为改变了 CurrentWeatherView
的初始化方法。
现在已完成了大部分的工作,是时候编写导航了。打开 WeeklyWeatherBuilder.swift 并添加以下内容:
import SwiftUI
enum WeeklyWeatherBuilder {
static func makeCurrentWeatherView(
withCity city: String,
weatherFetcher: WeatherFetchable
) -> some View {
let viewModel = CurrentWeatherViewModel(
city: city,
weatherFetcher: weatherFetcher)
return CurrentWeatherView(viewModel: viewModel)
}
}
该实体将 创建从 WeeklyWeatherView
导航时的目标界面。
打开 WeeklyWeatherViewModel.swift 并使用这个 builder:
extension WeeklyWeatherViewModel {
var currentWeatherView: some View {
return WeeklyWeatherBuilder.makeCurrentWeatherView(
withCity: city,
weatherFetcher: weatherFetcher
)
}
}
最后,打开 WeeklyWeatherView.swift,并改变 cityHourlyWeatherSection
属性的实现:
var cityHourlyWeatherSection: some View {
Section {
NavigationLink(destination: viewModel.currentWeatherView) {
VStack(alignment: .leading) {
Text(viewModel.city)
Text("Weather today")
.font(.caption)
.foregroundColor(.gray)
}
}
}
}
这里的关键是 viewModel.currentWeather
,WeeklyWeatherView
询问 WeeklyWeatherViewModel
应该导航到下一个视图。 WeeklyWeatherViewModel
使用 WeeklyWeatherBuilder
提供必要的视图。职责之间有很好的隔离,同时使职责之间的整体关系易于遵循。
还有许多其他方法可以解决导航问题。一些开发人员会争辩说,视图层不应该知道要导航的位置,甚至不应该知道导航应该如何发生(模态或推送)。如果这是一个争论点,那么苹果提供的 NavigationLink
便不再有意义。在实用主义和可扩展性之间取得平衡非常重要,本教程倾向于前者。
至此,项目可以完整运行了,恭喜您创建了天气应用! :]