CoreData探索和记录

2,096 阅读14分钟

创建方式

1.创建项目时勾选使用CoreData

此时,AppDelegate 文件会自动包含CoreData的相关代码,项目里也会有*.xcdatamodeld文件可以直接使用。

2.给项目手动添加CoreData

em,直接把1里面的代码copy一遍就好:

import CoreData
    // MARK: - Core Data stack

    lazy var persistentContainer: NSPersistentContainer = {
        
        let container = NSPersistentContainer(name: "CoreDataTest")
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                //这里需要用合适的方式处理错误信息,开发环境下可以直接fatalError,线上最好处理下错误
                /*
                 这里出现错误的典型原因包括:
                 * 父目录不存在,无法创建或不允许写入。
                 * 由于设备被锁定时的权限或数据保护,持久化存储是不可访问的。
                 * 设备空间不足。
                 * 存储器不能迁移到当前的模型版本。
                 检查错误消息以确定实际的问题是什么
                 */
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()

    // MARK: - Core Data Saving support

    func saveContext () {
        let context = persistentContainer.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                //保存失败时最好处理下相应的错误信息,线上最好不要直接fatalError
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }

感觉CoreData的代码放在AppDelegate里面有些冗余,可以自己创建一个专门用来处理数据库的单例,然后放里面。这样便于封装管理

xcdatamodeld文件解释

Attribute属性解释

oc里面属于值类型的属性,在生成的类文件里面都是必不为空的属性;其它的如StringUUIDBinary Data 等引用类型的属性,都是可为空的属性:虽然String在swift里面也是值类型。

@NSManaged public var content: String?
@NSManaged public var createTime: NSNumber?
@NSManaged public var id: UUID?
@NSManaged public var title: String?
@NSManaged public var image: Data?
@NSManaged public var type1: Int16
@NSManaged public var type3: NSNumber?

Optional

是否可选;勾选后,保存对象到数据库时,如果该值为空,则会报错。

Transient

临时属性;勾选后,该字段和其它字段使用时没有区别,只是不会保存到持久存储器中。每次生成到context时,都会恢复成初始值。

Derived

派生属性; iOS13以后才有的。由Sqlite直接计算,不经过swift或者oc的代码,效率高,但是支持的表达式有限。

派生属性是只读的属性,需要同时勾选Optional,否则保存数据时会报错。

派生属性需要配合Derivation属性来使用:

派生属性会在每次保存时更新一次,即viewContext.save()

Derivation

派生属性的描述表达式,对应的类是NSDerivedAttributeDescription,使用方法如下:

仅复制内容

复制的属性类型没有特别的限制。

比如一个职位Position对应一个公司Company,在RelationsShips里面配置好了company一对一的关系。

当我们要获取公司id时,我们需要Position.company.id

此时可以在Position里面新建一个派生属性companyId, 勾选Optional和Derived,Derivation设置:

company.id

就可以直接使用Position.companyId来获取公司id了。

字符串转换

仅支持字符串类型的属性

转换成大写:

uppercase:(name)

转换成小写:

lowercase:(name)

转换成不区分大小写和变音符号的字符串:

canonical:(name)
一对多关系时的聚合操作

例如一个公司Company发布了多个职位Position,在RelationsShips里面配置好了positions一对多的关系。

计算个数:

positions.@count

求和:

只能针对计算类型属性。比如Position有一个在招人数personCountInt16属性,一个公司有多个职位,现在给公司增加一个派生属性totalPersonCount来统计总共需要招的人数:

positions.personCount.@sum
当前时间

比如Postion有一个Date类型的属性updateTime,用于保存最近更新职位信息的时间,如果不用派生属性,则需要在每次修改职位信息后手动修改一次更新时间,再保存到数据库。使用派生属性,则会在每次保存时自动修改更新时间:

now()

Allows External Storage

是否允许文件外存储,Binary Data 时可用,当二进制文件数据超过1M时,CoreData会把数据保存在Sqlite存储区之外。

Store in External Record File

启用该选项之后,系统会把持久化存储区里的数据复制成XML格式,并保存在存储区外。

Default Value

默认值,设置后可以给属性指定一个默认值。

但是在生成的类文件里面,可为空的属性依然是可为空的属性,不会变成去掉

Use Scalar Type

使用标量类型,在数字类型(IntX, Double, Float等)的属性时会出现并默认勾选,取消勾选后,生成的类文件里面,数字类型会变成NSNumber?

Decimal 类型的属性,无论是否勾选,生成的类文件里面对应的属性类型都是NSDecimalNumber

Validation

数据校验,如果是字符串类型的,可以限制长度区间;数值类型的属性,可以限制值区间。

Reg.Ex

正则表达式校验,仅用于字符串类型的属性。

Constraints

唯一约束,可以添加某个属性作为唯一约束,比如Position的id,同一id只允许存在一条数据。

Relationship 属性解释

Destination

目标类型。比如Company添加一个Position的Relationship,命名为positions,此时Position就是目标

Inverse

反向关联目标。想要有反向关联,需要在Position里面,同样添加一个Company的Relationship,命名为company。否则就是No Inverse

Delete Rule

关联删除规则,决定了从数据库删除当前对象时,它对应的Relationship里的关联对象该如何处理。总共有四种选项:

比如,公司A发布有3个职位,职位1,职位2,职位3,当我删除公司A时:

  • No Action 啥也不做。需要自己手动维护3个职位里面的company属性;
  • Nullify 设为nil。3个职位里面对应的company属性都设为nil;
  • Cascade 同时删除关联对象。3个职位同时被删除;
  • Deny 如果存在关联对象则无法删除。删除公司A失败,需要先删除职位123,才能删除公司A。

Type

对应关系。一对一或者一对多,一对多时,属性类型会是Set。


使用探索

创建对象

NSManagedObject对象无法直接使用init()方法来创建,需要使用NSEntityDescription相关方法,封装一次如下:

    /// 创建一个实例对象,写入上下文,保存时会直接保存到数据库
    public func createInContext<T: NSManagedObject>() -> T {
        let name = "\(T.self)"
        let model = NSEntityDescription.insertNewObject(forEntityName: name, into: persistentContainer.viewContext) as! T
        return model
    }

调用此方法,会在上下文中插入一个NSManagedObject对象,此处支持泛型。

保存对象

直接保存上下文中的所有修改

/// 保存上下文
public func saveContext() throws {
    if persistentContainer.viewContext.hasChanges {
        try persistentContainer.viewContext.save()
    }
}

保存单个对象

无法只保存单个对象,但是可以创建一个新的上下文来写入单个对象,然后保存上下文

let context = NSManagedObjectContext.init(.mainQueue)
//指定一个存储调度器
context.persistentStoreCoordinator = viewContext.persistentStoreCoordinator
// 或者指定父context,这样可以直接使用父context的存储调度器,不可同时指定两个,会报错
context.parent = generalViewContext;

创建新的上下文,需要指定存储调度器,可以和其他上下文公用一个调度器,如果不指定persistentStoreCoordinator或者parent,调用context.save()时依旧无法保存数据

persistentStoreCoordinator或者parent不可同时指定,会导致crash


补充:后来在新创建的context里面,保存一个带Data的对象到数据库时,每次对象保存成功,但是Data数据在数据库查看就是Null,而使用默认的persistentContainer.viewContext不会出现这种问题,最后改成下面的代码创建新的context:

let context = persistentContainer.newBackgroundContext()

这样就不需要设置其它属性,直接使用。


使用多个NSManagedObjectContext

有很多场景下,如果我们直接使用NSManagedObject来处理用户交互数据,就可能需要用到多个Context。比如:

我打开记账页面记一笔账,但是必输的信息没有输入,然后又从记账页面跳转到新增银行卡的页面去录入新的银行卡信息;此时,context会有一个Record对象,缺少关键字段,后面又多了一个需要保存的Card对象。此时如果直接调用context.save,会报错

查询操作NSFetchRequest

查询符合条件的对象

封装方法:

public func query<T:NSManagedObject>(_ predicateString: String = "") throws -> [T] {
    let fetchRequest:NSFetchRequest<T> = T.fetchRequest() as? NSFetchRequest<T> ?? NSFetchRequest<T>.init(entityName: "\(T.self)")
    if !predicateString.isEmpty {
        fetchRequest.predicate = NSPredicate.init(format: predicateString,"")
    }
    let results = try viewContext.fetch(fetchRequest)
    return results
}

直接使用NSManagedObject.fetchRequest()可以快捷的构造一个NSFetchRequest对象;

筛选

查询筛选条件使用NSPredicate传入:

fetchRequest.predicate = NSPredicate.init(format: "userId==%@","123456")

或者直接把参数写在完整的字符串里面,方便使用一个字符串进行传参,就像封装方法里面的那样:

fetchRequest.predicate = NSPredicate.init(format: "userId=='123456'","")

如果是userId是字符串,需要使用单引号进行包裹,或者使用转义双引号\",否则查询时会出错

NSPredicate的使用
运算符作用示例
> 、< 、== 、>= 、<= 、!=比较运算age > 18
IN被包含name IN {'张三','李四'}
BETWEEN在区间内age BETWEEN {18,65}
BEGINSWITH开头是name BEGINSWITH '张'
ENDSWITH结尾是name ENDSWITH '三'
CONTAINS包含有name CONTAINS '三'
LIKE通配符 *和?name LIKE '*三'
MATCHES正则name MATCHES '\(regex)'
排序

除了筛选以外,NSFetchRequest还支持使用sortDescriptors参数进行排序,还可以传入多个NSSortDescriptor进行多重排序:

let sort = NSSortDescriptor.init(key: "id", ascending: true)
fetchRequest.sortDescriptors = [sort]
分页

NSFetchRequest支持进行分页查询:

fetchRequest.fetchLimit = pageSize
fetchRequest.fetchOffset = pageNum * pageSize

fetchLimit参数,限制每次查询的最大条数

fetchOffset参数,表示每次查询的数据的起始位置,

比如查第一页10条数据,就是:

fetchRequest.fetchLimit = 10
fetchRequest.fetchOffset = 0

再接着查第二页的10条数据:

fetchRequest.fetchLimit = 10
fetchRequest.fetchOffset = 10

...

查询某一列或几列数据

此时需要把NSFetchRequest<ResultType> 的返回类型设置为**NSFetchRequest<NSDictionary>**

修改NSFetchRequest的如下属性:

fetchRequest.propertiesToFetch = properties
fetchRequest.resultType = .dictionaryResultType

properties为属性key的数组;例如查询用户的姓名和年龄,那么:

let properties: [String] = ["name","age"]

此时查询获得的结果是[NSDictionary]类型,直接控制台输出如下:

(
  {
    name = "张三",
    age = 18
  },
  {
    name = "李四",
    age = 19
  },
  {
    name = "李四",
    age = 19
  }
  ...
)
数据去重

NSFetchRequest有专门的去重属性:

fetchRequest.returnsDistinctResults = true

returnsDistinctResults用于决定查询时是否只返回不相同的结果,默认为false;开启后,会自动去重:

(
  {
    name = "张三",
    age = 18
  },
  {
    name = "李四",
    age = 19
  }
  ...
)

删除对象

删除对象的操作,其实就是查询到符合要求的对象,然后进行删除:

    @discardableResult
    /// 删除
    public func delete<T:NSManagedObject>(_ predicateString: String = "") throws -> [T] {
        let results: [T] = try query(predicateString)
        results.forEach { item in
            viewContext.delete(item)
        }
        try saveContext()
        return results
    }

修改对象

修改和删除同理:

    @discardableResult
    /// 修改
    public func modify<T:NSManagedObject>(_ predicateString: String = "", closure: @escaping ([T]) -> Void) throws -> [T] {
        let results: [T] = try query(predicateString)
        closure(results)
        try saveContext()
        return results
    }

批量操作

如果数据量比较大时,可以使用批量操作

NSBatchDeleteRequest 批量删除

批量删除是iOS9+新增的类,有两种初始化方式:

使用指定的NSFetchRequest初始化:

public init(fetchRequest fetch: NSFetchRequest<NSFetchRequestResult>)

使用NSFetchRequest时,可以和常规操作一样,查询特定的数据进行批量删除,因为不关心NSFetchRequest的返回数据处理,所以我们只需要设置好查询条件即可:

    @discardableResult
    /// 删除
    public func delete<T:NSManagedObject>(_ predicateString: String = "") throws -> [NSManagedObjectID] {
        let fetchRequest:NSFetchRequest<T> = T.fetchRequest() as? NSFetchRequest<T> ?? NSFetchRequest<T>.init(entityName: "\(T.self)")
        if !predicateString.isEmpty {
            fetchRequest.predicate = NSPredicate.init(format: predicateString,"")
        }
        
        let batchDeleteRequest = NSBatchDeleteRequest.init(fetchRequest: fetchRequest as! NSFetchRequest<NSFetchRequestResult>)
        batchDeleteRequest.resultType = .resultTypeObjectIDs
        let result = try persistentContainer.viewContext.execute(batchDeleteRequest) as! NSBatchDeleteResult
        let changedIds = result.result as? [NSManagedObjectID] ?? []
        return changedIds
    }

需要注意的是,批量删除的操作是直接在SQL层进行,不会同步到context里面,例如:

先创建一个user,保存到数据库

let user: LXUser = XXDataBase.createInContext()
user.id = "1"
XXDataBase.save()

如果通过常规删除:

viewContext.delete(item)
try? viewContext.save()
print("userId = \(user.id)")

此时控制台输出userId = nil

如果通过批量删除:

XXDataBase.batchDelete(LXUser.self)
print("userId = \(user.id)")

控制台依然会输出userId的值:userId = Optional("1")

因此,如果使用批量删除时,需要在删除后进行一次上下文同步:

...
let changedIds = result.result as? [NSManagedObjectID] ?? []
let changes: [AnyHashable: Any] = [
    NSDeletedObjectsKey: changedIds
]
// 把所有删除的id,合并到上下文中,这样就会刷新上下文里面的对象并发送通知
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [persistentContainer.viewContext])
try saveContext()

