原文:《Modern MVVM iOS App Architecture with Combine and SwiftUI》
iOS 开发人员社区调查显示,MVVM 是设计 iOS 应用的第二大最流行的架构模式。它为使用 SwiftUI 和 Combine 研究现代 MVVM 的状态提供了一个很好的理由。
在本文中,我们将介绍:
- MVVM 的目的。
- MVVM 模式的组件。
- MVVM 中的数据流和依赖项。
- 为什么我们应该使用单向数据流而不是双向绑定?
- 如何将 UI 表示为有限状态机?
并使用 MVVM 架构模式、Combine 和 SwiftUI 框架构建 iOS 应用程序。
历史
MVVM起源于1988年在Smalltalk工程领域发明的应用程序模型模式。该模式的主要目标是将两种逻辑(表示法和业务逻辑)拆分为两个单独的对象:分别是应用程序模型和域模型。
2004年,Martin Fowler将Application Model更名为Presentation Model(PM)。 PM 的想法是创建一个与 UI 无关的对象表示模型,该模型从视图中提取所有状态和行为。这样,视图仅将表示模型的状态投影到屏幕上。
Microsoft 于 2006 年推出了 MVVM,用于使用 Windows Presentation Foundation (WPF) UI 框架设计和实现桌面客户端应用程序。借助 MVVM,Microsoft 追求了标准化 WPF 应用程序开发方式的目标。该模式旨在利用 WPF 框架的强大功能,例如数据绑定。
MVVM 的目的
MVVM 的目标是将业务和表示逻辑与 UI 分离。它提高了可测试性和可维护性,这通常是应用程序成功的关键因素。
为了实现其目标,MVVM 将视图中的决策设置降至最低,并将视图状态和行为移动到视图模型中。这样,视图将变为被动:
- 视图不会从视图模型中提取数据。
- 视图不负责从视图模型更新自身。
- 视图的状态由视图模型管理。
这样的设计允许我们在独立于 GUI 堆栈的情况下测试表示逻辑。
MVVM 模式
MVVM 是 UI 模式。与最丰富的客户端系统一样,这是iOS应用程序代码库的很大一部分所在位置。SwiftUI 视图、UIKit 视图和视图控制器、情节提要和 xib 都属于这里。
MVVM 提供了一组以下方面的准则:
- 如何在 UI 上显示信息。
- 如何处理用户与应用之间的交互。
- 如何将用户输入解释为对业务规则和数据执行的操作。
MVVM 可以分解为遵循严格依赖规则的三个组件:
依赖项按以下方式组织:
- 视图取决于视图模型。
- 视图模型取决于模型。
- 模型和视图模型都不依赖于视图。
取决于均值代码依赖关系,如导入、引用、函数调用。
请注意,与依赖项的流相比,数据流是不同的:
也就是说,数据在两个方向上流动。它从用户交互开始,该交互由视图处理。接下来,视图将交互事件传递给视图模型。然后,视图模型将事件转换为对模型和数据的 CRUD(创建、读取、更新和删除)操作。
反之亦然。该模型从后端、数据库或任何其他源获取数据。接下来,模型将数据传递到视图模型。然后,视图模型以便于视图使用的形式准备数据。最后,视图将数据呈现到屏幕上。
现在,让我们来了解 MVVM 组件的角色。
视图模型
ViewModel 表示应在视图中显示的数据,并包含表示逻辑。
ViewModel 的职责是:
- 管理 UI 行为和状态。
- 将用户输入解释为对业务规则和数据执行的操作。通常,视图模型与模型对象保持一对多关系。
- 准备模型中要呈现给用户的数据。视图模型以方便视图使用的方式构造数据。
Viewmodel 独立于 ui 框架。如果要在 viewmodel.swift
文件中导入 swiftui 或 uikit,请三思而后行。
视图
视图呈现 UI 并将用户交互向前传递。它没有状态,也不包含任何解释用户操作的代码隐藏。
视图的职责是:
- 呈现 UI。
- 执行动画。
- 将用户交互传递到视图模型。
型
模型是业务概念的软件表示,这些概念可以赚钱或为您的客户带来任何其他价值。这是实际编写iOS应用程序的主要原因。
尽管 MVVM 将模型作为其名称的一部分,但 MVVM 不会对其实现做出任何假设。它可以是Redux或Clean Architecture的变体,如VIPER。
MVVM 的现代状态
以下技术塑造了我认为是 Swift 中 MVVM 的现代状态。
FRP和数据绑定
首先,使 MVVM 可行的最重要的一个方面是数据绑定。
数据绑定是一种将数据提供程序与使用者连接并同步它们的技术。
使用数据绑定技术,我们可以创建随时间变化的值流。函数式反应式编程(FRP) 是一种与数据流和变化传播有关的编程范式。在FRP中,价值观流是一等公民。这意味着我们可以在运行时构建它们,传递并存储在变量中。
Combine 和 SwiftUI 框架提供了第一方 FRP 支持,这使我们能够在视图中无缝地反映视图模型更改,并且无需在直接更新视图的视图模型中编写代码。
双向绑定上的单向数据流
MVVM 模式的许多应用程序都使用双向绑定将视图与视图模型同步。对这种方法的解释通常伴随着一个计数器应用程序的示例。尽管它适用于两个数据流(计数器增量和计数器递减),但双向绑定方法在应用于类似生产的功能时不能很好地扩展。
让我们通过示例来演示这些问题。下面是一个只有四个状态的注册屏幕:
如果我们希望使用MVVM实现它,并使用双向绑定连接视图和视图模型,它可能会如下所示:
每个箭头都表示一个值流。这些流也相互连接:
该图仍然缺少一些细节。通常,在生产应用中,您将发送网络请求,并允许用户使用身份提供商(如 Google 或 Facebook)登录。双向绑定方法很快就会失控,最终结果是这样的:
双向绑定的第二个问题是错误处理。开箱即用的Combine框架不提供永无止境的价值流的概念,就像RxSwift Relay一样。因此,当发生错误时,它将终止整个流,并可能使应用的 UI 无响应。尽管您可以在PublishSubject
或 CurrentValueSubject
之上的 Combine 中重新创建中继,但由于下一节中解释的原因,它可能不是正确的方法。
作为状态机的 UI
从双向绑定方法中掉落的另一个重要问题是状态爆炸。
什么是状态?对象的状态是指其字段中所有值的组合。因此,UI 状态的组合数随着阶乘复杂性而增长。但是,大多数此类状态都是不需要的,甚至是退化的。
例如,让我们以注册示例中的isSigningUp
和errorMessage
流为例。在isSigningUp
发送true
而errorMessage
发送非nil
值的情况下,如何呈现UI还不清楚。我们应该显示装载指示器吗?还是带有错误消息的警报框?或者两者都有?
出现更多意外状态的问题:
- 他们创建了许多很难详尽测试的代码路径。
- 添加新状态的复杂性不断累积。
解决方案是找出所有可能的状态以及触发状态转换的所有可能操作,并使其显式化。有限状态机(FSM) 是将这一想法形式化的计算模型。
在任何给定时间 ,FSM可以恰好处于有限数量的状态之一。它可以从一种状态更改为另一种状态以响应外部输入;这种变化被称为过渡[1]。
UI FSM 将管理视图的状态,并通过状态转换函数处理用户输入,该函数可能包含其他副作用。状态机完全定义为其[2]:
- 输入集。
- 输出集。
- 状态集。
- 初始状态。
- 状态转换功能。
- 输出功能。
在本文中,我们将使用CombineFeedback库,该库适合于设计反应状态机。使用 CombineFeedback,接下来将介绍应用组件的结构:
我们来描述一下上图的核心组件。
状态表示有限状态机的状态。
事件描述系统中发生的情况。
Reduce指定状态如何响应事件而变化。
反馈是生成事件的代码与将 事件还原为新状态的代码之间的扩展点。你所有的副作用都会坐在这里。反馈允许我们将副作用与状态机本身的纯结构分开(请参阅绿色)。
ViewModel完全初始化 UI 状态机。
要设置状态机,我们需要system
运算符和Feedback
类型。system
运算符创建一个反馈循环并引导所有依赖项:
下面的代码段基于RxFeedback 的实现。
extension Publishers {
static func system<State, Event, Scheduler: Combine.Scheduler>(
initial: State,
reduce: @escaping (State, Event) -> State,
scheduler: Scheduler,
feedbacks: [Feedback<State, Event>]
) -> AnyPublisher<State, Never> {
let state = CurrentValueSubject<State, Never>(initial)
let events = feedbacks.map { feedback in feedback.run(state.eraseToAnyPublisher()) }
return Deferred {
Publishers.MergeMany(events)
.receive(on: scheduler)
.scan(initial, reduce)
.handleEvents(receiveOutput: state.send)
.receive(on: scheduler)
.prepend(initial)
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
Feedback
生成响应状态更改的事件流。它允许我们在发送事件的时刻和到达reduce函数的时刻之间执行副作用,例如IO:
下面的代码段基于CombineFeedback的实现。
struct Feedback<State, Event> {
let run: (AnyPublisher<State, Never>) -> AnyPublisher<Event, Never>
}
extension Feedback {
init<Effect: Publisher>(effects: @escaping (State) -> Effect) where Effect.Output == Event, Effect.Failure == Never {
self.run = { state -> AnyPublisher<Event, Never> in
state
.map { effects($0) }
.switchToLatest()
.eraseToAnyPublisher()
}
}
}
最后,让我们来看看如何将所有内容放在一起。
使用 MVVM 构建应用
要遵循代码示例,您需要一些 SwiftUI 和 Combine 框架的基本知识。Combine和Apple SwiftUI 教程入门将帮助你快速上手。
让我们通过从头开始构建电影应用程序来探索 MVVM iOS 应用程序体系结构。以下是最终结果的外观:
实现影片列表视图模型
该应用程序有两个屏幕:
- 热门电影列表。
- 电影的详细信息。
我们从电影列表开始。在编写任何代码之前,我们必须设计状态机:
根据该图,在代码中表示状态和事件的列表是微不足道的。我更喜欢将它们声明为视图模型的内部类型:
final class MoviesListViewModel: ObservableObject {
@Published private(set) var state = State.idle
...
}
extension MoviesListViewModel {
enum State {
case idle
case loading
case loaded([ListItem])
case error(Error)
}
enum Event {
case onAppear
case onSelectMovie(Int)
case onMoviesLoaded([ListItem])
case onFailedToLoadMovies(Error)
}
}
请注意,MoviesListViewModel
实现了ObserveObject
协议。这允许我们将视图绑定到视图模型。每当视图模型更新其状态时,SwiftUI将自动更新视图。
某些状态具有关联的值,以便在 UI 上绘制它们或传递到下一个状态。类似地,事件携带数据,这是我们在reduce()
函数内生成新状态时的唯一信息源。
现在,我们可以实现一个定义所有可能的状态到状态转换的reduce()
函数:
extension MoviesListViewModel {
static func reduce(_ state: State, _ event: Event) -> State {
switch state {
case .idle:
switch event {
case .onAppear:
return .loading
default:
return state
}
case .loading:
switch event {
case .onFailedToLoadMovies(let error):
return .error(error)
case .onMoviesLoaded(let movies):
return .loaded(movies)
default:
return state
}
case .loaded:
return state
case .error:
return state
}
}
}
在状态机图上,您可以看到除onSelectMovie
之外的所有事件。这是因为onSelectMovie
是用户与应用程序交互后发送的。用户输入是一个副作用,需要在反馈中处理:
extension MoviesListViewModel {
static func userInput(input: AnyPublisher<Event, Never>) -> Feedback<State, Event> {
Feedback { _ in input }
}
}
然后,我们使用system
运算符初始化状态机:
final class MoviesListViewModel: ObservableObject {
@Published private(set) var state = State.idle
private var bag = Set<AnyCancellable>()
private let input = PassthroughSubject<Event, Never>()
init() {
// 1.
Publishers.system(
initial: state,
reduce: Self.reduce,
scheduler: RunLoop.main,
feedbacks: [
// 2.
Self.whenLoading(),
Self.userInput(input: input.eraseToAnyPublisher())
]
)
.assign(to: .state, on: self)
.store(in: &bag)
}
deinit {
bag.removeAll()
}
// 3.
func send(event: Event) {
input.send(event)
}
}
要点是:
MoviesListViewModel
是功能的入口点。它连接所有依赖项并启动状态机。whenLoading()
反馈处理网络。我们稍后将实现它。send()
方法提供了一种传递用户输入和查看生命周期事件的方法。使用input
主题,我们将事件传播到反馈循环中进行处理。
缺少的两个部分是whenLoading()
反馈和ListItem
类型。它们都与从网络加载电影有关。
当系统进入loading
状态时,我们发起网络请求:
static func whenLoading() -> Feedback<State, Event> {
Feedback { (state: State) -> AnyPublisher<Event, Never> in
// 1.
guard case .loading = state else { return Empty().eraseToAnyPublisher() }
// 2.
return MoviesAPI.trending()
.map { $0.results.map(ListItem.init) }
// 3.
.map(Event.onMoviesLoaded)
// 4.
.catch { Just(Event.onFailedToLoadMovies($0)) }
.eraseToAnyPublisher()
}
}
以下是我们正在做的事情:
- 检查系统当前是否处于
loading
状态。 - 触发网络请求。
- 如果请求成功,反馈将发送一个包含电影列表的
onMoviesLoaded
事件。 - 如果发生故障,反馈将发送一个包含错误的
onFailedToLoadMovies
事件。
网络客户端与TMDB API 通信,以获取热门影片。我跳过了一些实现细节,以保持对主要主题的关注:
您可以在此处了解如何使用 Combine 构建基于承诺的网络层。
enum MoviesAPI {
static func trending() -> AnyPublisher<PageDTO<MovieDTO>, Error> {
let request = URLComponents(url: base.appendingPathComponent("trending/movie/week"), resolvingAgainstBaseURL: true)?
.addingApiKey(apiKey)
.request
return agent.run(request!)
}
}
列表条目用对象表示:
struct MovieDTO: Codable {
let id: Int
let title: String
let poster_path: String?
var poster: URL? { ... }
}
DTO 后缀表示我们正在使用域转移对象模式。
ListItem
是MovieDTO
的映射,用于演示:
extension MoviesListViewModel {
struct ListItem: Identifiable {
let id: Int
let title: String
let poster: URL?
init(movie: MovieDTO) {
id = movie.id
title = movie.title
poster = movie.poster
}
}
}
实现影片列表视图
设计视图模型后,现在我们可以从视图的实现开始。
首先,通过@ObservedObject
属性包装将视图绑定到视图模型状态更新:
struct MoviesListView: View {
@ObservedObject var viewModel: MoviesListViewModel
var body: some View {
...
}
}
接下来,在body
中,我们要向视图模型发送一个生命周期事件:
struct MoviesListView: View {
...
var body: some View {
NavigationView {
content
.navigationBarTitle("Trending Movies")
}
.onAppear { self.viewModel.send(event: .onAppear) }
}
private var content: some View {
...
}
}
状态呈现发生在content
变量中:
struct MoviesListView: View {
...
private var content: some View {
switch viewModel.state {
case .idle:
return Color.clear.eraseToAnyView()
case .loading:
return Spinner(isAnimating: true, style: .large).eraseToAnyView()
case .error(let error):
return Text(error.localizedDescription).eraseToAnyView()
case .loaded(let movies):
return list(of: movies).eraseToAnyView()
}
}
private func list(of movies: [MoviesListViewModel.ListItem]) -> some View {
...
}
}
以下是list(of:)
方法的实现方式:
private func list(of movies: [MoviesListViewModel.ListItem]) -> some View {
return List(movies) { movie in
NavigationLink(
destination: MovieDetailView(viewModel: MovieDetailViewModel(movieID: movie.id)),
label: { MovieListItemView(movie: movie) }
)
}
}
MovieDetailView
表示影片的详细信息。如果用户点击列表行,则该行将被推送到导航堆栈上。 MovieDetailView
使用视图模型进行初始化,而视图模型又接受电影标识符。
MovieListItemView
表示列表行。请注意,它接受MoviesListViewModel.ListItem
类型的视图模型,而不是MovieDTO
。重要的是不要将基础结构详细信息(即MovieDTO
)与表示(即视图模型)混合在一起。我跳过MovieListItemView
的实现,因为它与我们的主题没有直接关系。
实现影片详细信息
与热门电影列表相比,电影详细信息状态机是相同的:
以下是我们如何在代码中表示电影详细信息状态机:
final class MovieDetailViewModel: ObservableObject {
@Published private(set) var state: State
...
}
extension MovieDetailViewModel {
enum State {
case idle(Int)
case loading(Int)
case loaded(MovieDetail)
case error(Error)
}
enum Event {
case onAppear
case onLoaded(MovieDetail)
case onFailedToLoad(Error)
}
struct MovieDetail {
...
}
}
为了传递用户事件,我们会创建一个userInput
反馈:
final class MovieDetailViewModel: ObservableObject {
...
private let input = PassthroughSubject<Event, Never>()
func send(event: Event) {
input.send(event)
}
static func userInput(input: AnyPublisher<Event, Never>) -> Feedback<State, Event> {
Feedback(run: { _ in
return input
})
}
}
接下来,我们声明另一个触发网络请求的反馈:
static func whenLoading() -> Feedback<State, Event> {
Feedback { (state: State) -> AnyPublisher<Event, Never> in
guard case .loading(let id) = state else { return Empty().eraseToAnyPublisher() }
return MoviesAPI.movieDetail(id: id)
.map(MovieDetail.init)
.map(Event.onLoaded)
.catch { Just(Event.onFailedToLoad($0)) }
.eraseToAnyPublisher()
}
}
它从MoviesAPI
中调用movieDetail()
,以获取电影详细信息提供的电影标识符:
enum MoviesAPI {
...
static func movieDetail(id: Int) -> AnyPublisher<MovieDetailDTO, Error> {
let request = URLComponents(url: base.appendingPathComponent("movie/(id)"), resolvingAgainstBaseURL: true)?
.addingApiKey(apiKey)
.request
return agent.run(request!)
}
}
struct MovieDetailDTO: Codable {
let id: Int
let title: String
let overview: String?
let poster_path: String?
let vote_average: Double?
let genres: [GenreDTO]
let release_date: String?
let runtime: Int?
let spoken_languages: [LanguageDTO]
...
}
MovieDetailDTO
是为分析网络响应而创建的。它不应该泄漏到 UI 层中。当视图模型收到成功的网络响应时,它会将MovieDetailDTO
映射到MovieDetailViewModel
。后者是便于视图使用的相同数据的表示形式。
然后我们初始化状态机:
final class MovieDetailViewModel: ObservableObject {
@Published private(set) var state: State
private var bag = Set<AnyCancellable>()
init(movieID: Int) {
state = .idle(movieID)
Publishers.system(
initial: state,
reduce: Self.reduce,
scheduler: RunLoop.main,
feedbacks: [
Self.whenLoading(),
Self.userInput(input: input.eraseToAnyPublisher())
]
)
.assign(to: .state, on: self)
.store(in: &bag)
}
...
}
现在我们可以实现一个视图:
struct MovieDetailView: View {
@ObservedObject var viewModel: MovieDetailViewModel
var body: some View {
content
.onAppear { self.viewModel.send(event: .onAppear) }
}
private var content: some View {
switch viewModel.state {
case .idle:
return Color.clear.eraseToAnyView()
case .loading:
return spinner.eraseToAnyView()
case .error(let error):
return Text(error.localizedDescription).eraseToAnyView()
case .loaded(let movie):
return self.movie(movie).eraseToAnyView()
}
}
private func movie(_ movie: MovieDetailViewModel.MovieDetail) -> some View {
...
}
}
源码
您可以在此处找到最终项目。
推荐
iOS技术资料|地址;感谢您的阅读!然后如果有错误的地方,也请在评论区指出,帮助我把错的地方订正回来!