读SwiftUI和Combine编程有感

书里介绍了一种类似于Redux,但是针对SwiftUI的特点进行一些改变的数据管理方式。
在这里插入图片描述
这套数据流动的方式的特点是:

  1. 将 app 当作一个状态机,状态决定用户界面。
  2. 这些状态都保存在一个Store对象中。
  3. View不能直接操作State,而只能通过发送Action的方式,间接修改存储在Store中的State。
  4. Reducer接受原有的State和发送过来的Action,生成新的State。
  5. 用新的State替换Store中原有的状态,并用新状态来驱动更新界面。

首先针对第3点,太过严苛,SwiftUI中还可以用Binding来修改状态。
其次,我们希望 Reducer 具有纯函数特性,但是在实际开发中,我们会遇到非常多带有副作用 (side effect) 的情况:比如在改变状态的同时,需要向磁盘写入文件,或者需要进行网络请求。在上图中,我们没有阐释这类副作用应该如何处理。有一些架构实现选择不区分状态和副作用,让它们混在一起进行,有一些架构选择在 Reducer 前添加一层中间件 (middleware),来把 Action 进行预处理。我们在 PokeMaster app 的架构中,选择在 Reducer 处理当前 StateAction 后,除了返回新的 State 以外,再额外返回一个 Command 值,并让 Command 来执行所需的副作用。


项目里使用了一个统一的Store,然后一个AppState,而不同的数据则是在AppState里面去创建不同的struct来管理,这样管理就比较统一。

struct AppState {
    var pokemonList = PokemonList()
    var settings = Settings()
    var mainTab = MainTab()
}

不像很多项目中一样,不同的State分到不同的class里面,用的时候需要很多的.environmentObject(store)。这样之后,所有的需要的页面都只需要添加@EnvironmentObject var store: Store就可以了。


项目里很多的更改State都是通过Action来操作的,而这个Action是一个enum:

enum AppAction {
    // Settings
    case accountBehaviorButton(enabled: Bool)
    case accountBehaviorDone(result: Result<User, AppError>)

    case emailValid(valid: Bool)
    case register(email: String, password: String)
    case login(email: String, password: String)
    case logout
    case clearCache

    // Pokemon List
    case toggleListSelection(index: Int?)
    case togglePanelPresenting(presenting: Bool)

    case toggleFavorite(index: Int)

    case closeSafariView

    case loadPokemons
    case loadPokemonsDone(result: Result<[PokemonViewModel], AppError>)

    case loadAbilities(pokemon: Pokemon)
    case loadAbilitiesDone(result: Result<[AbilityViewModel], AppError>)

    // General
    case switchTab(index: AppState.MainTab.Index)
}

选用enum作为AppAction的类型,这可以让我们对action进行switch语句。而且编译器会帮助我们保证所有的AppAction都得到处理。
可以让View调用的用于表示发送了某个Action的方法。在这个方法中,将当前的AppState和收到的Action交给reduce,然后把返回的AppState设置为新的状态:

class Store: ObservableObject {
@Published var appState = AppState()
func dispatch(_ action: AppAction) {
#if DEBUG
print("[ACTION]: \(action)")
#endif
let result = Store.reduce(state: appState, action: action)
appState = result
}
// ...
}

Reducer 的唯一职责应该是计算新的 State,而发送请求和接收响应,显然和返回新的 State 没什么关系,它们属于设置状态这一操作的“副作用”。在我们的架构中我们使用 Command 来代表“在设置状态的同时需要触发一些其他操作”这个语境。Reducer 在返回新的 State 的同时,还返回一个代表需要进行何种副作用的 Command 值 (对应上一段中的第一个时间点)。Store 在接收到这个 Command 后,开始进行额外操作,并在操作完成后发送一个新的 Action。这个 Action 中带有异步操作所获取到的数据。它将再次触发 Reducer 并返回新的 State,继而完成异步操作结束时的 UI 更新 (对应上一段中的第二个时间点)。


需要执行的副作用AppCommand定义成了一个protocol:

import Foundation
import Combine
protocol AppCommand {
func execute(in store: Store)
}

然后不同的需求,可以定义不同的struct来实现这个协议,例如:

struct LoginAppCommand: AppCommand {
    let email: String
    let password: String

