Core Data and SwiftData, as powerful persistence frameworks in the Apple ecosystem, not only provide declarative data listening tools like @FetchRequest
and @Query
, but also have a complete set of data tracking and notification mechanisms built-in. Understanding and mastering these mechanisms is crucial for building robust data-driven applications. This article will take you through multi-layered solutions—from simple custom notifications to the powerful Persistent History Tracking and SwiftData History—to help you handle various complex data synchronization scenarios.
Custom Data Operation Notifications
Before delving into the complex mechanisms provided by the framework, let’s first look at a straightforward method: integrating custom notifications into your data operation code. This approach is simple yet practical. Let’s understand it through the following example:
extension Notification.Name {
static let didInsertNewItem = Notification.Name("didInsertNewItem")
static let willInsertNewItem = Notification.Name("willInsertNewItem")
}
func newItem(date: Date) throws -> PersistentIdentifier {
// Send notification before creating a new item
NotificationCenter.default.post(name: .willInsertNewItem, object: nil)
let newItem = Item(timestamp: date)
modelContext.insert(newItem)
try modelContext.save()
// Send notification after creating a new item
NotificationCenter.default.post(name: .didInsertNewItem, object: newItem)
return newItem.persistentModelID
}
Although this notification mechanism requires developers to build it manually, it is highly flexible. We can customize notifications for specific operations and control the sending conditions. Moreover, this type of notification is not limited to a specific framework or even the same process. Developers can utilize the Darwin Notification Center or other cross-process mechanisms to enable communication between the main app and widgets.
However, this approach has obvious shortcomings. Only data operations that include the notification-sending code will trigger notifications. For instance, when iCloud synchronization is enabled, modifications synced from other devices through the cloud will not trigger this notification mechanism. Additionally, if the same type of data operation is scattered across multiple code locations, any omission in sending notifications will result in incomplete information.
Extending Managed Object Subclasses
In Core Data, extending managed object subclasses (NSManagedObject
) allows developers to intervene at specific moments in an object’s lifecycle, such as sending notifications or processing property data.
For example, by overriding the willSave
and didSave
methods of the Item
object (a managed object subclass), we can send notifications before and after the data is persisted:
extension Item {
public override func willSave() {
super.willSave()
NotificationCenter.default.post(name: .willInsertNewItem, object: nil)
}
public override func didSave() {
super.didSave()
NotificationCenter.default.post(name: .didInsertNewItem, object: self)
}
}
This is a powerful mechanism provided by Core Data, offering methods for developers to call at multiple stages of a managed object’s lifecycle.
awakeFromInsert
Called when a managed object is inserted into the managed object context. We can perform initialization operations or adjust data here. This method is called only once during the lifecycle of each object instance.
func newItem() {
let item = Item(context: context)
// Date not set
try? context.save()
}
public override func awakeFromInsert() {
super.awakeFromInsert()
// Set the timestamp property to the current date
self.setPrimitiveValue(Date.now, forKey: "timestamp")
}
awakeFromFetch
Called when a managed object transitions from a fault state to a non-fault state (data is loaded). We typically use this to dynamically provide values for transient properties.
public override func awakeFromFetch() {
super.awakeFromFetch()
// Set the transient property 'visible' to true
self.setPrimitiveValue(true, forKey: "visible")
}
Transient
is a property type that can dirty a managed object instance but will not be persisted. You can read this article to learn more.
willSave
Called when the managed object is about to be persisted. We can adjust property data here.
public override func willSave() {
super.willSave()
let date = primitiveValue(forKey: "timestamp") as? Date
let startDate = Calendar.current.date(from: DateComponents(year: 2020, month: 3, day: 11))!
// If the date is earlier than startDate, set timestamp to startDate
if let date, date < startDate {
setPrimitiveValue(startDate, forKey: "timestamp")
}
}
Using
primitiveValue(forKey:)
andsetPrimitiveValue(_:forKey:)
can avoid triggering unnecessary KVO notifications. Note thatsetPrimitiveValue(_:forKey:)
will bypass some of Core Data’s automatic management mechanisms, so ensure the operation does not affect data consistency.
didSave
Called after the managed object has been persisted. Besides sending a notification of successful save, we can also clean up data here, such as handling files related to the object. In the following code, the file corresponding to the object’s url
property will be deleted:
func deleteFile(_ url: URL) {
// Delete file
}
public override func didSave() {
super.didSave()
// Check if it's a delete operation; if so, delete the associated file
if self.isDeleted, let url = self.primitiveValue(forKey: "url") as? URL {
deleteFile(url)
}
}
validateForInsert, validateForUpdate, validateForDelete
Validate data before performing Insert, Update, or Delete operations. They are called before willSave
. If validation fails, you can throw an error to prevent persistence.
enum MyError: Error {
case dateError
}
public override func validateForInsert() throws {
try super.validateForInsert() // Triggers validation rules set in the model editor
let startDate = Calendar.current.date(from: DateComponents(year: 2020, month: 3, day: 11))!
if let timestamp = primitiveValue(forKey: "timestamp") as? Date, timestamp < startDate {
throw MyError.dateError
}
}
func addItem(date: Date) {
let newItem = Item(context: viewContext)
newItem.timestamp = date
do {
try viewContext.save()
} catch {
if let error = error as? MyError, error == .dateError {
print("Date Error")
viewContext.undo()
} else {
print("Unexpected error: \(error)")
// Other error handling
}
}
}
When you set validation rules for properties in the model editor (e.g., maximum length, minimum value), Core Data automatically checks these conditions during insert or update operations. In your custom
validateForInsert
method, callingsuper.validateForInsert()
will execute these preset validation rules, ensuring compliance with the settings in the model editor.
willTurnIntoFault, didTurnIntoFault
Called when the managed object is about to turn into a fault and has turned into a fault. Generally, it’s not recommended to override these two methods.
prepareForDeletion
Called when the managed object is about to be deleted, typically used for cleaning up resources or manually managing relationships. Note that this method is triggered before validateForDelete
. If the deletion operation fails due to validation failure, changes made in this method may be rolled back.
Advantages and Limitations
Extending managed object subclasses provides numerous lifecycle intervention points, allowing developers to control and respond precisely at various stages of an object’s lifecycle. Since this extension targets the type itself, it doesn’t limit specific calling methods. Even if there are different codes in multiple places in the project, as long as they operate on the same type of data, they can achieve consistent timing calls. Moreover, in the current Core Data with CloudKit, data synchronized from the network and persisted by NSPersistentCloudKitContainer
will also invoke these methods.
However, because these operations are extensions of managed object subclasses, if you don’t build subclasses or don’t operate through specific types, these methods won’t be called. For example, during batch operations, specific managed object subclasses are usually not involved, so these lifecycle methods won’t be called.
Although SwiftData’s default storage implementation is based on Core Data, it doesn’t declare managed object subclasses for each entity. Therefore, SwiftData doesn’t provide similar functionality—these notification timings are unique to Core Data.
Notifications from Context
In both Core Data and SwiftData, developers perform most data operations within a context (NSManagedObjectContext
or ModelContext
). The context is a data operation area provided by the persistence framework, responsible for controlling data objects’ lifecycle, managing relationship graphs, tracking, and saving changes.
Beyond these common functionalities, the context is more complex than we might imagine. For example, when we save the same entity data in two persistent stores, the context merges data from two different sources and re-sorts them. In SwiftData versions iOS 18 and above, developers can implement custom stores without any sorting capabilities; sorting and filtering are mechanisms handled by the context.
Due to its significance, the context provides three different notifications to help developers understand data changes.
NSManagedObjectContextObjectsDidChange
This is a notification unique to the Core Data context. It is published when managed objects registered and managed within the context change. In other words, as long as the data is dirty (modified), even if it hasn’t been persisted, this notification will be sent. Operations that don’t dirty the data (e.g., data retrieval) won’t trigger this notification.
let item = Item(context: viewContext) // Just creation, won't send notification
item.timestamp = Date() // Data changes, marked as dirty; context will send notification
In the userInfo
of this notification, it contains sets of inserted, updated, and deleted managed objects.
.onReceive(
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: nil))
{ notification in
// The object is the context that sent the notification
let context = notification.object as? NSManagedObjectContext
if let userInfo = notification.userInfo {
let insertedObjects = userInfo[NSInsertedObjectsKey] as? Set<NSManagedObject> ?? []
let updatedObjects = userInfo[NSUpdatedObjectsKey] as? Set<NSManagedObject> ?? []
let deletedObjects = userInfo[NSDeletedObjectsKey] as? Set<NSManagedObject> ?? []
}
}
Since the notification contains the complete data of the managed objects, when we receive this notification in a thread different from the context, we must not directly operate on the properties of the managed objects, as this can cause thread safety issues and may lead to application crashes. The correct approach is to extract the object’s NSManagedObjectID
and fetch the object again in the current thread’s context. However, in most cases, we’ll operate on the data after didChange
.
Read NSManagedObjectID and PersistentIdentifier: Mastering Data Identifiers in Core Data and SwiftData to learn more about identifiers.
NSManagedObjectContextDidSave and ModelContext.didSave
Notifications published after the context completes a persistence operation. In SwiftData, the corresponding notification is named ModelContext.didSave
.
Compared to NSManagedObjectContextObjectsDidChange
, developers often use didSave
for processing after persistence. In SwiftData, to avoid thread issues caused by improper cross-thread operations on managed object instances in Core Data, the notification only contains the PersistentIdentifier
corresponding to the data.
NotificationCenter.default.publisher(for: ModelContext.didSave)
.sink(receiveValue: { notification in
if let userInfo = notification.userInfo {
let inserted = (userInfo["inserted"] as? [PersistentIdentifier]) ?? []
let deleted = (userInfo["deleted"] as? [PersistentIdentifier]) ?? []
let updated = (userInfo["updated"] as? [PersistentIdentifier]) ?? []
}
})
.store(in: &cancellables)
In iOS 18, the invocation timing of
mainContext
’s automatic save mechanism is unclear, and intervals are usually long. Therefore, developers should explicitly addsave
code; otherwise, they may not receive thedidSave
notification for an extended period.
When the automaticallyMergesChangesFromParent
property of NSManagedObjectContext
is set to true
, it’s equivalent to enabling the context’s automatic response mechanism to this notification. The activated context will automatically merge data changes from the notification into the current context. SwiftData has this mechanism enabled by default.
For thread safety issues in persistence frameworks, please read Concurrent Programming in SwiftData and Several Tips on Core Data Concurrency Programming.
NSManagedObjectContextWillSave and ModelContext.willSave
Notifications published when the context is about to perform persistence. In both Core Data and SwiftData, the userInfo
in the notification is empty. The only information developers can obtain when responding to this notification is which context is about to perform persistence.
Advantages and Limitations of Context Notifications
By responding to context notifications, developers can centrally handle various types of data operations at a single entry point, simplifying code logic.
However, for operations that don’t go through the context (e.g., batch data operations), this mechanism is ineffective. Additionally, this mechanism can only be used within the same process. In multi-process scenarios (like the main app and widgets), different processes cannot perceive each other’s changes.
Persistent History Tracking and SwiftData History
To overcome the limitations of context notifications, Core Data introduced the Persistent History Tracking (PHT) feature a few years ago. Starting from iOS 18, SwiftData also provides a similar mechanism: SwiftData History.
In simple terms, Persistent History Tracking (PHT) adds a log at the persistence layer (SQLite database), recording each transaction of data operations and the specific operations (add, delete, update) within the transaction. Since these operations occur at the database level, it doesn’t care which code or process performed the operation; it faithfully records everything.
In Core Data, we need to enable this feature as follows (this will add several tables to the current database):
let desc = container.persistentStoreDescriptions.first!
desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
If you use Core Data with CloudKit, NSPersistentCloudKitContainer
will automatically enable this feature. SwiftData’s database also has this feature enabled by default. For a database that has enabled this feature, a container that hasn’t enabled this option cannot load it.
After enabling the PHT feature in the database, new transactions won’t send notifications by default. Developers need to enable the option to send notifications for this persistent store so that other code can receive changes from this store.
desc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
Then, we only need to respond to the NSPersistentStoreRemoteChange
notification in the Core Data project to know that a persistent store has changed.
Unlike context notifications, PHT or SwiftData History doesn’t include specific change information in the notification. Developers need to retrieve changes that occurred after a specific time from the database through code to obtain this data.
We can set names for contexts or transactions to understand the source of changes when retrieving information, allowing for targeted processing. Whether it’s PHT or SwiftData History, the change information developers get from transactions is represented by data identifiers. Therefore, similar to the context response mechanism, we need to build data filtering mechanisms for specific types and operations.
For a detailed implementation of PHT, I have a dedicated article: Using Persistent History Tracking in CoreData. SwiftData History’s mechanism is very similar to PHT, with slight differences in implementation details.
Compared to the other centralized notification mechanisms mentioned above, PHT or SwiftData requires an additional database retrieval after receiving the notification, so their response efficiency is relatively low. Also, storing these changes occupies more storage space (developers need to regularly clean up through code).
However, because the change information is stored in the database, this notification mechanism has the least limitations. It can be used for batch processing and across different processes. If you can accept a slight delay in response, PHT and SwiftData are the most comprehensive solutions.
Summary
There’s no perfect solution; each mechanism has its advantages and drawbacks.
Notification Mechanism | Flexibility | Limitations | Platforms | Other Features |
---|---|---|---|---|
Custom Notifications | Highly flexible; developers can freely decide sending conditions and content | Only triggered through manual code; not suitable for external data (like cloud sync); other processes need similar custom notifications | Core Data, SwiftData, Others | Supports cross-process operations (e.g., Darwin notifications); offers flexible sending and receiving mechanisms |
Managed Object Subclass Extensions | Allows triggering at multiple stages of an object’s lifecycle (e.g., willSave , didSave ) | Only effective for specific managed object types; doesn’t support batch operations or scenarios without subclasses | Core Data | Provides precise control; suitable for complex scenarios requiring data state management and notifications before and after persistence |
Context Notifications | Can centrally respond to various operation types | Only applicable within the same process; cannot detect operations from other processes | Core Data, SwiftData | Provides detailed information on object insertion, update, deletion; in multi-threaded environments, avoid directly using objects; need to convert NSManagedObjectID ; pay attention to thread safety |
PHT and SwiftData History | Doesn’t concern data source; supports cross-process; covers all data operations (including batch operations and multi-process synchronization) | Requires additional database retrieval; impacts performance | Core Data, SwiftData | Can record specific transactions and operation history; a solution for data synchronization in multi-process and batch operations; suitable for complex applications needing local data operation tracking; lower response efficiency; need to regularly clean up history to avoid occupying space |
When choosing a notification mechanism, developers should comprehensively consider flexibility, performance, and project requirements. Understanding the advantages and limitations of each mechanism will help implement efficient and reliable data synchronization and state management in applications.