同步之后再执行批量删除操作,则会输出userId = nil

使用特定的[NSManagedObjectID]数组初始化:

public convenience init(objectIDs objects: [NSManagedObjectID])

此时使用时和上面一样,删除特定的一组NSManagedObjectID的对象。

需要注意:[NSManagedObjectID]里面的id,需要来自同一个的Entity

NSBatchInsertRequest 批量插入

批量插入是iOS13以后新增的类,可以绕过内存直接把一组[String: Any]对象存入数据库,因为不经过context,数据变更时不会触发Notification,节省了大量时间

    @discardableResult
    /// 插入
    public func batchInsert<T:NSManagedObject>(_ entity: T.Type, objects: [[String: Any]]) throws -> [NSManagedObjectID] {

        let insert = NSBatchInsertRequest.init(entityName: "\(T.self)", objects: objects)
        insert.resultType = .objectIDs
        
        let result = try persistentContainer.viewContext.execute(insert) as! NSBatchInsertResult
        
        return result.result as? [NSManagedObjectID] ?? []
    }

这样批量保存数据时非常有优势,而且不用去上下文创建一个个对象:

let ids = XXDataBase.batchInsert(LXUser.self, objects: [
    ["id":"0001","name":"张三","age":2],
    ["id":"0002","name":"张四","age":6],
    ["id":"0003","name":"张五","age":8],
    ["id":"0004","name":"张六","age":16],
    ["id":"0005","name":"张七","age":18],
])

