WWDC Quick Look 💓 By SwiftGGTeam
Code-along: Add persistence with SwiftData

Code-along: Add persistence with SwiftData

观看原视频

Highlight

这场 Code-along 演示了如何将一个纯内存的 SwiftUI 旅行清单 App 迁移到 SwiftData,通过 @Model 宏、@Query 谓词查询和模型继承,用不到 30 分钟的时间替换掉了数百行手动状态管理代码,同时获得了跨平台持久化存储能力。

核心内容

从内存到持久化:一个真实痛点

Wishlist 是一个旅行规划 App。用户添加旅行目的地、记录待办活动、按季节分组浏览。在改造之前,所有数据都存放在一个 DataSource 类里,用全局变量和字典管理。过滤、排序、搜索全部在内存中完成。

对于演示项目来说,这够用。但关掉 App 再打开,刚才添加的 “Northern Lights” 旅行就消失了。所有修改重置回预置数据。数据量一大,内存占用也会持续攀升。

SwiftData 要解决的正是这类问题:把动态状态接入持久化存储层,让数据在 App 生命周期之外存活。

改造路径:从模型定义到视图查询

改造分三步走。

第一步,把数据结构标记为 SwiftData 模型。给 ActivityTrip 等类型加上 @Model 宏,替换原来的 @Observable。SwiftData 会自动为这些类型生成 Observable 协议实现(03:39)。

第二步,定义模型之间的关系。用 @Relationship 宏声明 Trip 和 Activity 之间的一对多关系,并设置级联删除规则。删除一个 Trip 时,其下的所有 Activity 自动清理(10:32)。

第三步,在 SwiftUI 视图中用 @Query 替换手动数据获取。每个子视图只拉取自己需要的数据,通过谓词在数据库层面过滤,而不是把全部数据读到内存再筛选(16:27)。

一个意外收获:删掉了几百行代码

改造完成后,DataSource 类、TripEditModel 等辅助类型不再需要了。SwiftData 的 ModelContext 自动接管了状态管理、存储逻辑、过滤排序和关系遍历。这些原本需要手动编写的代码被框架内建能力替代。

详细内容

用 @Model 标记持久化模型

改造从 Activity 类型开始。原代码用 @Observable 宏,SwiftUI 视图通过属性绑定观察变化。

import Foundation
import SwiftData

@Model
class Activity {
    var name: String
    var isComplete: Bool = false
    var dateCreated = Date.now
    var dateEdited = Date.now
}

关键点:

  • @Model 宏同时生成 Observable 协议实现,视图可以继续绑定这些属性
  • 不需要手动写 ObservableObject@Published
  • 所有存储属性默认会被持久化到数据库

原代码中 nameisComplete 带有 didSet 观察器,用于在属性变化时更新 dateEdited@Model 宏不支持 didSet,所以先移除这些观察器,后面用 Observation 框架的新能力替代(03:39)。

枚举转类:Goal 的模型化改造

Goal 在原代码中是一个枚举,定义了 18 个固定的成就目标。枚举是值类型,无法作为 SwiftData 的持久化模型。

改造策略:把 Goal 转成类,保留 namekindtarget 三个属性,新增 completedCountisComplete 来存储进度状态。然后用 SwiftData 的模型继承,把不同种类的 Goal 拆成子类:

// Goal 作为基类
class Goal {
    var name: String
    var target: Int
    var completedCount: Int = 0
    var isComplete: Bool = false
    var dateAchieved: Date?
    var sortOrder: Int = 0
}

// TripGoal 和 ActivityGoal 继承 Goal
class TripGoal: Goal { }
class ActivityGoal: Goal { }

关键点:

  • 枚举的闭集特性不适合持久化场景,因为用户进度是动态数据
  • 模型继承适用于有明确层级关系的类型,子类代表同一概念的更具体形态
  • 子类自动继承父类的所有持久化属性

定义模型关系

Wishlist 中每个 Trip 包含多个 Activity。原代码用字典手动维护这种映射。SwiftData 中用数组和 @Relationship 宏声明:

import Foundation
import SwiftData

@Model
class Trip {
    var name: String
    var collection: TripCollection

