SwiftData 教程:通过 Swift 简化数据持久化(二)

了解如何使用 @Model 和 @Attribute 来定义数据模型,并介绍 SwiftData 中的关系处理与 CRUD 操作。

SwiftData 教程:通过 Swift 简化数据持久化(二)

上一篇文章之中,我们已经站在更高的层面理解了 SwiftData 的作用以及工作原理,如果你还没有看过不妨先看看:

SwiftData 教程与实例:简化 iOS 开发数据持久化(一)
SwiftData 是 Apple 为 iOS 开发者提供的新型类 ORM 工具,简化数据库管理,无需编写 SQL,本文包含 SwiftData 教程和使用实例。

在这篇文章,我们则会更加深度 SwiftData 的代码细节,瞧瞧在实际操作中如何进行使用。

使用 SwiftData 定义数据结构

@Model:定义数据模型

SwiftData 的一个关键角色是@Model宏,使用 @Model类进行简单的装饰,将类的存储属性转换为持久化属性。

以下是如何通过 SwiftData 创建表的简化示例:

@Model
class User {
    var name: String
    var gender: String
    var birthday: Date
}

在这个例子中,@Model 注解将 User 类标记为数据模型,SwiftData 会根据这个类自动创建数据库表。

再来看一个稍微复杂一点的示例,假设我们有如下数据结构:

// Models/Habit.swift
import Foundation

enum HabitCategory: String, Codable {
    case positive = "Positive"
    case neutral = "Neutral"
    case negative = "Negative"
}

struct Habit: Identifiable, Codable {
    var id: UUID = UUID()
    var name: String
    var category: HabitCategory = .neutral  // 默认为中性
    var icon: String = "circle"  // 默认使用圆圈图标
    var description: String?  // 可选
    var createDate: Date = Date()  // 创建时自动生成日期
    var notes: String?  // 可选
}

将上述数据模型转换成 SwiftData 模型:

import Foundation
import SwiftData

enum HabitCategory: String, Codable {
    case positive = "Positive"
    case neutral = "Neutral"
    case negative = "Negative"
}

@Model
class Habit {
    @Attribute(.unique) var id: String = UUID().uuidString
    var name: String
    var category: HabitCategory
    var icon: String = "circle"  // 默认使用圆圈图标
    var summary: String?  // 可选
    var createDate: Date = Date()  // 创建时自动生成日期
    var notes: String?  // 可选

    init(
        id: String = UUID().uuidString, name: String,
        category: HabitCategory = .neutral, icon: String = "circle",
        description: String? = nil, createDate: Date = Date(),
        notes: String? = nil
    ) {
        self.id = id
        self.name = name
        self.category = category
        self.icon = icon
        self.summary = description
        self.createDate = createDate
        self.notes = notes
    }
}

