聊一下在SwiftUI中使用CoreData

本文并非一个教你如何在SwiftUI下使用CoreData的教程。主要探讨的是在我近一年的SwiftUI开发中使用CoreData的教训、经验、心得。

SwiftUI lifecycle 中如何声明持久化存储和上下文

在XCode12中,苹果新增了SwiftUI lifecycle,让App完全的SwiftUI化。不过这就需要我们使用新的方法来声明持久化存储和上下文。

好像是从beta6开始,XCode 12提供了基于SwiftUI lifecycle的CoreData模板

@main
struct CoreDataTestApp: App {
    //持久化声明
    let persistenceController = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)  
          //上下文注入
        }
    }
}

在它的Presitence中,添加了用于preview的持久化定义

struct PersistenceController {
    static let shared = PersistenceController()

    static var preview: PersistenceController = {
        let result = PersistenceController(inMemory: true)
        let viewContext = result.container.viewContext
        //根据你的实际需要,创建用于preview的数据
        for _ in 0..<10 {
            let newItem = Item(context: viewContext)
            newItem.timestamp = Date()
        }
        do {
            try viewContext.save()
        } catch {
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }
        return result
    }()

    let container: NSPersistentCloudKitContainer
    //如果是用于preview便将数据保存在内存而非sqlite中
    init(inMemory: Bool = false) {
        container = NSPersistentCloudKitContainer(name: "Shared")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }
}

虽然对于用于preview的持久化设置并不完美,不过苹果也意识到了在SwiftUI1.0中的一个很大问题,无法preview使用了@FetchRequest的视图。

由于在官方CoreData模板出现前,我已经开始了我的项目构建,因此,我使用了下面的方式来声明

struct HealthNotesApp:App{
  static let coreDataStack = CoreDataStack(modelName: "Model") //Model.xcdatemodeld
  static let context = DataNoteApp.coreDataStack.managedContext
  static var storeRoot = Store() 
   @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
  WindowGroup {
        rootView()
            .environmentObject(store)
                    .environment(\.managedObjectContext, DataNoteApp.context)
  }
}

在UIKit App Delegate中,我们可以使用如下代码在App任意位置获取上下文

let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext

但由于我们已经没有办法在SwiftUI lifecycle中如此使用,通过上面的声明我们可以利用下面的方法在全局获取想要的上下文或其他想要获得的对象

let context = HealthNotesApp.context

比如在 delegate中

class AppDelegate:NSObject,UIApplicationDelegate{
    
    let send = HealthNotesApp.storeRoot.send
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
       
        logDebug("app startup on ios")
       
        send(.loadNote)
        return true
    }

    func applicationDidFinishLaunching(_ application: UIApplication){
        
        logDebug("app quit on ios")
        send(.counter(.save))

    }

}

//或者直接操作数据库,都是可以的

如何动态设置 @FetchRequest

在SwiftUI中,如果无需复杂的数据操作,使用CoreData是非常方便的。在完成xcdatamodeld的设置后,我们就可以在View中轻松的操作数据了。

我们通常使用如下语句来获取某个entity的数据

@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Student.studentId, ascending: true)],
              predicate:NSPredicate(format: "age > 10"),
              animation: .default) 
private var students: FetchedResults<Student>

不过如此使用的话,查询条件将无法改变,如果想根据需要调整查询条件,可以使用下面的方法。

健康笔记2中的部分代码:

struct rootView:View{
        @State var predicate:NSPredicate? = nil
    @State var sort = NSSortDescriptor(key: "date", ascending: false)
    @StateObject var searchStore = SearchStore()
    @EnvironmentObject var store:Store
    var body:some View{
      VStack {
       SearchBar(text: $searchStore.searchText) //搜索框
       MemoList(predicate: predicate, sort: sort,searching:searchStore.showSearch)
        }
      .onChange(of: searchStore.text){ _ in
          getMemos()
      }
    }
  
       //读取指定范围的memo
    func getMemos() {
        var predicators:[NSPredicate] = []
        if !searchStore.searchText.isEmpty && searchStore.showSearch {
            //memo内容或者item名称包含关键字
            predicators.append(NSPredicate(format: "itemData.item.name contains[cd] %@ OR content contains[cd] %@", searchStore.searchText,searchStore.searchText))
        }
        if star {
            predicators.append(NSPredicate(format: "star = true"))
        }
        
        switch store.state.memo{
        case .all:
            break
        case .memo:
            if !searchStore.searchText.isEmpty && noteOption == 1 {
                break
            }
            else {
                predicators.append(NSPredicate(format: "itemData.item.note = nil"))
            }
        case .note(let note):
            if !searchStore.searchText.isEmpty && noteOption == 1 {
                break
            }
            else {
                predicators.append(NSPredicate(format: "itemData.item.note = %@", note))
            }
        }
        
        withAnimation(.easeInOut){
            predicate =  NSCompoundPredicate(type: NSCompoundPredicate.LogicalType.and, subpredicates: predicators)
            sort =  NSSortDescriptor(key: "date", ascending: ascending)
        }
    }
}

