vlambda博客
学习文章列表

[SwiftUI 知识碎片] 为什么 @State 只能在结构体中工作

译自 Why @State only works with structs
建议横屏阅读代码

我们知道,SwiftUI 的State 属性包装器被设计用于存储当前视图的本地数据。不过一旦你需要在视图之间共享数据,它就不管用了。

让我们把理论分解为代码 —— 下面是一个结构体,存储了用户的姓和名。

struct User {
var firstName = "Bilbo"
var lastName = "Baggins"
}

我们可以在 SwiftUI 视图中创建一个 @State 的 User 属性,然后把 $user.firstName 和 $user.lastName 同一些视图绑定,像这样:

struct ContentView: View {
@State private var user = User()

var body: some View {
VStack {
Text("Your name is \(user.firstName) \(user.lastName).")

TextField("First name", text: $user.firstName)
TextField("Last name", text: $user.lastName)
}
}
}

上面的代码可以完美工作:SwiftUI 很聪明,知道整个 User 对象包含了我们的全部的数据,并且会在 User 内部的值发生变化时更新 UI 。在幕后实际发生的事情是:每当我们的结构体中的某个值改变时,整个结构体随之改变 —— 就如同我们重新输入姓和名构建了一个新的 User 那样。听起来好像很浪费,但实际上这个过程非常快。

此前我们研究过类和结构体之间的差异,我提及它们有两个重要的差异。其一,结构体总是只有唯一的所有者,而对于类,可以有很多个对象指向同一份数据。其二,类不需要在方法名前写mutating 关键字以便修改它们的属性,因为即使是常量类,属性也可以直接修改。

实践中,这意味着如果我们有两个 SwiftUI 的视图,并且我们用相同的结构体实例赋给它们,它们实际上是各自拥有一份唯一的结构体拷贝;如果其中一个改变,另外一个并不会随着改变。另一方面,如果我们创建一个类实例,赋给两个视图,它们会共享改变。

对于 SwiftUI 开发者,这意味着如果我们想在多个视图之间共享数据,或者说让两个或者更多视图引用相同的数据,以便一个改变,全部跟随改变 —— 这种情况下我们需要用类而不是结构体。

所以,我们是不是可以把 User 结构体改成一个类,把下面的代码:

struct User {

改成这样:

class User {

现在运行代码,看看会发生什么?

我们搞砸了:app 无法正常工作。是的,当我们像之前那样往文本框里输入字符串时,文本视图不再改变了。这是为什么?

当我们使用 @State的时候,我们是在要求 SwiftUI 为我们监视某个属性的变化。这样让我们改变一个字符串,反转一个布尔型,或者往数组里加东西的时候,属性会变化,而 SwiftUI 会重新调用视图的 body属性。

当 User 还是一个结构体的时候,每当我们修改它的属性时,Swift 实际上创建了一个新的结构体实例。@State 能够看穿这种变化,并自动重新载入视图。现在我们把它改成类,这种行为不再发生:因为 Swift 能够直接修改目标对象的值 —— 没有新实例产生。

还记得我们需要在结构体的方法前添加 mutating 关键字以便修改结构体的属性对吧?这是因为虽然我们是以变量的方式创建结构体的属性,但结构体本身是不可变的,我们无法修改它的属性值 —— Swift 需要销毁并重建整个结构体以完成属性的改动。(mutating 相当于向编译器表态我就是要这个过程发生)。类并不需要 mutating 关键字,因为即便类本身是一个常量,Swift 仍然可以直接修改它的变量属性。

上面是一个很费解的理论点。让我针对我们的动作简单解释:由于 User 现在变成类了, 类中的属性值虽然在变化,但 @State 无法监控到这一点,所以没有重新加载视图以反映变化。

为了解决共享数据的问题,先把 @State 放一放。我们接下来要引入一个更强大的属性包装器,它叫 @ObservedObject,请拭目以待。


译自 Sharing SwiftUI state with @ObservedObject

用 @ObservedObject 共享 SwiftUI 状态

当我们用类承载 SwiftUI 的数据时,需要跨越多个视图共享这些数据时是怎么实现的呢?—— 对此 SwiftUI 给了我们两个有用的属性包装器: @ObservedObject 和 @EnvironmentObject。Environment object 我们稍后再讨论,现在先来聚焦在 observed object 。

下面是一份创建承载用户数据的 User 类,以及在视图中显示这些用户数据的代码:

class User {
var firstName = "Bilbo"
var lastName = "Baggins"
}

struct ContentView: View {
@State private var user = User()

var body: some View {
VStack {
Text("Your name is \(user.firstName) \(user.lastName).")

TextField("First name", text: $user.firstName)
TextField("Last name", text: $user.lastName)
}
}
}

我们给 user 属性标记了 @State,因此我们应该是希望输入到文本框,然后上面的文本视图跟着更新。但是,代码没有像我们想象的那样运作:

为了修正这个问题,我们需要让 SwiftUI 知道,类中的哪些我们感兴趣的部分发生了变化。这里说的 “感兴趣的部分”,指的是会导致 SwiftUI 需要监控并根据它们重新载入视图的部分—— 很有可能你的类里面有很多属性,但只有一小部分需要以这种方式暴露给外面。

User 类有两个属性:firstName 和 lastName。无论什么时候,这两个属性中的任何一个变化,我们都希望通知监视这个类的视图有变化发生以便它们重新加载。所以我们可以对这两个属性使用 @Published 属性观察者,就像这样:

class User {
@Published var firstName = "Bilbo"
@Published var lastName = "Baggins"
}

@Published 起的作用等同于半个的 @State:它也可以在属性值发生变化的时候发表“声明”。

那么声明给谁听?这就要用到另一个属性包装器@ObservedObject,相当于另一半的 @State—— 它告诉 SwiftUI 要监视变化的目标对象。

因此,我们把 user 属性改成这样:

@ObservedObject var user = User()

我同时把 private 访问控制移除了,不过实际上要不要这么做取决于你的需求。

由于我们用了 @ObservedObject,我们的代码现在无法编译通过了。这不是大问题,实际上修正很简单:@ObservedObject 属性包装器只能用在遵循了 ObservableObject 协议的类型上。这个协议没有具体要求,只是简单表明 “我们想要某些别的东西能够观察这个类型的对象”。

User 类稍作调整:

class User: ObservableObject {
@Published var firstName = "Bilbo"
@Published var lastName = "Baggins"
}

现在代码不仅编译通过,而且可以正常工作了。运行 app ,你会发现上面的文本视图可以正确地跟随文本框里的输入变化同步更新。

如你所见,相比于用 @State声明本地状态,要实现共享状态我们需要三个步骤:

  • 创建一个遵循 ObservableObject 协议的类

  • 标记类里的某些属性为 @Published 以便使用这个类的视图能够根据这些属性的变化来更新

  • 用 @ObservedObject 属性包装器来创建类的实例

这三个步骤的成果是,我们可以把状态存储在外部对象中,甚至可以在多个视图中使用这个对象,让所有视图都指向相同的数据。