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:
@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:
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:
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:
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:
let context = HealthNotesApp.context
For example, in the 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))
}
// 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:
@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:
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:
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:
@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:
@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.
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:
// Code for data operations using ViewModel
// ...
In the View:
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.
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.
#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:
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:
// 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:
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
}
}
Or a simpler count:
@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:
@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.