← 所有文章

iOS 27 中的 SwiftData:观察与历史记录

SwiftData 在 iOS 17 中发布时,提供了两种监视数据的方式:在 SwiftUI 视图中使用 @Query,或者在其他场景下手动为 ModelContext 搭建通知管道。但这两者都没有覆盖对同步类应用最关键的场景,也就是得知另一台设备何时修改了存储。iOS 27 在一个版本中同时补上了这两处缺口。ResultsObserver 让变更跟踪成为一个可以在视图之外持有的一等对象,而 HistoryObserver 会观察 SwiftData 的持久化历史,并在有新事务落地时递增一个可观察的计数器,让您的同步代码只拉取最新的变更。iOS 27 把观察作为一种基础能力,而非 SwiftUI 的副产品。1

这一定位与本系列其余文章一脉相承:SwiftData 上手成本低,协调成本高。要在视图层级之外监视一次提取,意味着要手工重建 @Query 所做的事情。要让存储与外部服务器保持同步,或要对来自应用扩展的写入做出响应,就意味着要自己遍历持久化历史并核对事务。iOS 27 为这两项工作都给出了一个遵循 Observable 协议的具名类型,于是驱动您视图更新的那套 SwiftUI 机制,同样也能驱动您的同步层。

TL;DR / 要点速览

  • ResultsObserver 观察并跟踪模型上下文中一组持久化模型的变更,在底层数据发生变化时提供实时更新。它遵循 Observable,因此 SwiftUI 视图会自动更新,而且它能在 @Query 触及不到的视图之外工作。2
  • HistoryObserver 观察 SwiftData 的持久化历史,并暴露唯一一个可观察属性 eventCounter,在新事务到达时递增。您可以按模型类型和事务作者进行筛选,然后调用 ModelContext.fetchHistory 读取这些变更。对于与外部服务器同步或响应应用扩展写入而言,它就是那个结构化的答案。3
  • @Attribute(.codable) 使用属性自身的 codable 表示来存储该属性,提供了一种声明式的方式来持久化 Codable 值类型,而无需 ValueTransformer4
  • 这三者都在 27.0 beta 中登陆 iOS、iPadOS、macOS、Mac Catalyst、tvOS、visionOS 和 watchOS。234

在视图之外监视一次提取:ResultsObserver

@Query 既出色又受限。它存在于 SwiftUI 视图中,会在谓词变化时重新执行,并把一个数组交给视图。它的限制在于位置。视图模型、同步协调器、导出任务或后台核对器都有会变化的数据,却没有可依赖的 @Query。在 iOS 27 之前,这些调用方只能订阅 ModelContext 的保存通知再手工重新提取,而这恰恰是手工重建 @Query 内部早已做好的事情。

ResultsObserver 是 iOS 27 给出的具名答案。它的声明说明了它是什么:2

final class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable

该类会自动监视与指定提取条件相匹配的模型的变更,并维护一个已提取结果的集合,这使它成为让任何消费方与持久化数据保持同步的工具,而不只是视图。2 您可以用两种方式配置它:用一个完整的 FetchDescriptor,或用单独的筛选谓词和排序描述符。2 两条 SectionName 路径对分组列表很重要;当您不需要分节时,就把 Never 作为 SectionName 类型参数传入。2

相比旧式手工方式,真正的收获在于对 Observable 的遵循。ResultsObserver 遵循 Observable,这让 SwiftUI 视图能在结果变化时自动更新,与 @Observable 模型对象驱动视图失效的方式如出一辙(参见@Observable 内部机制)。2 持有 ResultsObserver 的同步协调器无需写一行 NotificationCenter 代码就能收到变更通知,而任何读取该观察器结果的视图也会免费重新渲染。

在第 274 场中,Apple 针对 @Query 无法服务的场景引入了 ResultsObserver,借助 Swift Observation 从应用中的任何位置提取并观察存储,包括状态对象,或一个从不触及 SwiftUI 的游戏。5

Watch on Apple Developer ↗
ResultsObserver 把 query 风格的观察能力带到了 SwiftUI 视图之外的代码中。

import SwiftData
import Observation

@Observable
final class ShoppingListModel {
    let observer: ResultsObserver<ShoppingItem, Never>

