SwiftUI
数据流
随着 SwiftUI 新的 UI 框架的到来,在数据管理方面有了新的典范和工具,例如 @State
、@Binding
、ObservedObject
。在讲解 SwiftUI 提供的这些属性标志符之前,最好要理解管理数据方式改变的原因,以及数据管理方式的改变到底要解决什么样的问题,然后再学习这些标志符才更有意义。
UIKit
框架伴随开发人员走过了10多年风雨,在使用 UIKit
开发时,需要手动维护视图-数据的同步,随着业务的增加,维护视图-数据的依赖关系是一个不小的工作量,如果没有一套合理的管理方式,便会出现视图-数据不同步的问题。虽然可以使用通知、KVO 等技术保持数据和视图的同步,但这并不是好的方式, UIKit
并没有提供技术从根本解决这些问题。UIKit
本身是就是命令式的开发方式,需要 controller 处理各种事件,协调 View、Model 之间的同步,于是非常容易出现臃肿的 controller 的情况。
SwiftUI
在设计时便考虑到这些因素,并且从根本上解决了这些问题,所以在后面使用 SwiftUI
数据管理工具的时候,会发现更易使用、更能切中要害。
数据是驱动界面的所有信息, SwiftUI
制定了2条数据流转的原则:
Data Access as a Dependency
:数据一旦被 View 使用,就会与 View 形成依赖,当数据变动 SwiftUI 会自动同步更新 View。A single source of truth
:单一数据源原则,视图中的数据源只能有一个。
视图层级结构上的数据只有一个数据源,而且应该如此。数据可以来自视图内部,例如按钮的高亮状态,也可以来自外部,例如登录界面的用户数据。其实不管数据来自哪里,应该只有一个数据源,因为多份数据可能导致数据之间不同步。大家可能都有体会,当多个视图有多份数据的时候,要维护数据之间的信息同步其实很麻烦。
维护两份数据源的例子,Feed 列表和 Feed 详情页使用不同数据。详情页的收藏状态变化后,Feed 列表没有同步变化。
在 SwiftUI 中我们可以把数据变换分成两类,内部、外部变化。先来看一下内部数据,例如下面播放按钮的状态、列表中过滤收藏开关的状态,这些数据只需要保存在 View 内部就可以了。
以播客的播放器为例子,看一下数据流工具的用法
@State
@State
是为 View 内部使用而设计的,Apple 推荐其和 private
一起使用,用来强调 @State
属性只由 View 内部使用。@State
变量的内存有 SwiftUI 管理,框架会为变量分配一块内存,并且当变量变化的时候,框架会重新渲染视图。
首先实现一个下面的播放界面,
代码如下:
import SwiftUI
struct Episode {
var title: String
var showTitle: String
}
struct PlayerView : View {
let episode = Episode(title: "WWDC 2019", showTitle: "Data Flow Throght SwiftUI")
private var isPlaying: Bool = false
var body: some View {
VStack {
Text(episode.title).foregroundColor(isPlaying ? .white: .gray)
Text(episode.showTitle).font(.caption).foregroundColor(.gray)
Button(action: {
print("Hello WWDC 2019")
}) {
Image(systemName: isPlaying ? "pause.circle" : "play.circle")
}
}
}
}
我们不能直接拿到控件修改 UI,只能修改属性间接改变 UI。上面代码中,isPlaying 与 Text 访问形成依赖关系,控制标题的颜色,此外还与按钮颜色有依赖,控制播放按钮的图标。如果想点击按钮修改 isPlaying 的值,在按钮点击事件里直接写上 self.isPlaying.toggle()
不就可以了吗?由于 PlayerView 是结构体,按照 Swift 的语法,结构体的属性不能修改,否则会报错:
Cannot use mutating member on immutable value: 'self' is immutable
正确的做法是在 isPlaying 上添加 @State,
struct PlayerView : View {
let episode = Episode(title: "WWDC 2019", showTitle: "Data Flow")
@State private var isPlaying: Bool = false // 修改
var body: some View {
VStack {
Text(episode.title).foregroundColor(isPlaying ? .white: .gray)
Text(episode.showTitle).font(.caption).foregroundColor(.gray)
Button(action: {
self.isPlaying.toggle() // 修改
}) {
Image(systemName: isPlaying ? "pause" : "play")
}
}
}
}
@State 的作用域是本View,和 private 一起用。修改 @State 属性会刷新界面。
@Binding (共享绑定)
@Binding
是用在父子控件同步数据的时候使用。如果我们把播放按钮抽取成一个单独的控件,可把代码修改成下面这样
struct PlayerView : View {
let episode = Episode(title: "WWDC 2019", showTitle: "Data Flow Throght SwiftUI")
@State private var isPlaying: Bool = false
var body: some View {
VStack {
Text(episode.title).foregroundColor(isPlaying ? .blue : .red)
Text(episode.showTitle).font(.caption).foregroundColor(.gray)
PlayerButton(isPlaying: $isPlaying)
}
}
}
struct PlayerButton : View {
@Binding var isPlaying: Bool
var body: some View {
return Button(action: {
self.isPlaying.toggle()
}) {
Image(systemName: isPlaying ? "pause.circle" : "play.circle")
}
}
}
创建 PlayerButton 的时候,isPlaying 前用 $ 表示传递一个依赖。@Binding
用来显示定义一个依赖、并且不持有父控件数据。对于用 @Binding
标记的任何变量,你可以对其进行读写操作。当子控件的 @Binding
属性修改时,父控件的 UI 都会更新。
从上面的例子,可以看出 SwiftUI 在管理数据和 UIKit 有很大的改变,我们只需要遵守 SwiftUI 的数据管理原则就可以管理好数据,框架会做好数据-视图的同步,开发者只需要关注业务逻辑。
上图可以看出 SwiftUI 事件、数据的流转过程。SwiftUI 不再需要 ViewController 承载 View 控件,处理各种事件。
目前,我们只谈到了 View 内部的数据变化,@State 修饰 View 内部、私有的属性,@Binding 可以声明对 @State 属性的依赖。
@ObservedObject
@State 只是标记视图内部的状态,通常情况下,UI 和业务代码、数据是分开的,这个时候想把外部数据传递到视图上,就可以用 ObservableObject
。
使用的时候需要做3件事:
- 数据必须是遵守
ObservableObject
的 class 类型。 - 在自定义的数据模型中,对于改变后需要刷新 UI 的属性用
@Published
标记。 - 在 view 内部用
@ObservedObject
符号修饰对象实例。
例子:
class UserSettings: ObservableObject {
@Published var score = 0
}
上述 model 中代码不多,这是因为 SwiftUI 为我们做了很多事。
- 遵守
ObservableObject
协议后,在实例的属性发生变化后能让 view 刷新。 @Published
是告诉 SwiftUI,score 属性发生变化可以触发 view 刷新。
在 view 里使用:
struct ContentView: View {
@ObservedObject var settings: UserSettings
var body: some View {
VStack {
Text("Your score is \(settings.score)")
Button(action: {
self.settings.score += 1
}) {
Text("Increase Score")
}
}
}
}
@EnvironmentObject
当我们需要把 @ObservedObject 的属性传给很多个子视图,或者要访问的数据离当前 view 很远,一个个传很不实际,这就需要用到 @EnvironmentObject
了。
EnvironmentObject
类似一个全局的 @State
,@EnvironmentObject
数据的作用域是整个 View 的层级结构,可以在 View、或子视图任何地方用 @EnvironmentObject
访问数据。
例子:
class GlobalState: ObservableObject {
@Published var currentTopic: String = "Default GlobalState"
}
struct EnvironmentView: View {
@EnvironmentObject var globalState: GlobalState
var body: some View {
VStack {
Text("EnvironmentView")
Text(globalState.currentTopic)
HStack {
EnvironmentOneView()
EnvironmentTwoView()
}
}
}
}
struct EnvironmentView_Previews: PreviewProvider {
static var previews: some View {
EnvironmentView().environmentObject(GlobalState())
}
}
struct EnvironmentOneView: View {
@EnvironmentObject var g: GlobalState
var body: some View {
VStack {
Text("EnvironmentOneView")
Text(g.currentTopic)
}
}
}
struct EnvironmentTwoView: View {
@EnvironmentObject var globalState: GlobalState
var body: some View {
VStack {
Text("EnvironmentTwoView")
Text(globalState.currentTopic)
Button("changeTopic") {
globalState.currentTopic = "Changed Topic"
}
}
}
}
@StateObject
这是一个在2020年 WWDC 新加的一个修饰符。为什么要新加这个修饰符呢?
@State 只能修饰基本值类型,对于稍微复杂的、需要定义为 class 类型的内部数据,只能用 @ObservedObject 修饰,@ObservedObject 属性是存在 View 中的,会随着 View 的创建被多次创建,有一些情况 View 释放会导致 @ObservedObject 数据丢失。
@StateObject 是 @State 的 class 版本,可以用来修饰对象,@State 与 @StateObject 属性的生命周期都是由 SwiftUI 接管。@StateObject 的出现刚好解使用 @ObservedObject 导致决数据丢失的问题。
看一个 @ObservedObject 导致数据丢失的例子:
struct StateObjectView: View {
@State var showName: Bool = false
var body: some View {
VStack {
Button("name:\(showName ? "Jaly" : "Null")") {
showName.toggle()
}
StateSubview().padding()
}
}
}
struct StateSubview: View {
@ObservedObject var settings: UserSettings = UserSettings()
var body: some View {
VStack {
Text("Your score is \(settings.score)")
Button(action: {
self.settings.score += 1
}) {
Text("Increase Score")
}
}
}
}
点击名字按钮可以显示、隐藏姓名,点击 Increase Score
可以增加分数,现在都没有问题,最后点击 name 隐藏姓名后,分数确消失了。这是因为点击 name 按钮后,View 刷新导致 StateSubview 重新创建,StateSubview 内的 settings 由 View 管理内存, settings 在每次刷新会生成新的数据,导致之前的数据丢失。使用 @StateObject
修饰 settings 便可解决这个问题。
从上面例子中可以看到 @StateObject、@ObservedObject 在内存管理方面的差异,具体使用哪个修饰符还要根据具体业务场景决定。
总结
管理工具特性
@State
- 值改变后,会 reload
View
显示最新信息; - 内存由 SwiftUI 管理,只要
View
存在属性便会在内存中保留; - 只能用于基本数据类型、结构体等值传递的类型,不能用于引用类型;
- 私有,只能本
View
内使用;
- 值改变后,会 reload
@Binding
- 父子视图之间进行数据源共享,双向绑定,一般只接受处理值类型
ObservedObject
- 使用外部的 class 类型(遵守
ObservableObject
协议)而不能用基本类型; View
自己管理内存;- 非私有,多个
View
间共享数据;
- 使用外部的 class 类型(遵守
StateObject
- @State 的 class 版本,内存由 SwiftUI 管理,可看做 @State 和 @ObservedObject 的结合体
@EnvironmentObject
- 全局的数据绑定机制,View 的层级结构中可以随意访问绑定数据
以 @State 为例,看一下 SwiftUI 数据管理工具的实现。 SwiftUI 用 @State 来维护状态,状态改变后,会自动更新 UI。类似的语法还有 @Binding,@@Environment 等。
State 定义:
@propertyWrapper
public struct State<Value> : DynamicProperty {
public init(wrappedValue value: Value)
public init(initialValue value: Value)
public var wrappedValue: Value { get nonmutating set }
public var projectedValue: Binding<Value> { get }
}
@State 是一个 @propertyWrapper 修饰的结构体,@State 修饰的属性,会转换成下面这样的伪代码:
@State private var isPlaying: Bool = false
// 转换为:
var $isPlaying: Bool = false
public var isPlaying: Bool {
get {
...
createDependency(view, value) // 建立视图与数据依赖关系
return $text.value // 返回一个引用
}
set {
$text.value = newValule
notify(to: swiftui) // 通知 SwiftUI 数据有变化
...
}
}
在获取数据的时候建立视图与数据的依赖,在 @State 属性变化后,能够触发 View 重新绘制。
@propertyWrapper
是 Swift 5.1 的一个特性,后面会单独讲解。