初识 The Composable Architecture(二)

本文延续上一节对 TCA 的建模思路,聚焦「副作用」该如何在 reducer 中显式表达与测试。我们会把原本隐式的 I/O 操作抽象为 Effect,让 reducer 返回副作用,并进一步调整 combine/pullback 以支持一组 effect 的组合。通过 Favorite Primes 的保存/读取示例,展示从「纯函数」到「可追踪副作用」的演化过程。

Reducer 的副作用

同步副作用

我们在 FavoritePrimesAction 中新增一个 action:saveButtonTapped

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public enum FavoritePrimesAction {
case deleteFavoritePrimes(IndexSet)
case loadedFavoritePrimes([Int])
case saveButtonTapped // 新增 action
}

// 在原本的 reducer 中新增对这个 action 的处理
...
case .saveButtonTapped:
let data = try! JSONEncoder().encode(state)
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
try! data.write(
to: documentsUrl.appendingPathComponent("favorite-primes.json")
)
...

可以发现:favoritePrimesReducer 已经不再是纯函数了。它不只是根据输入(state + action)计算输出,而是隐式依赖外部世界(JSONEncoder 等),并且产生隐式输出(写数据至硬盘),这些都没有体现在函数签名里。换句话说,这个 reducer 在收到 action saveButtonTapped 时执行了副作用:把 favoritePrimes 数组写入硬盘。当你第一眼看到这个 reducer 时,很难看出它除了更新 state 之外还会写数据到硬盘。为这个 reducer 写测试会受外部状态影响(比如写入前磁盘已满),导致测试不稳定且成本高。

在 reducer 里直接执行副作用,会让函数有隐藏输入/输出,破坏可理解性与可测试性。

我们看一个简单的例子:

1
2
3
4
5
func compute(_ x: Int) -> Int {
let computation = x * x + 1
print("Computed \(computation)")
return computation
}

compute 函数在计算完成后会返回结果。这样看来,它似乎满足纯函数的定义,但它的副作用是打印出计算结果,这种行为不可测试、也没体现在函数签名里。我们可以让 compute 函数返回「需要打印的内容」,由调用者决定是否真的打印,这样副作用就变成了显式输出。

1
2
3
4
func computeAndPrint(_ x: Int) -> (Int, [String]) {
let computation = x * x + 1
return (computation, ["Computed \(computation)"])
}

回看 Reducer 的函数签名:

1
public typealias Reducer<Value, Action> = (inout Value, Action) -> Void

我们需要定义一个结构体 Effect,代表 reducer 所执行的副作用,并显式返回这个副作用。

1
2
public typealias Effect = () -> Void
public typealias Reducer<Value, Action> = (inout Value, Action) -> Effect

更新 favoritePrimesReducer,这样写入硬盘的副作用就被显式返回。

1
2
3
4
5
6
7
8
9
10
11
12
case .saveButtonTapped:
let state = state
return {
let data = try! JSONEncoder().encode(state)
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
try! data.write(
to: documentsUrl.appendingPathComponent("favorite-primes.json")
)
}

如果我们往 FavoritePrimesAction 里新增一个 action loadButtonTapped 呢?在点击 Load 按钮后,从硬盘读取保存的 favoritePrimes 数组,再通过 action loadedFavoritePrimes 把结果发送回 store 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public enum FavoritePrimesAction {
case deleteFavoritePrimes(IndexSet)
case loadButtonTapped
case loadedFavoritePrimes([Int])
case saveButtonTapped
}

...
case .loadButtonTapped:
return {
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
let favoritePrimesUrl = documentsUrl
.appendingPathComponent("favorite-primes.json")
guard
let data = try? Data(contentsOf: favoritePrimesUrl),
let favoritePrimes = try? JSONDecoder()
.decode([Int].self, from: data)
else { return }
self.store.send(.loadedFavoritePrimes(favoritePrimes)) // Error: Use of unresolved identifier 'self'
}

