SwiftUI 状态管理系统指南

前言

SwiftUI与苹果之前的UI框架的区别不仅仅在于如何定义视图和其他UI组件,还在于如何在整个使用它的应用程序中管理视图层级的状态。

SwiftUI没有使用委托、数据源或任何其他在UIKit和AppKit等命令式框架中常见的状态管理模式,而是配备了一些属性包装器,使我们能够准确地声明我们的数据如何被我们的视图观察、渲染和改变。

本周,让我们仔细看看这些属性包装器中的每一个,它们之间的关系,以及它们如何构成SwiftUI整体状态管理系统的不同部分。

属性状态

由于SwiftUI主要是一个UI框架(尽管它也开始获得用于定义更高层次结构(如应用程序和场景)的API),其声明式设计不一定需要影响应用程序的整个模型和数据层——而只是直接绑定到我们各种视图的状态。

例如,假设我们正在开发一个SignupView,使用户能够通过输入用户名和电子邮件地址在应用程序中注册一个新账户。我们将使用这两个值形成一个用户模型,并将其传递给一个闭包:

1
2
3
4
5
6
7
8
9
struct SignupView: View {
var handler: (User) -> Void
var username = ""
var email = ""

var body: some View {
...
}
}

由于这三个属性中只有两个——usernameemail——实际上会被我们的视图修改,而且这两个状态可以保持私有,我们将使用SwiftUI的State属性包装器来标记它们——像这样:

1
2
3
4
5
6
7
8
9
10
struct SignupView: View {
var handler: (User) -> Void

@State private var username = ""
@State private var email = ""

var body: some View {
...
}
}

这样做将自动在这两个值和我们的视图本身之间建立一个连接——这意味着我们的视图将在每次改变这两个值的时候被重新渲染。在我们的主体中,我们将把这两个属性分别绑定到一个相应的TextField上,以使它们可以被用户编辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct SignupView: View {
var handler: (User) -> Void

@State private var username = ""
@State private var email = ""

var body: some View {
VStack {
TextField("Username", text: $username)
TextField("Email", text: $email)
Button(
action: {
self.handler(User(
username: self.username,
email: self.email
))
},
label: { Text("Sign up") }
)
}
.padding()
}
}

因此,State被用来表示SwiftUI视图的内部状态,并在该状态被改变时自动使视图更新。因此,最常见的做法是将State属性包装器保持为私有,这可以确保它们只在该视图的主体内被改变(试图在其他地方改变它们实际上会导致运行时崩溃)。

双向绑定

看一下上面的代码样本,我们将每个属性传入其TextField的方式是在这些属性名称前加上$。这是因为我们不只是将普通的String值传入这些文本字段,而是与我们的State包装的属性本身绑定。

为了更详细地探讨这意味着什么,让我们现在假设我们想创建一个视图,让我们的用户编辑他们最初在注册时输入的个人资料信息。由于我们现在要修改外部状态值,而不仅仅是私人状态值,所以这次我们将usernameemail属性标记为Bingding:

1
2
3
4
5
6
7
8
9
10
11
12
struct ProfileEditingView: View {
@Binding var username: String
@Binding var email: String

var body: some View {
VStack {
TextField("Username", text: $username)
TextField("Email", text: $email)
}
.padding()
}
}

最酷的是,绑定不仅仅局限于单一的内置值,比如字符串或整数,而是可以用来将任何Swift值绑定到我们的一个视图中。例如,我们可以将用户模型本身传递给ProfileEditingView,而不是传递两个单独的usernameemail:

1
2
3
4
5
6
7
8
9
10
11
struct ProfileEditingView: View {
@Binding var user: User

var body: some View {
VStack {
TextField("Username", text: $user.username)
TextField("Email", text: $user.email)
}
.padding()
}
}

就像我们在将StateBinding包装的属性传入各种TextField实例时用$作为前缀一样,我们在将任何State值连接到我们自己定义的Binding属性时也可以做同样的事情。