    var photo: TripImage
    var thumbnailData: Data?

    @Relationship(deleteRule: .cascade, inverse: \Activity.trip)
    var activities: [Activity] = []

    private(set) var creationDate = Date.now
    var subtitle: String?
    var isComplete: Bool = false
}

关键点:

  • inverse: \Activity.trip 建立了双向关系,Activity 可以通过 activity.trip 反向引用所属 Trip
  • deleteRule: .cascade 表示删除 Trip 时,其 activities 数组中的所有 Activity 也会被删除
  • thumbnailDataData? 类型将缩略图的原始字节直接存入数据库,而全分辨率图片通过 TripImage 模型以外部文件引用方式存储

配置 ModelContainer

在 App 入口设置 ModelContainer,SwiftUI 会自动将其注入到视图环境中:

import SwiftUI
import SwiftData

@main
struct WishlistApp: App {
    let container: ModelContainer = {
        do {
            let modelContainer = try ModelContainer(
                for: Trip.self, Activity.self, TripImage.self,
                Goal.self, TripGoal.self, ActivityGoal.self
            )
            try SampleData.seedIfNeeded(in: modelContainer.mainContext)
            return modelContainer
        } catch {
            fatalError("Could not create model container: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .preferredColorScheme(.dark)
        }
        .modelContainer(container)
    }
}

关键点:

  • ModelContainer(for:) 接收所有需要持久化的模型类型,构建数据库 Schema
  • SampleData.seedIfNeeded 在首次启动时插入预置数据
  • .modelContainer(container) 修饰符让 SwiftUI 环境持有这个容器,@QueryModelContext 自动从中获取数据

用 @Query 做定向查询

改造前,视图从 DataSource 获取全部数据再自行过滤。改造后,每个视图用 @Query 声明自己需要的数据子集。

已达成目标和待达成目标分开查询(16:27):

@Query(
    filter: #Predicate<Goal> { $0.isAchieved },
    sort: \Goal.dateAchieved,
    order: .reverse
)
private var achievedGoals: [Goal]

@Query(
    filter: #Predicate<Goal> { !$0.isAchieved },
    sort: \Goal.sortOrder
)
private var upcomingGoals: [Goal]

最近添加的旅行,限制返回 5 条(16:49):

@Query(FetchDescriptor<Trip>(
    sortBy: [SortDescriptor(\Trip.creationDate, order: .reverse)],
    fetchLimit: 5
))
private var trips: [Trip]

关键点:

  • FetchDescriptor 控制排序、返回数量等查询参数
  • fetchLimit: 5 限制只取最近 5 条,避免加载过多数据到内存
  • 谓词在数据库层面执行过滤,比全量加载后内存过滤更高效

动态构造查询:按季节分组

TripCollectionView 在初始化时才确定要显示哪个季节,因此需要在 init 中动态构造 @Query

init(tripCollection: TripCollection, cardSize: TripCard.Size, namespace: Namespace.ID) {
    _trips = Query(
        filter: #Predicate<Trip> { $0.collection == tripCollection },
        sort: \Trip.name
    )
    self.tripCollection = tripCollection
    self.cardSize = cardSize
    self.namespace = namespace
}

关键点:

  • _trips 是属性包装器的底层存储,在 init 中直接赋值可以传入动态参数
  • tripCollection 从初始化参数捕获后嵌入谓词中
  • 查询结果自动驱动视图更新

搜索功能的查询实现

搜索视图根据输入文本动态切换查询策略(18:13):

init(searchText: String, namespace: Namespace.ID) {
    self.searchText = searchText
    self.namespace = namespace

    if searchText.isEmpty {
        // 空搜索时显示最近 3 条旅行
        _trips = Query(FetchDescriptor(
            sortBy: [SortDescriptor(\Trip.creationDate, order: .reverse)],
            fetchLimit: 3
        ))
        _activities = Query(filter: #Predicate<Activity> { _ in false })
    } else {
        // 按名称匹配旅行
        let tripSearchPredicate = #Predicate<Trip> {
            $0.name.localizedStandardContains(searchText)
        }
        _trips = Query(filter: tripSearchPredicate, sort: \Trip.name)

        // 按名称匹配活动,且必须属于某个旅行
        let activitySearchPredicate = #Predicate<Activity> {
            $0.trip != nil && $0.name.localizedStandardContains(searchText)
        }
        _activities = Query(filter: activitySearchPredicate, sort: \Activity.name)
    }
}

