SwiftUI 状态管理 —— Composible Binding
在 SwiftUI 中,需要通过数据来驱动 UI 的变化。数据结构抽象描述的质量也影响着我们对 SwiftUI 界面的维护。
通常数据中可能存在很多状态,如果使用很多的 boolean 值来描述这些状态,那么 App 的可维护性可能会大大降低。
管理独立状态的问题
假设我们有一个 App,用户可以在登录与非登录状态下进行操作。所以我们的界面需要兼容这两种状态,其描述可能是这样的:
class AppState: ObservableObject {
@Published var user: User? = nil
@Published var error: Error? = nil
var authenticated: Bool { user != nil }
var hasError: Bool { error != nil }
}
基于这样的状态描述,如果我们想创建一个仅展示用户名的组件大概会是这样:
var body: some View {
Group {
if state.hasError {
Text("Oops, sth went wrong: \(state.error!.localizedDescription)")
}
if state.authenticated {
Text("Hello \(state.user?.name ?? "Unknown")!")
} else {
Text("Hello, stranger")
}
}
}
粗看没有什么问题,实际上在维护这样的数据结构时就需要格外小心了。比如第一次我们登录失败,为了展示错误信息给 error
设置了值之后。必须在登录成功之后要及时地去清空 error,否则即使 state.authenticated
等于 true
,用户依然无法看到正确的信息。
这还仅仅是有两个状态的情况下,像这样独立状态属性会带来很大的维护成本,开发者需要牢记各个属性之间的依赖关系,甚至编写界面的时候,还需要注意代码执行顺序。
引入状态机
把状态抽象成带有 associated values 的 enum 是个更好的选择,比如:
class AppState: ObservableObject {
enum AccountState {
case authenticated(User)
case unauthenticated
case error(Error)
}
@Published var accountState: AccountState = .unauthenticated
}
// 界面中的使用
var body: some View {
VStack {
switch state.accountState {
case .authenticated(let user):
Text("Hello \(user.name)!")
case .unauthenticated:
Text("unregister")
case .error(let error):
Text("Oops, sth went wrong: \(error.localizedDescription)")
}
}
这样被状态机驱动的界面看起来要直观多了。并且在每个状态中对数据的操作,也由 enum 赋予了隔离能力。
双向绑定怎么办?
但这样做又来带个新的问题,现在没办法直接通过 $
来获取 Binding wrapper 来修改状态:
switch state.accountState {
case .authenticated(let user):
Text("Hello \(user.name)!")
TextField("Change username", text: <Binding<String>>)
// 不支持填入 self.$state.accountState.name 👆
...
Xcode 会提示: dynamic member ’name’ using key path from root type ‘AppState.AccountState’
为什么当操作对象是 class 时,是可以做到通过
self.$state.user.name
来获取到 name 属性的 Binding 封装的?
实现动态成员查找(dynamic member lookup)
因为 Enum 还不支持动态成员查找特性。什么是动态成员查找?简而言之就是通过 \
来获取到成员属性的 KeyPath
封装(什么又是 KeyPath?),然后将其转化为对应的 Binding 封装。
extension Binding {
func transform<LocalValue>(
_ keyPath: WritableKeyPath<Value, LocalValue>
) -> Binding<LocalValue> {
Binding<LocalValue>(
get: {
self.wrappedValue[keyPath: keyPath]
},
set: { localValue in
self.wrappedValue[keyPath: keyPath] = localValue
}
)
}
}
self.$state.accountState.username
// 等价于
self.$state.transform(\accountState).transform(\username)
所以接下来只需要为 enum 添加动态成员查找的支持就可以了
extension Binding {
func unwrap<Wrapped>() -> Binding<Wrapped>? where Value == Wrapped? {
guard let value = self.wrappedValue else { return nil }
return Binding<Wrapped>(
get: { value },
set: { self.wrappedValue = $0 }
)
}
}
然后再到 enum 里添加一个计算属性方便我们获取
enum AccountState {
case loggedIn(User)
case unregister
//
var username: String? {
get {
guard case .loggedIn(let user) = self else {
return nil
}
return user.username
}
set {
guard case .loggedIn(let user) = self,
let newValue = newValue else { return }
user.username = newValue
self = .loggedIn(user)
}
}
}
现在就可以在 SwiftUI 中对 enum 使用绑定了
if let username = self.$state.accountState.username.unwrap() {
TextField("Change username", text: username)
}
看起来还是有些麻烦,对于每个要使用 Binding 的属性都需要去写一个计算属性来包装一层。这样显然这并不如 KeyPath
那样,直接通过 self.$state.accountState[\.authenticated]
来获取到 enum 中 associated value 的 Binding 包装来得方便。
虽然 Swift 目前不支持,但我们还是可以通过引入 CasePaths 这个第三方依赖来实现。
import CasePaths
enum AccountState {
case authenticated(User)
case unauthenticated
case error(Error)
// 计算属性可以删除掉了
// var username: String? {
// get {
// guard case .authenticated(let user) = self else {
// return nil
// }
// return user.name
// }
// set {
// guard case .authenticated(let user) = self,
// let newValue = newValue else { return }
// user.name = newValue
// self = .authenticated(user)
// }
// }
}
// 界面中直接使用 CasePath
if let user = self.$state.accountState.matching(/.authenticated) {
TextField("Change username", text: user.name)
}
由此为 Enum 也增加了与 Struct、Class 等效的 KeyPath 支持,从而使得文章开头用 Enum 作为 SwiftUI 的状态机管理工具更便捷了一些。