摘自《SwiftUI和Combine编程》---《SwiftUI架构》
Redux For SwiftUI 架构图
Action
创建动作,规定 View 不能直接操作 State,而只能通过发送 Action 的方式,间接改变存储在 Store 中的 State。
// 创建模块的相关动作
enum AppAction {
case login(email: String, pwd: String)
case loginDone(result: Result<User, AppError>)
case logout
case logoutDone
}
// 外部想改变 State 刷新界面,只能通过调用 action 来实现
Button(settings.accountBehavior.text) {
self.store.dispatch(
.login(email: self.settings.email, password: self.settings.password)
)
}
Store
状态决定用户界面,将所有状态都保存在 Store 中
State
1、为模块创建一个状态机,状态决定用户界面
// 状态机 appState
struct AppState {
var settings = Settings()
}
extension AppState {
struct Settings {
enum AccountBehavior: CaseIterable {
case register, login
}
enum Sorting: CaseIterable {
case id, name, color, favorite
}
var accountBehavior = AccountBehavior.login
var email = ""
var password = ""
var verifyPassword = ""
// 纯副作用 考虑使用 @propertyWrapped 和 didset
var sorting: Sorting {
// UserDefault 无法保存枚举值,只能保存对应的 rawValue,所以在外面包一层映射
set { sortingRawValue = newValue.rawValue }
get { Sorting(rawValue: sortingRawValue) ?? .id }
}
@UserDefaultStorage(Sorting.id.rawValue, key: "sorting")
var sortingRawValue: Sorting.RawValue
@UserDefaultStorage(true, key: "showEnglishName")
var showEnglishName: Bool
@UserDefaultStorage(false, key: "showFavoriteOnly")
var showFavoriteOnly: Bool
@FileStorage(directory: .documentDirectory, fileName: "user.json")
var loginUser: User?
var loginRequesting: Bool = false
var loginError: AppError?
}
}
2、状态机保存在一个 Store 对象中。
- Store 声明为 @ObservableObject
- State 声明为 @Published
这样,根据 SwiftUI 特性,每当 State 有变化,就会抛出通知触发相关界面更新。
class Store: ObservableObject {
@Published var appState = AppState()
}
外部 View 使用属性:
// 使用需要绑定的值
var settingsBinding: Binding<AppState.Settings> {
$store.appState.settings
}
// 仅展示值:
var settings: AppState.Settings {
store.appState.settings
}
Reduce
3、View 不能直接操作 State,而只能通过发送 Action 的方式,间接改变存储在 Store 中的 State,所以 Store 中需提供与外部交流的接口
func dispatch(_ action: AppAction)
Static func reduce(state: AppState, action: AppAction) -> (AppState, AppCommand?)
Reduce 方法作为操作的核心,接受原有的 State 和发送过来的 Action,生成新的 State 和 command。用新的 State 替换 Store 中原有的状态,并用新状态来驱动更新界面。
- command 是状态的副作用,一般是由于异步操作带来的,需要在异步操作结束之后执行新的 action 驱动 State 进行再次改变。
- 如果是同步操作,则 command 为 nil
可以再提供一个 dispatch 方法,进而将 reduce 的职责单一化:只负责分拣出 Command 和新的 State
static func reduce(state: AppState,
action: AppAction) -> (AppState, AppCommand?) {
var appState = state
var appCommand: AppCommand? // 声明可空的 command
// 通过不同的操作,分拣出 command 和新的 state
switch action {
case .login(let email, let pwd):
guard !appState.setting.loginRequesting else {
break
}
appState.setting.loginRequesting = true
appCommand = LoginCommand(email: email, pwd: pwd)
case .loginDone(result: let result):
appState.setting.loginRequesting = false
switch result {
case .success(let user):
appState.setting.loginUser = user
case .failure(let error):
appState.setting.loginError = error
}
case .logout:
guard !appState.setting.loginRequesting,
let user = appState.setting.loginUser else {
break
}
appState.setting.loginRequesting = true
appCommand = LogoutCommand(user: user)
case .logoutDone:
appState.setting.loginRequesting = false
appState.setting.loginUser = nil
}
return (appState, appCommand)
}
dispatch 作为与 View 联系的接口
,仅需要接收 action,内部调用reduce,将 OldState 和 action 传入即可。
func dispatch(_ action: AppAction) {
let result = Store.reduce(state: appState, action: action)
self.appState = result.0
if let command = result.1 {
command.execute(in: self)
}
}
Command
我们希望 Reducer 具有纯函数特性,但是在实际开发中,我们会遇到非常多带有副作用 (side effect) 的情况:比如在改变状态的同时,需要向磁盘写入文件,或者需要进行网络请求。选择在 Reducer 处理当前 State 和 Action 后,除了返回新的 State 以外,再额外返回一个 Command 值,并让 Command 来执行所需的副作用。
写法:
提供 Command 协议,提供 execute 方法,因为回调结束之后要调用 store 接口改变 state,所以需要与 store 相关联,给 execute 附加一个参数 store
protocol AppCommand {
func execute(in store: Store)
}
struct LoginCommand: AppCommand {
let email: String
let pwd: String
func execute(in store: Store) {
let token = SubscriptionToken()
LoginRequest(email: self.email, pwd: self.pwd).publisher
.sink(receiveCompletion: { complete in
if case .failure(let error) = complete {
// 回调完成后 调用store接口改变状态
store.dispatch(.loginDone(result: .failure(error)))
}
// 是为了持有token至block结束,避免提前取消
token.unseal()
}, receiveValue: { user in
// 回调完成后 调用store接口改变状态
store.dispatch(.loginDone(result: .success(user)))
// WriteUserAppCommand(user: user).execute(in: store)
})
.seal(in: token) // 持有anyCancellable,否则会提前取消
}
}
struct LogoutCommand: AppCommand {
let user: User
func execute(in store: Store) {
let token = SubscriptionToken()
LogoutRequest(user: user).publisher
.sink(receiveCompletion: { _ in
token.unseal()
}, receiveValue: { _ in
store.dispatch(.logoutDone)
})
.seal(in: token)
}
}
// 最标准的副作用 Command 写法
// 但是此处使用 loginUser的 @propertyWrapper更为简单
// 纯副作用考虑使用 didset 或者 @propertyWrapper
//struct WriteUserAppCommand: AppCommand {
// let user: User
//
// func execute(in store: Store) {
// try? FileHelper.writeJSON(user,
// to: .documentDirectory,
// fileName: "user.json")
// }
//}
class SubscriptionToken { // 为了避免提前取消
var cancellable: AnyCancellable?
func unseal() {
cancellable = nil
}
}
extension AnyCancellable {
func seal(in token: SubscriptionToken) {
token.cancellable = self
}
}
// request
struct LoginRequest {
let email: String
let password: String
var publisher: AnyPublisher<User, AppError> {
Future { promise in
DispatchQueue.global().asyncAfter(deadline: .now()+1.5) {
if self.password == "password" {
let user = User(email: self.email, favoritePokemonIDs: [])
promise(.success(user))
} else {
promise(.failure(.passwordWrong))
}
}
}
.receive(on: DispatchQueue.main) // 之后执行的操作放在主线程
.eraseToAnyPublisher()
}
}
struct LogoutRequest {
let user: User
var publisher: AnyPublisher<Void, Never> {
Future { promise in
DispatchQueue.global().asyncAfter(deadline: .now()+1) {
promise(.success(()))
}
}
.receive(on: DispatchQueue.main) // 之后执行的操作放在主线程
.eraseToAnyPublisher()
}
}