Swift 中的函数式核心与命令式外壳:单向数据流

前言

之前,我们讨论了在 Swift 中的函数式核心与命令式外壳的概念。其目标是通过值类型提取纯逻辑,并将副作用保持在薄薄的对象层中。本周,我们将展示如何以单向数据流的方式应用这一方法。

函数式核心

函数式核心是负责我们应用中所有逻辑的层,我们希望通过单元测试验证它们。它应该是纯粹的,没有任何副作用。我们希望提供输入并验证输出。通常,单向数据流的实现需要许多接收状态和动作并返回新状态的 reducer 函数。让我们在代码中定义 reducer 函数。

如果你不熟悉单向数据流的概念,我强烈建议你阅读我关于"在 SwiftUI 中类似 Redux 的状态容器"的系列文章。

swift 复制代码
typealias Reducer<State, Action> = (State, Action) -> State

正如你所见,reducer 函数接受当前状态和要应用于该状态的动作,并返回一个新状态。我正在开发一个间歇性禁食追踪的应用。让我们看看我如何实现计时器逻辑。

swift 复制代码
struct TimerState: Equatable {
    var start: Date?
    var end: Date?
    var goal: TimeInterval
}

enum TimerAction {
    case start
    case finish
    case reset
}

let timerReducer: Reducer<TimerState, TimerAction> = { state, action in
    var state = state

    switch action {
    case .start:
        state.start = .now
    case .finish:
        state.end = .now
    case .reset:
        state.start = nil
        state.end = nil
    }

    return state
}

这是我代码库中实现计时器管理逻辑的真实示例。正如你所见,它是纯粹的,没有任何副作用。它允许我快速使用单元测试验证逻辑,无需使用 mocks 和 stubs。

swift 复制代码
import XCTest

final class TimerReducerTests: XCTestCase {
    func testStart() {
        let state = TimerState(goal: 13 * 3600)
        XCTAssertNil(state.start)
        let newState = timerReducer(state, .start)
        XCTAssertNotNil(newState.start)
    }
}

像结构体和枚举这样的值类型是实现应用逻辑的极佳工具,既纯粹又非常可测试。但我们仍然需要副作用。例如,我想通过 CloudKit 与朋友分享计时器状态。

命令式外壳

命令式外壳是通过值类型表示应用状态的对象层。我们还利用对象层进行副作用操作,并将结果应用于状态之上。首先定义一个持有状态的通用对象。

swift 复制代码
@MainActor public final class Store<State, Action>: ObservableObject {
    @Published public private(set) var state: State

    private let reducer: Reducer<State, Action>

    public init(
        initialState state: State,
        reducer: @escaping Reducer<State, Action>
    ) {
        self.reducer = reducer
        self.state = state
    }

    public func send(_ action: Action) {
        state = reducer(state, action)
    }
}

这是使用 Store 类定义的命令式外壳。正如你所见,我们使用对象层持有通过值类型表示的应用状态。对象层允许我们分享应用状态,并使其成为单一事实来源。我们还通过利用 MainActor 并仅通过将动作传递给 Store 类型的 send 方法来允许变更,提供线程安全。这就是我们在函数式核心与命令式外壳的理念下实现单向数据流的方式。但我们仍然缺少副作用。

副作用

命令式外壳应为我们提供进行副作用操作的方法。我们应该将副作用与应用的纯逻辑分开,但我们仍希望通过集成测试来测试副作用。让我们引入一种称为 Middleware 的新类型,它定义了一个副作用处理程序。

swift 复制代码
typealias Middleware<State, Action, Dependencies> = (State, Action, Dependencies) async -> Action?

Middleware 类型的主要思想是拦截纯动作,进行副作用操作(如异步请求),并返回一个新的动作,我们可以将其传递给 store 并进行归约。让我们将此功能添加到 Store 类型中。

swift 复制代码
@MainActor public final class Store<State, Action, Dependencies>: ObservableObject {
    @Published public private(set) var state: State

    private let reducer: Reducer<State, Action>
    private let dependencies: Dependencies
    private let middlewares: [Middleware<State, Action, Dependencies>]

    public init(
        initialState state: State,
        reducer: @escaping Reducer<State, Action>,
        dependencies: Dependencies,
        middlewares: [Middleware<State, Action, Dependencies>] = []
    ) {
        self.reducer = reducer
        self.state = state
        self.dependencies = dependencies
        self.middlewares = middlewares
    }

    public func send(_ action: Action) async {
        state = reducer(state, action)

        await withTaskGroup(of: Optional<Action>.self) { [state, dependencies] group in
            for middleware in middlewares {
                group.addTask {
                    await middleware(state, action, dependencies)
                }
            }

            for await case let action? in group {
                await send(action)
            }
        }
    }
}

正如你所见,我们使用新的 Swift 并发特性在 Store 类型中实现异步工作。它允许我们并行运行副作用并将动作传递给 store。通过标记 Store 类型为 @MainActor,我们确保了对状态的访问。使用 TaskGroup,我们自动获得了副作用的协作取消。Store 类型还持有所有依赖项(如网络、通知中心等),以便提供给 middlewares。