例如,这里有一个ProfileView的实现,它使用一个Stage包装属性来跟踪一个用户模型,然后在将上述ProfileEditingView的实例作为工作表呈现时,将该模型传递一个绑定——这将自动同步用户对该原始State属性值的任何改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct ProfileView: View {
@State private var user = User.load()
@State private var isEditingViewShown = false

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Username: ")
.foregroundColor(.secondary)
+ Text(user.username)
Text("Email: ")
.foregroundColor(.secondary)
+ Text(user.email)
Button(
action: { self.isEditingViewShown = true },
label: { Text("Edit") }
)
}
.padding()
.sheet(isPresented: $isEditingViewShown) {
VStack {
ProfileEditingView(user: self.$user)
Button(
action: { self.isEditingViewShown = false },
label: { Text("Done") }
)
}
}
}
}

请注意,我们也可以通过给一个State包装的属性分配一个新的值来改变它——比如我们在 “Done “按钮的动作处理程序中把isEditingViewShown设置为false

因此,一个Binding标记的属性在给定的视图和定义在该视图之外的状态属性之间提供了一个双向的连接,而StatrBinding包装的属性都可以通过在其属性名前加上$来作为绑定物传递。

观察对象

StateBingding的共同点是,它们处理的是在SwiftUI视图层次结构本身中管理的值。然而,虽然建立一个将所有的状态都保存在其各种视图中的应用程序是肯定可行的,但从架构和关注点分离的角度来看,这通常不是一个好主意,而且很容易导致我们的视图变得相当庞大和复杂

值得庆幸的是,SwiftUI还提供了一些机制,使我们能够将外部模型对象连接到我们的各种视图。其中一个机制是ObservableObject协议,当它与ObservedObject属性包装器结合时,我们可以设置与我们视图层之外管理的引用类型的绑定。

作为一个例子,让我们更新上面定义的ProfileView——通过将管理User模型的责任从视图本身转移到一个新的、专门的对象中。现在,我们可以用许多不同的方式来描述这样一个对象,但由于我们正在寻找创建一个类型来控制我们的一个模型的实例——让我们把它变成一个符合SwiftUI的ObservableObject协议的模型控制器:

1
2
3
4
class UserModelController: ObservableObject {
@Published var user: User
...
}

Published属性包装器用于定义对象的哪些属性在被修改时应让观察通知被触发。

有了上面的类型,现在让我们回到ProfileView,让它观察新的UserModelController的实例,作为一个ObservedObject,而不是用一个State属性包装器来跟踪我们的用户模型。最重要的是,我们仍然可以很容易地将这个模型绑定到我们的ProfileEditingView上,就像以前一样,因为ObservedObject属性包装器也可以转换为绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct ProfileView: View {
@ObservedObject var userController: UserModelController
@State private var isEditingViewShown = false

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Username: ")
.foregroundColor(.secondary)
+ Text(userController.user.username)
Text("Email: ")
.foregroundColor(.secondary)
+ Text(userController.user.email)
Button(
action: { self.isEditingViewShown = true },
label: { Text("Edit") }
)
}
.padding()
.sheet(isPresented: $isEditingViewShown) {
VStack {
ProfileEditingView(user: self.$userController.user)
Button(
action: { self.isEditingViewShown = false },
label: { Text("Done") }
)
}
}
}
}

然而,我们的新实现与之前使用的基于状态的实现之间的一个重要区别是,我们的UserModelController现在需要作为初始化器的一部分被注入ProfileView中。

除了 “迫使 “我们在代码库中建立一个更明确的依赖关系图之外,原因是一个标有ObservedObject的属性并不意味着对这个属性所指向的对象有任何形式的所有权。

因此,虽然下面的内容在技术上可能会被编译,但最终会导致运行时的问题——因为当我们的视图在更新时被重新创建,UserModelController实例可能会被删除(因为我们的视图现在是它的主要所有者):

1
2
3
4
struct ProfileView: View {
@ObservedObject var userController = UserModelController.load()
...
}

重要的是要记住: SwiftUI视图不是对正在屏幕上渲染的实际UI组件的引用,而是描述我们的UI的轻量级值——因此它们没有像UIView实例那样的生命周期。