    init(context: ModelContext) {
        let descriptor = FetchDescriptor<ShoppingItem>(
            sortBy: [SortDescriptor(\.sortOrder)]
        )
        // No sectioning, so SectionName is Never.
        observer = ResultsObserver(context: context, fetchDescriptor: descriptor)
    }
}

Apple 已发布的参考文档确认了类声明和配置接口(一个 FetchDescriptor,或筛选谓词和排序描述符;不分节时用 Never 作为分节名),但在撰写本文时省略了确切的初始化器签名,因此请把这些示例中的调用形态视为示意性的,并对照 SDK 确认参数标签。

思维上的转变在于:提取变成了一个由您拥有并可四处传递的对象,而不再是困在视图 body 里的属性包装器。@Query 回答的是”这个视图展示什么?”,而 ResultsObserver 回答的是”无论我在哪里持有它,这次提取的当前状态是什么?”后一个问题,才是非视图调用方真正会问的。

响应历史变更:HistoryObserver

@QueryResultsObserver 都未覆盖的缺口,是源自您进程内提取之外的变更。每次存储被保存时,SwiftData 都会记录一条历史事务,描述发生了什么变化、变化来自何处,以及一个标识它的令牌。要让存储与外部服务器保持同步,或要响应来自应用扩展的写入,就意味着要自己遍历那段持久化历史。iOS 27 为这项工作提供了一个具名的观察器。3

HistoryObserver 就是那个结构化的答案:3

final class HistoryObserver

该观察器监视 SwiftData 的持久化历史,并让您的代码能在新事务被添加时做出响应。3 如果您只需要某些种类的变更,就按模型类型和事务作者筛选,这样您响应的是关心的写入,而不是每一次存储变更。3

整个接口就是一个可观察属性 eventCounter。当新事务落入持久化历史时,计数器递增;您观察它,并在每次递增时调用 ModelContext.fetchHistory API 来只读取最新的变更。3 历史令牌存在于事务自身之上,因此 fetchHistory 只返回新增的内容,而不会重新扫描整个存储。这让同步处理器在历史不断增长时仍保持高效。

import SwiftData
import Observation

// Illustrative call shapes; confirm parameter labels against the SDK.
let historyObserver = HistoryObserver(container: modelContainer, authors: "App")

// Observe eventCounter; on each increment, fetch and process the new history.
let token = withContinuousObservation(of: historyObserver.eventCounter) {
    Task { await processChanges() }
}

按事务作者筛选,是让服务器同步正确的关键细节。在 Apple 的示例中,观察器把 "App" 作为作者传入,于是它只响应应用自身做出的变更,而不会把来自服务器的变更又回放给服务器。3 每次递增时,您的 processChanges 步骤都会调用 ModelContext.fetchHistory 读取新事务并将其上传。本系列里那些应用此前用手写历史管道解决的多进程与外部同步模式(参见 SwiftData 模式纪律),如今有了一个可供挂靠的框架接缝。

与历史搭配的轻量读取

一旦 HistoryObserver 唤醒了您的同步处理器,下一个问题就是要做多少工作。一个朴素的处理器会在每次递增时重新提取受影响的模型,这会实例化出您未必需要的完整模型对象。SwiftData 提供了两种读取,能在不实例化任何对象的情况下回答更窄的问题,二者都天然地与历史观察相搭配。

ModelContext.fetchCount(_:) 以一个普通 Int 返回与提取描述符相匹配的模型数量,而不加载匹配的对象:6

func fetchCount<T>(_ descriptor: FetchDescriptor<T>) throws -> Int where T : PersistentModel

ModelContext.fetchIdentifiers(_:)[PersistentIdentifier] 返回匹配项,同样不加载其背后的模型。一个带 batchSize 的重载会针对大型结果集分块流式返回这些标识符:6

func fetchIdentifiers<T>(_ descriptor: FetchDescriptor<T>) throws -> [PersistentIdentifier] where T : PersistentModel

SwiftData 实验室建议了一种把这些直接绑到历史观察上的模式。当历史变更到达时,提取受影响的标识符,并在决定是否重新加载之前,把它们与视图实际展示的内容做比对,从而避免实例化您不需要的对象。6 对一行用户并未在看的数据的变更不会移动任何像素,而一次标识符比对只需一次键查找的代价就能告诉您这一点,而非一次完整的提取。fetchCount 回答的是更廉价的问题——是否有任何项匹配——这已足够决定一个徽标或空状态是否需要翻转。

