The Many Flavors of Unidirectional Architectures in Swift
Swift 中多种单向架构的韵味
How have SwiftUI and async/await changed the concept of state containers in these last six years?
分离逻辑与效果的不同方式
Back in 2017, I wrote about the concept of “State Containers”. Six years later, I still like to build most of my apps around them, using the very same concept for the two main layers inside my apps:
回到 2017 年,我曾撰文探讨“状态容器”的概念。六年后的今天,我依然倾向于围绕这一概念构建大部分应用,将其应用于应用内的两大主要层次:
- The view layer: a view model is a “view state container” modeling view-specific state and business logic.
视图层:视图模型作为“视图状态容器”,负责建模视图专属状态及业务逻辑。 - The domain layer: usually aggregate root models (or data repositories/services), representing the business rules and keeping the integrity and consistency around a specific bounded context (or entity) in the application.
领域层:通常为聚合根模型(或数据仓库/服务),代表业务规则,并确保应用中特定边界上下文(或实体)的完整性与一致性。
A lot has changed since 2017, though. In 2019, Apple introduced SwiftUI. Two years later, async/await came along. While we always like to think of good architectures as those not coupled to the specifics of the framework, good architectures are also good citizens of those same frameworks and the general ecosystem. So… How have SwiftUI and async/await changed the concept of state containers in these last six years? Let’s see.
自 2017 年以来,许多事物已发生巨变。2019 年,苹果推出了 SwiftUI;两年后,async/await 异步编程模型随之而来。尽管我们总希望优秀的架构能独立于框架细节,但同样地,优秀的架构也应成为这些框架及整个生态系统中的良好公民。那么……SwiftUI 与 async/await 在过去六年中如何改变了状态容器的概念呢?让我们一探究竟。
The Case Against MVVM in SwiftUI
SwiftUI 与 async/await 如何在过去六年中改变了状态容器的概念?
But first, let's talk about some recent conversations in the iOS community regarding MVVM and whether it is a good fit for SwiftUI. The premise is that “the View is the view model” already, so MVVM is unnecessary.
不过在此之前,先来聊聊 iOS 社区近期关于 MVVM 是否适合 SwiftUI 的讨论。其前提是“视图即视图模型”,因此认为 MVVM 显得多余。
I disagree with that premise. While it’s true that SwiftUI already has built-in primitives (in the form of property wrappers) to simplify a lot of the glue code around state observation and re-rendering, the view is just a declarative description of how the view layer looks like. A view model is so much more than that. It’s the place where the view-related business logic lives.
我不同意那个前提。虽然 SwiftUI 确实已经内置了原语(以属性包装器的形式)来简化围绕状态观察和重新渲染的胶水代码,但视图仅仅是对视图层外观的声明性描述。视图模型远不止于此,它是视图相关业务逻辑的栖息地。
But let’s start with the right question. Why would we want to move code out of the view layer? Without entering in (sometimes subjective) debates about responsibilities and how software should be split, there’s something clear: unless we move the code out of the view layer, it will be complicated to unit test. But where should we put that code?
但让我们从正确的问题开始。为什么我们要将代码从视图层移出?在不陷入(有时主观)的责任划分和软件应如何分割的争论中,有一点是明确的:除非我们将代码移出视图层,否则单元测试将变得复杂。那么,我们应该将这些代码放在哪里呢?
My answer to this, as most times, is “it depends”. Sometimes, we can have that logic inside a domain state container observed from different views that need the same information. Sometimes, it’s just view-specific business logic. In that case, I like to put that logic inside a view model (a view state container). Other times, it’s a matter of processing different information from different data sources and validating it to produce a view state. In all these scenarios, a view model makes a lot of sense. It makes sense because it bundles essential business logic, which is only important for that view. Moving this view-specific business logic to a domain model will likely make it less cohesive and, eventually, the wrong abstraction.
我的回答,一如往常,是“视情况而定”。有时,我们可以在一个领域状态容器内实现这种逻辑,并从需要相同信息的多个视图中进行观察。有时,它仅仅是视图特有的业务逻辑。在这种情况下,我喜欢将该逻辑放入视图模型(即视图状态容器)中。还有时候,涉及处理来自不同数据源的不同信息并进行验证,以生成视图状态。在所有这些场景中,视图模型都显得非常合理。它之所以合理,是因为它捆绑了仅对该视图至关重要的核心业务逻辑。将这种视图特有的业务逻辑迁移到领域模型中,很可能会降低其内聚性,最终导致错误的抽象层次。
Ultimately, it’s just a matter of moving business logic out of the view in a place where it makes sense, guided by cohesion and coupling principles (or SOLID principles in general), so it can be easily testable.
Finally, I wonder why the best iOS course in the world, Standford’s CS193P, still teaches MVVM as the main presentation pattern for SwiftUI apps.
归根结底,这只是将业务逻辑从视图中移出到一个合理位置的问题,这个位置应遵循内聚与耦合原则(或更广泛的 SOLID 原则),以便于轻松测试。
Also, Apple seems to feel the same way about not using View as the view model 🤔.
最后,我不禁好奇,为何全球最佳的 iOS 课程——斯坦福大学的 CS193P,仍然将 MVVM 作为 SwiftUI 应用的主要展示模式来教授。
此外,苹果公司似乎也持有类似观点,认为不应将 View 作为视图模型来使用🤔。
Now, get back to state containers.
现在,回到状态容器的话题。
The Shape of a State Container
反对在 SwiftUI 中使用 MVVM 的案例
I like to think of state containers (also "Stores" from now on) as just “black boxes” with some inputs and outputs. Specifically, a black box with some observable state that can also emit outputs.
我喜欢将状态容器(从现在起也称为“存储”)视为带有某些输入和输出的“黑盒子”。具体来说,是一个具有可观察状态并能发出输出的黑盒子。
@MainActor
protocol StoreType<State, Output>: ObservableObject where State: Equatable {
associatedtype State
associatedtype Output
associatedtype StateStream: AsyncSequence where StateStream.Element == State
associatedtype OutputStream: AsyncSequence where OutputStream.Element == Output
var stateStream: StateStream { get }
var outputStream: OutputStream { get }
var state: State { get set }
}
Some important decisions in that code are worth mentioning.
代码中的一些重要决策值得一提。
- I have decided to use
ObservableObject
and@MainActor
, so we can be sure that it can be used correctly from a SwiftUI view layer, regardless of the specific type of state: view state or domain state. We should allow enough flexibility to decide whether a view or domain layer makes sense.
我决定使用ObservableObject
和@MainActor
,这样我们就能确保无论状态的具体类型是视图状态还是领域状态,它都能从 SwiftUI 视图层正确使用。我们应该提供足够的灵活性,以便决定视图层或领域层是否合理。 - While Combine’s future is still unclear, I feel it’s safer and more future-proof to provide an API based on
AsyncSequence
and not couple the store with Combine in this case.
尽管 Combine 的未来尚不明朗,但我感觉基于AsyncSequence
提供 API 更为稳妥且更具前瞻性,在这种情况下不将存储与 Combine 耦合在一起。
One interesting nitpick detail about choosing AsyncSequence
instead of Combine is that the API is incorrect. Both StateStream
and OutputStream
should be streams that never fail, but there’s no way to constrain that with the AsyncSequence
type. In Combine, and thanks to the recent primary associated types in protocols, it would be as easy and succinct as this:
选择 AsyncSequence
而非 Combine 的一个有趣细节是 API 存在不准确之处。 StateStream
和 OutputStream
本应是永不失败的流,但 AsyncSequence
类型无法对此进行约束。在 Combine 中,得益于近期协议中的主要关联类型,实现起来既简单又精炼:
associatedtype StateStream: Publisher<State, Never>
associatedtype OutputStream: Publisher<Output, Never>
Unlike Combine, AsyncSequence
works by using async throwing functions, whose errors are always untyped by design.
与 Combine 不同, AsyncSequence
通过使用异步抛出函数工作,其错误设计为始终无类型。
mutating func next() async throws -> Self.Element?
Interestingly, Swift allows conforming to a protocol that declares a throwing function by omitting the throw
keyword in the implementation. That means that the equivalent of the Never
type in the Combine world would be to use a concrete implementation of AsyncSequence
with a non-throwing next
function:
有趣的是,Swift 允许通过在实现中省略 throw
关键字来遵循声明抛出函数的协议。这意味着在 Combine 世界中, Never
类型的等价物是使用一个具体实现的 AsyncSequence
,并搭配一个不抛出的 next
函数:
mutating func next() async -> Self.Element?
Fortunately, we will end up using the concrete StateStream
and OutputStream
types, so all this won’t be a problem.
幸运的是,我们最终将使用具体的 StateStream
和 OutputStream
类型,因此这些问题将不复存在。
Now, it’s time to create the different flavors of that StoreType
.
现在,是时候创建 StoreType
的不同版本了。
Different Flavors of State Size
状态容器的形态
This section brings us to the first two main flavors there are, depending on how we decide to organize our state:
本节将我们引向两种主要的状态管理方式,取决于我们如何组织状态:
- One single piece of state for the whole application (the Redux way).
- 整个应用共享单一状态(Redux 方式)。 - Different distributed pieces of state across the app (the Flux way).
- 应用中分布着不同的状态片段(Flux 方式)。
Each one has its pros and cons. The distributed approach's main "con" is keeping data consistent across those different state containers, which can be a hard problem to solve. This problem disappears altogether by just having a single state value. That is a huge advantage. But as you might expect, there are also some big cons with the single-state value approach: performance and coupling.
The performance problem 每种方式都有其优缺点。分布式方法的主要缺点在于保持不同状态容器间数据的一致性,这可能是一个难以解决的问题。而通过使用单一状态值,这一问题便不复存在,这是一个巨大的优势。但正如你可能预料到的,单一状态值的方法也存在一些重大缺点:性能问题和耦合性。
The performance problem comes in two ways. The mutation can be expensive due to the big state value and the lack of persistent structures in Swift. But, more importantly, any change of that value will retrigger view re-renders across the app (or lots of recomputations of functions and Equatable
checks). Even if SwiftUI can be smart enough not to render some view nodes, your view bodies will still be called, and you could reach performance bottlenecks. For simple apps, this might not be a problem. I know many apps using TCA, the well-known iOS architecture using the Redux approach, which are working great. But for more complex apps, you might likely run into performance issues, although you can always use a multi-store approach. For more info, take a look at Krzysztof Zabłocki’s blog post TCA Performance and Multi-Store.
性能问题主要体现在两个方面。首先,由于 Swift 中状态值庞大且缺乏持久化结构,状态突变可能会带来高昂的代价。更为关键的是,该值的任何变动都将导致整个应用内的视图重新渲染(或大量函数重新计算及 Equatable
检查)。即便 SwiftUI 足够智能,能够避免某些视图节点的渲染,视图主体仍会被调用,从而可能触及性能瓶颈。对于简单的应用而言,这或许不是问题。我了解许多采用 TCA(知名 iOS 架构,基于 Redux 方法)的应用运行良好。然而,在更为复杂的应用中,您很可能会遭遇性能挑战,尽管可以采用多存储方式来应对。欲了解更多详情,请参阅 Krzysztof Zabłocki 的博文《TCA 性能与多存储》。
The coupling problem 耦合问题
The coupling problem is something that a lot of libraries using the Redux pattern have. You are just coupled to the whole “AppState” value. Some libraries like TCA solve that by adding quite a lot of complexity to the architecture. Propagating state changes and messages from children states to eventually the root state comes with many “TCA DSL” and helpers that you have to learn: like using their prisms library (CasePaths) to work correctly with enums. Depending on your team and their knowledge about functional programming paradigms, the price to couple your whole app to TCA might be fine or simply too much. I still think TCA might be one of the best state management libraries available for iOS nowadays.
耦合问题是许多采用 Redux 模式的库所面临的挑战。你被整个“AppState”值所绑定。一些库如 TCA 通过增加相当多的架构复杂性来解决这一问题。从子状态传播状态变化和消息到最终的根状态,伴随着许多“TCA DSL”和辅助工具,你需要学习,比如使用他们的棱镜库(CasePaths)来正确处理枚举。根据你的团队及其对函数式编程范式的了解程度,将整个应用与 TCA 耦合的代价可能是可以接受的,也可能过于高昂。我仍然认为 TCA 可能是当今 iOS 上最好的状态管理库之一。
As for my personal preference, I prefer to use distributed state containers and bind the state as low as possible in the view hierarchy to avoid many view re-renders and body recomputations. Also, having the correct bounded contexts and modules that don’t need complex synchronization greatly helps. If you want to read more, Redux is half of a pattern is a great article.
就我个人偏好而言,我更倾向于使用分布式状态容器,并将状态尽可能低地绑定在视图层级中,以避免多次视图重渲染和主体重新计算。此外,拥有正确的边界上下文和模块,这些模块不需要复杂的同步,极大地帮助了简化流程。如果你想了解更多,《Redux 是半模式》是一篇很棒的文章。
Different Flavors of Separating Logic and Effects
状态大小的不同风格
Mixing everything 混合一切
Let’s start with the simplest flavor, which I already talked about in this blog post, and the one most people should be used to. Having state and effects mixed together by using a Store
base class that we’ll subclass (if needed).
让我们从最简单的风格开始,这种风格我在本博客文章中已经讨论过,也是大多数人应该熟悉的。通过使用一个 Store
基类(必要时我们可以继承它),将状态和效果混合在一起。
@dynamicMemberLookup
class Store<State, Output>: StoreType where State: Equatable {
private let stateSubject: CurrentValueSubject<State, Never>
lazy var stateStream = stateSubject.removeDuplicates().values
private let outputSubject: PassthroughSubject<Output, Never> = .init()
lazy var outputStream = outputSubject.values
init(state: State) {
self.state = state
stateSubject = .init(state)
}
@Published
var state: State {
didSet {
if !Thread.isMainThread {
// While `Store` is a main actor, we could have some non-isolated contexts where the state
// is not changed on the main thread. This problem can be minimized by setting "Strict Concurrency Checking"
// to "complete".
assertionFailure("Not on main thread")
}
// We have traceability of the state here, being able to log it if needed.
stateSubject.send(state)
}
}
func send(output: Output) {
outputSubject.send(output)
}
subscript<Value>(dynamicMember keypath: KeyPath<State, Value>) -> Value {
state[keyPath: keypath]
}
}
We can use it in its simplest form, just wrapping some values that we can change and observe:
我们可以以最简单的形式使用它,只需包装一些可以更改和观察的值:
enum Output { case somethingHappened }
let store = Store<Int, Output>(state: 0)
Task {
for await state in store.stateStream {
print("New state", state)
}
}
Task {
for await output in store.outputStream {
print("New output", output)
}
}
store.state = 1
store.send(output: .somethingHappened)
Usually, we’ll subclass that Store
type, where we’ll have those state changes and outputs with the appropriate business logic.
通常,我们会继承那个 Store
类型,在那里我们将拥有那些带有适当业务逻辑的状态变化和输出。
enum NumbersViewState: Equatable {
case idle
case loading
case loaded([Int])
case error
}
enum NumbersViewOutput {
case numbersDownloaded
}
final class NumbersViewModel: Store<NumbersViewState, NumbersViewOutput> {
init() {
super.init(state: .idle)
}
func load() async {
state = .loading
do {
let numbers = try await apiClient.numbers()
state = .loaded(numbers)
// In this case, the output message is not adding anything meaningful, but it's just
// an example of some "fire and forget" event that can be useful for things that, unlike state, are not persistent.
send(output: .numbersDownloaded)
} catch {
state = .error
}
}
}
Simple enough. We subclass the Store
type and provide methods (inputs) to bundle the business logic, execute side effects, change state, and trigger output messages.
简单明了。我们继承 Store
类型并提供方法(输入)来打包业务逻辑、执行副作用、改变状态并触发输出消息。
Then, we can very easily subscribe to that NumbersViewModel
, both from SwiftUI (by using @StateObject
or @ObservedObject
), or from UIKit, by listening to StateStream
and rendering the state to UIKit views correctly.
然后,我们可以非常容易地订阅那个 NumbersViewModel
,无论是从 SwiftUI(通过使用 @StateObject
或 @ObservedObject
),还是从 UIKit,通过监听 StateStream
并将状态正确渲染到 UIKit 视图中。
Task {
for await state in numbersViewModel.stateStream {
switch state {
case .idle, .loading:
loadingView.isHidden = false
numbersView.isHidden = true
errorView.isHidden = true
case .loaded(let numbers):
loadingView.isHidden = true
errorView.isHidden = true
numbersView.isHidden = false
numbersView.render(with: numbers)
case .error:
loadingView.isHidden = true
numbersView.isHidden = true
errorView.isHidden = false
}
}
}
I’ve personally had great success with this first kind of flavor. While it lacks some proper encapsulation of the state (as it can be mutated from outside the concrete store subclass due to the lack of protected
semantics in Swift), it’s familiar, simple, flexible, gives us accurate traceability of the state changes, and keeps them on the main thread due to @MainActor
semantics. I don’t know of any concrete well-known iOS libraries using this pattern, but I know some of them in the Android world. Airbnb’s Mavericks could be the most important one. Orbit is another nice one.
我个人在使用这种第一种风格时取得了巨大成功。尽管它缺乏对状态的适当封装(由于 Swift 中缺少 protected
语义,可以从具体存储子类外部进行突变),但它熟悉、简单、灵活,为我们提供了状态变化的准确可追溯性,并由于 @MainActor
语义而保持在主线程上。我不清楚是否有知名的 iOS 库采用这种模式,但我知道在 Android 领域有一些。Airbnb 的 Mavericks 可能是最重要的一个,Orbit 是另一个不错的例子。
But sometimes, having traceability of the state is just not enough. We want to know why that state changed and have a more constrained way of managing effects. And for that, we’ll need to complicate things a little bit.
但有时,仅具备状态的可追溯性还不够。我们希望了解状态变化的原因,并采用更受限的方式来管理效果。为此,我们需要稍微复杂化一些。
Separating logic from effects
将逻辑与效果分离
As I said, this is where things get much more complex (but also fun). I also talked about this flavor in the past here.
正如我所说,这里事情变得更加复杂(但也更有趣)。我过去在这里也谈过这种风格。
While appealing, the simplicity and flexibility of the previous approach have their drawbacks. The main one is that the freedom to manage logic, state changes, and effects however we want comes at a price. In straightforward examples like the previous one, it’s hard to see the advantage of complicating that. But with more complex business logic, teams, and requirements, having a more constrained and formal approach to dealing with state and effects is extremely useful and tends to scale better in my experience.
虽然吸引人,但前述方法的简单性和灵活性也存在不足。主要问题在于,自由管理逻辑、状态变化和效果的代价是高昂的。在像之前那样简单的示例中,很难看出复杂化的优势。但在涉及更复杂的业务逻辑、团队和需求时,采用更为约束和正式的状态与效果处理方法,在我看来极为有用且更易于扩展。
Let’s have some preconditions first.
- We need a way to establish the relationship between the messages, the state changes, and the effects triggered by them.
首先,我们设定一些前提条件: - We need to have proper encapsulation of the different message types. Wrong message encapsulation is, unfortunately, very common amongst these types of architectures. It’s not the same a public
onAppear
message than a privatedidReceiveData
message. - We need a way to model our effects correctly, leveraging async/await.
我们需要一种方法来建立消息、状态变化及其触发效果之间的关系。 我们需要对不同消息类型进行适当的封装。不幸的是,在这些架构中,错误的消息封装非常普遍。公开的@0#消息与私有的@1#消息并不相同。 我们需要一种方式,利用 async/await 正确地建模我们的效果。
During these last few years, there have been many (too many, I’d say) flavors of these unidirectional architectures, each with its own “opinions” and “trade-offs” in the way they manage state and effects.
在过去的几年里,出现了许多(我得说,太多了)这类单向架构的变体,每一种都有其独特的“见解”和“权衡”,关于它们如何管理状态和效果。
- Some of those are “too Combine heavy”, forcing you to couple your app to those Combine combinators.
有些变体过于依赖 Combine,迫使你将应用与那些 Combine 组合器紧密耦合。 - Some others bring the concept of “mutation” on top of the concept of “message” (or event/action) so they can differentiate between the actual “state change” and the “intention of change”. The main inspiration is Vue’s architecture, Vuex. ReactorKit is an excellent iOS library using that concept.
另一些则将“突变”概念叠加在“消息”(或事件/动作)之上,以便区分实际的“状态变化”和“变化意图”。其主要灵感来自 Vue 的架构 Vuex。ReactorKit 是一个优秀的 iOS 库,采用了这一概念。 - Some other flavors model effects as “feedbacks”: a simple
(Publisher<State>) -> Publisher<Event>
function. ReactiveFeedback is another great library following this pattern.
还有一些变体将效果建模为“反馈”:一个简单的(Publisher<State>) -> Publisher<Event>
函数。ReactiveFeedback 是遵循这一模式的另一个出色库。
In my experience, those flavors complicate things unnecessarily and make the code harder to understand. Especially the concept of modeling effects as “feedback packages”, as it worsens readability and obfuscates control flow. Let’s imagine that we want to understand what happens in the system whenever an action happens:
根据我的经验,这些变体不必要地使事情复杂化,并使代码更难理解。特别是将效果建模为“反馈包”的概念,它降低了可读性,并混淆了控制流。想象一下,我们想要理解每当一个动作发生时系统中会发生什么:
- First, we’d go to the function that processes the message and changes the state. That function is usually called a reducer.
首先,我们会进入处理消息并改变状态的函数,这个函数通常被称为 reducer。 - Then, to know what effects are triggered by the previous message, we’d have to check all the different “feedbacks” to see if that new state will trigger any of those.
接着,为了了解前一条消息触发了哪些效果,我们需要检查所有不同的“反馈”,看看新状态是否会触发其中任何一个。
That makes the code hard to understand and to build the appropriate mental model needed to change that code. Here’s a nice article explaining some of these problems in an MVI architecture.
这使得代码难以理解和构建修改代码所需的心智模型。这里有一篇不错的文章,解释了 MVI 架构中的一些此类问题。
The solution? Instead of “magically running” the effects based on the state change, let the reducer decide the effects to run.
解决方案是什么?与其“神奇地”根据状态变化运行效果,不如让 reducer 决定要运行的效果。
That way, we can follow precisely the code execution from the point where a message is sent into the system to the point where all the effects have finished running.
这样一来,我们可以精确地跟踪从消息被发送到系统中,到所有效果运行完毕的代码执行过程。
Input message -> State changed -> Effect -> Feedback message…
输入消息 -> 状态变更 -> 执行效果 -> 反馈消息……
The different ways of commanding effects
命令效果的不同方式
There are different ways that a reducer can decide the effect to run:
一个 reducer 可以通过多种方式决定要运行的效果:
- We can wrap the effectful computation inside a data structure with the correct async context. Something like this
我们可以将具有正确异步上下文的效果计算封装在一个数据结构中。类似于这样
func handle(event: Event, state: inout State) -> Effect<Event> {
switch event {
case .onAppear:
state = .loading
return .task { send in
let numbers = await apiClient.numbers()
send(.didFinishFetching(numbers))
}
case .didFinishFetching(let numbers):
state = .loaded(numbers)
return .none
}
}
This is how Pointfree’s TCA library works.
这就是 Pointfree 的 TCA 库的工作原理。
- Another option is to return a value representing the effect that will be interpreted later (by an effects handler).
另一种选择是返回一个值,该值代表稍后由效果处理器解释的效果。
func handle(event: Event, state: inout State) -> [Effect] {
switch event {
case .onAppear:
state = .loading
return [.downloadNumbers]
case .didFinishFetching(let numbers):
state = .loaded(numbers)
return .none
}
}
class EffectHandler {
func handle(effect: Effect) async {
switch effect {
case .downloadNumbers:
let numbers = await apiClient.numbers()
send(.didFinishFetching(numbers))
}
}
}
This is how Spotify’s Mobius library works, which, IIRC, it’s based on Andy Matuschak's writing: A composable pattern for pure state machines with effects.
这就是 Spotify 的 Mobius 库的工作方式,据我所知,它基于 Andy Matuschak 的文章:一种用于纯状态机的效果组合模式。
- The last option is to return a type conforming to a protocol that bundles the asynchronous work.
最后一种选择是返回符合某个协议的类型,该协议捆绑了异步工作。
func handle(event: Event, state: inout State) -> [Effect] {
switch event {
case .onAppear:
state = .loading
return [DownloadNumbers()]
case .didFinishFetching(let numbers):
state = .loaded(numbers)
return .none
}
}
class DownloadNumbers: Effect {
func run() async {
let numbers = await apiClient.numbers()
send(.didFinishFetching(numbers))
}
}
This is how Square’s Workflow architecture works.
这就是 Square 的 Workflow 架构的工作方式。
Each of these flavors has its pros and cons.
每种方式都有其优缺点。
- The first approach has fewer “jumps” and it’s very easy to see the result of the effect and go to the feedback message
.didFinishFetching
. The downside is that, for complex effects, the reducer can end up big and complicated. But we can always extract that code into separate functions as needed.
第一种方法跳转较少,很容易看到效果的结果并跳转到反馈消息.didFinishFetching
。缺点是,对于复杂的效果,reducer 可能会变得庞大且复杂。不过,我们可以根据需要将代码提取到单独的函数中。 - The second approach has the advantage of being able to test the reducer very easily. We can exercise the function without needing to create dependencies or mock anything. The reducer becomes the “pure layer”, which is easy to test, while the “effects handler” becomes the “impure layer”, which we may not need to test, as it should be a humble object.
- The third approach might have a more self-contained, cohesive package for a specific effect, bundling the dependencies needed without polluting the reducer with all the different effects dependencies.
第二种方法的优势在于能够非常容易地测试 reducer。我们可以在不创建依赖或模拟任何内容的情况下执行该函数。reducer 成为了“纯层”,易于测试,而“效果处理器”则成为了“非纯层”,我们可能不需要测试它,因为它应该是一个谦卑的对象。
I don’t have strong opinions about the best option here, to be completely honest. The second approach is the one I’ve used the most, and it has worked well for me. But sometimes, those “jumps” between the reducer function and the effect handling could be inconvenient. I recommend putting both in the same file to jump between both parts easily.
第三种方法可能为特定效果提供了一个更自包含、内聚的包,捆绑了所需依赖而不将所有不同效果的依赖污染到 reducer 中。
说实话,我对哪种选项最好没有强烈的看法。第二种方法是我使用最多的,对我来说效果很好。但有时,reducer 函数和效果处理之间的“跳转”可能会带来不便。我建议将两者放在同一个文件中,以便轻松地在两部分之间跳转。
And about testing with the second approach… while we can test the reducer to see the actual effects values returned by a particular message, that test might not be the best test considering that effects are implementation details (at least from the store’s public API point of view). The actual state container “observable API” we should assert against is the tuple (state, output)
. Changing how we handle our effects without modifying our observable API and behavior shouldn’t break our tests.
至于第二种方法的测试……虽然我们可以测试 reducer,以观察特定消息返回的实际效果值,但考虑到效果是实现细节(至少从 store 的公共 API 角度来看),这种测试可能并非最佳选择。我们应断言的实际状态容器“可观察 API”是元组 (state, output)
。在不修改可观察 API 和行为的情况下改变效果处理方式,不应导致测试失败。
For those reasons, I guess the first option might be my preferred option and the one I will implement in the next section.
基于这些原因,我猜第一种选项可能是我更偏爱的选择,也是我将在下一节中实现的方法。
Final implementation 最终实现
Let’s now build the complete implementation of the aforementioned architecture. Feel free to copy everything into a playground and play with the result.
现在让我们构建上述架构的完整实现。欢迎将所有内容复制到 Playground 中,尽情体验结果。
As I mentioned, having a correct encapsulation of the messages is important and leads to a safer and cleaner API. That’s why we have three types of messages:
正如我所言,确保消息的正确封装至关重要,它带来更安全、更简洁的 API。因此,我们定义了三种消息类型:
- Input:
.onAppear
events for view stores or.downloadData
commands for domain stores.
输入:.onAppear
视图存储的事件或.downloadData
领域存储的命令。 - Feedback:
.didDownloadData
event. These are private, sent from the effects, and cannot be sent directly from a store instance.
反馈:.didDownloadData
事件。这些是私有的,由副作用发送,不能直接从存储实例发送。 - Output: Fire and forget events when we need to signal information that should not persist, unlike the state.
输出:当我们需要传递不应持久化的信息时,触发即忘的事件,这与状态不同。
The Effect
type has two responsibilities:
Effect
类型承担两项职责:
- Wrapping the asynchronous work, the effect itself.
包装异步工作,即效果本身。 - Notifying of any output message.
通知任何输出消息。
struct Effect<Feedback, Output> {
typealias Operation = (@Sendable @escaping (Feedback) async -> Void) async -> Void
fileprivate let output: Output?
fileprivate let operation: Operation
init(
output: Output?,
operation: @escaping Operation
) {
self.output = output
self.operation = operation
}
}
extension Effect {
static var none: Self {
return .init(output: nil) { _ in }
}
static func output(_ output: Output) -> Self {
return .init(output: output) { _ in }
}
static func run(operation: @escaping Operation) -> Self {
self.init(output: nil, operation: operation)
}
}
The reducer will be the one modifying the state given a specific message and returning the correct Effect
type.
reducer 将根据特定消息修改状态,并返回正确的 Effect
类型。
@MainActor
protocol Reducer<State, Input, Feedback, Output>: AnyObject where State: Equatable {
associatedtype State
associatedtype Input
associatedtype Feedback = Never
associatedtype Output = Never
func reduce(
message: Message<Input, Feedback>,
into state: inout State
) -> Effect<Feedback, Output>
}
With Message
being just the sum type of the Input
and Feedback
messages.
其中, Message
仅是 Input
和 Feedback
消息的和类型。
enum Message<Input, Feedback>: Sendable where Input: Sendable, Feedback: Sendable {
case input(Input)
case feedback(Feedback)
}
And finally, the Store. 最后,是 Store。
import Combine
@MainActor
@dynamicMemberLookup
class Store<State, Input, Feedback, Output>: ObservableObject where State: Equatable, Input: Sendable, Feedback: Sendable {
@Published
private(set) var state: State
private let reducer: any Reducer<State, Input, Feedback, Output>
private let stateSubject: CurrentValueSubject<State, Never>
lazy var stateStream = stateSubject.removeDuplicates().values
private let outputSubject: PassthroughSubject<Output, Never> = .init()
lazy var outputStream = outputSubject.values
private var tasks: [Task<Void, Never>] = []
deinit {
for task in tasks {
task.cancel()
}
}
init(
state: State,
reducer: some Reducer<State, Input, Feedback, Output>
) {
self.state = state
self.reducer = reducer
stateSubject = .init(state)
}
@discardableResult
func send(_ message: Input) -> Task<Void, Never> {
let task = Task { await send(.input(message)) }
tasks.append(task)
return task
}
func send(_ message: Input) async {
await send(.input(message))
}
private func send(_ message: Message<Input, Feedback>) async {
guard !Task.isCancelled else { return }
let effect = reducer.reduce(message: message, into: &state)
stateSubject.send(state)
if let output = effect.output {
outputSubject.send(output)
}
await effect.operation { [weak self] feedback in
guard !Task.isCancelled else { return }
await self?.send(.feedback(feedback))
}
}
subscript<Value>(dynamicMember keypath: KeyPath<State, Value>) -> Value {
state[keyPath: keypath]
}
}
Let’s now build the previous NumbersViewModel
example by using the new architecture.
现在,让我们利用新架构来构建之前的 NumbersViewModel
示例。
import Foundation
final class APIClient {
func numbers() async throws -> [Int] {
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
return [1, 2, 3]
}
}
final class NumbersViewReducer: Reducer {
enum State: Equatable {
case idle
case loading
case loaded([Int])
case error
}
enum Input {
case onAppear
}
enum Feedback {
case numbersDownloaded(Result<[Int], Error>)
}
enum Output {
case numbersDownloaded
}
private let apiClient = APIClient()
func reduce(message: Message<Input, Feedback>, into state: inout State) -> Effect<Feedback, Output> {
switch message {
case .input(.onAppear):
state = .loading
return .run { [weak self] send in
guard let self else { return }
do {
let numbers = try await apiClient.numbers()
await send(.numbersDownloaded(.success(numbers)))
} catch {
await send(.numbersDownloaded(.failure(error)))
}
}
case .feedback(.numbersDownloaded(.success(let values))):
state = .loaded(values)
return .output(.numbersDownloaded)
case .feedback(.numbersDownloaded(.failure)):
state = .error
return .none
}
}
}
import SwiftUI
struct NumbersListView: View {
@StateObject private var viewModel = Store(
state: NumbersViewReducer.State.idle,
reducer: NumbersViewReducer()
)
var body: some View {
Group {
switch viewModel.state {
case .idle, .loading:
Text("…")
case .loaded(let values):
List(values, id: \.self) { value in
Text("\(value)")
}
case .error:
Text("Some error happened")
}
}.onAppear {
viewModel.send(.onAppear)
}
}
}
Conclusion 结论
Thanks a lot for reaching the end of the article. It was a long (and hopefully interesting) read.
非常感谢您阅读到文章的结尾,这是一段漫长(且希望是有趣的)阅读之旅。
During these last years, we’ve had an explosion of state management libraries. We’ve seen how unidirectional architectures have taken over the Mobile space, especially alongside new UI declarative frameworks like SwiftUI (and Jetpack Compose on Android), where they perfectly fit.
在过去的几年里,状态管理库如雨后春笋般涌现。我们见证了单向数据流架构如何主导移动开发领域,尤其是在 SwiftUI(以及 Android 上的 Jetpack Compose)等新型声明式 UI 框架中,它们完美契合。
But not only SwiftUI… async/await has also made a big impact on how we develop our apps and handle concurrency and side effects, which has impacted many of the new architectures that have emerged during these last years.
但不仅仅是 SwiftUI……async/await 也极大地影响了我们开发应用的方式以及处理并发和副作用的手段,这进而影响了近年来涌现的许多新架构。
All the separation of state management and effects has emerged a lot of new functional paradigms within the iOS community, with many people already using TCA as their default architecture for any new iOS project with SwiftUI. This is great news and shows the maturity of the iOS ecosystem at this point.
状态管理与效果分离的理念在 iOS 社区中催生了许多新的功能性范式,许多人已将 TCA 作为使用 SwiftUI 的新 iOS 项目的默认架构。这是个好消息,展现了 iOS 生态系统在此阶段的成熟度。
While Swift will keep evolving, I don’t particularly foresee any major changes in the coming years that will dramatically change how we develop applications, or at least in a way that can impact state management libraries so drastically, as has happened recently.
尽管 Swift 将继续演进,我并不特别预见未来几年会有重大变革会显著改变我们开发应用的方式,至少不会像最近那样对状态管理库产生如此剧烈的影响。
Well… Maybe “Swift Data” to replace Core Data… we’ll see in a few months at WWDC23.
嗯……或许“Swift Data”会取代 Core Data……我们将在几个月后的 WWDC23 上见分晓。