开始使用RealmSwift

1,409 阅读11分钟

概述

数据持久性是所有应用程序都应该具备的重要功能,以便保存重要的数据,如用于快速加载的最新饲料、用户偏好和服务器关键信息。为了避免丢失数据和提供不一致的体验,正确管理本地数据是至关重要的。

在这篇文章中,我们将学习如何使用Realm作为SwiftUI的数据持久化引擎,以简单的方式管理iOS应用程序中的数据。

我们将创建具有以下功能的待办事项应用程序。

  • 使用SwiftUICombine根据数据变化自动刷新待办事项列表
  • 在Realm本地数据库中列出、存储、编辑和删除任务
  • 使用Realm迁移修改模式

请注意,本教程使用的是Xcode 12、Swift v5和iOS 14。

为什么是Realm?

在我们开始开发之前,让我们看一下使用Realm的主要原因,你会从中受益。

  • 具有面向对象的数据模型的轻量级移动数据库--不需要ORM
  • 使用简单 - 你将花费更少的时间来设置Realm,编写查询,创建嵌套对象等。
  • 通过全面的文档和广泛的社区支持,易于学习
  • 支持多平台,更容易实现跨平台的数据库结构同步

设置您的SwiftUI项目

打开Xcode并创建一个新的SwiftUI项目。

image.png

安装 Realm SDK

在 Xcode 菜单中,转到文件>Swift 包>添加包依赖,并输入 Realm 仓库的 URL,如下图所示。

https://github.com/realm/realm-cocoa

点击下一步,它将重定向到这个屏幕。这个包的最新版本是v10.15.1。

Adding the RealmSwift package

在这个屏幕上,选中RealmRealmSwift两个包。

Choose both the Realm and RealmSwift packages

创建一个待办事项模型

让我们用Identifiable 协议创建一个名为Task的待办事项模型。

struct Task: Identifiable {
    var id: String
    var title: String
    var completed: Bool = false
    var completedAt: Date = Date()
}

创建主列表视图

在本节中,我们将创建一个列表视图和可重用的项目视图。

TaskRowView

添加一个新的SwiftUIView 文件,名为TaskRowView ,并用以下代码进行更新。

struct TaskRowView: View {
    // 1
    let task: Task
    var body: some View {
        // 2
        HStack(spacing: 8) {
            Button(action: {
                // mark complete action
            }) {
                Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
                    .resizable()
                    .frame(width: 20, height: 20)
                    .foregroundColor(task.completed ? Color.green : Color.gray)
            }
            Text(task.title)
                .foregroundColor(.black)
            Spacer()
        }
        .padding(EdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20))
    }
}

下面是上面写的代码的细节。

  1. task 是一个视图依赖数据,在视图初始化时需要。
  2. 视图中包含一个标记任务完成状态的Button ,以及一个用于任务标题的Text ,这些都在水平堆栈中管理。

创建任务列表视图

在这个项目中,我将使用LazyVStackScrollViewLazyVStack只适用于iOS v14及以上版本,但被称为列出项目的伟大SwiftUI组件之一。

最初,在与Realm整合之前,我们将使用样本待办事项数据。

创建一个名为TaskListView 的新文件来显示待办事项列表。

struct TaskListView: View {
    // 1
    private var mockTasks = [
        Task(id: "001", title: "Eat Burger"),
        Task(id: "002", title: "Go Swimming with Fred"),
        Task(id: "003", title: "Make a Coffee"),
        Task(id: "004", title: "Travel to Europe"),
    ]
    var body: some View {
        ScrollView {
            LazyVStack (alignment: .leading) {
                // 2
                ForEach(mockTasks, id: \.id) { task in
                    // 3
                    TaskRowView(task: task)
                    Divider().padding(.leading, 20)
                }
                .animation(.default)
            }
        }
    }
}

下面是我们上面所写的细节。

  1. 正如你所看到的,在我们与Realm数据库整合之前,会使用一些模拟数据。
  2. TaskRowViewForEach 闭包中被调用,以显示每个mockTasks 的项目
  3. 最后,我们将task 对象传递给TaskRowView

更新ContentView

一旦我们完成了这两个与任务相关的视图的创建,我们需要更新主ContentView 文件以包括NavigationView 和新创建的TaskListView 。下面的代码还将添加一个导航标题。

struct ContentView: View {
    var body: some View {
        NavigationView {
            TaskListView()
            .navigationTitle("Todo")
            .navigationBarTitleDisplayMode(.automatic)
        }
    }
}

现在,如果我们尝试运行该项目,它将显示与下面类似的输出。

Current output with mock data

很好,我们已经为主要的待办事项列表创建了一个视图。现在,让我们在列表中添加一个简单的表单,使我们能够动态地添加更多的任务。

用添加新任务AddTaskView

创建一个新的view 文件,名为AddTaskView ,并用下面的代码更新它。

struct AddTaskView: View {
    // 1
    @State private var taskTitle: String = ""
    var body: some View {
        HStack(spacing: 12) {
            // 2
            TextField("Enter New Task..", text: $taskTitle)
            // 3
            Button(action: handleSubmit) {
                Image(systemName: "plus")
            }
        }
        .padding(20)
    }

    private func handleSubmit() {
        // some action
    }
}

下面是对这个视图中添加的每个重要点的解释。

  1. taskTitle@State 属性包装器被用来接收每一个变化的更新。
  2. 然后,我们添加了TextField 视图,使用户能够添加新的文本,并使用$ 符号将其与taskTitle 变量绑定。
  3. 然后,handleSubmit 被添加到Button 视图中,作为动作处理函数,我们将在下一节中与数据插入过程整合。

创建表单后,我们需要更新ContentView 。在ContentView 内添加一个VStack ,并同时包括AddTaskViewTaskListView

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                AddTaskView()
                TaskListView()
            }
            .navigationTitle("Todo")
            .navigationBarTitleDisplayMode(.automatic)
        }
    }
}

如果我们再次运行该项目,输出将显示新添加的表单在列表上方。

image.png

创建一个Realm模型

Realm模型是一个普通的Swift类,它子类化了RealmObject 协议,并将创建的对象与Realm数据库模式相一致。Realm对象将被自动保存为Realm数据库中的一个表,并具有所有定义的属性。它还具有额外的功能,如实时查询、反应式事件处理器和实时数据更新。

这些是支持的 Swift 数据类型,可以在 Realm 模型中使用。

  • String
  • Data
  • Int
  • Double
  • Float
  • Bool
  • Date

创建TaskObject Realm 模型

首先,我们将创建另一个名为TaskObject 的 Realm 模型。

现在,我们有两个模型,TaskTaskObject 。RealmTaskObject 只与 Realm 对象协议和数据库通信,而Task 类从 Realmobject 获取数据并与 Swift 视图通信。然后你可以通过Task类对数据进行修改,这样它就可以在应用程序的其他区域使用。Task 模型用于显示将来会有格式化、编码和解码等功能的数据,而TaskObject 是专门为 Realm 数据模型创建的。

创建一个名为TaskObject 的新文件,它继承了 RealmObject 类。请注意,Realm模型中的每个属性都应与@Persisted wrapper一起使用,以标记每个属性为Realm模型的一部分,在读写操作中会被相应处理。

import Foundation
import RealmSwift

class TaskObject: Object {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var title: String
    @Persisted var completed: Bool = false
    @Persisted var completedAt: Date = Date()
}

然后,用自定义的init(taskObject:) 函数更新Task 模型,以实现与Realm对象的快速数据映射。

struct Task: Identifiable {
    var id: String
    var title: String
    var completed: Bool = false
    var completedAt: Date = Date()

    init(taskObject: taskObject) {
        self.id = taskObject.id.stringValue
        self.title = taskObject.title
        self.completed = taskObject.completed
        self.completedAt = taskObject.completedAt
    }
}

创建任务视图模型

视图模型来实现我们新创建的视图和Realm数据库之间的通信。最初,我们将集中讨论如何插入新的任务和获得所有任务的列表。

创建一个名为TaskViewModel 的新文件,并添加以下代码。

// 1
import Foundation
import Combine
import RealmSwift

// 2
final class TaskViewModel: ObservableObject {
    // 3
    @Published var tasks: [Task] = []
    // 4
    private var token: NotificationToken?

    init() {
        setupObserver()
    }

    deinit {
        token?.invalidate()
    }
    // 5
    private func setupObserver() {
        do {
            let realm = try Realm()
            let results = realm.objects(TaskObject.self)

            token = results.observe({ [weak self] changes in
                // 6
                self?.tasks = results.map(Task.init)
                    .sorted(by: { $0.updatedAt > $1.updatedAt })
                    .sorted(by: { !$0.completed && $1.completed })
            })
        } catch let error {
            print(error.localizedDescription)
        }
    }
    // 7
    func addTask(title: String) {
        let taskObject = TaskObject(value: [
            "title": title,
            "completed": false
        ])
        do {
            let realm = try Realm()
            try realm.write {
                realm.add(taskObject)
            }
        } catch let error {
            print(error.localizedDescription)
        }
    }
    // 8
    func markComplete(id: String, completed: Bool) {
        do {
            let realm = try Realm()
            let objectId = try ObjectId(string: id)
            let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId)
            try realm.write {
                task?.completed = completed
                task?.completedAt = Date()
            }
        } catch let error {
            print(error.localizedDescription)
        }
    }
}

下面是对上述代码中添加的每个重要点的解释。

  1. 有两个额外的框架需要被导入,CombineRealmSwift 。Combine是一个强大的Swift API,可以管理异步事件,是原生iOS框架的一部分,所以我们可以直接把它们导入到我们的项目中,不需要任何安装。RealmSwift也是需要的,以便在访问Realm数据库时使用其功能。
  2. 视图模型是对ObservableObject 协议的子类化,它将向视图发出重要的变化。
  3. tasks 正在使用@Published 包装器,以使订阅者的视图在其值更新时能够接收更新。
  4. token 是一个RealmNotificationToken ,持有observer 对象。
  5. setupObserver() 主要是设置一个观察者来观察TaskObject 列表上的任何变化,例如添加、更新和删除操作。
  6. 每当tasks 变量上发生变化时,它将通知所有的订阅者视图。结果将先按未完成的任务进行排序,然后是已完成的任务。
  7. 然后,我们添加了一个名为addTask() 的函数,它允许我们创建新的对象来存储在Realm数据库中。
  8. 然后,我们添加了另一个函数markComplete() ,通过给定的主键(任务ID)改变TaskObject 的完成状态。

更新主列表并添加一个表单

完成模型后,我们需要更新TaskListViewAddTaskView

更新TaskListView

ForEach 参数中,我们现在将传递tasks 作为从Realm数据库中获取的动态数据。我们不需要写额外的函数来保持数据的更新,因为一旦收到来自视图模型的更新,视图会自动重新加载自己。

struct TaskListView: View {
    @EnvironmentObject private var viewModel: TaskViewModel
    var body: some View {
        ScrollView {
            LazyVStack (alignment: .leading) {
                ForEach(viewModel.tasks, id: \.id) { task in
                    TaskRowView(task: task)
                    Divider().padding(.leading, 20)
                }
                .animation(.default)
            }
        }
    }
}

AddTaskView

在这一节中,我们通过调用视图模型addTask ,来完成handleSubmit 的功能。

struct AddTaskView: View {
    @State private var taskTitle: String = ""
    @EnvironmentObject private var viewModel: TaskViewModel

    var body: some View {
        HStack(spacing: 12) {
            TextField("Enter New Task..", text: $taskTitle)
            Button(action: handleSubmit) {
                Image(systemName: "plus")
            }
        }
        .padding(20)
    }

    private func handleSubmit() {
        viewModel.addTask(title: taskTitle)
        taskTitle = ""
    }
}

@EnvironmentObject 包装器

环境对象是SwiftUI中一个强大的功能,它可以自动保持多个视图中单个共享对象的变化。

正如我们在TaskListViewAddTaskView 视图中看到的那样,我们需要使用@EnvironmentObject 包装器,以便观察TaskViewModel 中可能发生的任何变化。

为了使环境对象可以在视图中使用,我们需要使用environmentObject() 来传递该对象。在这种情况下,我们需要更新TodoRealmSwiftUIApp 中的App 文件。

@main
struct TodoRealmSwiftUIApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(TaskViewModel())
        }
    }
}

棒极了,现在主列表已经完全与Realm数据库集成。让我们运行该项目,尝试添加一些任务,并将其中一些任务标记为完成或未完成。

Checkbox demo in the main list view after the Realm database integration

任务细节视图

在这一部分,我们将增加一个视图,以显示我们列表中每个任务的细节。我们还将为这个新视图添加编辑和删除功能。

创建一个名为TaskView 的新文件,并用以下代码进行更新。

import SwiftUI

struct TaskView: View {
    // 1
    @EnvironmentObject private var viewModel: TaskViewModel
    // 2
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    @State private var taskTitle: String = ""
    // 3
    let task: Task

    var body: some View {
        VStack(alignment: .leading, spacing: 24) {
            // 4
            VStack(alignment: .leading, spacing: 4) {
                Text("Title")
                    .foregroundColor(Color.gray)
                TextField("Enter title..", text: $taskTitle)
                    .font(.largeTitle)
                Divider()
            }
            // 5
            Button(action: deleteAction) {
                HStack {
                    Image(systemName: "trash.fill")
                    Text("Delete")
                }
                .foregroundColor(Color.red)
            }
            Spacer()
        }
        .navigationBarTitle("Edit Todo", displayMode: .inline)
        .padding(24)
        .onAppear(perform: {
            taskTitle = task.title
        })
        // 6
        .onDisappear(perform: updateTask)
    }

    private func updateTask() {
        viewModel.updateTitle(id: task.id, newTitle: taskTitle)
    }

    private func deleteAction() {
        viewModel.remove(id: task.id)
        presentationMode.wrappedValue.dismiss()
    }
}

下面是对上述代码中添加的每个重要点的详细解释。

  1. 在这段代码中,我们使用了TaskViewModel 作为EnvironmentObject 变量,以实现对共享视图模型的访问。
  2. 然后我们用presentationMode ,以编程方式解散视图。
  3. 在初始化过程中,task 被添加为依赖模型
  4. T``extField ,以使我们能够编辑任务的标题。
  5. 然后,我们添加了一个删除按钮,从Realm数据库中删除任务。
  6. 最后,一旦用户离开视图,updateTask 被调用以保存数据。

更新视图模型

接下来,用删除和更新功能更新TaskViewModel

func remove(id: String) {
    do {
        let realm = try Realm()
        let objectId = try ObjectId(string: id)
        if let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId) {
            try realm.write {
                realm.delete(task)
            }
        }
    } catch let error {
        print(error.localizedDescription)
    }
}

func updateTitle(id: String, newTitle: String) {
    do {
        let realm = try Realm()
        let objectId = try ObjectId(string: id)
        let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId)
        try realm.write {
            task?.title = newTitle
        }
    } catch let error {
        print(error.localizedDescription)
    }
}

TaskListView 项目添加导航

最后,用NavigationLink ,更新TaskListView 中的项目视图,这样,每当用户点击该行,就会导航到详细视图。

NavigationLink (destination: TaskView(task: task)) {
    TaskRowView(task: task)
}

Edit and delete operations

太好了。我们已经成功实现了所有的CRUD操作。

模式迁移

当我们想以下列方式修改数据库模式时,迁移变得非常重要。

  1. 添加新的属性或字段
  2. 改变属性的数据类型
  3. 重命名属性
  4. 更新属性的默认值

在下面的例子中,我们要添加一个新的任务字段,叫做Due Date。我们将需要对我们的视图和模型做一些小的更新改动。

在我们的视图和模型中添加到期日字段

TaskObjectTask 模型中添加一个名为dueDate 的新字段,其类型是可选的Date

TaskObject model
我们将创建一个新的TaskObject模型,和我们上面做的一样。

class TaskObject: Object {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var title: String
    @Persisted var completed: Bool = false
    @Persisted var completedAt: Date = Date()
    // New property
    @Persisted var dueDate: Date? = nil
}

Task model
在下面的更新代码中,我们将添加一个新的属性(dueDate),用于格式化日期的计算变量,并更新init 函数。

struct Task: Identifiable {
    var id: String
    var title: String
    var completed: Bool = false
    var completedAt: Date = Date()
    // New property
    var dueDate: Date? = nil

    init(taskObject: TaskObject) {
        self.id = taskObject.id.stringValue
        self.title = taskObject.title
        self.completed = taskObject.completed
        self.completedAt = taskObject.completedAt
        // Also map the new property
        self.dueDate = taskObject.dueDate
    }

    var formattedDate: String {
        if let date = dueDate {
            let format = "MMM d, y"
            let dateFormatter = DateFormatter()
            dateFormatter.dateFormat = format
            return "Due at " + dateFormatter.string(from: date)
        }
        return ""
    }
}

更新任务视图模型

然后,更新视图模型,在update() 函数中存储到期日期值。

func update(id: String, newTitle: String, dueDate: Date?) {
        do {
            let realm = try Realm()
            let objectId = try ObjectId(string: id)
            let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId)
            try realm.write {
                task?.title = newTitle
                // Update due date value (Optional value)
                task?.dueDate = dueDate
            }
        } catch let error {
            print(error.localizedDescription)
        }
    }

迁移需要的错误

作为提醒,每次用户添加或更新一个新的属性时,都需要迁移。让我们试着在迁移前运行该项目,看看Xcode日志中的错误输出,这将从异常处理程序中捕捉到。

Migration is required due to the following errors:
- Property 'TaskObject.dueDate' has been added.

设置迁移

默认的模式版本是1 ,所以我们必须在配置中把模式改为2

用这个代码添加或更新你的AppDelegate 文件。在configMigration 函数中,我们已经指定了模式版本为2

import UIKit
import RealmSwift

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        configMigration()
        return true
    }

    private func configMigration() {
        // perform migration if necessary
        let config = Realm.Configuration(
            schemaVersion: 2,
            migrationBlock: { migration, oldSchemaVersion in
                // additional process such as rename, combine fields and link to other object
            })
        Realm.Configuration.defaultConfiguration = config
    }
}

还要确保包括AppDelegate 适配器。

import SwiftUI

@main
struct TodoRealmSwiftUIApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    ...
}

现在,一切都应该正常工作了。运行该项目,结果将与下面的屏幕截图相似。

image.png

项目完成

恭喜你!我们已经完成了一个待办事项的构建。我们已经完成了使用Realm和SwiftUI构建一个待办事项应用程序。

谢谢你的阅读,祝你编码愉快