ios14还新增了一套用block方式来进行批量插入初始化的api。

合并冲突处理

如果LXUser里面,id设置了唯一约束,但是需要添加的数据里面,有相同的id,如

[
    ["id":"0006","name":"张八","age":16],
    ["id":"0006","name":"张九","age":18],
]

context里面有一个合并策略属性mergePolicy

viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

下面列举各个合并策略以及结果:

策略单例说明数据库最终结果
NSErrorMergePolicy默认合并策略,冲突时返回错误,错误信息包含(NSInsertedObjectsKey, NSUpdatedObjectsKey)张八
NSMergeByPropertyStoreTrumpMergePolicy单独属性合并,当外部修改和内存修改同时发生,外部修改覆盖内存修改张八
NSMergeByPropertyObjectTrumpMergePolicy单独属性合并,当外部修改和内存修改同时发生,内存修改覆盖外部修改张九
NSOverwriteMergePolicy将当前的对象的状态写入数据库张九
NSRollbackMergePolicy丢弃冲突中所有的修改,保持数据库版本不变张八

NSBatchUpdateRequest批量更新

可以直接批量更新符合指定条件的数据库数据的某一列或者多列:

@discardableResult
/// 批量更新
public func batchUpdate<T:NSManagedObject>(_ entity: T.Type, propertiesToUpdate: [AnyHashable : Any], predicateString: String = "") throws -> [NSManagedObjectID] {

    let update = NSBatchUpdateRequest.init(entityName: "\(T.self)")
    if !predicateString.isEmpty {
        update.predicate = NSPredicate.init(format: predicateString,"")
    }
    update.resultType = .updatedObjectIDsResultType
    update.propertiesToUpdate = propertiesToUpdate
    
    let result = try persistentContainer.viewContext.execute(update) as! NSBatchUpdateResult
    
    return result.result as? [NSManagedObjectID] ?? []
}