我们发现一个问题:reducer 里没有 selfstore,副作用完成后无法把结果送回去更新状态。在视图里时,副作用可以直接 send action;但在 reducer 里,Effect 只是 () -> Void,只能「做完事就消失」,结果无法影响后续 state。因此需要调整模型:让 Effect 返回一个 Action,把副作用的结果封装成 action,再交给 reducer 处理。这样 effect 只负责最小工作量,状态更新仍由 reducer 完成,更清晰也更可测试。

并不是所有 effect 都需要回传数据。例如把 favoritePrimes 写入磁盘这种「一次性」的操作,并没有新的 action 要交给系统。因此更合理的做法是让 effect 返回一个可选的 action:需要回传时返回具体 action,不需要时返回 nil,由 store 忽略即可:

1
2
public typealias Effect<Action> = () -> Action?
public typealias Reducer<Value, Action> = (inout Value, Action) -> Effect<Action>

回看 combine 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
public func combine<Value, Action>(
_ reducers: Reducer<Value, Action>...
) -> Reducer<Value, Action> {
return { value, action in
let effects = reducers.map { $0(&value, action) }
return {
for effect in effects {
effect() // Warning: Result of call to function returning ‘Action?’ is unused
}
}
...
}
}

假设这里是要组合三个 reducer,那么 combine 后得出的新 reducer 在收到一个 action 后,至多会产生三个副作用,这就无法只返回一个副作用。因此更合理的方案是让 reducer 返回多个 effect,这样组合 reducer 时就能把所有副作用完整保留:

1
public typealias Reducer<Value, Action> = (inout Value, Action) -> [Effect<Action>]

再看看 pullback 函数应如何更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction>(
_ reducer: @escaping Reducer<LocalValue, LocalAction>,
value: WritableKeyPath<GlobalValue, LocalValue>, // GlobalValue -> LocalValue
action: WritableKeyPath<GlobalAction, LocalAction?> // GlobalAction -> LocalAction?
) -> Reducer<GlobalValue, GlobalAction> {
return { globalValue, globalAction in
guard let localAction = globalAction[keyPath: action] else { return [] }
let localEffects = reducer(&globalValue[keyPath: value], localAction)
return localEffects.map { localEffect in
return {
guard let localAction = localEffect() else { return nil }
var globalAction = globalAction
globalAction[keyPath: action] = localAction
return globalAction
}
}
}
}

pullback 函数接受一个局部 reducer 和两个 WritableKeyPath,返回一个全局 reducer。而返回的全局 reducer 需要返回一个副作用数组([Effect<GlobalAction>])。目标是返回 [Effect<GlobalAction>],我们拥有的信息是 [Effect<LocalAction>]WritableKeyPath<GlobalAction, LocalAction?>。不难想到:利用该 WritableKeyPathlocalEffect() 得出的 localAction 来计算出 globalEffect 所需的 globalAction

pullback 的实现虽然代码不少,但逻辑其实很清晰:它把「处理局部 state / action 的 reducer」提升为「处理全局 state / action 的 reducer」。之所以能做到这一点,是因为我们用 enum property 生成了 WritableKeyPath,可以从全局取出局部、再把局部改动写回全局。具体流程是:当全局 stateaction 进入时,先用 WritableKeyPath 提取出局部 state / action,交给局部 reducer 处理,然后把更新后的局部 state 写回全局 state

同时,局部 reducer 会产生一组局部 effect,这些 effect 可能会返回局部 action。我们再用 key path 把局部 action 嵌回全局 action,从而把局部 effect 变成全局 effect,并对数组逐个转换完成整个过程。

更新 favoritePrimesReducer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public enum FavoritePrimesAction {
case deleteFavoritePrimes(IndexSet)
case loadButtonTapped
case loadedFavoritePrimes([Int])
case saveButtonTapped
}

public func favoritePrimesReducer(state: inout [Int], action: FavoritePrimesAction) -> [Effect<FavoritePrimesAction>] {
switch action {
case let .deleteFavoritePrimes(indexSet):
for index in indexSet {
state.remove(at: index)
}
return []

case let .loadedFavoritePrimes(favoritePrimes):
state = favoritePrimes
return []

case .saveButtonTapped:
return [saveEffect(favoritePrimes: state)]

case .loadButtonTapped:
return [loadEffect]
}
}

