Skip to content

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

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

前提条件

  1. Swift async await

示例代码

demo5-todo-list

创建 xcdatamodeld 文件

在 Model 文件夹下创建一个新的文件, 类型是 "Data Model".

创建 Persistence.swift

在 Data/Local 文件夹下创建 Persistence.swift 文件, 里面定义访问 Core Data 的逻辑.

swift
//
//  Persistence.swift
//  demo5-todo-list
//
//  Created by arno_solo on 3/20/25.
//

import CoreData

struct PersistenceController {
    static let shared = PersistenceController(dbName: "demo5_todo_list", appGroupsId: nil)
    
    static let preview: PersistenceController = {
        let result = PersistenceController(dbName: "demo5_todo_list", appGroupsId: nil, inMemory: true)
        return result
    }()

    let container: NSPersistentContainer

    init(dbName: String, appGroupsId: String? = nil, inMemory: Bool = false) {
        container = NSPersistentContainer(name: dbName)

        if let appGroupsId,
           let appGroupsURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupsId) {
            // Specify the URL for the persistent store
            let storeURL = appGroupsURL.appendingPathComponent("\(dbName).sqlite")
            // Set up the persistent store with the shared URL
            container.persistentStoreDescriptions = [NSPersistentStoreDescription(url: storeURL)]
        }
        
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                print(error.localizedDescription)
            }
        })
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
}

定义数据类型

在 Model 文件夹定义 TodoModel, 然后在 xcdatamodeld 文件中定义一个对应的 TodoEntity.

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?
}

定义 DAO

定义一个 TodoDAO 负责持久化 TodoModel.

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

关系

修改 .xcdatamodeld 文件

多对多

  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

一对多

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

写入

swift
// Models/TodoModel.swift
class TodoDAO {
    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()
        }
    }
}

读取

swift
// Models/TodoModel.swift
class TodoDAO {
    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
        )
    }
}

分离数据层与视图层

我个人的偏好是,

  • 一种数据类型定义一个 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)
    }
}

参考资料