本文延续上一节对 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 } ... 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)) }
我们发现一个问题:reducer 里没有 self 或 store,副作用完成后无法把结果送回去更新状态。在视图里时,副作用可以直接 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() } } ... } }
假设这里是要组合三个 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 >, 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 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?>。不难想到:利用该 WritableKeyPath 和 localEffect() 得出的 localAction 来计算出 globalEffect 所需的 globalAction。
pullback 的实现虽然代码不少,但逻辑其实很清晰:它把「处理局部 state / action 的 reducer」提升为「处理全局 state / action 的 reducer」。之所以能做到这一点,是因为我们用 enum property 生成了 WritableKeyPath,可以从全局取出局部、再把局部改动写回全局。具体流程是:当全局 state 和 action 进入时,先用 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 } } 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) }
单向数据流 从上文的 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) } }
当 effect 收到结果时,继续调用 self.send,把副作用结果传递出去。
当 effect 停止发送元素时,移除掉之前保存下来的订阅 token(AnyCancellable)。
当 effect 订阅完毕后,保存订阅 token 至 effectCancellables。
乍一看这个逻辑似乎没问题,但当遇到没有副作用的 action 时,就会有问题了!我们先来看个小例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import Combinelet 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)
当我们订阅一个立即结束的 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 { effectCancellables.insert(effectCancellable) }
但这里还有 1 处内存泄漏:effectCancellable 是 sink 的返回值,因此它强引用订阅者(Subscribers.Sink)。Subscribers.Sink 又强引用传入的 receiveCompletion / receiveValue 闭包;而这些闭包捕获了 effectCancellable 本身,于是形成了 AnyCancellable → Subscribers.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。