在 WWDC 2023 中,苹果介绍了 Swift 规范库中的新成员:Observation 结构。它的呈现有望缓解开发者长时间面临的 SwiftUI 视图无效更新问题。本文将采纳问答的办法,全面而翔实地探讨 Observation 结构,内容涉及其产生原因、运用办法、作业原理以及注意事项等。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的大众号:【肘子的Swift记事本】

为什么要创立 Observation 结构

在 Swift 5.9 版别之前,苹果没有为开发者供给一种一致高效的机制来调查引证类型特点对改动。KVO 仅限于 NSObject 子类运用,Combine 无法供给特点等级的精确调查,并且两者都无法完结跨渠道支撑。

此外,在 SwiftUI 中,引证类型的数据源(Source of Truth)采用了依据 Combine 结构的 ObservableObject 协议完结。这导致在 SwiftUI 中,极易产生了很多不必要的视图刷新,然后影响 SwiftUI 运用的功能。

为了改善这些约束,Swift 5.9 版别推出了 Observation 结构。比较现有的 KVO 和 Combine,它具有以下优点:

  1. 适用于一切 Swift 引证类型,不限于 NSObject 子类,供给跨渠道支撑。
  2. 供给特点等级的精确调查,且无需对可调查特点进行特别注解。
  3. 减少 SwiftUI 中对视图的无效更新,提高运用功能。

怎么声明可调查目标

运用 Combine 结构,咱们能够这样声明一个可被调查的引证类型:

class Store: ObservableObject {
    @Published var firstName: String
    @Published var lastName: String
    var fullName: String {
        firstName + " " + lastName
    }
    @Published private var count: Int = 0
    init(firstName: String, lastName: String, count: Int) {
        self.firstName = firstName
        self.lastName = lastName
        self.count = count
    }
}

当实例的 firstName、lastName 以及 count 发生改动时,@Published 会经过 objectWillChange( ObjectWillChangePublisher ) 发送告诉,告知一切订阅者,当前的实例即将发生改动。

运用 Observation 结构,咱们将采用彻底不同的声明办法:

@Observable
class Store {
    var firstName: String = "Yang"
    var lastName: String = "Xu"
    var fullName: String {
        firstName + " " + lastName
    }
    private var count: Int = 0
    init(firstName: String, lastName: String, count: Int) {
        self.firstName = firstName
        self.lastName = lastName
        self.count = count
    }
}
  • 在类的声明前增加 @Observalbe 标示,不需求指定 Store 类型要恪守某个协议。
  • 不需求经过 @Published 来标示能引发告诉的特点,没有特别标示的存储特点都能够被调查
  • 能够调查核算特点( 在例中,fullName 也可被调查 )
  • 关于不想被调查的特点,需求在其前方标示 @ObservationIgnored
// count 不可被调查
@ObservationIgnored
private var count: Int = 0
  • 一切的特点必须有字面默认值,即便供给了自界说的 init 办法

相较于依据 Combine 的声明办法,Observation 让可调查目标的声明愈加简练、愈加符合直觉,一起也供给了对核算特点的调查支撑。

@Observable 做了哪些作业

与其他常见的运用 @ 开头的关键字不同(例如@Published 特点包装器和@available 条件编译),@Observable 在这儿表示的是宏(Macro)。

宏(Macro)是 Swift 5.9 中新增的一项功用。它答应开发者在编译时操纵和处理 Swift 代码。开发者能够供给一段宏界说,该界说会在编译器编译源代码时履行,并对源代码进行修正、增加或删除等操作。

在 Xcode 15 中,在@Observable 处点击鼠标右键,挑选“Expand Macro”操作。经过这步操作,咱们能够看到 @Observable 宏为咱们生成的代码:

深度解读 Observation —— SwiftUI 性能提升的新途径

