Simplex 架构

image

一个简单的架构库,将状态变化与 SwiftUI 的 View 解耦。

Language:Swift License:MIT Latest Release Twitter

该库的灵感来源于 TCA (swift-composable-architecture),它允许您将状态更改逻辑从 SwiftUI 的 View 和 ObservableObject 中分离出来,并将其限制在 Reducer 中。

在 TCA 中,将子域集成到父域中会导致更高的计算成本,尤其是在应用程序的叶节点上。我们的库通过避免将子域集成到父域中来解决这个问题,从而消除了不必要的计算开销。为了与深度嵌套的视图共享值或逻辑,我们利用 SwiftUI 的 EnvironmentObject 属性包装器。这允许您无缝地编写可以在整个应用程序中访问的逻辑或状态。此外,我们的库简化了应用程序的构建过程。您不再需要记住各种 TCA 修饰符或自定义视图,例如 ForEachStore、IfLetStore、SwitchStore、sheet(store:) 等。

示例

我们在此库中提供了示例实现。目前,我们仅提供了一个简单的 GitHub 存储库搜索应用程序,但我们计划在未来扩展更多示例。

文档

主要功能的文档可在此处找到

安装

let package = Package(
    name: "YourProject",
    ...
    dependencies: [
        .package(url: "https://github.com/Ryu0118/swiftui-simplex-architecture", exact: "0.9.0")
    ],
    targets: [
        .target(
            name: "YourTarget",
            dependencies: [
                .product(name: "SimplexArchitecture", package: "swiftui-simplex-architecture"),
            ]
        )
    ]
)

基本用法

用法几乎与 TCA 相同。状态定义使用 SwiftUI 中使用的属性包装器,例如 @State@Binding@FocusState

@Reducer
struct MyReducer {
    enum ViewAction {
        case increment
        case decrement
    }
    func reduce(into state: StateContainer<MyView>, action: Action) -> SideEffect<Self> {
        switch action {
        case .increment:
            state.counter += 1
            return .none
        case .decrement:
            state.counter -= 1
            return .none
        }
    }
}

@ViewState
struct MyView: View {
    @State var counter = 0
    
    let store: Store<MyReducer> = Store(reducer: MyReducer())

    var body: some View {
        VStack {
            Text("\(counter)")
            Button("+") {
                send(.increment)
            }
            Button("-") {
                send(.decrement)
            }
        }
    }
}

来自 View 的事件使用 ViewAction 定义。应保持私有或仅在 Reducer 中使用的操作应使用 ReducerAction。

ReducerAction

如果存在您不想向 View 公开的 Actions,则 ReducerAction 非常有效。这是一个示例代码

@Reducer
struct MyReducer {
    enum ViewAction {
        case login
    }

    enum ReducerAction {
        case loginResponse(TaskResult<Response>)
    }

    @Dependency(\.authClient) var authClient

    func reduce(into state: StateContainer<MyView>, action: Action) -> SideEffect<Self> {
        switch action {
        case .login:
            return .run { [email = state.email, password = state.password] send in
                await send(
                    .loginResponse(
                        TaskResult { try await authClient.login(email, password) }
                    )
                )
            }
        case let .loginResponse(result):
            ...
            return .none
        }
    }
}

@ViewState
struct MyView: View {
    @State var email: String = ""
    @State var password: String = ""

    let store: Store<MyReducer>
    ...
}

ReducerState

如果您只想将状态保留在 Reducer 中,请使用 ReducerState。ReducerState 还可以有效地提高性能,因为即使值发生更改,也不会更新 View。

这是一个示例代码

@Reducer
struct MyReducer {
    enum ViewAction {
        case increment
        case decrement
    }

    struct ReducerState {
        var totalCalledCount = 0
    }

