WWDC 26 全新 ResultsObserver:终于能在 View 外面盯着 SwiftData 了

引子

写 SwiftData 久了,大概都会碰到同一个别扭的地方。

@Query 很好用------挂在 View 上,列表自动刷新,过滤排序也都顺手。可一旦逻辑稍微复杂一点,比如要根据所有行程算地图镜头范围,或者要在一个 @Observable 的 Store 里汇总预算,你就会发现:@Query 跟不过去了。它只能待在 SwiftUI 视图里。

以前怎么办?自己听 ModelContext 通知,或者干脆把聚合计算塞进 View。能跑,

但总觉得哪里不对:业务明明不该长在界面上。

WWDC26 的 SwiftData 专场里,Apple 给这个问题塞了个答案,还盖了个绿色的 NEW 标签:ResultsObserver。

让我们一起来了解一下吧?;-)


它到底解决什么问题

官方说法很直白:在某个 ModelContext 里,按你给定的条件拉一批模型,然后持续盯着这批结果;数据变了,结果跟着变。

听起来像 @Query?对,能力上确实很像------过滤、排序、分区都支持。关键差别只有一个:

@Query 是 View 的属性;ResultsObserver 是你自己拿着的对象。

ViewModel、后台协调器,甚至 SceneKit 那种根本不沾 SwiftUI 的代码,都能用。

Apple 自己举的例子就是:行程变了,地图相机边界要重算------这件事显然更适合放在一个 Controller 里,而不是某个 List 的 body 里。

类型大概长这样:

swift 复制代码
final class ResultsObserver<Element, SectionName>
    where Element: PersistentModel, SectionName: Hashable

不需要分区的时候,第二个类型参数传 Never 就行,意思就是「别分组,给我平铺的结果」:

swift 复制代码
let observer = try ResultsObserver<Trip, Never>(modelContext: modelContext)

读 observer.results,就是当前命中的那批模型。


怎么用:一个控制器 + 一条观察线

WWDC 里的写法大致如下。注意两件事:一是 withContinuousObservation,二是返回的那个 token。

swift 复制代码
import SwiftData
import Observation
import MapKit

@Observable
@MainActor
final class MapCameraController {
    private let resultsObserver: ResultsObserver<Trip, Never>
    var bounds: MapCameraBounds?

    @ObservationIgnored
    private var token: ObservationTracking.Token?

    init(modelContext: ModelContext) throws {
        resultsObserver = try ResultsObserver<Trip, Never>(
            modelContext: modelContext
        )

        token = withContinuousObservation(options: [.didSet]) { [weak self] _ in
            guard let self else { return }
            bounds = calculateBounds(trips: resultsObserver.results)
        }
    }

    private func calculateBounds(trips: [Trip]) -> MapCameraBounds? {
        // 根据 trips 算镜头范围
        nil
    }
}

流程其实不玄:

先建一个 ResultsObserver 盯住 store;再用 withContinuousObservation(options: [.didSet]) 注册回调,结果集变了就重算;最后把返回的 ObservationTracking.Token 存起来。

Token 这东西容易被忽略。它相当于「这条监听还活着」的凭证------属性没了,观察也就停了。所以别建完就扔,挂在实例上。

还有个细节:闭包里一定要读到 resultsObserver.results。Swift Observation 靠「你访问了什么」决定「下次通知谁」。你不读 results,它就不知道你在乎这个集合。


再挖一点:withContinuousObservation 在干什么

这不是 SwiftData 私有的魔法,是 Observation 框架里偏「长期监听」的那一挂。

以前的 withObservationTracking 更像单次跟踪:属性要变时喊一声。withContinuousObservation 则是持续回调,所以才需要外部持有 Token;不再持有,或者主动 cancel,监听就结束。

[.didSet] 的意思是:值已经写完再通知你。适合做汇总、重算边界这类「根据最新快照更新派生状态」的事。如果在 @MainActor 上注册,回调也会回到主线程,写 UI 状态会省心不少。

可以简单记:Observer 负责数据源,Continuous Observation 负责把变化递给你,Token 负责线路不断。


更贴近日常的例子:预算汇总

地图镜头有点「演示味」。换个更常见的:预算 App 要显示总额度、已花、剩余,还有哪些超支了。这些数字依赖一整批 Budget,既不适合塞进单个模型,也不适合永远写在 View 里------尤其你还想单测的时候。

swift 复制代码
@Observable
final class BudgetSummaryStore {
    private let observer: ResultsObserver<Budget, Never>

    @ObservationIgnored
    private var token: ObservationTracking.Token?

    var totalBudget: Double = 0
    var totalSpent: Double = 0
    var remaining: Double = 0
    var overspentBudgets: [Budget] = []

    init(modelContext: ModelContext) throws {
        observer = try ResultsObserver<Budget, Never>(modelContext: modelContext)

        token = withContinuousObservation(options: [.didSet]) { [weak self] _ in
            self?.updateSummary()
        }
    }

