阅读 444

薛定谔的 @State

目录

  • @State,@Published, @ObservedObject,等等
  • 根据 projectedValue 进行分类
  • 薛定谔的 @State
  • 幽灵般的状态更新
译自 nalexn.github.io/stranger-th…
更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀

同许多开发者一样,我对于 SwiftUI 的了解是从苹果官方的精彩教程开始的。然而,这个开局姿势也给我灌输了一个 “SwiftUI 极其易学” 的错误观念。

在那之后,SwiftUI 的众多充满趣味和技巧的主题强烈地吸引了我。想要快速地搞清楚它们是一件有挑战的事情。即便是一些有经验的开发者,也说学习 SwiftUI 的过程就像从头开始学习一切。

在这篇文章中,我收集了跟状态管理相关的我个人对 SwiftUI 最为困惑的一些方面。可以说,假如我自己有这篇文章在手,可以省去数以小时计的解决问题的痛苦经历。

让我们开始吧!

@State,@Published, @ObservedObject,等等

一开始,我对这些 @Something 的认知是:它们是一组崭新的语言属性,就像 weak 或者 lazy,只不过是专门为 SwiftUI 引入的。

因此,我很快就因为这些新的“关键词”可以基于前缀产生各种变体而感到困惑:

value$value_value代表三个完全不同的东西!

我不知道,这几个 @Things 其实只是 SwiftUI 框架中的几个结构体,并非 Swift 语言的一部分。

而真正属于语言的一部分的是 Swift 5.1 引入的一个新特性:属性包装器.

在阅读了关于属性包装器的文档之后,我才恍然大悟,@State 或者 @Published 的背后其实没有秘密。正是这些包装器给原始变量赋予了“超能力”,比如 @State不可变性可变形@Published 的响应式能力。

听完之后更疑惑了吗?不用担心 —— 我立刻给你解释。

事情的全貌其实很清晰:当我们用 SwiftUI 里的 @Something 给变量标注属性时,比如 @State var value: Int = 0,Swift 编译器将为我们生成三个变量!(其中有两个是计算属性):

value —— 被包装的由我们声明类型的原始值(wrappedValue),比如例子中的 Int

$value —— 一个 “额外的” projectedValue,它的类型由我们使用的属性包装器决定。@StateprojectedValue 的类型是 Binding,因此我们的例子中就是 Binding 类型。

_value —— 属性包装器本身的引用,在视图初始化过程中可能用到:

struct MyView: View {
    @Binding var flag: Bool

    init(flag: Binding<Bool>) {
        self._flag = flag
    }
}复制代码

根据 projectedValue 进行分类

让我们浏览一下 SwiftUI 中最常用的 @Things,看看他们的 projectedValue 分别都是些什么:

  • @State —— Binding<Value>
  • @Binding —— Binding<Value>
  • @ObservedObject —— Binding<Value> (*)
  • @EnvironmentObject - Binding<Value> (*)
  • @Published - Publisher<Value, Never>

技术上来讲,(*) 给到我们的是 Wrapper 类型的中间值,一旦我们为该对象中的实际值指定了 keyPath,就会变成一个 Binding

如你所见,SwiftUI 中大部分的属性包装器,其职能都是跟视图的状态有关,并且被投射为 Binding,用于在视图之间传递状态。

唯一的跟大多数包装器不同的是 @Published,不过请注意:

  1. 它是在 Combine 框架而不是 SwiftUI 里声明的
  2. 它的用途是让值变为可观察的
  3. 它不用于视图的变量声明,只用在 ObservableObject 内部。

考虑一个在 SwiftUI 中相当常见的场景:声明一个 ObservableObject,并在某个视图中以 @ObservedObject 属性使用它:

class ViewModel: ObservableObject {
    @Published var value: Int = 0
}

struct MyView: View {
    @ObservedObject var viewModel = ViewModel()

    var body: some View { ... }
}复制代码

MyView 可以引用 $viewModel.valueviewModel.$value —— 两个表达式都是合法的。有点犯迷糊了是不是?

其实这两个表达式分别代表了完全不同的两个类型:BindingPublisher

两者都有实际的用途:

var body: some View {
    OtherView(binding: $viewModel.value)     // Binding
        .onReceive(viewModel.$value) { value // Publisher
            // 执行某些不需要视图更新的操作
        }
}复制代码

薛定谔的 @State

我们都知道包含在一个不可变的 struct 内部的 struct 也是不可变的。

在 SwiftUI 中,多数情况下我们面对是一个不可修改的 self,例如,在某个 Button 的回调中。基于这种上下文,每个实例变量,包括 @State 结构体也都是不可变的。

那么,你能解释一下为什么下面的代码是完全合法的吗?

struct MyView: View {
    @State var counter: Int = 0