@Observable
class Store {
    @ObservationTracked
    var firstName: String = "Yang" {
        get {
            access(keyPath: \.firstName)
            return _firstName
        }
        set {
            withMutation(keyPath: \.firstName) {
                _firstName = newValue
            }
        }
    }
    @ObservationTracked // 能够进一步打开
    var lastName: String = "Xu"
    var fullName: String {
        firstName + " " + lastName
    }
    @ObservationIgnored
    private var count: Int = 0
    init(firstName: String, lastName: String, count: Int) {
        self.firstName = firstName
        self.lastName = lastName
        self.count = count
    }
    @ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
    internal nonisolated func access<Member>(
        keyPath: KeyPath<Store, Member>
    ) {
        _$observationRegistrar.access(self, keyPath: keyPath)
    }
    internal nonisolated func withMutation<Member, T>(
        keyPath: KeyPath<Store, Member>,
        _ mutation: () throws -> T
    ) rethrows -> T {
        try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }
    @ObservationIgnored private var _firstName: String = "Yang"
    @ObservationIgnored private var _lastName: String = "Xu"
}
extension Store: Observable {}

能够看到,Observable 宏对咱们本来的声明进行了调整。在 Store 中,声明晰一个 ObservationRegistrar 结构,用于维护和管理可调查特点和调查者之间的关系。存储特点被改写为核算特点,原有值被保存在同名但带_前缀的版别中。在 get 和 set 办法中,经过 _$observationRegistrar 来注册和告诉调查者。最后,宏增加了让可调查目标恪守 Observable 协议的代码(Observable 协议相似于 Sendable, 它不供给任何完结,仅起标示作用)。

怎么在视图中运用可调查目标

在视图中声明可调查目标

与恪守 ObservableObject 协议的 Source of Truth 不同,咱们会在视图中运用 @State 来保证可调查目标的声明周期。

@Observable
class Store {
   ....
}
struct ContentView: View {
    @State var store = Store()
    var body: some View {
       ...
    }
}

经过环境在视图树中注入可调查目标

相较于恪守 ObservableObject 协议的 Source of Truth,用 Observation 结构声明的可调查目标具有愈加多样和灵敏的环境注入选项。

  • 经过 environment 注入实例
@Observable
class Store {
   ....
}
struct ObservationTest: App {
    @State var store = Store()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
    }
}
struct ContentView: View {
    @Environment(Store.self) var store // 在视图中经过环境注入
    var body: some View {
       ...
    }
}
  • 经过自界说 EnvironmentKey
struct StoreKey: EnvironmentKey {
    static var defaultValue = Store()
}
extension EnvironmentValues {
    var store: Store {
        get { self[StoreKey.self] }
        set { self[StoreKey.self] = newValue }
    }
}
struct ContentView: View {
    @Environment(\.store) var store // 在视图中经过环境注入
    var body: some View {
       ...
    }
}
  • 注入可选值
struct ObservationTest: App {
    @State var store = Store()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
    }
}
struct ContentView: View {
    @Environment(Store.self) var store:Store? // 在视图中注入可选值
    var body: some View {
       if let firstName = store?.firstName  {
                Text(firstName)
       }
    }
}

其中,自界说 EnvironmentKey 和注入可选值两者办法都完美的处理了忘记注入后导致的 Preview 崩溃问题。尤其是 EnvironmentKey,让开发者具有了供给默认值的才能。

或许有人会感到困惑,为什么运用 Observation 结构声明的可调查目标的注入办法与值类型相似,而恪守 ObservableObject 协议的引证类型,都需求运用注明晰 Object 的办法才能注入(StateObject、EnvironmentObject),这样不会引起混杂吗?

能够预期,在开发 iOS 17+ 运用程序时,经过 Observation 结构声明的可调查目标和遵循 ObservableObject 协议的可调查目标,一起呈现的场景将越来越少。因而,在不久后,引证类型和值类型在注入方法上将取得高度一致( 几乎不会再呈现运用 environmentObject 或 StateObject 的场景 )。

在视图中传递可调查目标

struct ContentView: View {
    @State var store = Store()
    var body: some body {
        SubView(store: store)
    }
}
struct SubView:View {
    let store:Store
    var body: some body {
       ....
    }
}