关键点:

  • localizedStandardContains 执行本地化、大小写不敏感的字符串匹配
  • 空搜索时返回最近 3 条旅行作为默认内容
  • ContentUnavailableView 在结果为空时展示友好提示

错误处理

SwiftData 操作可能因磁盘空间不足等原因失败。在视图中捕获并展示错误(19:42):

.onDisappear {
    do {
        try updateGoalAchievements()
    } catch {
        updateError = error
        reportError(error)
    }
}
.alert(error: $updateError) {
    // 自定义错误展示
}

关键点:

  • .alert(error:) 是 SwiftUI 的内建错误提示修饰符
  • 生产环境中建议同时上报到遥测系统以便排查

用 Continuous Observation 替代 didSet

改造后需要恢复 dateEdited 的自动更新行为。Observation 框架在 2027 版本中新增了 withContinuousObservation

init(activity: Activity, isLast: Bool, isEditing: Bool) {
    activity.token = withContinuousObservation(options: .didSet) { event in
        _ = activity.name
        _ = activity.isComplete

        if event.matches(\Activity.name) {
            activity.dateEdited = .now
        }

        if event.matches(\Activity.isComplete) {
            activity.dateEdited = .now
            activity.trip?.isComplete =
                activity.trip?.activities.isEmpty == false
                && activity.trip?.activities.allSatisfy { $0.isComplete } == true
        }
    }
    self.activity = activity
    self.isLast = isLast
    self.isEditing = isEditing
}

关键点:

  • withContinuousObservation(options: .didSet) 在属性赋值后触发回调
  • _ = activity.name 声明对属性的观察兴趣
  • event.matches(\Activity.name) 精确判断哪个属性发生了变化
  • 在 Activity 完成状态变化时,连带更新所属 Trip 的 isComplete 状态

核心启发

1. 把任意清单类 App 改成持久化版本

Todo、记账、阅读列表这类 App 的数据结构天然适合 SwiftData。给模型加 @Model,给视图加 @Query,一个下午就能完成迁移。入口 API:@Model + ModelContainer

2. 用谓词查询替代全量加载

很多 App 启动时把所有数据读到内存。改用 FetchDescriptor 配合 fetchLimit 和谓词,只加载当前屏幕需要的数据。列表滚动性能会明显提升。入口 API:FetchDescriptor(sortBy:fetchLimit:) + #Predicate

3. 利用模型继承处理异构数据

如果 App 中有多种相似但略有差异的实体(如不同类型的任务、不同级别的会员),可以用模型继承而不是枚举或协议。每个子类有自己的持久化表,同时共享父类属性。入口 API:class SubModel: ParentModel

4. 用 Continuous Observation 做属性级副作用

需要在属性变化时触发级联更新(如修改订单项后重算订单总价),withContinuousObservationdidSet 更灵活,因为它支持跨类型的观察,且与 SwiftData 的持久化生命周期兼容。入口 API:withContinuousObservation(options: .didSet)

5. 搜索功能的数据库化

把内存中的过滤逻辑搬到数据库谓词中。localizedStandardContains 支持本地化搜索,空搜索时回退到最近数据。配合 ContentUnavailableView 给出空状态提示。入口 API:#Predicate { $0.name.localizedStandardContains(text) }

关联 Session

  • SwiftData — SwiftData 框架的完整概述,适合在动手前先了解整体架构
  • SwiftUI — SwiftUI 与 SwiftData 的集成细节,包括 @Query.modelContainer 的更多用法
  • Swift — Swift 语言新特性,包括宏系统和 Observation 框架的底层机制
  • SwiftData: Dive into inheritance and schema migration — 模型继承和 Schema 迁移的深入讲解,Goal 的继承设计参考了这里的方法

评论

GitHub Issues · utterances