Discussing CoreData Usage in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

This article is not a tutorial on how to use CoreData with SwiftUI. Instead, it focuses on lessons, experiences, and insights I’ve gathered over the past year in using CoreData in SwiftUI development.

Declaring Persistent Storage and Context in SwiftUI Lifecycle

With XCode 12, Apple introduced the SwiftUI lifecycle, enabling apps to be fully SwiftUI-based. This necessitates new methods for declaring persistent storage and context.

Starting from beta 6, XCode 12 offers a CoreData template based on the SwiftUI lifecycle:

Swift
@main
struct CoreDataTestApp: App {
    // Persistent storage declaration
    let persistenceController = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)  
          // Context injection
        }
    }
}

In its Persistence, there’s an added definition for persistence used in previews:

Swift
struct PersistenceController {
    static let shared = PersistenceController()

    static var preview: PersistenceController = {
        let result = PersistenceController(inMemory: true)
        let viewContext = result.container.viewContext
        // Create preview data according to your actual needs
        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
    // For preview, data is saved in memory instead of 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)")
            }
        })
    }
}

Although the persistence setting for previews is not perfect, Apple recognized a major issue in SwiftUI 1.0: the inability to preview views using @FetchRequest.

Since my project build started before the official CoreData template was available, I declared it as follows:

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

In the UIKit App Delegate, we can obtain the context anywhere in the app with the following code:

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

However, since we can’t use this method in the SwiftUI lifecycle, we can globally obtain the desired context or other objects through the above declaration:

Swift
let context = HealthNotesApp.context

For example, in the delegate:

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

    }

    // Or directly operate on the database, both are feasible
}

How to Dynamically Set @FetchRequest

Using CoreData in SwiftUI is very convenient for simple data operations. After setting up xcdatamodeld, we can easily manipulate data in the View.

Typically, we use the following statement to fetch data for an entity:

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

However, this makes the query condition unchangeable. If you want to adjust the query condition as needed, you can use the following method.

Part of the code from Health Notes 2:

Swift
struct rootView:View{
    // Code for dynamic predicate creation and view handling
    // ...
}

This code dynamically creates predicates based on search keywords and other range conditions to obtain the desired data.

For operations like queries, it’s best to use Combine to limit the frequency of data retrieval.

For example:

Swift
class SearchStore:

ObservableObject{
    // Code for a search store with Combine
    // ...
}

All the above code is incomplete, only illustrating the thought process.

Adding a Transformation Layer to Facilitate Code Development

During the development of Health Notes 1.0, I was often troubled by code like the following:

Swift
@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()))
}

In CoreData, setting Attributes often doesn’t go as planned.

Several types are optional, such as String and UUID. Changing a newly added attribute from optional to non-optional and setting a default value in an already published app increases migration difficulty. Also, if NSPersistentCloudKitContainer is used, XCode forces you to change many Attributes to styles you don’t want due to the difference between Cloudkit and CoreData Attributes.

To improve development efficiency and leave room for future modifications, in Health Notes 2.0, I added a middle layer for each NSManagedObject for easier use in Views and other data operations.

For example:

Swift
@objc(Student)
public class Student: NSManagedObject,Identifiable {
    // Core Data entity properties
    // ...
}

public struct StudentViewModel: Identifiable{
    // ViewModel properties
    // ...
}

extension Student{
   var viewModel:StudentViewModel(
        // Conversion to ViewModel
        // ...
   )
  
}

This approach makes calling in the View very convenient, and even if the entity settings change, the code modification throughout the program will be significantly reduced.

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

Similarly, other data operations are also performed through this viewModel.

For example:

Swift
// Code for data operations using ViewModel
// ...

In the View:

Swift
Button("New"){
      // Using ViewModel to create and manage data
      // ...
}

This way, handling optional values or type conversions is controlled within a minimal scope.

Issues to Consider When Using NSPersistentCloudKitContainer

From iOS 13, Apple introduced NSPersistentCloudKitContainer, simplifying database cloud synchronization for apps. However, several issues need attention when using it.

  • Attribute Compatibility As mentioned in the previous section, CloudKit’s data settings are not fully compatible with CoreData. Therefore, if your initial project development was with NSPersistentContainer, switching to NSPersistentCloudKitContainer might lead to incompatibility issues signaled by XCode. These are easier to handle if you’ve used a middle layer for data processing; otherwise, substantial modifications to existing code are required. For efficiency in development and debugging, I often switch to NSPersistentCloudKitContainer only at the final stages, making this issue more pronounced.

  • Merge Strategy Surprisingly, XCode’s default CoreData template (with CloudKit enabled) doesn’t set a merge policy. Without this, you might encounter merging errors during cloud synchronization, and @FetchRequest may not refresh the View upon data changes. Thus, it’s essential to explicitly define a merge strategy.

Swift
      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)")
              }
          })
          // Explicitly stating the following merge strategy is crucial to avoid merging errors!
          container.viewContext.automaticallyMergesChangesFromParent = true
          container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
          return container
      }()
  • Debugging Information Enabling cloud synchronization floods the debug log with sync data, obstructing observation of other debug info. Although sync info can be filtered out through launch commands, sometimes its monitoring is necessary. I use a temporary workaround for this.
Swift
  #if !targetEnvironment(macCatalyst) && canImport(OSLog)
  import OSLog
  let logger = Logger.init(subsystem: "com.fatbobman.DataNote", category: "main") // For debugging
  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

For displaying specific debug info:

Swift
  logDebug("Data format error")

Then, set the Filter in the Debug window to $$$$ to temporarily block other info.

Don’t Limit CoreData’s Capabilities with SQL Thinking

Although CoreData primarily uses SQLite for data storage, don’t strictly apply SQL habits to its data object operations.

Some examples:

Sorting:

Swift
// SQL-style
NSSortDescriptor(key: "name", ascending: true)
// More CoreData-like, avoiding spelling errors
NSSortDescriptor(keyPath: \Student.name, ascending: true)

Using direct object comparisons in predicates instead of subqueries:

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

Count:

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

Or a simpler count:

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

students.count

For small data sets, you can forego dynamic predicates and directly manipulate fetched data in the View, like so:

Swift
@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
            }
        }
   }
}

In summary, treat data as objects.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed