书里介绍了一种类似于Redux,但是针对SwiftUI的特点进行一些改变的数据管理方式。
这套数据流动的方式的特点是:
- 将 app 当作一个状态机,状态决定用户界面。
- 这些状态都保存在一个Store对象中。
- View不能直接操作State,而只能通过发送Action的方式,间接修改存储在Store中的State。
- Reducer接受原有的State和发送过来的Action,生成新的State。
- 用新的State替换Store中原有的状态,并用新状态来驱动更新界面。
首先针对第3点,太过严苛,SwiftUI中还可以用Binding来修改状态。
其次,我们希望 Reducer 具有纯函数特性,但是在实际开发中,我们会遇到非常多带有副作用 (side effect) 的情况:比如在改变状态的同时,需要向磁盘写入文件,或者需要进行网络请求。在上图中,我们没有阐释这类副作用应该如何处理。有一些架构实现选择不区分状态和副作用,让它们混在一起进行,有一些架构选择在 Reducer 前添加一层中间件 (middleware),来把 Action
进行预处理。我们在 PokeMaster app 的架构中,选择在 Reducer 处理当前 State
和 Action
后,除了返回新的 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)
}
复制代码
在这里传入state
和action
,会更改state状态,并且如果有额外的command
会执行这个command
,而这些command
同样是以action
的形式来修改状态。或者直接修改binding
值也可以修改状态。