上述代码会根据搜索关键字以及一些其他的范围条件,动态的创建predicate,从而获得所需的数据。

对于类似查询这样的操作,最好配合上Combine来限制数据获取的频次

例如:

class SearchStore:ObservableObject{
    @Published var searchText = ""
    @Published var text = ""
    @Published var showSearch = false
    
    private var cancellables:[AnyCancellable] = []
    
    func registerPublisher(){
        $searchText
            .removeDuplicates()
            .debounce(for: 0.4, scheduler: DispatchQueue.main)
            .assign(to: &$text)
    }
    
    func removePublisher(){
        cancellables.removeAll()
    }
    
}

上述所有代码均缺失了很大部分,仅做思路上的说明

增加转换层方便代码开发

在开发健康笔记 1.0的时候我经常被类似下面的代码所烦恼

@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Student.name, ascending: true)],
              animation: .default) 
private var students: FetchedResults<Student>

ForEach(students){ student in
  Text(student.name ?? "")
  Text(String(student.date ?? Date()))
}

在CoreData中,设置Attribute,很多时候并不能完全如愿。

好几个类型是可选的,比如String,UUID等,如果在已发布的app,将新增的attribute其改为不可选,并设置默认值,将极大的增加迁移的难度。另外,如果使用了NSPersistentCloudKitContainer,由于Cloudkit的atrribute和CoreData并不相同,XCode会强制你将很多Attribute改成你不希望的样式。

为了提高开发效率,并为未来的修改留出灵活、充分的更改空间,在健康笔记2.0的开发中,我为每个NSManagedObject都增加了一个便于在View和其他数据操作中使用的中间层。

例如:

@objc(Student)
public class Student: NSManagedObject,Identifiable {
    @NSManaged public var name: String?
    @NSmanaged public var birthdate: Date?
}

public struct StudentViewModel: Identifiable{
    let name:String
    let birthdate:String
}

extension Student{
   var viewModel:StudentViewModel(
        name:name ?? ""
        birthdate:(birthdate ?? Date()).toString() //举例
   )
  
}

如此一来,在View中调用将非常方便,同时即使更改entity的设置,整个程序的代码修改量也将显著降低。

ForEach(students){ student in
  let student = student.viewModel
  Text(student.name)
  Text(student.birthdate)
}

同时,对于数据的其他操作,我也都通过这个viewModel来完成。

比如:


//MARK:通过ViewModel生成Note数据,所有的prepare动作都需要显示调用 _coreDataSave()
    func _prepareNote(_ viewModel:NoteViewModel) -> Note{
        let note = Note(context: context )
        note.id = viewModel.id 
        note.index = Int32(viewModel.index)  
        note.createDate = viewModel.createDate  
        note.name = viewModel.name 
        note.source = Int32(viewModel.source)  
        note.descriptionContent = viewModel.descriptionContent 
        note.color = viewModel.color.rawValue 
        return note
    }
    
    //MARK:更新Note数据,仍需显示调用save
    func _updateNote(_ note:Note,_ viewModel:NoteViewModel) -> Note {
        note.name = viewModel.name
        note.source = Int32(viewModel.source)
        note.descriptionContent = viewModel.descriptionContent
        note.color = viewModel.color.rawValue
        return note
    }

func newNote(noteViewModel:NoteViewModel) -> AnyPublisher<AppAction,Never> {
       let _ = _prepareNote(noteViewModel)
       if  !_coreDataSave() {
            logDebug("新建Note出现错误")
       }
       return Just(AppAction.none).eraseToAnyPublisher()
    }
    
func editNote(note:Note,newNoteViewModel:NoteViewModel) -> AnyPublisher<AppAction,Never>{
        let _ = _updateNote(note, newNoteViewModel)
        if !_coreDataSave() {
            logDebug("更新Note出现错误")
        }
        return Just(AppAction.none).eraseToAnyPublisher()
}

在View中调用

Button("New"){
      let noteViewModel = NoteViewModel(createDate: Date(), descriptionContent: myState.noteDescription, id: UUID(), index: -1, name: myState.noteName, source: 0, color: .none)
     store.send(.newNote(noteViewModel: noteViewModel))
     presentationMode.wrappedValue.dismiss()
}

从而将可选值或者类型转换控制在最小范围

使用NSPersistentCloudKitContainer 需要注意的问题

从iOS13开始,苹果提供了NSPersistentCloudKitContainer,让app可以以最简单的方式享有了数据库云同步功能。

