Skip to content

在 iOS 应用中使用 Core Data 保存数据

本文通过描述编写一个待办清单应用的过程, 阐述在 iOS 应用中, 如何分离数据层与视图层. 如何使用 Core Data 保存数据. 如何保存两个数据结构之间的关系.

前提条件

  1. Swift async await

示例代码

demo5-todo-list

Core Data 保存数据

定义数据类型

我个人的偏好是, 在程序中使用的数据结构叫做TagModel, 在 Core Data 中对应的数据结构是TagEntity. TagModel 的结构在下文中给出. TagEntity 请使用 Xcode 的创建一个 xcdatamodeld 文件, 并在其中新建一个 Entity, 名为TagEntity

swift
import Foundation

struct TagModel {
    let tagId: String
    let createdAt: Date
    let updatedAt: Date
    let title: String
}

定义 DAO

您不要看下面这个示例代码很长, 其实所有的 DAO 都长差不多. 下面的 DAO 包含了几个部分:

  1. 共享实例
  2. 增删改查的方法
  3. Model 与 Entity 之间如何进行转化
swift
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 操作数据库时, 我们需要使用图形界面来完成类似操作.

  1. 在 TodoEntity > Relationships 界面中增加 tags 字段, Destination 选择 TagEntity. 打开右侧栏 > Relationship, 选择 Type 为 To Many
  2. 在 TagEntity > Relationships 界面中增加 todos 字段, Destination 选择 TodoEntity. 打开右侧栏 > Relationship, 选择 Type 为 To Many
  3. 选择 TagEntity > Relationships > Inverse 为 todospicture 0

在 todo 中包含 tags

  1. 首先在 TodoModel 增加 tags 字段.
  2. TodoDAO.updateOne 方法中加入为 TodoEntity 连接上 TagEntity 的方法
  3. TodoDAO.entityToModel 方法中加入将 TagEntity 转化为 TagModel 的方法
  4. 以后新建 Todo 时, 先调用 TodoDAO.createOne 创建一个 TodoEntity, 然后再调用 TodoDAO.updateOne 方法为 TodoEntity 连接上 TagEntity.
swift
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?
}
swift
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

swift
//
//  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 文件

  1. 在 TodoEntity > Relationships 界面中增加 location 字段, Destination 选择 LocationEntity
  2. 在 LocationEntity > Relationships 界面中增加 todos 字段, Destination 选择 TodoEntity. 打开右侧栏 > Relationship, 选择 Type 为 To Many

在 todo 中包含 location

写入数据库

swift
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()
    }
}

从数据库读取

swift
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 而不是 DAOService 来读写数据.

下面是一个文件结构的示例:

yml
Data/
    Local/
        TodoDAO.swift
        TagDAO.swift
    Network/
        FirestoreService.swift
    TodoRepository.swift
    TagRepository.swift

下面是 TodoRepository 的代码, 目前只有读写本地数据库的代码, 如果以后有服务器请求的代码, 也可以往这个类里写.

swift
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)
    }
}

参考资料