引子
写 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里。
类型大概长这样:
finalclassResultsObserver<Element,SectionName>whereElement:PersistentModel,SectionName:Hashable不需要分区的时候,第二个类型参数传Never就行,意思就是「别分组,给我平铺的结果」:
letobserver=tryResultsObserver<Trip,Never>(modelContext:modelContext)读observer.results,就是当前命中的那批模型。
怎么用:一个控制器 + 一条观察线
WWDC 里的写法大致如下。注意两件事:一是withContinuousObservation,二是返回的那个token。
importSwiftDataimportObservationimportMapKit@Observable@MainActorfinalclassMapCameraController{privateletresultsObserver:ResultsObserver<Trip,Never>varbounds:MapCameraBounds?@ObservationIgnoredprivatevartoken:ObservationTracking.Token?init(modelContext:ModelContext)throws{resultsObserver=tryResultsObserver<Trip,Never>(modelContext:modelContext)token=withContinuousObservation(options:[.didSet]){[weakself]_inguardletselfelse{return}bounds=calculateBounds(trips:resultsObserver.results)}}privatefunccalculateBounds(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 里——尤其你还想单测的时候。
@ObservablefinalclassBudgetSummaryStore{privateletobserver:ResultsObserver<Budget,Never>@ObservationIgnoredprivatevartoken:ObservationTracking.Token?vartotalBudget:Double=0vartotalSpent:Double=0varremaining:Double=0varoverspentBudgets:[Budget]=[]init(modelContext:ModelContext)throws{observer=tryResultsObserver<Budget,Never>(modelContext:modelContext)token=withContinuousObservation(options:[.didSet]){[weakself]_inself?.updateSummary()}}privatefuncupdateSummary(){letbudgets=observer.results totalBudget=budgets.reduce(0){$0+$1.limit}totalSpent=budgets.reduce(0){sum,budgetinsum+spentAmount(for:budget)}remaining=totalBudget-totalSpent overspentBudgets=budgets.filter{spentAmount(for:$0)>$0.limit}}privatefuncspentAmount(forbudget:Budget)->Double{budget.expenses.reduce(0){$0+($1.amount*Double($1.quantity))}}}View 只负责展示:
structBudgetDashboardView:View{@Environment(BudgetSummaryStore.self)privatevarstorevarbody:someView{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即可:
letdescriptor=FetchDescriptor<Trip>(predicate:#Predicate{$0.endDate>Date.now},sortBy:[SortDescriptor(\.startDate)])letupcoming=tryResultsObserver<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 文档。