脱离 SwiftUI 也能用 @Observable:深入理解 withObservationTracking 的玩法、坑点与 Swift 6 突围
前言
iOS 17 引入的 @Observable 宏让 SwiftUI 刷新机制大变天,但官方文档只告诉你“在 View 里用就行”。
如果我们想在 非 SwiftUI 场景(比如 NetworkLayer、ViewModel、Unit Test)里监听属性变化,就只能靠 withObservationTracking。
@Observable 是什么
| 特性 | @Observable | ObservableObject + @Published |
|---|---|---|
| 适用系统 | iOS 17+ | iOS 13+ |
| 刷新粒度 | 属性级(仅变更的属性触发视图更新) | 对象级(整个 ObjectWillChange 触发更新) |
| 依赖协议 | 无需额外协议 | 必须继承 ObservableObject 协议 |
| 非 SwiftUI 监听 | 使用 withObservationTracking 闭包 |
使用 objectWillChange 发布者(Publisher) |
一句话: @Observable 是 Swift 5.9 宏加持的“轻量级可观测对象”,专为 SwiftUI 优化,但 宏本身不限制使用场景,所以我们可以在任意线程/任意模块里手动订阅。
withObservationTracking 原理解剖
函数签名(简化):
func withObservationTracking<T>(
_ apply: () -> T, // ① 访问属性 → 被记录
onChange: @autoclosure () -> @Sendable () -> Void // ② 属性“即将”变时回调
) -> T
关键行为:
- apply 闭包里访问到的任何被
@Observable标记类的存储属性,都会被加入“本次观测清单”。 - 当清单里任意属性发生 willSet 时,系统会执行 onChange;
- onChange 只会被触发一次,之后如果想继续监听,必须手动重新调用
withObservationTracking,即“递归订阅”。
最小可运行示例
import Observation
// 1. 声明被观测的模型
@Observable
class Counter {
var count = 0
}
// 2. 声明监听者
class CounterObserver {
let counter: Counter
init(counter: Counter) {
self.counter = counter
}
/// 开始监听,打印最新值
func observe() {
// ① apply 闭包:读取属性 → 被系统记录
// ② onChange:count 即将改变时调用(旧值仍可见)
withObservationTracking {
print("当前计数:\(counter.count)")
} onChange: { [weak self] in
// 系统只给一次通知,想持续监听必须重新调用 observe()
print("检测到变化,重新订阅")
self?.observe()
}
}
}
// 3. 客户端代码
let counter = Counter()
let observer = CounterObserver(counter: counter)
// 启动监听
observer.observe()
// 制造变化
counter.count = 1 // 控制台:检测到变化,重新订阅 → 当前计数:1
counter.count = 2 // 同上
坑点 1:onChange 给出的是“旧值”
因为 onChange 触发在 willSet 阶段,所以闭包里再读属性仍是老数据。
解决思路:把读取动作推迟到下一个 RunLoop → 拿到 didSet 之后的新值。
func observe() {
withObservationTracking {
print("RunLoop 前读取 → 旧值:\(counter.count)")
} onChange: { [weak self] in
// 关键:异步到下一轮
DispatchQueue.main.async {
print("RunLoop 后读取 → 新值:\(self?.counter.count ?? -1)")
self?.observe() // 继续监听
}
}
}
坑点 2:模板代码太多 → 二次封装
每次手写“递归 + 异步”太烦,可以提炼成一个可重用的泛型助手:
import Observation
/// 让任意闭包“持续”被监听,自动 re-subscribe
/// - Parameter execute: 需要跟踪属性的读取闭包
public func keepObserving<T: AnyObject>(
target: T,
execute: @escaping @Sendable () -> Void
) {
Observation.withObservationTracking {
execute()
} onChange: {
DispatchQueue.main.async {
keepObserving(target: target, execute: execute)
}
}
}
使用姿势:
class CounterObserver {
let counter: Counter
init(counter: Counter) { self.counter = counter }
func start() {
// 捕获 [weak target] 防止循环引用
keepObserving(target: self) { [weak self] in
guard let self else { return }
print("封装后读取:\(self.counter.count)")
}
}
}
坑点 3:Swift 6 语言模式 + Sendable
开启 Swift 6 后,编译器会报错:
Capture of 'self' with non-sendable type 'CounterObserver?' in a `@Sendable` closure
原因:
withObservationTracking 的 onChange 要求 @Sendable,意味着闭包里不能捕获“非 Sendable”的引用类型。
但 @Observable 类默认包含可变状态,无法自动符合 Sendable;如果我们把 Observer 标成 @MainActor,又会触发“MainActor隔离属性不能出现在 Sendable 闭包”的新错误。
折中方案
- 把 Observer 整体标为
@MainActor; - 在闭包内部再包一层
Task { @MainActor in ... }异步读取; - 或者 给 Observer 打上
@unchecked Sendable并自行保证线程安全(例如全部属性都通过 DispatchQueue 或 Actor 同步)。
示例:@MainActor 内再开 Task
@MainActor
class CounterObserver: Sendable /* 手动保证 */ {
init(counter: Counter) {
self.counter = counter
}
let counter: Counter
nonisolated func start() {
Observation.withObservationTracking { [weak self] in
// 这里不能直接接触 counter,因为它被隔离在 @MainActor
// 只记录“我关心”的事实,不读取值
return () // 仅触发跟踪
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
print("Swift6 模式新值:\(self.counter.count)")
self.start() // 继续
}
}
}
}
注意:由于 apply 闭包不能跨 Actor 读取,我们只能“空跑”跟踪,再在 Task 里安全取值。
这会导致 一次 onChange 只能异步拿到值,且如果属性变化非常快,中间事件可能丢失。
在 Swift 6 严苛模式下,Combine 的 @Published 仍是更成熟的答案。
完整可落地模板
//
// ObservableUtils.swift
// 用前导入 Observation 模块
//
import Foundation
import Observation
/// 线程安全且支持 Swift 6 的“属性监听”工具箱
public actor ObservableListener {
private var token: (() -> Void)? // 用于将来做手动取消
/// 持续监听目标对象指定 KeyPath 的新值
/// - Parameters:
/// - object: 被 @Observable 标记的实例
/// - keyPath: 要读取的 KeyPath
/// - handler: 变化后异步回调新值
public func keep<T: Any, V>(
object: T,
keyPath: KeyPath<T, V>,
handler: @escaping (V) -> Void
) where T: Observable {
// 用 Task 保证与 actor 隔离
Task {
Observation.withObservationTracking {
// 空读取,只为登记依赖
_ = object[keyPath: keyPath]
} onChange: { [weak object] in
guard let object else { return }
Task { @MainActor in
// 下一轮 RunLoop 拿到新值
handler(object[keyPath: keyPath])
}
// 继续监听
self.keep(object: object, keyPath: keyPath, handler: handler)
}
}
}
}
// ====== 使用示例 ======
@Observable
class User {
var name = "Tom"
var age = 18
}
let listener = ObservableListener()
let user = User()
Task {
await listener.keep(object: user, keyPath: \.name) { newName in
print("用户名已变更为:\(newName)")
}
}
user.name = "Jerry" // → 用户名已变更为:Jerry
user.name = "Spike" // → 用户名已变更为:Spike
总结与思考
-
宏 ≠ 魔法
@Observable只是帮你自动生成ObservationRegistrar代码,真正的订阅逻辑仍依赖withObservationTracking。 -
willSet 语义是最大绊脚石
这意味着它更适合“触发刷新”而不是“精确拿到新值”。如果你必须依赖“每一次新值”,要么异步到下一轮,要么回到 Combine。
-
递归订阅是官方默许的“官方模式”
别嫌它丑,目前 API 就是这样设计的;封装后可以让调用方无感。
-
Swift 6 的 Sendable 检查让“跨 Actor 读取”几乎无解
除非苹果将来放宽
withObservationTracking的@Sendable要求,否则在严苛并发场景下,Combine/AsyncSequence 才是更稳妥的事件源。 -
适用场景推荐
- ✅ 局部刷新:日志面板、调试计数器、调试 UI。
- ✅ SwiftUI 外部但主线程内:Widget 的 Timeline 生成、Preview 更新。
- ⚠️ 高吞吐实时数据:音频采样、传感器 120Hz 上报 → 建议 Combine + 环形缓冲区。
- ❌ 需要线程跳变/Actor 隔离:Swift 6 模式下成本