    private func updateSummary() {
        let budgets = observer.results

        totalBudget = budgets.reduce(0) { $0 + $1.limit }
        totalSpent = budgets.reduce(0) { sum, budget in
            sum + spentAmount(for: budget)
        }
        remaining = totalBudget - totalSpent
        overspentBudgets = budgets.filter { spentAmount(for: $0) > $0.limit }
    }

    private func spentAmount(for budget: Budget) -> Double {
        budget.expenses.reduce(0) { $0 + ($1.amount * Double($1.quantity)) }
    }
}

View 只负责展示:

swift 复制代码
struct BudgetDashboardView: View {
    @Environment(BudgetSummaryStore.self) private var store

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("总额度:\(store.totalBudget, format: .currency(code: "CNY"))")
            Text("已花费:\(store.totalSpent, format: .currency(code: "CNY"))")
            Text("剩余:\(store.remaining, format: .currency(code: "CNY"))")
        }
    }
}

单测也好办:内存版 ModelContainer,插入几条数据,直接断言 Store 里的数字。不必为了测业务逻辑去拉起一整棵 SwiftUI。


过滤、排序也行,不是只能全表盯着

需要条件时,塞 FetchDescriptor 即可:

swift 复制代码
let descriptor = FetchDescriptor<Trip>(
    predicate: #Predicate { $0.endDate > Date.now },
    sortBy: [SortDescriptor(\.startDate)]
)

let upcoming = try ResultsObserver<Trip, Never>(
    fetchDescriptor: descriptor,
    modelContext: modelContext
)

要按字段分区,用带 sectionBy 的初始化,并给 SectionName 传具体类型。之后可以拿 sections,也有 element(at:)、indexPath(for:) 这类接口,和现在强化过的 sectioned @Query 是同一套思路。

有个现实限制:目前分区的 key path 更偏向字符串一类场景。要是你的分区键更复杂,可能得拆多个 Observer,或者给 Apple 提 Feedback。


别跟 HistoryObserver 搞混

同一场 WWDC 还带了个 HistoryObserver。名字都带 Observer,用途却不一样。

ResultsObserver 关心的是:现在这批结果长什么样。 适合汇总、派生状态、驱动非 SwiftUI 的展示。

HistoryObserver 关心的是:发生了哪些变更事务。 它暴露一个会递增的 eventCounter,适合你跟着去 fetchHistory,做同步、审计、推后端。

一个看快照,一个看脚印。地图跟着行程动,用 Results;本地改动要推到自建服务器,用 History。


什么时候该用,什么时候别用

说白了就三条:

界面上就是展示列表、详情------继续 @Query,别为了新而新。

逻辑依赖一批模型、又不该长在 View 上,或者根本不在 SwiftUI 里------上 ResultsObserver。

你要的是变更流水,而不是当前集合------看 HistoryObserver。

另外,社区里有个提醒我觉得挺对:别因为有了 ResultsObserver,就把所有逻辑都搬进 @Observable 类。展示逻辑可以留在 View,单模型规则留在 Model;只有那些「跨一批模型、又不属于界面」的东西,才值得单独拎出来。


收个尾

SwiftData 早期最让人难受的地方之一,就是观察能力几乎绑死在 SwiftUI 上。业务要么挤进 View,要么自己接通知。

ResultsObserver 没发明全新范式,只是把大家早就想要的能力做成了框架原生 API:查询结果变成可持有的 Observable 对象,过滤排序分区还在,观察走的是 Swift Observation。

对写列表的人来说,可能感知不强;对开始拆 Store、写单测、做非 SwiftUI 消费端的人来说,这缺口终于补上了。

参考:WWDC26 Session 274、ResultsObserver 文档。

相关推荐
OliverQiu10 小时前
开源一个 macOS 小工具:集中管理 ChatGPT 的网络启动配置
macos·chatgpt·swiftui·mac·proxy·codex·dmg
用户380341658829712 小时前
Flutter 迁 SwiftUI:AI 把页面"搬"过来只用了两周,我们补结构花了两个月
ios·swift
茶底世界之下15 小时前
四角变换后的最小包围视口,为什么不能只把宽高取整?
ios·swift
茶底世界之下2 天前
预乘 Alpha 图层合成为什么会越叠越暗?
架构·swift
茶底世界之下4 天前
时间线效果层,为什么不能只复用一个处理器?
ios·swift
Cc.Y4 天前
Swift深度链接的极简之道
ios·cocoa·swift
传奇开心果编程4 天前
【SwiftUI提高练中学】第9课 安全与隐私
swiftui·移动开发·swift·编程语言理论·开发范式·编程框架·声明式ui
CocoaKier4 天前
苹果商店详情顶部头图已面向所有开发者开放!
ios·apple
茶底世界之下5 天前
动态切换 nearest / linear 时,为什么不能只替换 sampler
ios·swift
应用市场5 天前
把旧 Pixel 变成相册备份中转站(上):Mac 到安卓的照片传输工具设计——流式上传、sha256 校验、adb forward 与 Bonjour
android·macos·adb·kotlin·swift