为了解决上述问题,苹果在iOS 14和macOS Big Sur中引入了一个新的属性包装器,名为StateObject。标记为StateObject的属性与ObservedObject的行为完全相同——此外,SwiftUI将确保存储在此类属性中的任何对象不会因为框架在重新渲染视图时重新创建新实例而被意外释放:

1
2
3
4
struct ProfileView: View {
@StateObject var userController = UserModelController.load()
...
}

尽管从技术上来说,从现在开始可以只使用StateObject——我仍然建议在观察外部对象时使用ObservedObject,而在处理视图本身拥有的对象时只使用StateObject。把StateObjectObservedObject看作是StateBinding的参考类型,或者SwiftUI版本的强和弱属性。

观察和修改环境变量

最后,让我们来看看SwiftUI的环境系统如何被用来在两个互不直接连接的视图之间传递各种状态。尽管在一个父视图和它的一个子视图之间创建绑定通常很容易,但在整个视图层次结构中传递某个对象或值可能相当麻烦——而这正是环境变量旨在解决的问题类型。

有两种主要的方法来使用SwiftUI的环境。一种是首先在想要检索给定对象的视图中定义一个EnvironmentObject包装的属性——例如像这个ArticleView如何检索一个包含颜色信息的Theme对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ArticleView: View {
@EnvironmentObject var theme: Theme
var article: Article

var body: some View {
VStack(alignment: .leading) {
Text(article.title)
.foregroundColor(theme.titleTextColor)
Text(article.body)
.foregroundColor(theme.bodyTextColor)
}
}
}

然后,我们必须确保在我们的视图的某一个父类中提供我们的环境对象(在这种情况下是一个Theme实例),然后SwiftUI会处理其余的事情。这是通过使用environmentalObject修饰符完成的,例如,像这样:

1
2
3
4
5
6
7
8
9
struct RootView: View {
@ObservedObject var theme: Theme
@ObservedObject var articleLibrary: ArticleLibrary

var body: some View {
ArticleListView(articles: articleLibrary.articles)
.environmentObject(theme)
}
}

请注意,我们不需要将上述修改器应用于将使用我们的环境对象的确切视图——我们可以将其应用于我们的层次结构中任何在其之上的视图。

使用 SwiftUI 环境系统的第二种方式是定义一个自定义的EnvironmentKey ——然后它可以被用来向内置的EnvironmentValues 类型分配和检索值:

1
2
3
4
5
6
7
8
9
10
struct ThemeEnvironmentKey: EnvironmentKey {
static var defaultValue = Theme.default
}

extension EnvironmentValues {
var theme: Theme {
get { self[ThemeEnvironmentKey.self] }
set { self[ThemeEnvironmentKey.self] = newValue }
}
}

有了上述内容,我们现在可以使用Enviroment属性包装器(而不是EnvironmentObject)来标记我们视图的theme属性,并传入我们希望检索的环境键的键值路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ArticleView: View {
@Environment(\.theme) var theme: Theme
var article: Article

var body: some View {
VStack(alignment: .leading) {
Text(article.title)
.foregroundColor(theme.titleTextColor)
Text(article.body)
.foregroundColor(theme.bodyTextColor)
}
}
}

上述两种方法的一个明显区别是,基于键的方法要求我们在编译时定义一个默认值,而基于环境对象EnvironmentObject的方法则假设在运行时提供这样一个值(如果不这样做将导致崩溃)。

小结

SwiftUI管理状态的方式绝对是该框架最有趣的方面之一,它可能需要我们稍微重新思考数据在应用中的传递方式——至少在涉及到将被我们的UI直接消费和修改的数据时是这样。

我希望这篇指南能成为一个很好的方式来概述SwiftUI的各种状态处理机制,尽管一些更具体的API被遗漏了,这篇文章中强调的概念应该涵盖了所有基于SwiftUI的状态处理的绝大多数用例。

感谢你的阅读!

-------------本文结束感谢您的阅读-------------

本文标题:SwiftUI 状态管理系统指南

文章作者:Swift社区

发布时间:2022年07月06日 - 19:07

最后更新:2022年07月06日 - 19:07

原始链接:https://fanbaoying.github.io/SwiftUI-状态管理系统指南/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!