比如,把年龄小于10的对象名字都改为李四

let user: [LXUser] = XXDataBase.query("age < 10")
print("批量更新前\(user.map({$0.name ?? ""}))")
let ids = XXDataBase.batchUpdate(LXUser.self, propertiesToUpdate: ["name":"李四"], predicateString: "age < 10")
print("批量更新后\(user.map({$0.name ?? ""}))")

打印日志如下:

批量更新前["张三", "张四", "张五"]
批量更新后["张三", "张四", "张五"]

根据日志可以发现,和批量删除时情况一致,上下文中的对象并不会改变,需要手动去更新上下文:

@discardableResult
/// 批量更新
public func batchUpdate<T:NSManagedObject>(_ entity: T.Type, propertiesToUpdate: [AnyHashable : Any], predicateString: String = "") throws -> [NSManagedObjectID] {

    let update = NSBatchUpdateRequest.init(entityName: "\(T.self)")
    if !predicateString.isEmpty {
        update.predicate = NSPredicate.init(format: predicateString,"")
    }
    update.resultType = .updatedObjectIDsResultType
    update.propertiesToUpdate = propertiesToUpdate
    
    let result = try persistentContainer.viewContext.execute(update) as! NSBatchUpdateResult
    let changedIds = result.result as? [NSManagedObjectID] ?? []
    let changes: [AnyHashable: Any] = [
        NSUpdatedObjectsKey: changedIds
    ]
    NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [persistentContainer.viewContext])
    try saveContext()
    return changedIds
}