    func reduce(into state: StateContainer<MyView>, action: Action) -> SideEffect<Self> {
        state.reducerState.totalCalledCount += 1
        switch action {
        case .increment:
            if state.reducerState.totalCalledCount < 10 {
                state.counter += 1
            }
            return .none
        case .decrement:
            state.counter -= 1
            return .none
        }
    }
}

@ViewState
struct MyView: View {
    ...
    init() {
        store = Store(reducer: MyReducer(), initialReducerState: MyReducer.ReducerState())
    }
    ...
}

Pullback Action

如果您想将子 Reducer 的 Action 发送到父 Reducer,请使用 pullback。这是一个示例代码。

@ViewState
struct ParentView: View {
    let store: Store<ParentReducer> = Store(reducer: ParentReducer())

    var body: some View {
        ChildView()
            .pullback(to: /ParentReducer.Action.child, parent: self)
    }
}

@Reducer
struct ParentReducer {
    enum ViewAction {
    }
    enum ReducerAction {
        case child(ChildReducer.Action)
    }

    func reduce(into state: StateContainer<ParentView>, action: Action) -> SideEffect<Self> {
        switch action {
        case .child(.onButtonTapped):
            // do something
            return .none
        }
    }
}

此库中有两个宏

@Reducer

@Reducer 是一个宏,它集成了 ViewActionReducerAction 以生成 Action

@Reducer
struct MyReducer {
    enum ViewAction {
        case loginButtonTapped
    }
    enum ReducerAction {
        case loginResponse(TaskResult<User>)
    }
    // expand to ↓
    enum Action {
        case loginButtonTapped
        case loginResponse(TaskResult<User>)
        
        init(viewAction: ViewAction) {
            switch viewAction {
            case .loginButtonTapped:
                self = .loginButtonTapped
            }
        }
        
        init(reducerAction: ReducerAction) {
            switch reducerAction {
            case .loginResponse(let arg1):
                self = .loginResponse(arg1)
            }
        }
    }
    ...
}

Reducer.reduce(into:action:) 不再需要为两个操作 ViewActionReducerAction 准备,而是可以集成到 Action 中。

@ViewState

@ViewState 创建一个 ViewState 结构体,并使其符合 ActionSendable 协议。

@ViewState
struct MyView: View {
    @State var counter = 0

    let store: Store<MyReducer> = Store(reducer: MyReducer())

    var body: some View {
        VStack {
            Text("\(counter)")
            Button("+") {
                send(.increment)
            }
            Button("-") {
                send(.decrement)
            }
        }
    }
    // expand to ↓
    struct ViewState: ViewStateProtocol {
        var counter = 0
        static let keyPathMap: [PartialKeyPath<ViewState>: PartialKeyPath<MyView>] = [\.counter: \.counter]
    }
}

ViewState 结构体有两个主要目的

此外,通过符合 ActionSendable 协议,您可以将 Actions 发送到 Store。

测试

对于测试,我们使用 TestStore。这需要 ViewState 结构体的实例,该实例由 @ViewState 宏生成。此外,我们将进行进一步的操作来断言当调度一个 action 时,它的行为如何演变。

@MainActor
func testReducer() async {
    let store = MyView().testStore(viewState: .init())
}

我们需要始终证明状态按照我们期望的方式发生了改变。 例如,我们可以模拟用户点击递增和递减按钮的流程

@MainActor
func testReducer() async {
    let store = MyView().testStore(viewState: .init())
    await store.send(.increment) {
        $0.count = 1
    }
    await store.send(.decrement) {
        $0.count = 0
    }
}

此外,当 effects 被步骤执行并且数据反馈到 store 时,有必要断言这些 effects。

@MainActor
func testReducer() async {
    let store = MyView().testStore(viewState: .init())
    await store.send(.fetchData)
    await store.receive(\.fetchDataResponse.success) {
        $0.data = ...
    }
}

如果您正在使用 swift-dependencies,您可以按如下方式执行依赖注入

let store = MyView().testStore(viewState: .init()) {
    $0.apiClient.fetchData = { _ in ... }
}