运用 letvar 都能够

创立 Binding 类型

Binding 类型为 SwiftUI 供给了完结数据双向绑定的才能。运用 Observation 结构,咱们能够经过如下办法创立特点对应的 Binding 类型。

办法一:

struct ContentView: View {
    @State var store = Store()
    var body: some body {
        SubView(store: store)
    }
}
struct SubView:View {
    @Bindale var store:Store
    var body: some body {
       TextField("",text:$store.name)
    }
}

办法二:

struct SubView:View {
    var store:Store
    var body: some body {
       @Bindable var store = store
       TextField("",text:$store.name)
    }
}

办法三:

struct SubView:View {
    var store:Store
    var name:Binding<String>{
        .init(get: { store.name }, set: { store.name = $0 })
    }
    var body: some body {
       TextField("",text:name)
    }
}

Observation 结构支撑低版别的 SwiftUI 吗

不支撑。

怎么调查可调查目标

Observation 结构供给了一个大局函数 withObservationTracking。运用此函数,开发者能够盯梢可调查目标的特点是否发生改动。

函数签名:

func withObservationTracking<T>(
    _ apply: () -> T,
    onChange: @autoclosure () -> () -> Void
) -> T

测验一:

@Observable
class Store {
    var a = 10
    var b = 20
    var c = 20
}
let sum = withObservationTracking {
    store.a + store.b
} onChange: {
    print("Store Changed a:\(store.a) b:\(store.b) c:\(store.c)")
}
store.c = 100
// No output
store.b = 100
// Output
// Store Changed a:10 b:20 c:100
store.a = 100
// No output

测验二:

withObservationTracking {
   print(store)
   DispatchQueue.main.asyncAfter(deadline: .now() + 0.3){
      store.a = 100
   }
} onChange: {
    print("Store Changed")
}
store.b = 100
// No output
store.a = 100
// No output

在苹果为 withObservationTracking 供给的 官方文档 中,对函数的解说如下:

  • apply:一个包括要盯梢的特点的闭包( A closure that contains properties to track )
  • onChange:当特点值更改时调用的闭包( The closure invoked when the value of a property changes )
  • 返回值:假如apply闭包有返回值,则返回该值;否则,没有返回值( The value that the apply closure returns if it has a return value; otherwise, there is no return value )

因为描述的过于简略,阅览后仍是有不少让人困惑的地方:

  • withObservationTracking 是怎么判别 apply 闭包中哪些特点能够被调查?
  • 为什么同样呈现在 apply 闭包中的可调查特点,修正后并不会触发回调( 测验二 )?
  • withObservationTracking 创立的调查行为是一次性的仍是持久性的?
  • onChange 闭包的调用机遇是什么?所谓的 “when the value of a property changes” 是在特点被更改前仍是更改后?

庆幸的是,Observation 结构是 Swift 5.9 规范库的一部分。咱们能够经过查看其 源代码 来了解更多信息。

Observation 结构的调查原理是什么

经过阅览代码,咱们将对 withObservationTracking 创立调查的操作流程有必定的了解。我将其梳理如下:

创立调查阶段

  • withObservationTracking 在当前线程的 _ThreadLocal.value 中创立一个 _AccessList
  • 履行 apply 闭包
  • 可调查目标的可调查特点在 get 办法被调用时( 调用由 apply 闭包引发 ), 会经过 access 办法在可调查目标实例的 ObservationRegistrar 中保存 apply 闭包中呈现的可调查特点与回调闭包之间的对应关系 ( 这儿的回调闭包用于调用 withObservationTracking 中的 onChange 闭包)。
  • withObservationTracking_AccessList 中保存可调查特点与 onChange 回调闭包之间的对应关系

当被调查特点即将改动时

  • 被调查特点会调用 ObservationRegistrar 中的 willSet 办法,找到当前特点 KeyPath 对应的回调闭包
  • 经过调用该闭包,在 withObservationTracking 建议的线程中调用 onChange 闭包
  • onChange 闭包调用完结后,会铲除 withObservationTracking 当前线程中 _AccessList 中对应的信息
  • 铲除 ObservationRegistrar 中与本次调查操作有关的特点与回调闭包之间的对应关系

