English | 한국어

Redux

license MIT Platform Platform Xcode Swift 5.5

使用 Swift 5.5 中引入的 async/await 实现 Redux 变得非常简单。 从 Xcode 13.2 开始,Swift 5.5 的新并发特性支持 iOS 13。 因此,现有的 Redux 包基于 async/await 进行了重新实现。

安装

Redux 仅支持 Swift Package Manager。

dependencies: [
    .package(url: "https://github.com/ReactComponentKit/Redux.git", from: "1.2.1"),
]

流程

上图显示了 Redux 的流程。 内容很多,但实际上非常简洁。 Store 处理了大部分流程。 开发者所要做的就是定义 State 和 Store,并定义执行 Action 和 Mutation 的函数。 此外,可以定义类似中间件的任务,以便在 Mutation 发生之前或之后执行必要的任务。

State

State 可以定义如下。

struct Counter: State {
    var count = 0
}

请注意,State 应该符合 Equatable。

Store

定义 Store 时,需要一个 State。 您可以如下定义 Store。

struct Counter: State {
    var count = 0
}

class CounterStore: Store<Counter> {
    init() {
        super.init(state: Counter())
    }
}

Store 提供以下方法。

创建自定义 Store 时,主要使用的方法是 commit(mutation:, payload:)。 dispatch(action:, payload:) 很少使用。

Mutation

Mutation 定义为 Store 方法。 Mutation 方法是一个同步方法。

// mutation
private func increment(counter: inout Counter, payload: Int) {
    counter.count += payload
}
    
private func decrement(counter: inout Counter, payload: Int) {
    counter.count -= payload
}

Action

Action 也由 Store 的方法定义。 无需再为 Action 创建单独的自定义数据类型。 由于 Action 定义为 Store 方法,因此实际使用 Store 的 dispatch 方法的情况很少。

Action 可以通过将其划分为同步 Action 或异步 Action 来定义。 由于状态更改发生在 commit 中,因此无需为异步处理定义额外的中间件。 您可以在异步 action 中完成异步处理,然后提交更改。

// actions
func incrementAction(payload: Int) {
    self.commit(mutation: increment, payload: payload)
}
    
func decrementAction(payload: Int) {
    self.commit(mutation: decrement, payload: payload)
}
    
func asyncIncrementAction(payload: Int) async {
    await Task.sleep(1 * 1_000_000_000)
    self.commit(mutation: increment, payload: payload)
}
    
func asyncDecrementAction(payload: Int) async {
    await Task.sleep(1 * 1_000_000_000)
    self.commit(mutation: decrement, payload: payload)
}

此外,您可以使用简化的 commit 方法来定义 action 或 mutate 状态。

func asyncIncrementAction(payload: Int) async {
    await Task.sleep(1 * 1_000_000_000)
    self.commit { mutableState in 
        mutableState.count += 1
    }
}

Store 的 commit 方法是公共的,因此您可以在 UI 层使用它。