干净地持久化一个值类型:@Attribute(.codable)

第三项新增很小却很实用。SwiftData 原生存储 Swift 的基本类型和 @Model 关系,但一个类型为自定义 Codable 值的属性(一个持有几个字段的 struct,一个带关联值的 enum)此前需要 ValueTransformer.transformable(by:) 属性,这是 Core Data 的繁文缛节透过宏接口又渗了回来。

iOS 27 新增了 codable 存储选项:4

static var codable: Schema.Attribute.Option { get }

该选项使用属性自身的 codable 表示来存储该属性,于是一个 Codable 值类型通过它自己的 Encodable/Decodable 遵循来持久化,无需注册任何转换器。4 您可以像应用任何其他属性选项一样应用它:

import SwiftData

struct Coordinate: Codable {
    var latitude: Double
    var longitude: Double
}

@Model
final class Place {
    var name: String

    // Persisted via Coordinate's own Codable conformance.
    @Attribute(.codable) var location: Coordinate
}

实用的规则是:当一个属性是自包含、不值得拥有自己 @Model 表的 Codable 值时,就考虑使用 .codable。一对坐标、一个小型设置 struct、一个带载荷的 enum:这些是数据,而非实体,.codable 通过它们已经定义好的表示把它们内联存储,而不是硬塞一个转换器或一段人为的关系。

Apple 对其权衡说得很明确。codable 属性的内容对 SwiftData 是不透明的,因此您无法在谓词中用它们来筛选结果,也无法在排序描述符中使用它们,而且对 codable 类型形态的改动(增删属性)不会触发迁移,所以它的 Codable 实现必须保持向前和向后兼容。5 Apple 把 .codable 定位为针对您并不拥有的类型的一个逃生口;对于您自己定义的类型,把它们建模为 SwiftData 模型或受支持的值类型,能让排序、筛选和索引保留在表上。5

何时各取所需

这三项新增回答的是三个不同的问题,而问题本身就告诉您该用哪一个。

  • 当非视图调用方需要一次实时提取时,选用 ResultsObserver 视图模型、协调器、导出任务,任何有会变化的数据却又不是 SwiftUI body 的角色。在视图内部,@Query 仍是更轻量的工具;一旦消费方不是视图,观察器便有了用武之地。2
  • 当您要让存储与应用之外的某物同步时,选用 HistoryObserver 一台外部服务器,或一个写入同一存储的应用扩展。观察 eventCounter,按模型类型和事务作者筛选,并在每次递增时调用 ModelContext.fetchHistory 来只读取新事务。3
  • 当一个属性是 Codable 值而非实体时,选用 @Attribute(.codable) 随其所有者一同流转的小型 struct 和 enum。如果该类型需要自己的身份、关系或查询,那它想要的是 @Model;如果它只是内联数据,.codable 便省去了转换器。4

两个观察器可以组合使用。ResultsObserver 让您的进程内提取保持实时;HistoryObserver 在一次远程推送值得就所变更内容采取行动时告知您。一个真正做多设备同步的应用会同时用到两者,并在此过程中用 .codable 让它的值类型列保持规整。

常见问题

ResultsObserver@Query 有何不同?

@Query 是一个存在于视图内部、向该视图喂入数组的 SwiftUI 属性包装器。ResultsObserver 是一个您可以在任何地方(包括视图层级之外)创建并持有的独立类,它观察并跟踪模型上下文中一组持久化模型的变更。2 由于该观察器遵循 Observable,读取它的 SwiftUI 视图仍会自动更新,因此它既覆盖了视图内的场景,也覆盖了 @Query 触及不到的视图模型或协调器场景。2

HistoryObserver 究竟观察什么?

它观察 SwiftData 的持久化历史,也就是 SwiftData 在每次存储被保存时写入的事务记录。3 它暴露唯一一个可观察属性 eventCounter,在有新事务可用时递增;您可以按模型类型和事务作者筛选,让只有您关心的变更才会推动计数器。3 每次递增时,您的代码会调用 ModelContext.fetchHistory API 读取新事务,这使它成为与外部服务器同步或响应应用扩展写入的结构化处理器。3

我能把 ResultsObserverHistoryObserver 一起用吗?