swift 复制代码
struct TimerState: Equatable {
    var start: Date?
    var end: Date?
    var goal: TimeInterval
    var sharingStatus = SharingStatus.notShared
}

enum SharingStatus: Equatable {
    case shared
    case uploading
    case notShared
}

enum TimerAction: Equatable {
    case start
    case finish
    case reset
    case share
    case setSharingStatus(SharingStatus)
}

let timerReducer: Reducer<TimerState, TimerAction> = { state, action in
    var state = state

    switch action {
    case .start:
        state.start = .now
    case .finish:
        state.end = .now
    case .reset:
        state.start = nil
        state.end = nil
    case .share:
        state.sharingStatus = .uploading
    case let .setSharingStatus(status):
        state.sharingStatus = status
    }

    return state
}

struct TimerDependencies {
    let share: (Date, Date?) async throws -> Void
}

let timerMiddleware: Middleware<TimerState, TimerAction, TimerDependencies> = { state, action, dependencies in
    switch action {
    case .share:
        guard let start = state.start else {
            return .setSharingStatus(.notShared)
        }

        do {
            try await dependencies.share(start, state.end)
            return .setSharingStatus(.shared)
        } catch {
            return .setSharingStatus(.notShared)
        }
    default:
        return nil
    }
}

下面是实现 middleware 的示例代码。正如你所见,我们拦截传递给 store 的动作,进行异步请求,并向系统提供另一个动作。我们还可以通过 mock TimerDependencies 类型轻松编写集成测试。

swift 复制代码
import XCTest

final class TimerMiddlewareTests: XCTestCase {
    func testSharing() async throws {
        let state = TimerState(goal: 13 * 3600)
        let dependencies: TimerDependencies = .init { _, _ in }
        let action = await timerMiddleware(state, .share, dependencies)
        XCTAssertEqual(action, .setSharingStatus(.shared))
    }
}

想了解更多关于将异步闭包用作依赖项的信息,请查看我的"在 Swift 中的微应用架构:依赖注入"一文。

swift 复制代码
import SwiftUI

struct RootView: View {
    @StateObject var store = Store(
        initialState: TimerState(goal: 13 * 3600),
        reducer: timerReducer,
        dependencies: TimerDependencies.production,
        middlewares: [timerMiddleware]
    )

    var body: some View {
        NavigationView {
            VStack {
                if let start = store.state.start, store.state.end == nil {
                    Text(start, style: .timer)
                    
                    Button("Stop") {
                        Task { await store.send(.finish) }
                    }

                    Button("Reset") {
                        Task { await store.send(.reset) }
                    }
                } else {
                    Button("Start") {
                        Task { await store.send(.start) }
                }
            }
            .navigationTitle("Timer")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Share") {
                        Task {
                            await store.send(.share)
                        }
                    }
                }
            }
        }
    }
}

可运行 Demo

上面详细介绍了理论逻辑。下面根据这个些功能提供一个可以运行的 Demo。

我们将创建一个可以运行的 SwiftUI 应用示例,该应用将展示如何使用函数式核心与命令式外壳的理念来实现单向数据流和管理副作用。这个示例将实现一个简单的计时器应用,允许用户启动、停止、重置计时器并分享计时状态。

函数式核心部分

首先,我们定义应用的状态和动作,并实现一个 reducer 函数来管理状态变化。

swift 复制代码
import SwiftUI
import Combine

// 定义计时器状态
struct TimerState: Equatable {
    var start: Date?
    var end: Date?
    var goal: TimeInterval
    var sharingStatus = SharingStatus.notShared
}

// 定义计时器动作
enum TimerAction: Equatable {
    case start
    case finish
    case reset
    case share
    case setSharingStatus(SharingStatus)
}

// 定义共享状态
enum SharingStatus: Equatable {
    case shared
    case uploading
    case notShared
}

// 定义 Reducer 函数
typealias Reducer<State, Action> = (State, Action) -> State

let timerReducer: Reducer<TimerState, TimerAction> = { state, action in
    var state = state

    switch action {
    case .start:
        state.start = .now
    case .finish:
        state.end = .now
    case .reset:
        state.start = nil
        state.end = nil
    case .share:
        state.sharingStatus = .uploading
    case let .setSharingStatus(status):
        state.sharingStatus = status
    }

    return state
}

命令式外壳部分

接下来,我们定义一个 Store 类来持有应用的状态,并处理副作用。

swift 复制代码
@MainActor
public final class Store<State, Action, Dependencies>: ObservableObject {
    @Published public private(set) var state: State

    private let reducer: Reducer<State, Action>
    private let dependencies: Dependencies
    private let middlewares: [Middleware<State, Action, Dependencies>]