    var body: some View {
        Button(action: {
            self.counter += 1 // 修改一个不可变的结构体! 
        }, label: { Text("Tap me!") })
    }
}复制代码

尽管是不可变的结构体,我们还是可以修改它的值,@State 有什么魔法?

这里有一份关于 SwiftUI 如何处理这种场景下的值的变化的详细解释,但这里我想强调一个事实:对于 @State 变量实际的值,SwiftUI 使用了隐藏的外部存储。

@State 其实是一个代理:它拥有一个内部变量 _location,用于访问外部存储。

让我给你出道面试题:下面这个例子会打印出什么内容?

func test() {
    var view = MyView()
    view.counter = 10
    print("\(view.counter)")
}复制代码

上面的代码相当直观;直觉告诉我们打印的值应该是 10。

然而并不是 —— 输出是 0。

这其中的玄机在于视图并非总是同状态存储连接:SwiftUI 会在视图需要重绘或者视图接收来自 SwiftUI 的回调的时候接通连接,而在之后又断开。

与此同时,在 DispatchQueue.main.async 中对 State 做出的修改将不能保证成功:某些时候可能是工作的。但假如你引入某个延迟,而存储连接在闭包执行时已经被断开了,那么状态修改就不会生效了。

对于 SwiftUI 视图来说,传统的异步分发是不安全的 —— 不要引火烧身。

幽灵般的状态更新

在用了多年的 RxSwift 和 ReactiveSwift 之后,对于数据流通过响应式绑定和视图的属性建立连接这件事,我认为是理所当然的。

但是当我尝试将 SwiftUI 和 Combine 放在一起协作的时候,我震惊了。

这两个框架之间表现得相当异质:一方并不能很轻松地把某个 Publisher 连接到某个 Binding,或者把某个 CurrentValueSubject 转换成 ObservableObject

两种框架之间互操作的方式只有几种。

第一个接触点是 ObservableObject —— 它是一个声明在 Combine 里的协议,但已经广泛地用于 SwiftUI 的视图。

第二个是 .onReceive() 视图 modifier,它是让你将视图和任意数据连接的唯一 API。

我的下一个大大的疑惑正是和这个 modifier 有关。看一下这个例子:

struct MyView: View {

    let publisher: AnyPublisher<String, Never>

    @State var text: String = ""
    @State var didAppear: Bool = false

    var body: some View {
        Text(text)
            .onAppear { self.didAppear = true }
            .onReceive(publisher) {
                print("onReceive")
                self.text = $0
            }
    }
}复制代码

这是视图只是显示了由 Publisher 生产的字符串,并且在视图出现在屏幕时设置 didAppear标记 ,就这么简单而已。

现在,试着回答我,你认为在下面这两个用例中,print("onReceive") 会被触发几次?

struct TestView: View {

    let publisher = PassthroughSubject<String, Never>()    // 1
    let publisher = CurrentValueSubject<String, Never>("") // 2

    var body: some View {
        MyView(publisher: publisher.eraseToAnyPublisher())
    }
}复制代码

让我们先考虑 PassthroughSubject

如果你的答案是 0,那么恭喜你,回答正确。PassthroughSubject 从未接收到任何值,因此没有东西会被提交到 onReceive 闭包。

第二用例有一点欺骗性。请认真点,仔细分析其中的猫腻。

当试图被创建时,onReceive modifier 将订阅 Publisher,提供无限制的值“要求” (参考 Combine 中的说明)。

由于 CurrentValueSubject 拥有初始值 "" ,它会立即将值推送给它的新订阅者,触发 onReceive 回调。

然后,当视图即将第一次显示在屏幕上时,SwiftUI 会调用它的 onAppear 回调,在我们的例子,这个回调会通过设置 didAppeartrue 来修改视图的状态。

那么接下来会发生什么? 你猜的没错!onReceive 闭包再次调用了!为什么会这样?

MyView 修改 onAppear 中的状态时,SwiftUI 需要创建一个新的视图,以便和状态改变之前的视图做对比! 这是给视图层级打上合适的补丁所要求的步骤。

由于第二次创建过程的视图也订阅了 Publisher,后者欢欢喜喜地又推送了自己的值。

正确答案是 2。

你能想象我在调试这些被传递给 onReceive 的幽灵般的更新调用时的困惑吗?当我试图去过滤掉这些重复的更新调用时,我的脑门上挂满了问号。

最后一个测验:如果我们在 onAppear 里设置 self.text = "abc",那最后会显示什么文本?

如果你不知道上面这个故事,那合乎逻辑的答案应当是 “abc”,但是当你已经用新知识升级了自己:无论何时何地你给 text 赋值,onReceive 回调都会如影随形,用 CurrentValueSubject 的值擦掉你刚刚赋的值。


我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~