private func saveEffect(favoritePrimes: [Int]) -> Effect<FavoritePrimesAction> {
return {
let data = try! JSONEncoder().encode(favoritePrimes)
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
let favoritePrimesUrl = documentsUrl
.appendingPathComponent("favorite-primes.json")
try! data.write(to: favoritePrimesUrl)
return nil // 不需要回传数据给 store,直接返回 nil
}
}

private let loadEffect: Effect<FavoritePrimesAction> = {
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
let favoritePrimesUrl = documentsUrl
.appendingPathComponent("favorite-primes.json")
guard
let data = try? Data(contentsOf: favoritePrimesUrl),
let favoritePrimes = try? JSONDecoder().decode([Int].self, from: data)
else { return nil }
return .loadedFavoritePrimes(favoritePrimes) // 需要回传数据 favoritePrimes 数组
}

单向数据流

从上文的 loadButtonTapped 可以看出,导致状态变化的过程为:先运行 reducer、收集 effect,再执行 effect,把产出的 action 重新送回 reducer。这就是所谓的单向数据流:状态只能通过 action 进入 reducer 来改变。如果副作用想影响状态,也必须先生成新的 action,再由 reducer 统一处理。这种模式的优点是非常可理解——状态的变化只有一个入口;代价是需要额外的 action 来回传副作用结果。

异步副作用

如果想要 Effect<Action> 支持异步运行,我们还得修改它的函数签名。可以对比 URLSession 的 API 设计,从中汲取灵感:

1
2
3
4
5
6
7
8
func dataTask(
with url: URL,
completionHandler: @escaping (Data?, URLResponse?, (any Error)?) -> Void
) -> URLSessionDataTask

public typealias Effect<Action> = () -> Action? // 旧

public typealias Effect<Action> = (@escaping (Action) -> Void) -> Void // 新

在异步方法中持有这个闭包 (@escaping (Action) -> Void) -> Void,组装好 action 时,通过调用这个闭包把 action 传递出去。

再看看 pullback 函数应如何更新:

它需要把局部 effect 转换成全局 effect:做法是先执行局部 effect 拿到局部 action,再用回调把这个局部 action 嵌入到全局 action 中,从而得到可在全局环境里执行的 effect。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction>(
_ reducer: @escaping Reducer<LocalValue, LocalAction>,
value: WritableKeyPath<GlobalValue, LocalValue>,
action: WritableKeyPath<GlobalAction, LocalAction?>
) -> Reducer<GlobalValue, GlobalAction> {
return { globalValue, globalAction in
guard let localAction = globalAction[keyPath: action] else { return [] }
let localEffects = reducer(&globalValue[keyPath: value], localAction)
return localEffects.map { localEffect in
{ callback in
localEffect { localAction in
var globalAction = globalAction
globalAction[keyPath: action] = localAction
callback(globalAction)
}
}
}
}
}

使用 Combine 优化

能不能把 Effect 变成一个 Publisher,利用 Combine 来实现异步副作用呢?答案是可以的!

1
2
3
4
5
6
7
8
9
10
11
public struct Effect<Output>: Publisher {
public typealias Failure = Never

let publisher: AnyPublisher<Output, Failure>

public func receive<S>(
subscriber: S
) where S: Subscriber, Failure == S.Failure, Output == S.Input {
self.publisher.receive(subscriber: subscriber)
}
}

我们接着更新 Store 中的 send 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private var effectCancellables: Set<AnyCancellable> = []
...

public func send(_ action: Action) {
let effects = self.reducer(&self.value, action)
effects.forEach { effect in
var effectCancellable: AnyCancellable!
effectCancellable = effect.sink(
receiveCompletion: { [weak self] _ in
self?.effectCancellables.remove(effectCancellable)
},
receiveValue: self.send
)
self.effectCancellables.insert(effectCancellable)
}
}
  1. 当 effect 收到结果时,继续调用 self.send,把副作用结果传递出去。
  2. 当 effect 停止发送元素时,移除掉之前保存下来的订阅 token(AnyCancellable)。
  3. 当 effect 订阅完毕后,保存订阅 token 至 effectCancellables

