在 iOS 应用中使用 Core Data 保存数据
本文通过描述编写一个待办清单应用的过程, 阐述在 iOS 应用中, 如何分离数据层与视图层. 如何使用 Core Data 保存数据. 如何保存两个数据结构之间的关系.
前提条件
示例代码
Core Data 保存数据
定义数据类型
我个人的偏好是, 在程序中使用的数据结构叫做TagModel
, 在 Core Data 中对应的数据结构是TagEntity
. TagModel
的结构在下文中给出. TagEntity
请使用 Xcode 的创建一个 xcdatamodeld 文件, 并在其中新建一个 Entity, 名为TagEntity
import Foundation
struct TagModel {
let tagId: String
let createdAt: Date
let updatedAt: Date
let title: String
}
定义 DAO
您不要看下面这个示例代码很长, 其实所有的 DAO 都长差不多. 下面的 DAO 包含了几个部分:
- 共享实例
- 增删改查的方法
- Model 与 Entity 之间如何进行转化
import CoreData
class TagDAO {
enum CustomError: Error, LocalizedError {
case notFound
}
static let shared = TagDAO(container: PersistenceController.shared.container)
private let container: NSPersistentContainer
init(container: NSPersistentContainer) {
self.container = container
}
func createOne(tag: TagModel) async throws {
try await container.performBackgroundTask { ctx in
let entity = Self.findEntity(tagId: tag.tagId, ctx: ctx) ?? TagEntity(context: ctx)
Self.modifyEntity(entity: entity, tag: tag)
try ctx.save()
}
}
func updateOne(tag: TagModel) async throws {
try await container.performBackgroundTask { ctx in
guard let entity = Self.findEntity(tagId: tag.tagId, ctx: ctx) else {
throw CustomError.notFound
}
Self.modifyEntity(entity: entity, tag: tag)
try ctx.save()
}
}
func deleteOne(tagId: String) async throws {
return try await container.performBackgroundTask { ctx in
guard let entity = Self.findEntity(tagId: tagId, ctx: ctx) else {
throw CustomError.notFound
}
ctx.delete(entity)
try ctx.save()
}
}
func findOne(id: String) async -> TagModel? {
return await container.performBackgroundTask { ctx in
guard let entity = Self.findEntity(tagId: id, ctx: ctx) else { return nil }
return Self.entityToModel(entity: entity, ctx: ctx)
}
}
// page: Start from 0
func findMany(searchInput: String?, page: Int?, pageSize: Int?) async throws -> [TagModel] {
let req = TagEntity.fetchRequest()
// Search
if let searchInput, !searchInput.isEmpty {
// c: Case insensitive
// d: Diacritic insensitive
req.predicate = NSPredicate(format: "title CONTAINS[cd] %@", searchInput)
}
// Pagination
if let page, let pageSize {
req.fetchLimit = pageSize
req.fetchOffset = page * pageSize
}
// Sort
req.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
return try await container.performBackgroundTask { ctx in
let entities = try ctx.fetch(req)
return entities.compactMap { Self.entityToModel(entity: $0, ctx: ctx) }
}
}
static func findEntity(tagId: String, ctx: NSManagedObjectContext) -> TagEntity? {
do {
let req = TagEntity.fetchRequest()
req.predicate = NSPredicate(format: "tagId = %@", tagId)
let entities = try ctx.fetch(req)
return entities.isEmpty ? nil : entities[0]
} catch {
print(error.localizedDescription)
return nil
}
}
static func modifyEntity(entity: TagEntity, tag: TagModel) {
entity.tagId = tag.tagId
entity.createdAt = tag.createdAt
entity.updatedAt = tag.updatedAt
entity.title = tag.title
}
static func entityToModel(entity: TagEntity, ctx: NSManagedObjectContext) -> TagModel? {
guard let tagId = entity.tagId,
let createdAt = entity.createdAt,
let updatedAt = entity.updatedAt,
let title = entity.title
else { return nil }
return TagModel(
tagId: tagId,
createdAt: createdAt,
updatedAt: updatedAt,
title: title
)
}
}
多对多关系
修改 .xcdatamodeld 文件
一个常见的功能是, 一个待办引用了多个标签, 一个标签可以被指派给多个待办. 待办与标签之间的关系是多对多, 如果是使用 SQL 语句操作数据库, 这样的关系需要创建一张关联表来描述两者之间的关系. 但是使用 Core Data 操作数据库时, 我们需要使用图形界面来完成类似操作.
- 在 TodoEntity > Relationships 界面中增加
tags
字段, Destination 选择TagEntity
. 打开右侧栏 > Relationship, 选择 Type 为To Many
- 在 TagEntity > Relationships 界面中增加
todos
字段, Destination 选择TodoEntity
. 打开右侧栏 > Relationship, 选择 Type 为To Many
- 选择 TagEntity > Relationships > Inverse 为
todos
在 todo 中包含 tags
- 首先在
TodoModel
增加tags
字段. TodoDAO.updateOne
方法中加入为TodoEntity
连接上TagEntity
的方法TodoDAO.entityToModel
方法中加入将TagEntity
转化为TagModel
的方法- 以后新建
Todo
时, 先调用TodoDAO.createOne
创建一个TodoEntity
, 然后再调用TodoDAO.updateOne
方法为TodoEntity
连接上TagEntity
.
import Foundation
struct TodoModel {
let todoId: String
let createdAt: Date
let updatedAt: Date
let title: String
let completedAt: Date?
let tags: [TagModel]
let location: LocationModel?
}
import CoreData
class TodoDAO {
enum CustomError: Error, LocalizedError {
case notFound
}
static let shared = TodoDAO(container: PersistenceController.shared.container)
private let container: NSPersistentContainer
init(container: NSPersistentContainer) {
self.container = container
}
func createOne(todo: TodoModel) async throws {
try await container.performBackgroundTask { ctx in
let entity = Self.findEntity(todoId: todo.todoId, ctx: ctx) ?? TodoEntity(context: ctx)
Self.modifyEntity(entity: entity, todo: todo)
try ctx.save()
}
}
func updateOne(todo: TodoModel) async throws {
try await container.performBackgroundTask { ctx in
guard let entity = Self.findEntity(todoId: todo.todoId, ctx: ctx) else {
throw CustomError.notFound
}
Self.modifyEntity(entity: entity, todo: todo)
entity.removeFromTags(entity.tags ?? [])
for tag in todo.tags {
if let tagEntity = TagDAO.findEntity(tagId: tag.tagId, ctx: ctx) {
entity.addToTags(tagEntity)
}
}
if let id = todo.location?.id {
entity.location = LocationDAO.findEntity(id: id, ctx: ctx)
} else {
entity.location = nil
}
try ctx.save()
}
}
func deleteOne(todoId: String) async throws {
return try await container.performBackgroundTask { ctx in
guard let entity = Self.findEntity(todoId: todoId, ctx: ctx) else {
throw CustomError.notFound
}
ctx.delete(entity)
try ctx.save()
}
}
func findOne(todoId: String) async -> TodoModel? {
return await container.performBackgroundTask { ctx in
guard let entity = Self.findEntity(todoId: todoId, ctx: ctx) else { return nil }
return Self.entityToModel(entity: entity, ctx: ctx)
}
}
// page: Start from 0
func findMany(searchInput: String?, tagId: String?, page: Int?, pageSize: Int?) async throws -> [TodoModel] {
let req = TodoEntity.fetchRequest()
// Search
var predicates: [NSPredicate] = []
if let searchInput, !searchInput.isEmpty {
// c: Case insensitive
// d: Diacritic insensitive
let predicate = NSPredicate(format: "title CONTAINS[cd] %@", searchInput)
predicates.append(predicate)
}
if let tagId {
let predicate = NSPredicate(format: "ANY tags.tagId == %@", tagId)
predicates.append(predicate)
}
// Pagination
if let page, let pageSize {
req.fetchLimit = pageSize
req.fetchOffset = page * pageSize
}
// Sort
req.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
return try await container.performBackgroundTask { ctx in
let entities = try ctx.fetch(req)
return entities.compactMap { Self.entityToModel(entity: $0, ctx: ctx) }
}
}
static func findEntity(todoId: String, ctx: NSManagedObjectContext) -> TodoEntity? {
do {
let req = TodoEntity.fetchRequest()
req.predicate = NSPredicate(format: "todoId = %@", todoId)
let entities = try ctx.fetch(req)
return entities.isEmpty ? nil : entities[0]
} catch {
print(error.localizedDescription)
return nil
}
}
static func modifyEntity(entity: TodoEntity, todo: TodoModel) {
entity.todoId = todo.todoId
entity.createdAt = todo.createdAt
entity.updatedAt = todo.updatedAt
entity.title = todo.title
entity.completedAt = todo.completedAt
}
static func entityToModel(entity: TodoEntity, ctx: NSManagedObjectContext) -> TodoModel? {
guard let todoId = entity.todoId,
let createdAt = entity.createdAt,
let updatedAt = entity.updatedAt,
let title = entity.title
else { return nil }
let tagEntities = entity.tags?.allObjects as? [TagEntity]
let tags: [TagModel] = (tagEntities ?? []).compactMap { entity in
TagDAO.entityToModel(entity: entity, ctx: ctx)
}
var location: LocationModel? = nil
if let locationEntity = entity.location {
location = LocationDAO.entityToModel(entity: locationEntity, ctx: ctx)
}
return TodoModel(
todoId: todoId,
createdAt: createdAt,
updatedAt: updatedAt,
title: title,
completedAt: entity.completedAt,
tags: tags,
location: location
)
}
}
寻找与 tag 关联的 todos
我之前遇到了一个奇怪的问题是, 如果像在 todo 中包含 tags 那样把 todos
作为 TagEntity
的一个字段, 那么在应用内创建关联完全没有问题, 但是如果进行数据的导入和导出的时候, 应用常常会出现崩溃, 我不明白为什么. 所以, 我采用的方案是如果需要知道与 tag 关联的 todos, 那么并不在 TagDAO
中获取 todos, 而是在 TodoDAO.findMany
的方法中增加一个 tagId
的参数来过滤 todos.
一对多关系
定义 LocationModel
//
// LocationModel.swift
// demo5-todo-list
//
// Created by arno_solo on 4/8/25.
//
import Foundation
struct LocationModel {
let id: String
let latitude: Double
let longitude: Double
let altitude: Double?
let placeName: String?
}
修改 .xcdatamodeld 文件
- 在 TodoEntity > Relationships 界面中增加
location
字段, Destination 选择LocationEntity
- 在 LocationEntity > Relationships 界面中增加
todos
字段, Destination 选择TodoEntity
. 打开右侧栏 > Relationship, 选择 Type 为To Many
在 todo 中包含 location
写入数据库
class TodoDAO {
...
func updateOne(todo: TodoModel) async throws {
...
Self.modifyEntity(entity: entity, todo: todo)
...
if let id = todo.location?.id {
entity.location = LocationDAO.findEntity(id: id, ctx: ctx)
} else {
entity.location = nil
}
try ctx.save()
}
}
从数据库读取
class TodoDAO {
static func entityToModel(entity: TodoEntity, ctx: NSManagedObjectContext) -> TodoModel? {
...
var location: LocationModel? = nil
if let locationEntity = entity.location {
location = LocationDAO.entityToModel(entity: locationEntity, ctx: ctx)
}
return TodoModel(
todoId: todoId,
createdAt: createdAt,
updatedAt: updatedAt,
title: title,
completedAt: entity.completedAt,
tags: tags,
location: location
)
}
}
分离数据层与视图层
我个人的偏好是,
- 一种数据类型定义一个
DAO
(Data access object).DAO
负责读写本地的 sqlite 数据库. - 一项后端服务定义一个
Service
.Service
负责与远程数据库进行通讯. - 一种数据类型定义一个
Repository
.Repository
负责协调数据从本地数据库还是远程数据库获取. - 在视图中尽量通过调用
Repository
而不是DAO
或Service
来读写数据.
下面是一个文件结构的示例:
Data/
Local/
TodoDAO.swift
TagDAO.swift
Network/
FirestoreService.swift
TodoRepository.swift
TagRepository.swift
下面是 TodoRepository
的代码, 目前只有读写本地数据库的代码, 如果以后有服务器请求的代码, 也可以往这个类里写.
import Foundation
class TodoRepository {
static let shared = TodoRepository(
todoDAO: TodoDAO.shared,
tagDAO: TagDAO.shared
)
private var todoDAO: TodoDAO
private var tagDAO: TagDAO
init(todoDAO: TodoDAO, tagDAO: TagDAO) {
self.todoDAO = todoDAO
self.tagDAO = tagDAO
}
func createTodo(todo: TodoModel) async throws {
try await todoDAO.createOne(todo: todo)
try await todoDAO.updateOne(todo: todo)
}
func updateTodo(todo: TodoModel) async throws {
try await todoDAO.updateOne(todo: todo)
}
func deleteTodo(todo: TodoModel) async throws {
try await todoDAO.deleteOne(todoId: todo.todoId)
}
func findTodos(searchText: String?, tagId: String?, page: Int?, pageSize: Int?) async throws -> [TodoModel] {
return try await todoDAO.findMany(searchInput: searchText, tagId: tagId, page: page, pageSize: pageSize)
}
}
参考资料