    func execute(in store: Store) {
        let token = SubscriptionToken()
        LoginRequest(
            email: email,
            password: password
        ).publisher
        .sink(
            receiveCompletion: { complete in
                if case .failure(let error) = complete {
                    store.dispatch(.accountBehaviorDone(result: .failure(error)))
                }
                token.unseal()
            },
            receiveValue: { user in
                store.dispatch(.accountBehaviorDone(result: .success(user)))
            }
        )
        .seal(in: token)
    }
}

而在Store中,针对不同的Action执行不同的操作:

static func reduce(state: AppState, action: AppAction) -> (AppState, AppCommand?) {
        var appState = state
        var appCommand: AppCommand? = nil

        switch action {
        case .accountBehaviorButton(let isValid):
            appState.settings.isValid = isValid

        case .emailValid(let isValid):
            appState.settings.isEmailValid = isValid

        case .register(let email, let password):
            appState.settings.registerRequesting = true
            appCommand = RegisterAppCommand(email: email, password: password)

        case .login(let email, let password):
            appState.settings.loginRequesting = true
            appCommand = LoginAppCommand(email: email, password: password)

        case .logout:
            appState.settings.loginUser = nil

        case .accountBehaviorDone(let result):
            appState.settings.registerRequesting = false
            appState.settings.loginRequesting = false

            switch result {
            case .success(let user):
                appState.settings.loginUser = user
            case .failure(let error):
                appState.settings.loginError = error
            }

        case .toggleListSelection(let index):
            let expanding = appState.pokemonList.selectionState.expandingIndex
            if expanding == index {
                appState.pokemonList.selectionState.expandingIndex = nil
                appState.pokemonList.selectionState.panelPresented = false
                appState.pokemonList.selectionState.radarProgress = 0
            } else {
                appState.pokemonList.selectionState.expandingIndex = index
                appState.pokemonList.selectionState.panelIndex = index
                appState.pokemonList.selectionState.radarShouldAnimate =
                    appState.pokemonList.selectionState.radarProgress == 1 ? false : true
            }
            
        case .togglePanelPresenting(let presenting):
            appState.pokemonList.selectionState.panelPresented = presenting
            appState.pokemonList.selectionState.radarProgress = presenting ? 1 : 0

        case .toggleFavorite(let index):
            guard let loginUser = appState.settings.loginUser else {
                appState.pokemonList.favoriteError = .requiresLogin
                break
            }

            var newFavorites = loginUser.favoritePokemonIDs
            if newFavorites.contains(index) {
                newFavorites.remove(index)
            } else {
                newFavorites.insert(index)
            }
            appState.settings.loginUser!.favoritePokemonIDs = newFavorites

        case .closeSafariView:
            appState.pokemonList.isSFViewActive = false

        case .switchTab(let index):
            appState.pokemonList.selectionState.panelPresented = false
            appState.mainTab.selection = index

        case .loadPokemons:
            if appState.pokemonList.loadingPokemons {
                break
            }
            appState.pokemonList.pokemonsLoadingError = nil
            appState.pokemonList.loadingPokemons = true
            appCommand = LoadPokemonsCommand()

        case .loadPokemonsDone(let result):
            appState.pokemonList.loadingPokemons = false

            switch result {
            case .success(let models):
                appState.pokemonList.pokemons =
                    Dictionary(uniqueKeysWithValues: models.map { ($0.id, $0) })
            case .failure(let error):
                appState.pokemonList.pokemonsLoadingError = error
            }

        case .loadAbilities(let pokemon):
            appCommand = LoadAbilitiesCommand(pokemon: pokemon)

        case .loadAbilitiesDone(let result):
            switch result {
            case .success(let loadedAbilities):
                var abilities = appState.pokemonList.abilities ?? [:]
                for ability in loadedAbilities {
                    abilities[ability.id] = ability
                }
                appState.pokemonList.abilities = abilities
            case .failure(let error):
                print(error)
            }

        case .clearCache:
            appState.pokemonList.pokemons = nil
            appState.pokemonList.abilities = nil
            appCommand = ClearCacheCommand()
        }
        return (appState, appCommand)
    }

在这里传入stateaction,会更改state状态,并且如果有额外的command会执行这个command,而这些command同样是以action的形式来修改状态。或者直接修改binding值也可以修改状态。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值