让 @State 实现懒加载

发表于

Observation 框架为 Swift 带来了原生的属性级观察能力,有效避免了 SwiftUI 中因无关属性变化而引发的多余视图更新,从而提升了应用性能。但由于 @State 并未提供类似 @StateObject 的懒加载构造方式,在某些场景下会因实例过早构建而引起性能损失甚至逻辑问题。本文将探讨如何为 Observable 实例定制一个支持懒加载的 @State 解决方案。

问题示例

在 SwiftUI 中,视图实例的创建与加载到视图树中并非一一对应。在许多情况下,视图实例可能会被提前或多次创建。例如,下面的代码中,即便你尚未进入导航容器的下一层(LinkViewUsingObservation),SwiftUI 仍会提前构建该视图中的可观察实例 TestObject

Swift
import Observation
import SwiftUI

struct ContentView: View {
  var body: some View {
    NavigationStack {
      NavigationLink {
        LinkViewUsingObservation()
      } label: {
        Text("Hello")
      }
    }
  }
}

struct LinkViewUsingObservation: View {
  @State var object = TestObject()
  var body: some View {
    Text("State Observation")
  }
}

@Observable
class TestObject {
  init() {
    print("init")
  }
}

如你所见,NavigationLink 会提前创建 LinkViewUsingObservation 的实例。设想在使用 List 展示大量 LinkViewUsingObservation 时,这种提前构建将不可避免地带来性能损失。

若将实现改为基于 ObservableObject,则提前构建实例的问题便不会出现,因为 TestObject 只会在导航进入 LinkViewUsingStateObject 视图后才被构造:

Swift
struct ContentView: View {
  var body: some View {
    NavigationStack {
      NavigationLink {
        LinkViewUsingStateObject()
      } label: {
        Text("Hello")
      }
    }
  }
}

struct LinkViewUsingStateObject: View {
  @StateObject var object = TestObject()
  var body: some View {
    Text("StateObject")
  }
}

class TestObject: ObservableObject {
  init() {
    print("init")
  }
}

StateObject 的懒加载机制

StateObject 之所以不会在视图实例创建时立即构建 TestObject,是因为它采用了懒加载策略。其构造方法如下所示:

Swift
@inlinable nonisolated public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)

在视图真正加载时,StateObject 才会调用 thunk 闭包来创建并持有 ObservableObject 实例,从而避免了不必要的提前构建。你可以在 《避免 SwiftUI 视图的重复计算》 一文中找到对其懒加载实现的详细解析。

然而,当将原有的 ObservableObject 实现替换为 Observable 时,由于 @State 并未提供类似的懒加载机制,开发者便无法享受延迟构造的优势。

有效但不优雅的解决方案

一种较简单的替代方案是,让 Observable 实例同时遵循 ObservableObject 协议,并继续使用 @StateObject 声明。这样既能保持懒加载特性,又可在视图中响应属性变化,从而避免无效更新:

Swift
struct LinkViewUsingStateObject: View {
  @StateObject var object = TestObject() // 使用 StateObject 来声明
  var body: some View {
    let _ = print("update")
    Text("StateObject")
    Text(object.name)
    Button("Change Name"){
      object.name = "\(Int.random(in: 0...1000))"
    }
    Button("Change Age"){
      object.age = Int.random(in: 0...100)
    }
  }
}

@Observable
class TestObject: ObservableObject { // 增加 ObservableObject
  init() {
    print("init")
  }
  
  var name = "abc"
  var age = 10
}

不过,这种方式容易引发混淆——在团队协作中,成员可能难以区分到底采用了哪种观察机制。

@LazyState:支持懒加载的 @State 实现

针对上述问题,已有不少开发者向苹果反馈,期望未来能为 @State 添加懒加载机制。在苹果未作出修改之前,我们可以通过自定义属性包装器来实现这一功能:

Swift
@MainActor // 确保属性包装器在主线程操作,保证在调用 wrappedValue 前完成 setup 
@propertyWrapper
public struct LazyState<T: Observable>: @preconcurrency DynamicProperty { // 限定使用在 Observable 类型上
  @State private var holder: Holder

  // 保持与 State 和 StateObject 的一致性,实例只能创建一次,不可修改( 不创建 setter )
  public var wrappedValue: T {
    holder.wrappedValue
  }

  public var projectedValue: Binding<T> {
    // 只需要通过 keyPath 修改数据,因此忽略 setter,
    return Binding(get: { wrappedValue }, set: { _ in })
  }

  // 当视图加载时调用,创建实例
  public func update() {
    guard !holder.onAppear else { return }
    holder.setup()
  }

  public init(wrappedValue thunk: @autoclosure @escaping () -> T) {
    _holder = State(wrappedValue: Holder(wrappedValue: thunk()))
  }
}

extension LazyState {
  // 用于持有实例的助手类
  final class Holder {
    private var object: T!
    private let thunk: () -> T
    // 标记实例是否已初始化,避免重复创建
    var onAppear = false
    var wrappedValue: T {
      object
    }

    func setup() {
      object = thunk() // 延迟初始化实例
      onAppear = true // 标记为已初始化,防止重复调用
    }

    init(wrappedValue thunk: @autoclosure @escaping () -> T) {
      self.thunk = thunk
    }
  }
}

现在你便可以使用 @LazyState 来声明 Observable 实例。待苹果对 @State 进行增强后,我们只需简单地切换回来即可:

Swift
struct ContentView: View {
  var body: some View {
    NavigationStack {
      NavigationLink {
        LinkViewUsingLazyState()
      } label: {
        Text("Hello")
      }
    }
  }
}

struct LinkViewUsingLazyState: View {
  @LazyState var object = TestObject()
  var body: some View {
    Text("LazyState")
  }
}

@Observable
class TestObject {
  init() {
    print("init")
  }
}

总结

Observation 框架极大地提升了 SwiftUI 的性能,但由于其实现机制的变化,开发者仍需根据项目特点做出相应调整。期待苹果能尽快为 @State 添加懒加载机制,使得这种问题能够更加自然地解决。

"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!