乍一看这个逻辑似乎没问题,但当遇到没有副作用的 action 时,就会有问题了!我们先来看个小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import Combine

let pub: AnyPublisher = Empty<Void, Never>(completeImmediately: true).eraseToAnyPublisher()
var set: Set<AnyCancellable> = []
var cancellable: AnyCancellable!

cancellable = pub.sink { _ in
print("Received Completion")
set.remove(cancellable) // 崩溃发生在此处
} receiveValue: { _ in
print("Do nothing!")
}

print("insert cancellable")
set.insert(cancellable)

// Received Completion
// __lldb_expr_3/MyPlayground.playground:11: Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value

当我们订阅一个立即结束的 Publisher 时,在 sink 函数返回之前,Combine 就会触发 receiveCompletion 闭包。所以可以考虑把 cancellable 改为显式可选值 AnyCancellable?

1
2
3
4
5
6
7
8
9
10
11
var effectCancellable: AnyCancellable?
effectCancellable = effect.sink(
receiveCompletion: { [weak self] _ in
guard let effectCancellable = effectCancellable else { return }
self?.effectCancellables.remove(effectCancellable)
},
receiveValue: self.send
)
if let effectCancellable = effectCancellable {
effectCancellables.insert(effectCancellable)
}

这样又带来另一个问题:当我们订阅一个立即结束的 Publisher 时,先调用 receiveCompletion,并不会移除当前订阅 token,因为 sink 函数还没返回。当 sink 函数执行完后,我们把当前订阅 token 加入 effectCancellables 中。不难发现:我们没有移除这个订阅 token 的时机了!我们还得新增一个 flag didComplete 来标记当前 effect 是否结束:

1
2
3
4
5
6
7
8
9
10
11
12
13
var effectCancellable: AnyCancellable?
var didComplete = false
effectCancellable = effect.sink(
receiveCompletion: { [weak self] _ in
didComplete = true
guard let effectCancellable = effectCancellable else { return }
self?.effectCancellables.remove(effectCancellable)
},
receiveValue: { [weak self] in self?.send($0) }
)
if !didComplete, let effectCancellable = effectCancellable { // 在当前 effect 未结束时,且有对应的订阅 token,才把它加入 effectCancellables 中
effectCancellables.insert(effectCancellable)
}

但这里还有 1 处内存泄漏:effectCancellablesink 的返回值,因此它强引用订阅者(Subscribers.Sink)。Subscribers.Sink 又强引用传入的 receiveCompletion / receiveValue 闭包;而这些闭包捕获了 effectCancellable 本身,于是形成了 AnyCancellableSubscribers.Sink → 闭包 → AnyCancellable 的强引用环。

我们现在的做法是用 effectCancellable 自己本身来定位在集合 effectCancellables 中的位置,因为 AnyCancellable 遵循 Hashable 协议。换个思路,我们可以给每个 effectCancellable 创建独一无二的 UUID,这样 receiveCompletion 闭包就不需要强引用 effectCancellable 本身,从而达到删除 effectCancellable 的目的!把 effectCancellables 的类型从 Set<AnyCancellable> 改为 [UUID: AnyCancellable] 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private var effectCancellables: [UUID: AnyCancellable] = [:]

public func send(_ action: Action) {
let effects = self.reducer(&self.value, action)
effects.forEach { effect in
var didComplete = false
let uuid = UUID()

let effectCancellable = effect.sink(
receiveCompletion: { [weak self] _ in
didComplete = true
self?.effectCancellables[uuid] = nil
},
receiveValue: { [weak self] in self?.send($0) }
)

if !didComplete {
self.effectCancellables[uuid] = effectCancellable
}
}
}

结语

下次再聊 TCA 中的 Dependency Injection 与 Testing。