Button(action: { store.counter.commit { $0.count += 1 }) {
    Text(" + ")
        .font(.title)
        .bold()
}

或者使用 Store 的 action 方法。

Button(action: { store.counter.incrementAction(payload: 1) }) {
    Text(" + ")
        .font(.title)
        .bold()
}

Computed

定义要连接到 View 的属性。 Store 不发布 State。 因此,为了发布 State 的特定属性,可以在 computed 步骤中将值注入到该属性中。

class CounterStore: Store<Counter> {
    init() {
        super.init(state: Counter())
    }
    
    // computed
    @Published
    var count = 0
    
    override func computed(new: Counter, old: Counter) {
        self.count = new.count
    }
    ...
}

CounterStore

到目前为止定义的 CounterStore 的完整代码如下。

import Foundation
import Redux

struct Counter: State {
    var count = 0
}

class CounterStore: Store<Counter> {
    init() {
        super.init(state: Counter())
    }
    
    // computed
    @Published
    var count = 0
    
    override func computed(new: Counter, old: Counter) {
        self.count = new.count
    }
    
    // mutation
    private func increment(counter: inout Counter, payload: Int) {
        counter.count += payload
    }
    
    private func decrement(counter: inout Counter, payload: Int) {
        counter.count -= payload
    }
    
    // actions
    func incrementAction(payload: Int) {
        self.commit(mutation: increment, payload: payload)
    }
    
    func decrementAction(payload: Int) {
        self.commit(mutation: decrement, payload: payload)
    }
    
    func asyncIncrementAction(payload: Int) async {
        await Task.sleep(1 * 1_000_000_000)
        self.commit(mutation: increment, payload: payload)
    }
    
    func asyncDecrementAction(payload: Int) async {
        await Task.sleep(1 * 1_000_000_000)
        self.commit(mutation: decrement, payload: payload)
    }
}

Middlewares

您可以选择添加中间件。 中间件是在所有 Mutation 之前或之后调用的一组函数。

您可以选择添加中间件。中间件是在提交所有 mutations 之前和之后调用的一组同步函数。例如,您可以定义中间件来打印日志以调试状态更改。

class WorksBeforeCommitStore: Store<ReduxState> {
    init() {
        super.init(state: ReduxState())
    }
    
    override func worksBeforeCommit() -> [(ReduxState) -> Void] {
        return [
            { (state) in
                print(state.count)
            }
        ]
    }
}

class WorksAfterCommitStore: Store<ReduxState> {
    init() {
        super.init(state: ReduxState())
    }
    
    override func worksAfterCommit() -> [(ReduxState) -> Void] {
        return [
            { (state) in
                print(state.count)
            }
        ]
    }
}

UnitTest

测试上面定义的 CounterStore 非常容易。

import XCTest
@testable import Redux

final class CounterStoreTests: XCTestCase {
    private var store: CounterStore!
    
    override func setUp() {
        super.setUp()
        store = CounterStore()
    }
    
    override func tearDown() {
        super.tearDown()
        store = nil
    }
    
    func testInitialState() {
        XCTAssertEqual(0, store.state.count)
    }
    
    func testIncrementAction() {
        store.incrementAction(payload: 1)
        XCTAssertEqual(1, store.state.count)
        store.incrementAction(payload: 10)
        XCTAssertEqual(11, store.state.count)
    }
    
    func testPublisherValue() {
        XCTAssertEqual(0, store.count)
        store.incrementAction(payload: 1)
        XCTAssertEqual(1, store.count)
        store.incrementAction(payload: 10)
        XCTAssertEqual(11, store.count)
        store.decrementAction(payload: 10)
        XCTAssertEqual(1, store.count)
        store.decrementAction(payload: 1)
        XCTAssertEqual(0, store.count)
    }
    
    func testAsyncIncrementAction() async {
        await store.asyncIncrementAction(payload: 1)
        XCTAssertEqual(1, store.state.count)
        XCTAssertEqual(1, store.count)
        await store.asyncIncrementAction(payload: 10)
        XCTAssertEqual(11, store.state.count)
        XCTAssertEqual(11, store.count)
    }
    
    func testAsyncDecrementAction() async {
        await store.asyncDecrementAction(payload: 1)
        XCTAssertEqual(-1, store.state.count)
        XCTAssertEqual(-1, store.count)
        await store.asyncDecrementAction(payload: 10)
        XCTAssertEqual(-11, store.state.count)
        XCTAssertEqual(-11, store.count)
    }
}

UserStore

让我们定义一个使用 API 的 Store (https://jsonplaceholder.typicode.com)。

import Foundation
import Redux

struct User: Equatable, Codable {
    let id: Int
    var name: String
}

struct UserState: State {
    var users: [User] = []
}

class UserStore: Store<UserState> {
    
    init() {
        super.init(state: UserState())
    }
    
    // mutations
    private func SET_USERS(userState: inout UserState, payload: [User]) {
        userState.users = payload
    }
    
    private func SET_USER(userState: inout UserState, payload: User) {
        let index = userState.users.firstIndex { it in
            it.id == payload.id
        }
        
        if let index = index {
            userState.users[index] = payload
        }
    }
    
    // actions
    func loadUsers() async {
        do {
            let (data, _) = try await URLSession.shared.data(from: URL(string: "https://jsonplaceholder.typicode.com/users/")!)
            let users = try JSONDecoder().decode([User].self, from: data)
            commit(mutation: SET_USERS, payload: users)
        } catch {
            print(#function, error)
            commit(mutation: SET_USERS, payload: [])
        }
    }
    
    func update(user: User) async throws {
        let params = try JSONEncoder().encode(user)
        var request = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/users/\(user.id)")!)
        request.httpMethod = "PUT"
        request.httpBody = params
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.addValue("application/json", forHTTPHeaderField: "Accept")
        let (data, _) = try await URLSession.shared.data(for: request)
        let user = try JSONDecoder().decode(User.self, from: data)
        commit(mutation: SET_USER, payload: user)
    }
}

您可以如下测试上面的 UserStore。

import XCTest
@testable import Redux

final class UserStoreTests: XCTestCase {
    private var store: UserStore!
    
    override func setUp() {
        super.setUp()
        store = UserStore()
    }
    
    override func tearDown() {
        super.tearDown()
        store = nil
    }
    
    func testInitialState() {
        XCTAssertEqual([], store.state.users)
    }
    
    func testLoadUsers() async {
        await store.loadUsers()
        XCTAssertEqual(10, store.state.users.count)
        for user in store.state.users {
            XCTAssertGreaterThan(user.id, 0)
            XCTAssertNotEqual(user.name, "")
        }
    }
 
    func testUpdateUser() async {
        do {
            await store.loadUsers()
            XCTAssertEqual(10, store.state.users.count)
            var mutableUser = store.state.users[0]
            mutableUser.name = "Sungcheol Kim"
            try await store.update(user: mutableUser)
            XCTAssertEqual(10, store.state.users.count)
            let user = store.state.users[0]
            XCTAssertEqual("Sungcheol Kim", user.name)
        } catch {
            XCTFail("Failed update user")
        }
    }
}

Store Composition

有必要用单一事实来源在一个地方管理应用程序状态。 在这种情况下,在一个状态中定义应用程序的所有状态是危险的。 因此,建议将状态划分为模块,并创建和管理管理每个状态的 Store。 您可以如下定义 App Store。

import Foundation
import Redux

struct AppState: State {
}

class AppStore: Store<AppState> {
    
    // composition store
    let counter = CounterStore()
    let users = UserStore()
    
    init() {
        super.init(state: AppState())
    }
}

您可以如下使用上面的 AppStore。

import XCTest
@testable import Redux

// Single Source of Truth
final class SSOTTests: XCTestCase {
    private var store: AppStore!
    
    override func setUp() {
        super.setUp()
        store = AppStore()
    }
    
    override func tearDown() {
        super.tearDown()
        store = nil
    }
    
    func testLoadUsers() async {
        await store.users.loadUsers()
        XCTAssertEqual(10, store.users.state.users.count)
        for user in store.users.state.users {
            XCTAssertGreaterThan(user.id, 0)
            XCTAssertNotEqual(user.name, "")
        }
    }
 
    func testUpdateUser() async {
        do {
            await store.users.loadUsers()
            XCTAssertEqual(10, store.users.state.users.count)
            var mutableUser = store.users.state.users[0]
            mutableUser.name = "Sungcheol Kim"
            try await store.users.update(user: mutableUser)
            XCTAssertEqual(10, store.users.state.users.count)
            let user = store.users.state.users[0]
            XCTAssertEqual("Sungcheol Kim", user.name)
        } catch {
            XCTFail("Failed update user")
        }
    }
    
    func testIncrementAction() {
        store.counter.incrementAction(payload: 1)
        XCTAssertEqual(1, store.counter.state.count)
        store.counter.incrementAction(payload: 10)
        XCTAssertEqual(11, store.counter.state.count)
    }
    
    func testPublisherValue() {
        XCTAssertEqual(0, store.counter.count)
        store.counter.incrementAction(payload: 1)
        XCTAssertEqual(1, store.counter.count)
        store.counter.incrementAction(payload: 10)
        XCTAssertEqual(11, store.counter.count)
        store.counter.decrementAction(payload: 10)
        XCTAssertEqual(1, store.counter.count)
        store.counter.decrementAction(payload: 1)
        XCTAssertEqual(0, store.counter.count)
    }
    
    func testAsyncIncrementAction() async {
        await store.counter.asyncIncrementAction(payload: 1)
        XCTAssertEqual(1, store.counter.state.count)
        XCTAssertEqual(1, store.counter.count)
        await store.counter.asyncIncrementAction(payload: 10)
        XCTAssertEqual(11, store.counter.state.count)
        XCTAssertEqual(11, store.counter.count)
    }
    
    func testAsyncDecrementAction() async {
        await store.counter.asyncDecrementAction(payload: 1)
        XCTAssertEqual(-1, store.counter.state.count)
        XCTAssertEqual(-1, store.counter.count)
        await store.counter.asyncDecrementAction(payload: 10)
        XCTAssertEqual(-11, store.counter.state.count)
        XCTAssertEqual(-11, store.counter.count)
    }
}

Store Composition 示例

我们可以像下面这样定义 AppStore,但这不是一个好的设计。 如果您向 AppState 添加更多状态,AppStore 将变成更大的 Store。

struct AppState: State {
    var count: Int = 0
    var content: String? = nil
    var error: String? = nil
}

class AppStore: Store<AppState> {
    init() {
        super.init(state: AppState())
    }
    
    @Published
    var count: Int = 0
    
    @Published
    var content: String? = nil
    
    @Published
    var error: String? = nil
    
    override func computed(new: AppState, old: AppState) {
        if (self.count != new.count) {
            self.count = new.count
        }
        
        if (self.content != new.content) {
            self.content = new.content
        }
        
        if (self.error != new.error) {
            self.error = new.error
        }
    }
    
    override func worksAfterCommit() -> [(AppState) -> Void] {
        return [ { state in
            print(state.count)
        }]
    }
    
    private func INCREMENT(state: inout AppState, payload: Int) {
        state.count += payload
    }
    
    private func DECREMENT(state: inout AppState, payload: Int) {
        state.count -= payload
    }
    
    private func SET_CONTENT(state: inout AppState, payload: String) {
        state.content = payload
    }
    
    private func SET_ERROR(state: inout AppState, payload: String?) {
        state.error = payload
    }
    
    func incrementAction(payload: Int) {
        commit(mutation: INCREMENT, payload: payload)
    }
    
    func decrementAction(payload: Int) {
        commit(mutation: DECREMENT, payload: payload)
    }

    func fetchContent() async {
        do {
            let (data, _) = try await URLSession.shared.data(from: URL(string: "https://#")!)
            let value = String(data: data, encoding: .utf8) ?? ""
            commit(mutation: SET_ERROR, payload: nil)
            commit(mutation: SET_CONTENT, payload: value)
        } catch {
            commit(mutation: SET_ERROR, payload: error.localizedDescription)
        }
    }
}

一个好的实践是将状态分解成小块,然后将它们组成一个 store。

/**
 * CounterStore.swift
 */
struct Counter: State {
    var count: Int = 0
}

class CounterStore: Store<Counter> {
    @Published
    var count: Int = 0
    
    override func computed(new: Counter, old: Counter) {
        if (self.count != new.count) {
            self.count = new.count
        }
    }
    
    init() {
        super.init(state: Counter())
    }
    
    override func worksAfterCommit() -> [(Counter) -> Void] {
        return [ { state in
            print(state.count)
        }]
    }
    
    private func INCREMENT(state: inout Counter, payload: Int) {
        state.count += payload
    }
    
    private func DECREMENT(state: inout Counter, payload: Int) {
        state.count -= payload
    }
    
    func incrementAction(payload: Int) {
        commit(mutation: INCREMENT, payload: payload)
    }
    
    func decrementAction(payload: Int) {
        commit(mutation: DECREMENT, payload: payload)
    }
}

/**
 * ContentStore.swift
 */
struct Content: State {
    var value: String? = nil
    var error: String? = nil
}

class ContentStore: Store<Content> {
    @Published
    var value: String? = nil
    
    @Published
    var error: String? = nil
    
    override func computed(new: Content, old: Content) {
        if (self.value != new.value) {
            self.value = new.value
        }
        
        if (self.error != new.error) {
            self.error = new.error
        }
    }
    
    init() {
        super.init(state: Content())
    }
    
    override func worksAfterCommit() -> [(Content) -> Void] {
        return [
            { state in
                print(state.value ?? "없음")
            }
        ]
    }
    
    private func SET_CONTENT_VALUE(state: inout Content, payload: String) {
        state.value = payload
    }
    
    private func SET_ERROR(state: inout Content, payload: String?) {
        state.error = payload
    }
    
    func fetchContentValue() async {
        do {
            let (data, _) = try await URLSession.shared.data(from: URL(string: "https://#")!)
            let value = String(data: data, encoding: .utf8) ?? ""
            commit(mutation: SET_ERROR, payload: nil)
            commit(mutation: SET_CONTENT_VALUE, payload: value)
        } catch {
            commit(mutation: SET_ERROR, payload: error.localizedDescription)
        }
    }
}

/**
 * ComposeAppStore.swift
 */
struct ComposeAppState: State {
    // A state that depends on the state of another store.
    var allLength: String = ""
}

class ComposeAppStore: Store<ComposeAppState> {
    let counter = CounterStore();
    let content = ContentStore();
    
    // Set it to private to access counter.count with the counter namespace in the UI layer.
    @Published
    private var count = 0;
    
    @Published
    private var contentValue: String? = nil;
    
    @Published
    private var error: String? = nil;
    
    @Published
    var allLength: String? = nil;
    
    override func computed(new: ComposeAppState, old: ComposeAppState) {
        if (new.allLength != old.allLength) {
            self.allLength = new.allLength
        }
    }
    
    init() {
        super.init(state: ComposeAppState())
        // @Published chaining is required.
        counter.$count.assign(to: &self.$count)
        content.$value.assign(to: &self.$contentValue)
        content.$error.assign(to: &self.$error)
    }
    
    //Examples of actions and state mutations that depend on the state and actions of other stores are
    private func SET_ALL_LENGTH(state: inout ComposeAppState, payload: String) {
        state.allLength = payload
    }
    func someComposeAction() async {
        await content.fetchContentValue()
        commit(mutation: SET_ALL_LENGTH, payload: "counter: \(counter.state.count), content: \(content.state.value?.count ?? 0)")
    }
}

/**
 * ContentView.swift
 */
import SwiftUI

struct ContentView: View {
    
    @EnvironmentObject
    private var store: ComposeAppStore
    
    var body: some View {
        
        VStack {
            Text("\(store.counter.count)")
                .font(.title)
                .bold()
                .padding()
            if let error = store.content.error {
                Text("Error! \(error)")
            }
            HStack {
                Spacer()
                
                Button(action: { store.counter.decrementAction(payload: 1) }) {
                    Text(" - ")
                        .font(.title)
                        .bold()
                }
                
                Spacer()
                
                Button(action: { store.counter.incrementAction(payload: 1) }) {
                    Text(" + ")
                        .font(.title)
                        .bold()
                }
                
                Spacer()
                
            }
            VStack {
                Button(action: {
                    Task {
                        await store.someComposeAction()
                    }
                }) {
                    Text("All Length")
                        .bold()
                        .multilineTextAlignment(.center)
                }
                Text(store.allLength ?? "")
                    .foregroundColor(.red)
                    .font(.system(size: 12))
                    .lineLimit(5)
                
                Button(action: {
                    Task {
                        await store.content.fetchContentValue()
                    }
                }) {
                    Text("Fetch Content")
                        .bold()
                        .multilineTextAlignment(.center)
                }
                Text(store.content.value ?? "")
                    .foregroundColor(.red)
                    .font(.system(size: 12))
                    .lineLimit(5)
            }
        }
        .padding(.horizontal, 100)
    }
}

MIT License

版权所有 (c) 2021 Redux, ReactComponentKit

特此授予任何人免费获得本软件和相关文档文件(“软件”)副本的许可,以不受限制地处理本软件,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或出售本软件副本的权利,并允许向为此提供软件的人员提供以下条件

上述版权声明和本许可声明应包含在本软件的所有副本或重要部分中。

本软件按“原样”提供,不提供任何形式的明示或暗示的保证,包括但不限于适销性、适用于特定目的和不侵权的保证。 在任何情况下,作者或版权所有者均不对任何索赔、损害或其他责任负责,无论是在合同、侵权或其他方面,由软件或使用或其他与软件的交易引起的或与之相关的。