可以,而且一个同步类应用通常应当如此。ResultsObserver 让进程内提取随本地上下文的变化保持最新;HistoryObserver 浮现持久化历史中记录的、按模型类型和事务作者筛选过的变更。23 二者都是可观察对象,您可以从 SwiftUI 视图或另一个观察器对其做出响应,于是它们能并入同一套响应式流程,而无需各自单独的通知处理。23

我何时该用 @Attribute(.codable) 而非关系?

当属性是一个没有独立身份的自包含 Codable 值类型时,使用 .codable,因为该选项通过其自身的 codable 表示来存储属性。4 当该值是一个有自身生命周期、身份或查询的真实实体时,使用 @Model 关系。分界线在于:这个东西是属于其所有者的数据,还是一个被其他行引用的实体。

完整的 Apple 生态系统系列:SwiftData 模式纪律,讲观察所依托的迁移成本;SwiftData 迁移指南,讲 VersionedSchemaMigrationPlan 机制;@Observable 内部机制,讲这些类所接入的观察模型;SwiftUI 内部机制,讲底层的框架基底。中枢位于 Apple 生态系统系列。如需更宽泛的 iOS 与 AI agent 结合的背景,请参阅 iOS Agent 开发指南

参考资料


  1. Apple Developer Documentation: SwiftData. The framework reference covering @Model, ModelContext, ModelContainer, queries, and the iOS 27 observation additions. 

  2. Apple Developer Documentation: ResultsObserver (iOS 27.0 beta). “Observes and tracks changes to a collection of persistent models in a model context.” Declared as final class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable; configurable with a FetchDescriptor or with filter predicates and sort descriptors; Observable, so SwiftUI views update automatically; pass Never as SectionName when no sectioning is needed. 

  3. Apple, WWDC26 session 274, What’s new in SwiftData, and Apple Developer Documentation: HistoryObserver (iOS 27.0 beta). Declared as final class HistoryObserver. Per session 274, it observes SwiftData’s persistent history and “has a single observable property” (eventCounter); “when new transactions are available in the persistent history, the eventCounter increments,” and “your code can observe the eventCounter and when it increments, use ModelContext.fetchHistory API to fetch the latest changes.” It “lets you filter by model type and transaction author”; the session’s example passes "App" as the author so app-originated changes are not replayed back to an external server. 

  4. Apple Developer Documentation: codable (iOS 27.0 beta). “Uses the property’s codable representation to store the property.” Declared as static var codable: Schema.Attribute.Option { get }

  5. Apple, WWDC26 session 274, What’s new in SwiftData. Apple introduces ResultsObserver, which “fetches data from your SwiftData store and then observes your store for changes” but “works anywhere in your app (independent of SwiftUI views) using Swift Observation,” and names a state object or a game written in SceneKit as cases @Query cannot reach. 

  6. Apple Developer Documentation: fetchCount(_:) and fetchIdentifiers(_:) on ModelContext. fetchCount is declared func fetchCount<T>(_ descriptor: FetchDescriptor<T>) throws -> Int where T : PersistentModel and “returns the number of models that match the criteria of the specified fetch descriptor.” fetchIdentifiers is declared func fetchIdentifiers<T>(_ descriptor: FetchDescriptor<T>) throws -> [PersistentIdentifier] where T : PersistentModel and returns “an array of persistent identifiers, where each identifier represents a single model that satisfies the criteria”; a fetchIdentifiers(_:batchSize:) overload returns the identifiers in batches as a FetchResultsCollection<PersistentIdentifier>. The fetch-affected-identifiers-then-compare-against-the-view pattern source: Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. 

相关文章

SwiftData 迁移:轻量级与自定义,以及何时你并不需要 V2

SwiftData 的迁移模型使用 VersionedSchema、MigrationStage 和 SchemaMigrationPlan。大多数 schema 变更并不需要 V2 schema;而真正需要的那些场景,确实需要。

5 分钟阅读

SwiftData 的真正成本是 schema 纪律

SwiftData 的 API 只有两个宏。成本出现在你发布之后。可选字段是廉价的迁移;新增非可选字段则需要一个 VersionedSchema。

5 分钟阅读

The Robots Are Taking Exams in My Search Console

First-party GSC data: 91% of 3.8M impressions fail a human-query filter. Exam questions, pasted errors, and agent sweeps…

10 分钟阅读