聊一下在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.
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。