你可能看到的几个明显变化:

  • 需要将 struct 转成 class因为 struct 是值类型,每次更新都会创建数据的副本,而不是更新同一个对象。而 struct 是引用类型,不会有上述问题。
  • 使用 @Model 标记的class要求必须实现初始化方法(init()

@Attribute :定义属性行为

你可能还注意到一个新的宏——@Attribute()。它是苹果在 SwiftData 中引入的一个新的宏,用于为数据模型中的属性指定一些特定的元数据

为什么需要使用 @Attribute()

@Attribute 可以帮助你定义属性的行为,比如唯一性、可选性、默认值等。它简化了数据模型的配置,允许你通过 Swift 风格的语法直接在模型中定义这些属性的特性。

来看一个实际的对比例子。

假设我们有一个用户数据模型(User),其中包含唯一的用户 ID、必填的用户名和可选的电子邮件。如果不使用框架,我们可能会这样实现:

import Foundation

class User {
    var id: UUID
    var name: String
    var email: String? // 可选
    
    init(id: UUID = UUID(), name: String, email: String? = nil) {
        self.id = id
        self.name = name
        self.email = email
    }
}

class UserManager {
    private var users: [User] = []
    
    func addUser(name: String, email: String?) throws {
        // 手动检查唯一性
        if users.contains(where: { $0.name == name }) {
            throw NSError(domain: "UserManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "用户已存在"])
        }
        
        // 创建并保存用户
        let newUser = User(name: name, email: email)
        users.append(newUser)
    }
    
    func getUserById(id: UUID) -> User? {
        return users.first { $0.id == id }
    }
}

使用 SwiftData 后,我们可以将这些约束(如唯一性、必填项等)直接通过 @Attribute 注解实现,而不需要手动管理这些逻辑。

@Model
class User: Identifiable {
    @Attribute(.unique) var id: UUID = UUID()        // 唯一字段
    @Attribute(.required) var name: String           // 必填字段
    @Attribute(.optional) var email: String?         // 可选字段
}

这种通过 @Attribute(.required) 统一的声明方式,使代码更加清晰和直观。

常见的使用方式

  • @Attribute(.unique)

用于标记一个属性为唯一的。例如,某个属性必须具有唯一性,不能重复。

常用于主键或其他需要唯一约束的字段。

  • @Attribute(.required)

表示这个属性是必须的,不能为 nil。

类似于数据库中的 NOT NULL 约束。

  • @Attribute(.optional)

表示这个属性是可选的,允许为 nil,是 Swift 中可选类型的补充标记。

  • @Attribute(.indexed)

让这个属性被索引,便于快速查询。

  • @Attribute(.default(someValue))

为属性定义一个默认值。

@Relationship

在应用开发中,数据模型往往不仅仅是孤立的个体,它们之间可能存在多种关系,例如一对一、一对多、多对多等。SwiftData 中的 @Relationship 即是用来定义数据模型之间的关系

为什么使用 @Relationship 是推荐的?

  1. 明确关系@Relationship 属性可以明确地定义两个模型之间的关系,包括一对一、一对多、多对多等类型。它帮助 SwiftData 更好地管理和维护这些关系。
  2. 级联操作: 使用 @Relationship 可以设置级联删除(.cascade),这意味着当一个模型实例被删除时,所有相关联的模型实例也会被自动删除。这对于管理复杂数据结构很有帮助。
  3. 数据一致性@Relationship 确保了关联数据的一致性。如果不使用 @Relationship,你需要手动管理数据的关联和一致性,这会增加错误的风险。

配置 ModelContainer

首先,你需要创建一个 ModelContainer,它会包含你的数据模型,这通常是在应用启动时进行的,比如在 App中进行声明:

import SwiftData
import SwiftUI

@main
struct HOMOApp: App {
    // 创建 ModelContainer 来管理 Habit 和 HabitGroup 模型,并将两者包含到 ModelContainer 中。
    @State private var modelContainer: ModelContainer = try! ModelContainer(
        for: Habit.self, HabitGroup.self)

    var body: some Scene {
        WindowGroup {
            //            ContentView()
            MyHabitsTabView()
        }
        .modelContainer(modelContainer)  // 将 modelContainer 注入 SwiftUI 环境中
    }
}

使用 .modelContainer(container) 将 ModelContainer 注入到视图的环境中是为了让你的数据模型(在这个例子中是 Habit 和 HabitGroup)能够在 SwiftUI 视图层级中无缝地被访问和操作。

注入到环境中后,所有需要访问 ModelContainer 和 ModelContext 的视图都可以通过 @Environment 或 @Query 属性包装器获取并操作数据,而不需要手动传递这些对象。

在这个例子中,ModelContainer 被注入到了 ContentView 的环境中,这意味着 ContentView 及其子视图可以通过 @Environment(\.modelContext) 访问到 ModelContext,并对数据进行增删改查等操作。

使用 ModelContext 进行 CRUD 操作

在配置好 ModelContainer 并将其注入到 SwiftUI 环境后,就可以通过ModelContext 来执行数据的增删改查操作。如我们在上一篇文章中讲到的,ModelContext 就类似于传统 ORM 中的Session

在 SwiftUI 中,每个需要进行数据库操作的视图都可以通过 @Environment(\.modelContext) 来获取 ModelContext。然后,你可以使用它执行数据库操作。

查询数据

如果一个 View 组件只是查询和展示数据,而不涉及插入、更新或删除等修改操作,那么并不需要使用 ModelContext。相反,你可以使用 @Query 属性包装器来轻松从数据库中获取数据,并在 SwiftUI 视图中展示。

@Query 会自动从 ModelContext 中提取数据,并根据数据模型的变化自动更新 UI,这正是 SwiftUI 声明是编程风格的体现。

  • 只查询数据时:使用 @Query 即可,不需要使用 ModelContext。
  • 进行数据操作(插入、更新、删除)时:需要使用 ModelContext。

举个简单的例子:

struct HabitListView: View {
    @Query var habits: [Habit]  // 使用 @Query 查询数据
    
    var body: some View {
        List(habits) { habit in
            Text(habit.name)
        }
    }
}

在这个例子中,@Query 会负责数据的提取,并且自动监听数据的变化。如果数据模型发生更新,视图也会同步进行更新展示。

插入新数据

通过 ModelContext,插入新数据同样变得非常简单。你只需要实例化你的模型类,然后调用 ModelContext 的 .insert() 方法即可。

例如,向数据库中添加一个新的 Habit 实例:

struct AddHabitView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var habitName: String = ""

    var body: some View {
        VStack {
            TextField("Habit Name", text: $habitName)
            Button("Add Habit") {
                let newHabit = Habit(name: habitName)
                modelContext.insert(newHabit)
            }
        }
    }
}

在这个例子中,当用户点击按钮后,新的 Habit 实例会被创建并通过 modelContext.insert() 方法插入到数据库中。

删除数据

要删除某条记录,你可以使用 ModelContext 的.delete() 方法。通过提供模型对象作为参数,ModelContext 会自动处理删除操作。

例如,删除某个 Habit 实例:

struct HabitListView: View {
    @Query var habits: [Habit]
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        List {
            ForEach(habits) { habit in
                Text(habit.name)
            }
            .onDelete { indexSet in
                for index in indexSet {
                    let habitToDelete = habits[index]
                    modelContext.delete(habitToDelete)
                }
            }
        }
    }
}