    public init(
        initialState state: State,
        reducer: @escaping Reducer<State, Action>,
        dependencies: Dependencies,
        middlewares: [Middleware<State, Action, Dependencies>] = []
    ) {
        self.reducer = reducer
        self.state = state
        self.dependencies = dependencies
        self.middlewares = middlewares
    }

    public func send(_ action: Action) async {
        state = reducer(state, action)

        await withTaskGroup(of: Optional<Action>.self) { [state, dependencies] group in
            for middleware in middlewares {
                group.addTask {
                    await middleware(state, action, dependencies)
                }
            }

            for await case let action? in group {
                await send(action)
            }
        }
    }
}

副作用处理

定义一个中间件来处理异步副作用,比如共享计时状态。

swift 复制代码
typealias Middleware<State, Action, Dependencies> = (State, Action, Dependencies) async -> Action?

struct TimerDependencies {
    let share: (Date, Date?) async throws -> Void
}

let timerMiddleware: Middleware<TimerState, TimerAction, TimerDependencies> = { state, action, dependencies in
    switch action {
    case .share:
        guard let start = state.start else {
            return .setSharingStatus(.notShared)
        }

        do {
            try await dependencies.share(start, state.end)
            return .setSharingStatus(.shared)
        } catch {
            return .setSharingStatus(.notShared)
        }
    default:
        return nil
    }
}

SwiftUI 界面

最后,我们创建一个 SwiftUI 界面来展示计时器功能,并连接到 Store

swift 复制代码
struct RootView: View {
    @StateObject var store = Store(
        initialState: TimerState(goal: 13 * 3600),
        reducer: timerReducer,
        dependencies: TimerDependencies(share: { start, end in
            // 模拟共享计时状态的逻辑
            print("Shared from \(start) to \(String(describing: end))")
        }),
        middlewares: [timerMiddleware]
    )

    var body: some View {
        NavigationView {
            VStack {
                if let start = store.state.start, store.state.end == nil {
                    Text(start, style: .timer)

                    Button("Stop") {
                        Task { await store.send(.finish) }
                    }

                    Button("Reset") {
                        Task { await store.send(.reset) }
                    }
                } else {
                    Button("Start") {
                        Task { await store.send(.start) }
                    }
                }
            }
            .navigationTitle("Timer")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Share") {
                        Task {
                            await store.send(.share)
                        }
                    }
                }
            }
        }
    }
}

@main
struct TimerApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
}

代码运行截图

代码解释

  1. 状态和动作 :我们定义了 TimerStateTimerAction 来表示计时器的状态和可执行的动作。
  2. Reducer 函数timerReducer 函数接受当前状态和动作,并返回一个新的状态。这个函数是纯函数,没有副作用,方便进行单元测试。
  3. Store 类Store 类持有应用的状态,并提供 send 方法来处理动作。我们使用 Swift 的并发特性来处理异步任务和副作用。
  4. 中间件timerMiddleware 用于处理异步副作用,比如共享计时状态。它拦截动作,执行异步任务,并返回一个新的动作来更新状态。
  5. SwiftUI 界面RootView 使用 Store 提供的状态和动作来构建界面。用户可以启动、停止、重置计时器,并共享计时状态。

这个示例展示了如何使用函数式核心与命令式外壳的理念来实现一个简单的计时器应用,利用 Swift 的最新特性处理异步任务和副作用。

总结

这篇文章讨论了如何在 Swift 中结合使用函数式核心与命令式外壳的理念来实现单向数据流,并详细展示了如何在代码中实现这些理念,包括使用 Swift 并发特性处理异步任务和管理副作用。通过这种架构,开发者可以在保持代码清晰和易于测试的同时,处理复杂的应用状态和副作用。

参考资料

  1. swift-unidirectional-flow - 使用最新的 Swift 泛型和 Swift 并发特性实现单向数据流。
  2. "Boundaries", a talk by Gary Bernhardt from SCNA 2012
相关推荐
Magnetic_h6 小时前
【iOS】单例模式
笔记·学习·ui·ios·单例模式·objective-c
归辞...7 小时前
「iOS」——单例模式
ios·单例模式·cocoa
humiaor8 小时前
Xcode报错:No exact matches in reference to static method ‘buildExpression‘
swiftui·xcode
yanling20239 小时前
黑神话悟空mac可以玩吗
macos·ios·crossove·crossove24
归辞...11 小时前
「iOS」viewController的生命周期
ios·cocoa·xcode
crasowas15 小时前
Flutter问题记录 - 适配Xcode 16和iOS 18
flutter·ios·xcode
2401_8524035516 小时前
Mac导入iPhone的照片怎么删除?快速方法讲解
macos·ios·iphone
SchneeDuan16 小时前
iOS六大设计原则&&设计模式
ios·设计模式·cocoa·设计原则
JohnsonXin1 天前
【兼容性记录】video标签在 IOS 和 安卓中的问题
android·前端·css·ios·h5·兼容性
蒙娜丽宁1 天前
Go语言错误处理详解
ios·golang·go·xcode·go1.19