
引子
写 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 消费端的人来说,这缺口终于补上了。