SwiftUI与苹果之前的UI框架的区别不仅在于如何定义视图和其他UI组件,还在于如何在使用它的应用程序中管理视图级的状态。
而不是使用委托、数据源或其他任何在命令式框架(如UIKit和AppKit)中常见的状态管理模式 - SwiftUI附带了一些属性包装器,使我们能够准确地声明我们的数据是如何被观察、渲染和改变的。
本周,让我们深入了解每一个属性包装,它们之间的关系,以及它们如何构成SwiftUI的整体状态管理系统的不同部分。
State properties
由于SwiftUI主要是一个UI框架(尽管它也开始获得api来定义更高级的结构,如应用程序和场景),它的声明性设计不需要影响应用程序的整个模型和数据层-而是直接与我们的各种观点views相关联的状态。
例如,假设我们正在开发一个SignupView,它允许用户通过输入用户名和电子邮件地址在应用程序中注册一个新帐户。然后,我们将使用这两个值来形成一个用户模型,该模型被传递给一个handler闭包——给我们三个状态:
struct SignupView: View {
var handler: (User) -> Void
var username = ""
var email = ""
var body: some View {
...
}
}
因为这三个属性中只有两个——用户名和电子邮件——实际上会被我们的视图修改,而且这两个状态可以保持私有,我们将使用SwiftUI的状态属性包装器标记它们,如下所示:
struct SignupView: View {
var handler: (User) -> Void
@State private var username = ""
@State private var email = ""
var body: some View {
...
}
}
这样做将自动在这两个值和我们的视图本身之间创建一个连接——这意味着每次这两个值中的任何一个被更改时,我们的视图都会被重新渲染。在我们的正文中,我们将把这两个属性分别绑定到一个对应的TextField上,以使它们成为用户可编辑的——实现如下:
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视图的内部状态,并在状态发生变化时自动更新视图。因此,将状态包装的属性保留为私有的通常是一个好主意,这样可以确保它们只在视图的主体中发生突变(在其他地方尝试突变它们实际上会导致运行时崩溃)。
Two-way bindings
看看上面的代码示例,我们将每个属性传递到它们的TextField的方法是在这些属性名称前加上$。这是因为我们不只是将普通字符串值传递到这些文本字段中,而是传递到状态包装属性本身的绑定。
了更详细地了解这意味着什么,现在假设我们希望创建一个视图,允许用户编辑他们在注册时最初输入的概要信息。因为我们现在希望修改外部状态值,而不仅仅是私有状态值,所以这次我们将用户名和电子邮件属性标记为绑定:
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值绑定到我们的视图中。例如,下面是我们如何将我们的user模型本身传递到ProfileEditingView中,而不是传递两个单独的用户名和电子邮件值:
struct ProfileEditingView: View {
@Binding var user: User
var body: some View {
VStack {
TextField("Username", text: $user.username)
TextField("Email", text: $user.email)
}
.padding()
}
}
就像我们在将状态和绑定包装的属性传递给各种TextField实例时使用$作为前缀一样,当将任何状态值连接到我们自己定义的绑定属性时,我们也可以做完全相同的事情。
例如,这里有一个ProfileView的实现,它使用状态包装的属性来跟踪用户模型,然后在将上述ProfileEditingView的实例作为工作表表示时,将一个绑定传递给该模型,它将自动同步用户对原始状态属性值的任何更改:
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") }
)
}
}
}
}
请注意,我们还可以通过给状态包装属性赋一个新值来改变它——就像我们在“Done”按钮的操作处理程序中将isEditingViewShown设置为false一样。
因此,Binding标记的属性提供了给定视图和在该视图之外定义的状态属性之间的双向连接,state和binding包装的属性,它们可以通过在属性名称前加上$作为绑定传递。
Observing objects
State 和 Binding的共同之处在于,它们处理的值本身是在SwiftUI视图层次结构中管理的。 然而,构建一个将所有状态都保存在不同视图中的应用当然是可能的——就架构和关注点分离而言,这通常不是一个好主意,而且很容易导致我们的视图变得非常庞大和复杂。
值得庆幸的是,SwiftUI还提供了许多机制,使我们能够将外部模型对象连接到各种视图。其中一种机制是ObservableObject协议,当它与ObservedObject属性包装器结合使用时,可以让我们建立到视图层之外管理的引用类型的绑定。
作为一个例子,让我们更新上面定义的ProfileView——将管理用户模型的责任从视图本身转移到一个新的、专用的对象中。 现在,我们可以使用许多不同的隐喻来描述这样的对象,但由于我们希望创建一个类型来控制我们模型的一个实例——让我们让它成为一个符合SwiftUI的ObservableObject协议的模型控制器:
class UserModelController: ObservableObject {
@Published var user: User
...
}
Published属性包装器用于定义对象的哪些属性应该在修改时触发观察通知。
有了上面的类型,现在让我们回到ProfileView,并使它作为ObservedObject来观察我们的新UserModelController的实例,而不是使用状态包装的属性来跟踪我们的用户模型。真正巧妙的是,我们仍然可以像以前一样,轻松地将模型绑定到ProfileEditingView上,因为observedobobject包装的属性也可以转换为绑定——像这样:
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实例最终可能会被释放, 当我们的视图在更新期间被重新创建时(因为我们的视图现在是它的主要所有者):
struct ProfileView: View {
@ObservedObject var userController = UserModelController.load()
...
}
重要的是要记住,SwiftUI视图并不是对屏幕上呈现的实际UI组件的引用,而是描述UI的轻量级值 - 所以它们没有像UIView实例一样的生命周期。
为了解决上述问题,苹果引入了一个新的属性包装器,作为iOS 14和macOS大Sur的一部分,称为StateObject。一个标记为StateObject的属性的行为与一个ObservedObject的行为完全相同——只是SwiftUI会确保任何存储在这个属性中的对象在框架重新渲染视图时不会意外地被释放:
struct ProfileView: View {
@StateObject var userController = UserModelController.load()
...
}
从现在开始,虽然从技术上讲只使用StateObject是可能的——但我仍然建议在观察外部对象时使用ObservedObject,并且只在处理视图本身拥有的对象时使用StateObject。可以将StateObject和ObservedObject看作是State和Binding的引用类型,或者强属性和弱属性的SwiftUI版本。
Observing and modifying the environment
最后,让我们看看如何使用SwiftUI的环境系统在两个没有直接连接的视图之间传递不同的状态片段。虽然在父视图和其子视图之间创建绑定通常很容易,但在整个视图层次结构中传递某个对象或值可能非常麻烦 - 而这正是environment所要解决的问题。
使用SwiftUI的环境主要有两种方式。一种方法是在想要检索给定对象的视图中定义一个环境对象包装的属性 - 例如,这个ArticleView如何检索一个包含颜色信息的主题对象:
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)
}
}
}
然后,我们必须确保在视图的父视图中提供我们的环境对象(在本例中是一个主题实例),其余的由SwiftUI来处理。 这是通过使用environmentObject修饰符完成的,例如:
struct RootView: View {
@ObservedObject var theme: Theme
@ObservedObject var articleLibrary: ArticleLibrary
var body: some View {
ArticleListView(articles: articleLibrary.articles)
.environmentObject(theme)
}
}
请注意,我们不需要将上述修饰符应用到将要使用我们的环境对象的确切视图上——我们可以将其应用到层次结构中它之上的任何视图上。
使用SwiftUI环境系统的第二种方式是定义一个自定义的EnvironmentKey,它可以用于为内置的EnvironmentValues类型赋值和从内置的EnvironmentValues类型中获取值:
struct ThemeEnvironmentKey: EnvironmentKey {
static var defaultValue = Theme.default
}
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeEnvironmentKey.self] }
set { self[ThemeEnvironmentKey.self] = newValue }
}
}
有了上面的内容,我们现在可以使用Environment属性包装器(而不是EnvironmentObject)标记视图的主题属性,并传入我们希望获取值的Environment键的键路径:
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的方法假定将在运行时提供这样的值(如果不这样做将导致崩溃)。
Conclusion
SwiftUI管理状态的方式绝对是该框架最有趣的方面之一,这可能需要我们稍微重新思考一下数据在应用程序中是如何传递的——至少在直接被UI使用和改变的数据方面是这样。