需要注意:此处changes键值对里面的key,不是NSDeletedObjectsKey而是NSUpdatedObjectsKey。修改后输出日志如下:

批量更新前["张三", "张四", "张五"]
批量更新后["李四", "李四", "李四"]

因为批量操作可能导致context和数据库里面的内容不一致的问题,能用常规操作尽量使用常规操作。


之前看网上的文章说,批量删除操作并不会遵循实体的relationships规则,

即,如果我删除一个user,user的relationships关联了一组messages,即使设置的关联删除Cascade,数据库也不会自动删除messages,从而破坏表结构导致报错。

但是实际测试时(iPhone11, iOS15),关联的messages是会自动删除的,不知道其它系统版本是不是这样。

总结

CoreData本来也是拿着就用,没有想过那么多,以为掌握基础用法基本就够了。只是在实际使用场景中,使用了很多低效的数据处理方式。比如经常先查询拿到数据,再进行排序等低效操作。还有就是使用多个context去实现单独保存某一个对象,避免与全局context里面的数据冲突等操作。确实使用CoreData给开发工作带来了很多便利,同样也有很多地方需要深入研究,比如需要去重的时候,网上没找到资料,一个个属性查找才找到对应的属性。因为是自己摸索的,如果有什么错误或者疏漏,欢迎交流指正。