数据状态和绑定
上一篇文章没有涉及到如何使用数据让 app 界面真正能被使用。在 SwiftUI 里,用户界面是严格被数据驱动的:在运行时,任何对于界面的修改,都只能通过修改数据来达成,而不直接对界面进行调整和操作。相比于传统的 UIKit 或 AppKit,这在一定程度上对灵活性进行了限制,强制了我们必须使用更合理的数据处理方式。不过另一方面,它也规范了 SwiftUI 中数据流动的方式,让开发者更不容易犯错。在本章中,首先我们会为计算器 app 添加基本的运算逻辑,并将其作为 model 使界面正确工作。之后,我们会深入探索 SwiftUI 里 @State,@ObservedObject 和 @EnvironmentObject 等几个和数据传递相关的方式之间的异同及选择。
Store, Action, Reducer
以 Redux 为代表的状态管理和组件通讯架构,在近来的前端开发中很受欢迎。它的基本思想和步骤如下
- 将 app 当作一个状态机,状态决定用户界面。
2. 这些状态都保存在一个 Store 对象中,被称为 State。
3. View 不能直接操作 State,而只能通过发送 Action 的方式,间接改变存储在 Store 中的 State。
4. Reducer 接受原有的 State 和发送过来的 Action,生成新的 State。用新的 State 替换 Store 中原有的状态,并用新状态来驱动更新界面。
用图可以将这个过程表示为:
image.png这套架构方式在大中型的 app 中会让数据交互和状态管理十分清晰,但是它也引入了不少抽象概念。和 Swift 中的 protocol 为类型系统增加的复杂度类似,类 Redux 的架构在数据流动时也增加了一些额外的步骤。在大中型项目里,这么做可以硬性规范数据流动的方式,让 app 状态统一,但是在一些小项目里,这样的架构并不能起到太大作用,引入额外的复杂度得不偿失。
@State和@Binding 数据状态驱动界面
和一般的存储属性不同,@State 修饰的值,在 SwiftUI 内部会被自动转换为一对 setter 和 getter,对这个属性进行赋值的操作将会触发 View 的刷新,它的 body 会被再次调用,底层渲染引擎会找出界面上被改变的部分,根据新的属性值计算出新的 View,并进行刷新。
“使用 @State 是最简单的关联方式。在 ContentView 中,加入一个 brain 属性:
struct ContentView : View {
@State private var brain: CalculatorBrain = .left("0")
var body: some View {
VStack(spacing: 12) {
Spacer()
Text(brain.output) // 1
.font(.system(size: 76))”
.font(.system(size: 76))
.minimumScaleFactor(0.5)
.padding(.trailing, 24 * scale)
.frame(
minWidth: 0,
maxWidth: .infinity,
alignment: .trailing
)
Button("Test") { // 2
self.brain = .left("1.23")
}
CalculatorButtonPad()
.padding(.bottom)
}
}
}
运行 app,由于一开始 brain 的值为 .left("0"),因此初始显示为 “0”。在按下 “Test” 按钮后,brain 被设置为新的值 .left("1.23")。这一状态值的改变将触发 UI 刷新,让界面上的结果值变为 “1.23”。
@State 属性值仅只能在属性本身被设置时会触发 UI 刷新,这个特性让它非常适合用来声明一个值类型的值:因为对值类型的属性的变更,也会触发整个值的重新设置,进而刷新 UI。不过,在把这样的值在不同对象间传递时,状态值将会遵守值语义发生复制。所以,即使我们将 ContentView 里的 brain 通过参数的方式层层向下,传递给 CalculatorButtonPad 和 CalculatorButtonRow,最后在按钮事件中,因为各个层级中的 brain 都不相同,按钮事件对 brain 的变更也只会作用在同层级中,无法对 ContentView 中的 brain 进行改变,因此顶层的 Text 无法更新。
@Binding 就是用来解决这个问题的。和 @State 类似,@Binding 也是对属性的修饰,它做的事情是将值语义的属性“转换”为引用语义。对被声明为 @Binding 的属性进行赋值,改变的将不是属性本身,而是它的引用,这个改变将被向外传递。
对一个由 @ 符号修饰的属性,在它前面使用 $ 所取得的值,被称为投影属性 (projection property)。有些 @ 属性,比如这里的 @State 和 @Binding,它们的投影属性就是自身所对应值的 Binding 类型。不过要注意的是,并不是所有的 @ 属性都提供 $ 的投影访问方式
propertyWrapper: 在 Swift 中,这一特性的正式名称是属性包装 (Property Wrapper)。不论是 @State,@Binding,或者是我们在下一节中将要看到的 @ObjectBinding 和 @EnvironmentObject,它们都是被 @propertyWrapper 修饰的 struct 类型。
以 State 为例,在 SwiftUI 中 State 定义的关键部分如下:
@propertyWrapper
public struct State<Value> :
DynamicViewProperty, BindingConvertible
{
public init(initialValue value: Value)
public var value: Value { get nonmutating set }
public var wrappedValue: Value { get nonmutating set }
public var projectedValue: Binding<Value> { get }
}
init(initialValue:),wrappedValue 和 projectedValue 构成了一个 propertyWrapper 最重要的部分。在 @State 的实际使用里:
struct ContentView : View {
// 1
@State
private var brain: CalculatorBrain = .left("0")
var body: some View {
VStack(spacing: 12) {
Spacer()
Text(brain.output) // 2
//...
CalculatorButtonPad(brain: $brain) // 3
//...
}
}
}
-
由于 init(initialValue:) 的存在,我们可以使用直接给 brain 赋值的写法,将一个 CalculatorBrain 传递给 brain。我们可以为属性包装中定义的 init 方法添加更多的参数,我们会在接下来看到一个这样的例子。不过 initialValue 这个参数名相对特殊:当它出现在 init 方法的第一个参数位置时,编译器将允许我们在声明的时候直接为 @State var brain 进行赋值。
-
在访问 brain 时,这个变量暴露出来的就是 CalculatorBrain 的行为和属性。对 brain 进行赋值,看起来也就和普通的变量赋值没有区别。但是,实际上这些调用都触发的是属性包装中的 wrappedValue。@State 的声明,在底层将 brain 属性“包装”到了一个 State<CalculatorBrain> 中,并保留外界使用者通过 CalculatorBrain 接口对它进行操作的可能性。
-
使用美元符号前缀($) 访问 brain,其实访问的是 projectedValue 属性。在 State 中,这个属性返回一个 Binding 类型的值,通过遵守 BindingConvertible,State 暴露了修改其内部存储的方法,这也就是为什么传递 Binding 可以让 brain 具有引用语义的原因。
举个例子。比如,我们可以写一段代码,来进行货币间的转换:
@propertyWrapper struct Converter {
let from: String
let to: String
let rate: Double
var value: Double
var wrappedValue: String {
get { "\(from)\(value)" }
set { value = Double(newValue) ?? -1 }
}
var projectedValue: String {
return "\(to)\(value * rate)”
}
init(
initialValue: String,
from: String,
to: String,
rate: Double
)
{
self.rate = rate
self.value = 0
self.from = from
self.to = to
self.wrappedValue = initialValue
}
}
Converter 提供的 init 方法除了 initialValue 外,还接受 from,to 和 rate,它们分别代表转换货币的名称和汇率。属性中 wrappedValue 是 String 类型,表示我们希望包装一个字符串:它提供的 setter 负责将字符串转换为数字并存储 (当用户输入无法转换为数字时,用 -1 代表错误),getter 则输出源货币的信息。而访问 projectedValue 则能得到转换后的货币信息。
@Converter(initialValue: "100", from: "USD", to: "CNY", rate: 6.88)
var usd_cny
@Converter(initialValue: "100", from: "CNY", to: "EUR", rate: 0.13)
var cny_eur
print("\(usd_cny) = \($usd_cny)")
print("\(cny_eur) = \($cny_eur)")
// 输出:
// USD 100.0 = CNY 688.0
// CNY 100.0 = EUR 13.0
usd_cny = "324.3"
// USD 324.3 = CNY 2231.184”
操作回溯和数据共享
@State 非常适合 struct 或者 enum 这样的值类型,它可以自动为我们完成从状态到 UI 更新等一系列操作。但是它本身也有一些限制,我们在使用 @State 之前,对于需要传递的状态,最好关心和审视下面这两个问题:
- 这个状态是属于单个 View 及其子层级,还是需要在平行的部件之间传递和使用?@State 可以依靠 SwiftUI 框架完成 View 的自动订阅和刷新,但这是有条件的:对于 @State 修饰的属性的访问,只能发生在 body 或者 body 所调用的方法中。你不能在外部改变 @State 的值,它的所有相关操作和状态改变都应该是和当前 View 挂钩的。如果你需要在多个 View 中共享数据,@State 可能不是很好的选择;如果还需要在 View 外部操作数据,那么 @State 甚至就不是可选项了。
- 状态对应的数据结构是否足够简单?对于像是单个的 Bool 或者 String,@State 可以迅速对应。含有少数几个成员变量的值类型,也许使用 @State 也还不错。但是对于更复杂的情况,例如含有很多属性和方法的类型,可能其中只有很少几个属性需要触发 UI 更新,也可能各个属性之间彼此有关联,那么我们应该选择引用类型和更灵活的可自定义方式。
ObservableObject 和@ObjectBinding
如果说 @State 是全自动驾驶的话,ObservableObject 就是半自动,它需要一些额外的声明。ObservableObject 协议要求实现类型是 class,它只有一个需要实现的属性:objectWillChange。在数据将要发生改变时,这个属性用来向外进行“广播”,它的订阅者 (一般是 View 相关的逻辑) 在收到通知后,对 View 进行刷新。
创建 ObservableObject 后,实际在 View 里使用时,我们需要将它声明为 @ObservedObject。这也是一个属性包装,它负责通过订阅 objectWillChange 这个“广播”,将具体管理数据的 ObservableObject 和当前的 View 关联起来。
“作为第一步,让我们先来将 CalculatorBrain 作为属性,放到一个 ObservableObject 中。
新建文件,取名为 CalculatorModel.swift,并定义满足 ObservableObject 协议的 CalculatorModel:
class CalculatorModel: ObservableObject {
let objectWillChange = PassthroughSubject<Void, Never>()
}
“一般情况下,我们使用一个 PassthroughSubject 实例作为 objectWillChange 的值。在后面几章关于 Combine 的内容中,我们会更详细地介绍包括 PassthroughSubject 在内的知识。在这里我们只需要知道,PassthroughSubject 提供了一个 send 方法,来通知外界有事件要发生了 (此处的事件即驱动 UI 的数据将要发生改变)。
在 CalculatorModel 里添加 CalculatorBrain:
class CalculatorModel: ObservableObject {
let objectWillChange = PassthroughSubject<Void, Never>()
var brain: CalculatorBrain = .left("0") {
willSet { objectWillChange.send() }
}
}
然后在 ContentView 里将 @State 的内容都换成对应的 @ObservedObject 和 CalculatorModel:
struct ContentView : View {
// @State private var brain: CalculatorBrain = .left("0")
@ObservedObject var model = CalculatorModel() // 1
var body: some View {
VStack(spacing: 12) {
Spacer()
Text(model.brain.output) // 2
//...
CalculatorButtonPad(brain: $model.brain) // 3”
//...
}
}
}
- model 现在是一个引用类型 CalculatorModel 的值,使用 @ObservedObject 将它和 ContentView 关联起来。当 CalculatorModel 中的 objectWillChange 发出事件时,body 会被调用,UI 将被刷新。
- brain 现在是 model 的属性。
- CalculatorButtonPad 接受的是 Binding<CalculatorBrain>。model 的 $ 投影属性返回的是一个 Binding 的内部 Wrapper 类型,对它再进行属性访问 (这里的 .brain),将会通过动态查找的方式获取到对应的 Binding<CalculatorBrain>。
CalculatorButtonPad 通过和 @State 时同样的方式,将 brain 的 Binding 传递给 CalculatorButtonRow,并在按下按钮时重新设置状态值。这个对 model.brain 的设置,触发了 CalculatorModel 中 brain 的 willSet,并通过 objectWillChange 把事件广播出去。订阅了这个事件的 ContentView 在收到变更通知后
使用 @Published 和自动生成 “在 ObservableObject 中,对于每个对界面可能产生影响的属性,我们都可以像上面 brain 的 willSet 那样,手动调用 objectWillChange.send()。如果在 model 中有很多属性,我们将需要为它们一一添加 willSet,这无疑是非常麻烦,而且全是重复的模板代码。实际上,如果我们省略掉自己声明的 objectWillChange,并把属性标记为 @Published,编译器将会帮我们自动完成这件事情
在 ObservableObject 中,如果没有定义 objectWillChange,编译器会为你自动生成它,并在被标记为 @Published 的属性发生变更时,自动去调用 objectWillChange.send()。这样就省去了我们一个个添加 willSet 的麻烦
使用 @EnvironmentObject 传递数据
为了让除 ContentView 以外的其他 View (比如 CalculatorButtonPad,CalculatorButtonRow 和 HistoryView 等) 也能访问到同样的模型,我们现在通过它们的初始化方法将 model 进行传递。这在传递链条比较短,或者是链条上每个 View 都需要 model 时是相对合理的。但是,在很多时候实际情况会不同,比如计算器例子中 CalculatorButtonPad 其实完全不需要知道 model 的任何信息,它做的仅仅只是把这个值向下传递。在 SwiftUI 中,View 提供了 environmentObject(_:) 方法,来把某个 ObservableObject 的值注入到当前 View 层级及其子层级中去。在这个 View 的子层级中,可以使用 @EnvironmentObject 来直接获取这个绑定的环境值
比如,我们可以将 ContentView 里的 @ObservedObject model 换为 @EnvironmentObject:
struct ContentView : View {
@EnvironmentObject var model: CalculatorModel
// ...
}
类似地,在 CalculatorButtonRow 里,也可以修改 model 的定义
struct CalculatorButtonRow : View {
@EnvironmentObject var model: CalculatorModel
// ...
}
“在对应的 View 生成时,我们不需要手动为被标记为 @EnvironmentObject 的值进行指定,它们会自动去查询 View 的 Environment 中是否有符合的类型的值,如果有则使用它们,如没有则抛出运行时的错误。
由于 model 将通过 @EnvironmentObject 传递,而不是经由初始化方法传递,所以 CalculatorButtonPad 完全不再需要 CalculatorModel,可以将它从 CalculatorButtonPad 的属性声明中去掉
和 @ObservedObject 不同,@EnvironmentObject 不会在类型中自动创建变量,因此 CalculatorButtonRow 和 CalculatorButtonPad 的初始化方法不再会有 model 参数,将它们从对应的地方去掉
可能一开始认为@EnvironmentObject 和“臭名昭著”的单例很像:只要我们在 View 的层级上,不论何处都可以访问到这个环境对象。看似这会带来状态管理上的困难和混乱,但是 Swift 提供了清晰的状态变更和界面刷新的循环,如果我们能选择正确的设计和架构模式,完全可以避免这种风险。使用 @EnvironmentObject 带来很大的便捷性,也避免了大量不必要的属性传递,这会为之后代码变更带来更多的好处。
我们看到了 SwiftUI 中的几种处理数据和逻辑的方式。根据适用范围和存储状态的复杂度的不同,需要选取合适的方案。@State 和 @Binding 提供 View 内部的状态存储,它们应该是被标记为 private 的简单值类型,仅在内部使用。ObservableObject 和 @ObservedObject 则针对跨越 View 层级的状态共享,它可以处理更复杂的数据类型,其引用类型的特点,也让我们需要在数据变化时通过某种手段向外发送通知 (比如手动调用 objectWillChange.send() 或者使用 @Published),来触发界面刷新。对于“跳跃式”跨越多个 View 层级的状态,@EnvironmentObject 能让我们更方便地使用 ObservableObject,以达到简化代码的目的。
随着经验的积累,你会逐渐形成对于某个场景下应该使用哪种方式来管理数据和状态的直觉。在此之前,如果你纠结于选择使用哪种方式的话,从 ObservableObject 开始“从 ObservableObject 开始入手会是一个相对好的选择:如果发现状态可以被限制在同一个 View 层级中,则改用 @State;如果发现状态需要大批量共享,则改用 @EnvironmentObject。
前面两片文章总结可能写的有点草率大部分都是复制书里的下面的日志会认真写并加入自己的理解学习的时候勤记笔记,不急不躁
网友评论