目录
- @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,它的类型由我们使用的属性包装器决定。@State 的projectedValue 的类型是 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,不过请注意:
- 它是在 Combine 框架而不是 SwiftUI 里声明的
- 它的用途是让值变为可观察的
- 它不用于视图的变量声明,只用在
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.value 和 viewModel.$value —— 两个表达式都是合法的。有点犯迷糊了是不是?
其实这两个表达式分别代表了完全不同的两个类型:Binding 和 Publisher。
两者都有实际的用途:
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 回调,在我们的例子,这个回调会通过设置 didAppear 为 true 来修改视图的状态。
那么接下来会发生什么? 你猜的没错!onReceive 闭包再次调用了!为什么会这样?
当 MyView 修改 onAppear 中的状态时,SwiftUI 需要创建一个新的视图,以便和状态改变之前的视图做对比! 这是给视图层级打上合适的补丁所要求的步骤。
由于第二次创建过程的视图也订阅了 Publisher,后者欢欢喜喜地又推送了自己的值。
正确答案是 2。
你能想象我在调试这些被传递给 onReceive 的幽灵般的更新调用时的困惑吗?当我试图去过滤掉这些重复的更新调用时,我的脑门上挂满了问号。
最后一个测验:如果我们在 onAppear 里设置 self.text = "abc",那最后会显示什么文本?
如果你不知道上面这个故事,那合乎逻辑的答案应当是 “abc”,但是当你已经用新知识升级了自己:无论何时何地你给 text 赋值,onReceive 回调都会如影随形,用 CurrentValueSubject 的值擦掉你刚刚赋的值。
我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~