这里,onDelete 事件被触发时,会从 habits 列表中删除对应的记录,并调用 modelContext.delete() 来执行实际的数据库删除操作。

更新数据

数据的更新可以通过直接修改模型实例的属性,然后调用 modelContext.save() 来持久化更改。

例如,更新 Habit 的名称:

struct UpdateHabitView: View {
    @Environment(\.modelContext) private var modelContext
    @State var habit: Habit
    @State private var newName: String = ""

    var body: some View {
        VStack {
            TextField("New Name", text: $newName)
            Button("Update Habit") {
                habit.name = newName
                try? modelContext.save()
            }
        }
    }
}

当用户点击按钮后,habit 实力的 name 属性被修改,随后 modelContext.save() 方法会保存这些更改到数据库。

保存数据

无论是插入、更新还是删除操作,最终你都需要通过 modelContext.save() 将这些更改持久化到数据库。save() 方法会捕获所有在 ModelContext 中的变更,并将其保存到底层的数据库中。

例如,保存所有的修改:

do {
    try modelContext.save()
} catch {
    // 错误处理逻辑
}

SwiftData 与 MVVM 架构

ModelContext 与 SwiftUI 的结合使得数据操作更加简洁且符合 SwiftUI 的声明式编程风格。开发者不再需要手动管理繁琐的事务逻辑,数据操作能够无缝地与 UI 绑定。此外,由于数据的响应式特性,数据的增删改查会自动反映在界面上,极大地简化了开发过程。

尽管 ModelContext 非常适合与 SwiftUI 结合使用,但需要注意的是,SwiftData 的设计理念是在视图中直接操作模型,因此在使用 MVVM 架构时可能会与这一设计产生冲突。如果你的项目大量依赖 MVVM 架构,需谨慎评估 SwiftData 的使用。

删除 Xcode SwiftData 数据缓存

在 Xcode 中调试的时候,我们有时候需要删除创建的 SwiftData 缓存数据。在 Xcode 中 Clean Build Folder 并不能清除 SwiftData 的数据。

我会创建一个 checkDatabaseLocation() 方法,使用这个方法来获取数据库的位置:

import Foundation
import SwiftData

func checkDatabaseLocation() {
    if let urlApp = FileManager.default.urls(
        for: .applicationSupportDirectory, in: .userDomainMask
    ).last {
        let url = urlApp.appendingPathComponent("default.store")
        if FileManager.default.fileExists(atPath: url.path) {
            let path = url.path  // 获取标准文件路径
            print("SwiftData DB at \(path)")
        } else {
            print("SwiftData DB not found at \(url.path)")
        }
    } else {
        print("Failed to locate application support directory.")
    }
}

然后在我们的任意 View 文件中,通过 .onApper 来调用函数,此时终端中即可打印数据库文件地址:

.onAppear {
    checkDatabaseLocation()
}

最后在终端中使用 rm 命令删除文件即可。


在接下来的文章中,我们将进一步探讨 SwiftData 的高级特性及其在复杂应用中的最佳实践。敬请期待!