定论

经过梳理,咱们能够得到如下定论:

  • 只要 apply 闭包中被读取的可调查特点(经过调用其 get 办法)才会被调查(这解说了测验二中的问题)
  • withObservationTracking 创立的调查操作是一次性的行为,恣意一个被调查特点发生改动,在调用了 onChange 函数后,本次调查都将完毕
  • onChange 闭包是在特点值改动之前(willSet 办法中)被调用的
  • 在一次调查操作中,能够调查多个可调查特点。任一特点值改动都会完毕本次调查。
  • 调查行为是线程安全的,withObservationTracking 能够运行在另一个线程中,onChange 闭包将运行于 withObservationTracking 建议的线程中
  • 只要可调查特点能够被调查。apply 闭包中仅呈现的可调查目标并不会创立调查操作(这解说了测验二)

现在,Observation 结构并未供给创立继续调查行为的 API。或许在之后的版别中会增加这部分功用。

SwiftUI 的视图怎么调查特点的改动

依据 Observation 结构的作业原理,咱们能够估测 SwiftUI 大概会采用下面的办法在可调查特点与视图更新之间创立联系:

struct A:View {
   var body: some View {
       ...
   }
}
let bodyValue = withObservationTracking {
    viewA.body
} onChange: {
    PreparingToRe-evaluateTheBodyValue()
}

在上文中,咱们总结出“只要在 apply 闭包中被读取的可调查特点(经过调用其 get 办法)才会被调查”。因而能够得出以下定论:

Text(store.a) // Changes in store.a will trigger a re-evaluation of the body.
Button("Hi"){
    store.b = "abc" // Changes in store.b will not trigger a re-evaluation of the body.
}

经过 @Obervable 标示的类,是否还能够恪守 ObservableObject 协议

能够,不过因为 @Published 特点包装器 和 @Observable 宏之间会产生冲突,因而咱们需求经过 withObservationTracking 来达到目的:


@Observable
final class Store: ObservableObject {
    var name = ""
    var age = 0
    init(name: String = "", age: Int = 0) {
        self.name = name
        self.age = age
        observeProperties()
    }
    private func observeProperties() {
        withObservationTracking {
            let _ = name
            let _ = age
        } onChange: { [weak self] in
            guard let self else { return }
            objectWillChange.send()
            observeProperties()
        }
    }
}

如有需求,你能够经过自界说宏来完结在 observeProperties 办法中引进一切可调查特点的重复作业。

在视图中 @Obervable 与 ObservableObject 能够共存吗

能够。在一个视图中,能够一起存在以不同的办法声明的可调查目标。SwiftUI 将依据可调查目标在视图中的注入办法挑选对应的调查手段。

例如,上文中一起满足两种调查途径的可调查目标,依据其注入的办法不同,SwiftUI 采用的更新战略也将不同。

@State var store = Store() // 依据特点的改动,精细地决议是否从头评价 body
@StateObject var store = Store() // 只要有特点( @Publsiehd )发生改动,便对 body 从头评价

可调查目标支撑嵌套吗( 一个可调查目标的特点为另一个可调查目标 )

支撑。

因为 @Published 仅支撑值类型,因而关于恪守 ObservableObject 协议的可调查目标,很难完结相似的嵌套逻辑:

class A:ObservableObject {
    @Published var b = B()
}
class B:ObserableObject {
    @Published var a = 10
}
let a = A()
a.b.a = 100 // 并不会触发视图更新

我曾经编写过一个 @PublishedObject 特点包装器来处理这个问题。详细信息,请阅览 为自界说特点包装类型增加类 @Published 的才能 一文。原理上,@PublishedObject 是经过找到外部目标 A(enclosing instance)的 objectWillChange ,在 B 的特点发生改动时告诉 A 的订阅者。也就是说,用了高度耦合的办法才完结了可调查目标的嵌套。

