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 里,而不是某个 Listbody 里。

类型大概长这样:

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 274ResultsObserver 文档

相关推荐
大熊猫侯佩1 小时前
别再把大模型供在云端了,WWDC26 CoreAI 大模型下凡实战
ai编程·swift·wwdc
00后程序员张3 小时前
有没有更轻量的 iOS 开发环境?快蝎kxapp轻量化路径与构成
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
sakiko_1 天前
Swift学习笔记39-实战注意事项
前端·笔记·学习·swift
东坡肘子1 天前
Apple Intelligence 已通过审核,即将在中国提供服务 -- 肘子的 Swift 周报 #148
人工智能·swiftui·swift
初级代码游戏3 天前
iOS开发 Swift 速记7:结构体和类
开发语言·ios·swift
初级代码游戏4 天前
iOS开发 Swift 速记5:高级运算符
ios·移动开发·swift
初级代码游戏4 天前
iOS开发 Swift 速记4:函数与闭包
开发语言·ios·swift
ZacJi5 天前
当苹果将系统 API 划归 @MainActor:一次跨 SDK 版本的 Swift 并发踩坑与破局实战
ios·swift
ZZH_AI项目交付5 天前
638 处旧名称残留——Swift/ObjC 混编项目安全重命名完整指南
ios·objective-c·swift