不过在使用中,我们需要注意几个问题。

  • Attribute
    在上一节提高过,由于Cloudkit的数据设定和CoreData并不完全兼容,因此如果你在项目初始阶段是使用NSPersistentContainer进行开发的,当将代码改成NSPersistentCloudKitContainer后,XCode可能会提示你某些Attribute不兼容的情况。如果你采用了中间层处理数据,修改起来会很方便,否则你需要对已完成的代码做出不少的修改和调整。我通常为了开发调试的效率,只有到最后的时候才会使用NSPersistentCloudKitContainer,因此这个问题会比较突出。

  • 合并策略
    奇怪的是,在XCode的CoreData(点选使用CloudKit)默认模板中,并没有设定合并策略。如果没有设置的话,当app的数据进行云同步时,时长会出现合并错误,并且@FetchRequest也并不会在有数据发生变动时对View进行刷新。因此我们需要自己明确数据的合并策略。

        lazy var persistentContainer: NSPersistentCloudKitContainer = {
            let container = NSPersistentCloudKitContainer(name: modelName)
            container.loadPersistentStores(completionHandler: { (storeDescription, error) in
                if let error = error as NSError? {
                    fatalError("Unresolved error \(error), \(error.userInfo)")
                }
            })
            //需要显式表明下面的合并策略,否则会出现合并错误!
            container.viewContext.automaticallyMergesChangesFromParent = true
            container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
            return container
        }()
    
  • 调试信息
    当打开云同步后,在调试信息中将出现大量的数据同步调试信息,严重影响了对于其他调试信息的观察。虽然可以通过启动命令屏蔽掉数据同步信息,但有时候我还是需要对其进行观察的。目前我使用了一个临时的解决方案。

    #if !targetEnvironment(macCatalyst) && canImport(OSLog)
    import OSLog
    let logger = Logger.init(subsystem: "com.fatbobman.DataNote", category: "main") //调试用
    func logDebug(_ text:String,enable:Bool = true){
        #if DEBUG
        if enable {
            logger.debug("\(text)")
        }
        #endif
    }
    #else
    func logDebug(_ text:String,enable:Bool = true){
        print(text,"$$$$")
    }
    #endif
    

    对于需要显示调试信息的地方

    logDebug("数据格式错误")
    

    然后通过在Debug窗口中将Filter设置为$$$$来屏蔽掉暂时不想看到的其他信息

不要用SQL的思维限制了CoreData的能力

CoreData虽然主要是采用Sqlite来作为数据存储方案,不过对于它的数据对象操作不要完全套用Sql中的惯用思维。

一些例子

排序:

//Sql式的
NSSortDescriptor(key: "name", ascending: true)
//更CoreData化,不会出现拼写错误
NSSortDescriptor(keyPath: \Student.name, ascending: true)

在断言中不适用子查询而直接比较对象:

NSPredicate(format: "itemData.item.name = %@",name)

Count:

func _getCount(entity:String,predicate:NSPredicate?) -> Int{
        let fetchRequest = NSFetchRequest<NSNumber>(entityName: entity)  
        fetchRequest.predicate = predicate
        fetchRequest.resultType = .countResultType
        
        do {
            let results  = try context.fetch(fetchRequest)
            let count = results.first!.intValue
            return count
        }
        catch {
            #if DEBUG
            logDebug("\(error.localizedDescription)")
            #endif
            return 0
        }
    }

或者更加简单的count

@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Student.name, ascending: true)],
              animation: .default) 
private var students: FetchedResults<Student>

sutudents.count

对于数据量不大的情况,我们也可以不采用上面的动态predicate方式,在View中直接对获取后的数据进行操作,比如:

@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Student.name, ascending: true)],
              animation: .default) 
private var studentDatas: FetchedResults<Student>
@State var students:[Student] = []
var body:some View{
  List{
        ForEach(students){ student in
           Text(student.viewModel.name)
         }
        }
        .onReceive(studentDatas.publisher){ _ in
            students = studentDatas.filter{ student in
                student.viewModel.age > 10
            }
        }
   }
}

总之数据皆对象

遗憾和不足

苹果在努力提高CoreData在SwiftUI下的表现,不过目前还是有一些遗憾和不足的。

  • @FetchRequest的控制选项太少
    当前我们无法设置FetchRequest的limitNumber以及returnsObjectsAsFaults,它会直接将所有的数据读入到上下文中,当数据量较大时,这样的效率是很低下的。所以如果需要处理较大数据集的时候,最好不要依赖@FetchRequest。
  • animation有些神经刀
    在List中显示@FetchRquest获取的数据集,即使你明确设置了animation(FetchRequest,以及List),并且也显式的使用了withAnimation对所需操作强制动画调用,但动画并不能总如你的预期般实现。完全相同的代码,放置在不同的地方,有时会出现不同的结果。
    当通过UITableViewDiffableDataSource数据来调用自己包装的UITableView后,动画就不会再不可控了。希望苹果能早点解决这个Bug.
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,172评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,346评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,788评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,299评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,409评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,467评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,476评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,262评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,699评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,994评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,167评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,827评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,499评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,149评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,387评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,028评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,055评论 2 352