但是,经过 Observation 结构创立的可调查目标完结嵌套则会简略得多。经过 withObservationTracking 创立调查操作时,每个被读取的可调查特点都会主动地创立与订阅者之间的关联。不管它处在关系链中的任何层级,或以任何方法存在(如数组、字典等),都能被正确地盯梢。

例如:

@Observabl
class A {
   var a = 1
   var b = B()
}
@Observable
class B {
   var b = 1
}
let a = A()
withObservationTracking {
   let _ = a.b.b
} onChange: {
    print("update")
}

关于上面的代码,下面两种办法都会调用 onChange 闭包( 只会调用一次 )。

a.b.b = 100
// or
a.b = B()

let _ = a.b.b 这一行代码中,一起创立了对两个不同目标、不同层级的可调查特点的调查,a.b 以及 b.b 。这也是 Observation 结构的强壮之处。

Observation 是否处理了 ObservableObject 的功能问题

是的,Observation 结构从两方面改善了可调查目标在 SwiftUI 中的功能表现:

  • 经过调查视图中的可调查特点而不是可调查目标,能够减少很多无效的视图更新。
  • 相较于 Combine 的发布者-订阅者形式,Observation 的回调机制愈加高效。

但是,因为 Observation 结构暂不支撑创立可继续性的调查行为,每次评价后视图都需求从头创立调查操作( 用时很少 )。咱们需求更多时间来评价这是否会导致新的功能问题。

Observation 结构会影响 SwiftUI 编程习气吗

对我来说,是的。

比方,当前开发者通常会运用结构体( Struct )来构建运用的状况模型。运用了 Observation 结构后,为了完结特点等级的调查,咱们应该改用 Observation 结构创立可调查目标,乃至多层嵌套的对可调查目标来构建状况模型。

别的, 咱们之前在视图中很多的优化技巧也将发生改动。例如,在运用 ObservableObject 时,咱们会经过只引进与当前视图有用的数据,来减少不必要的刷新。

更多对视图优化技巧,请阅览 防止 SwiftUI 视图的重复核算 一文。

class Store:ObservableObject {
    @Published var a = 1
    @Published var b = "hello"
}
struct Root:View {
    @StateObject var store = Store()
    var body: some View {
        VStack{
            A(a: store.a)
            B(b: store.b)
        }
    }
}
struct A:View {
    let a:Int    // only get a(Int)
    var body:some View {
        Text("\(store.a)")
    }
}
struct B:View { // only get b(String)
    let b:String
    var body:some View {
        Text(store.b)
    }
}

store.b 发生改动时,只要 Root 和 B 两个视图会从头评价。

在改用 Observation 结构后,上述的优化战略将不再是最优解。相反,曾经不引荐的办法愈加合适新的可调查目标。

@Observabl
class Store {
    var a = 1
    var b = "hello"
}
struct Root:View {
    @State var store = Store()
    var body: some View {
        VStack{
            A(store: store)
            B(store: store)
        }
    }
}
struct A:View {
    let store: Store
    var body:some View {
        Text("\(store.a)")
    }
}
struct B:View {
    let store: Store
    var body:some View {
        Text(store.b)
    }
}

只要呈现在 body 中且被读取的特点才会触发视图的更新。经过修正后,当 store.b 发生改动时,只要 B 视图会从头评价。

因为 Observation 结构仍然是一个新事物,其 API 也还在不断演化中。跟着越来越多的 SwiftUI 运用转换到这个结构上,开发者会总结出更多的运用心得。

最后

经过本文的论述,读者应该对 Observation 结构以及该结构怎么改善 SwiftUI 的功能有了进一步的认识。虽然 Observation 结构现在与 SwiftUI 紧密绑定,但跟着其 API 的丰富,相信它会呈现在越来越多的运用场景中,而不仅仅是 SwiftUI。

欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行沟通。

订阅下方的 邮件列表,能够及时取得每周最新文章。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的大众号:【肘子的Swift记事本】