脱离 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 原理解剖

函数签名(简化):

swift 复制代码
func withObservationTracking<T>(
  _ apply: () -> T,          // ① 访问属性 → 被记录
  onChange: @autoclosure () -> @Sendable () -> Void  // ② 属性"即将"变时回调
) -> T

关键行为:

  1. apply 闭包里访问到的任何被 @Observable 标记类的存储属性,都会被加入"本次观测清单"。
  2. 当清单里任意属性发生 willSet 时,系统会执行 onChange;
  3. onChange 只会被触发一次,之后如果想继续监听,必须手动重新调用 withObservationTracking,即"递归订阅"。

最小可运行示例

swift 复制代码
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 之后的新值。

swift 复制代码
func observe() {
    withObservationTracking {
        print("RunLoop 前读取 → 旧值:\(counter.count)")
    } onChange: { [weak self] in
        // 关键:异步到下一轮
        DispatchQueue.main.async {
            print("RunLoop 后读取 → 新值:\(self?.counter.count ?? -1)")
            self?.observe() // 继续监听
        }
    }
}

坑点 2:模板代码太多 → 二次封装

每次手写"递归 + 异步"太烦,可以提炼成一个可重用的泛型助手:

swift 复制代码
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)
        }
    }
}

使用姿势:

swift 复制代码
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 后,编译器会报错:

typescript 复制代码
Capture of 'self' with non-sendable type 'CounterObserver?' in a `@Sendable` closure

原因:

withObservationTracking 的 onChange 要求 @Sendable,意味着闭包里不能捕获"非 Sendable"的引用类型。

@Observable 类默认包含可变状态,无法自动符合 Sendable;如果我们把 Observer 标成 @MainActor,又会触发"MainActor隔离属性不能出现在 Sendable 闭包"的新错误。

折中方案

  1. 把 Observer 整体标为 @MainActor
  2. 在闭包内部再包一层 Task { @MainActor in ... } 异步读取;
  3. 或者 给 Observer 打上 @unchecked Sendable 并自行保证线程安全(例如全部属性都通过 DispatchQueue 或 Actor 同步)。

示例:@MainActor 内再开 Task

swift 复制代码
@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 仍是更成熟的答案。

完整可落地模板

swift 复制代码
//
//  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

总结与思考

  1. 宏 ≠ 魔法

    @Observable 只是帮你自动生成 ObservationRegistrar 代码,真正的订阅逻辑仍依赖 withObservationTracking

  2. willSet 语义是最大绊脚石

    这意味着它更适合"触发刷新"而不是"精确拿到新值"。如果你必须依赖"每一次新值",要么异步到下一轮,要么回到 Combine。

  3. 递归订阅是官方默许的"官方模式"

    别嫌它丑,目前 API 就是这样设计的;封装后可以让调用方无感。

  4. Swift 6 的 Sendable 检查让"跨 Actor 读取"几乎无解

    除非苹果将来放宽 withObservationTracking@Sendable 要求,否则在严苛并发场景下,Combine/AsyncSequence 才是更稳妥的事件源。

  5. 适用场景推荐

    • ✅ 局部刷新:日志面板、调试计数器、调试 UI。
    • ✅ SwiftUI 外部但主线程内:Widget 的 Timeline 生成、Preview 更新。
    • ⚠️ 高吞吐实时数据:音频采样、传感器 120Hz 上报 → 建议 Combine + 环形缓冲区。
    • ❌ 需要线程跳变/Actor 隔离:Swift 6 模式下成本

学习资料

  1. www.donnywals.com/observing-p...
相关推荐
kk哥889913 小时前
Swift底层原理学习笔记
笔记·学习·swift
confiself13 小时前
通义灵码分析ms-swift框架中CHORD算法实现
开发语言·算法·swift
1024小神13 小时前
在 Swift 中,self. 的使用遵循明确的规则
开发语言·ios·swift
Swift社区13 小时前
Swift 类型系统升级:当协议遇上不可拷贝的类型
开发语言·ios·swift
小小8程序员19 小时前
swift的inout的用法
开发语言·ios·swift
南玖i20 小时前
vue2/html 实现高德点聚合
开发语言·ios·swift
Haha_bj2 天前
Swift ——详解Any、AnyObject、 AnyClass
ios·swift
Haha_bj2 天前
Swift 中的async和await
ios·swift