When there are changes in the database, Persistent History Tracking will send notifications to the subscribers. Developers can use this opportunity to respond to modifications made to the same database, including other applications, widgets (in the same App Group), and batch processing tasks. Since SwiftData integrates support for Persistent History Tracking, there is no need to write additional code. Subscription notifications and transaction merges will be automatically handled by SwiftData.
However, in some cases, developers may want to manually respond to transactions tracked by Persistent History Tracking for more flexibility. This article will explain how to observe specific data changes through Persistent History Tracking in SwiftData.
Why should we self-responsively handle persistent history tracking transactions?
SwiftData integrates support for persistent history tracking, allowing views to promptly and accurately respond to data changes. This is helpful when modifying data from the network, other applications, or widgets. However, in certain situations, developers may need to manually handle persistent history tracking transactions beyond just the view layer.
The reasons for manually handling persistent history tracking transactions are as follows:
- Integration with other functionalities: SwiftData may not fully integrate with certain features or frameworks, such as NSCoreDataCoreSpotlightDelegate. In such cases, developers need to handle transactions themselves to adjust the display in Spotlight.
- Performing actions on specific data changes: When data changes, developers may need to perform additional logic or operations. By manually responding, they can selectively execute actions only for the changed data, reducing operation costs.
- Extending functionality: Manual response provides developers with greater flexibility and extensibility to implement functions that SwiftData currently cannot achieve.
In conclusion, manually responding to persistent history tracking transactions allows developers to have more control in handling integration issues, specific data changes, and extending functionality. This enables developers to better utilize persistent history tracking to meet various requirements.
Persistent History Tracking Handling in Core Data
Handling persistent history tracking in Core Data involves the following steps:
- Set different transaction authors for different data operators (applications, widgets): You can assign a unique name to each data operator (application, widget) using the
transactionAuthor
property. This allows for differentiation between different data operators and ensures that each operator’s transactions can be properly identified. - Save the timestamp of the last fetched transaction for each data operator in a shared container: You can use
UserDefaults
to save the timestamp of the last fetched transaction for each data operator at a specific location in the App Group’s shared container. This allows for retrieving all newly generated persistent history tracking transactions since the last merge based on their timestamps. - Enable persistent history tracking and respond to notifications: In the Core Data stack, you need to enable persistent history tracking and register as an observer for notifications related to persistent history tracking.
- Retrieve newly generated persistent history tracking transactions: Upon receiving a persistent history tracking notification, you can fetch newly generated transactions from the persistent history tracking store based on the timestamp of the last fetched transaction. Typically, you only need to retrieve transactions generated by data operators other than the current one (application, widget).
- Process the transactions: Handle the retrieved persistent history tracking transactions, such as merging the changes into the current view context.
- Update the last fetched timestamp: After processing the transactions, set the timestamp of the latest fetched transaction as the last fetched timestamp to ensure that only new transactions are fetched in the next retrieval.
- Clear merged transactions: Once all data operators have completed processing the transactions, you can clear the merged transactions as needed.
NSPersistentCloudContainer automatically merges synchronization transactions from the network, so developers do not need to handle it themselves.
Read the article ”Using Persistent History Tracking in CoreData” for a complete implementation details.
Differences in Using Persistent History Tracking in SwiftData
In SwiftData, the use of persistent history tracking is similar to Core Data, but there are also some differences:
- View-level data merging: SwiftData can automatically handle view-level data merging, so developers do not need to manually handle transaction merging operations.
- Transaction clearance: In order to ensure that other members of the same App Group using SwiftData can correctly obtain transactions, transactions that have already been processed are not cleared.
- Saving timestamps: Each member of the App Group using SwiftData only needs to save their last retrieved timestamp individually, without the need to save it uniformly in the shared container.
- Transaction processing logic: Due to the completely different concurrency programming approach adopted by SwiftData, the transaction processing logic is placed in a
ModelActor
. This instance is responsible for handling the retrieval and processing of persistent history tracking transactions. fetchRequest
inNSPersistentHistoryChangeRequest
isnil
: In SwiftData, thefetchRequest
inNSPersistentHistoryChangeRequest
created throughfetchHistory
isnil
, so transactions cannot be filtered using predicates. The filtering process will be done in memory.- Type conversion: The data information contained in the persistent history tracking transaction is
NSManagedObjectID
, which needs to be converted toPersistentIdentifier
using SwiftDataKit for further processing in SwiftData.
In the following specific implementation, some considerations will be explained in more detail.
Implementation Details
You can find the complete demo code here.
Declare DataProvider
First, we will declare a DataProvider that includes ModelContainer and ModelActor for handling persistent history tracking:
import Foundation
import SwiftData
import SwiftDataKit
public final class DataProvider: @unchecked Sendable {
public var container: ModelContainer
// a model actor to handle persistent history tracking transaction
private var monitor: DBMonitor?
public static let share = DataProvider(inMemory: false, enableMonitor: true)
public static let preview = DataProvider(inMemory: true, enableMonitor: false)
init(inMemory: Bool = false, enableMonitor: Bool = false) {
let schema = Schema([
Item.self,
])
let modelConfiguration: ModelConfiguration
modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: inMemory)
do {
let container = try ModelContainer(for: schema, configurations: [modelConfiguration])
self.container = container
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}
}
Since both types of the only two stored properties in DataProvider conform to the Sendable
protocol, I declare DataProvider as Sendable
as well.
Naming the transactionAuthor for ModelContext
In the demonstration, to only handle transactions generated by the mainContext
of the current application, we need to name the transactionAuthor for ModelContext.
extension DataProvider {
@MainActor
private func setAuthor(container: ModelContainer, authorName: String) {
container.mainContext.managedObjectContext?.transactionAuthor = authorName
}
}
// in init
do {
let container = try ModelContainer(for: schema, configurations: [modelConfiguration])
self.container = container
// Set transactionAuthor of mainContext to mainApp
Task {
await setAuthor(container: container, authorName: "mainApp")
}
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
Declare ModelActor for handling persistent history tracking
SwiftData adopts a safer and more elegant approach to concurrent programming by placing all code related to persistent history tracking in a ModelActor.
Read the article on Concurrent Programming in SwiftData to master the new methods of concurrent programming.
import Foundation
import SwiftData
import SwiftDataKit
import Combine
import CoreData
@ModelActor
public actor DBMonitor {
private var cancellable: AnyCancellable?
// last history transaction timestamp
private var lastHistoryTransactionTimestamp: Date {
get {
UserDefaults.standard.object(forKey: "lastHistoryTransactionTimestamp") as? Date ?? Date.distantPast
}
set {
UserDefaults.standard.setValue(newValue, forKey: "lastHistoryTransactionTimestamp")
}
}
}
extension DBMonitor {
// Respond to persistent history tracking notifications
public func register(excludeAuthors: [String] = []) {
guard let coordinator = modelContext.coordinator else { return }
cancellable = NotificationCenter.default.publisher(
for: .NSPersistentStoreRemoteChange,
object: coordinator
)
.map { _ in () }
.prepend(())
.sink { _ in
self.processor(excludeAuthors: excludeAuthors)
}
}
// After receiving the notification, process the transaction
private func processor(excludeAuthors: [String]) {
// Get all transactions
let transactions = fetchTransaction()
// Save the timestamp of the latest transaction
lastHistoryTransactionTimestamp = transactions.max { $1.timestamp > $0.timestamp }?.timestamp ?? .now
// Filter transactions to exclude transactions generated by excludeAuthors
for transaction in transactions where !excludeAuthors.contains([transaction.author ?? ""]) {
for change in transaction.changes ?? [] {
// Send transaction to processing unit
changeHandler(change)
}
}
}
// Fetch all newly generated transactions since the last processing
private func fetchTransaction() -> [NSPersistentHistoryTransaction] {
let timestamp = lastHistoryTransactionTimestamp
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: timestamp)
// In SwiftData, the fetchRequest.fetchRequest created by fetchHistory is nil and predicate cannot be set.
guard let historyResult = try? modelContext.managedObjectContext?.execute(fetchRequest) as? NSPersistentHistoryResult,
let transactions = historyResult.result as? [NSPersistentHistoryTransaction]
else {
return []
}
return transactions
}
// Process filtered transactions
private func changeHandler(_ change: NSPersistentHistoryChange) {
// Convert NSManagedObjectID to PersistentIdentifier via SwiftDataKit
if let id = change.changedObjectID.persistentIdentifier {
let author = change.transaction?.author ?? "unknown"
let changeType = change.changeType
print("author:\(author) changeType:\(changeType)")
print(id)
}
}
}
In DBMonitor, we only handle transactions that are not generated by members of the excludeAuthors list. You can set excludeAuthors as needed, such as adding all transactionAuthors of the current App’s modelContext to it.
To enable DBMonitor in the DataProvider:
// DataProvider init
do {
let container = try ModelContainer(for: schema, configurations: [modelConfiguration])
self.container = container
Task {
await setAuthor(container: container, authorName: "mainApp")
}
// Create DBMonitor to handle persistent historical tracking transactions
if enableMonitor {
Task.detached {
self.monitor = DBMonitor(modelContainer: container)
await self.monitor?.register(excludeAuthors: ["mainApp"])
}
}
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
In Xcode, when the Strict Concurrency Checking
setting is set to Complete (to prepare for Swift 6 and perform strict scrutiny of concurrent code), if the DataProvider does not conform to Sendable, you will receive the following warning message:
Capture of 'self' with non-sendable type 'DataProvider' in a `@Sendable` closure
Testing
So far, we have completed the work of responding to persistent history tracking in SwiftData. To verify the results, we will create a new ModelActor to create new data through it (without using mainContext).
@ModelActor
actor PrivateDataHandler {
func setAuthorName(name: String) {
modelContext.managedObjectContext?.transactionAuthor = name
}
func newItem() {
let item = Item(timestamp: .now)
modelContext.insert(item)
try? modelContext.save()
}
}
In the ContentView, add a button to create data through PrivateDataHandler:
ToolbarItem(placement: .topBarLeading) {
Button {
let container = modelContext.container
Task.detached {
let handler = PrivateDataHandler(modelContainer: container)
// Set transactionAuthor of PrivateDataHandler's modelContext to Private, you can also not set it
await handler.setAuthorName(name: "Private")
await handler.newItem()
}
} label: {
Text("New Item")
}
}
After running the application, click the +
button in the upper right corner. Because the new data is created through the mainContext (with mainApp excluded from the excludeAuthors list), the corresponding transaction will not be sent to the changeHandler
. However, data created through the “New Item” button in the upper left corner, which corresponds to the modelContext not included in the excludeAuthors list, will have the changeHandler
print the corresponding information.
Summary
Handling persistent history tracking transactions on our own can allow us to achieve more advanced features in SwiftData, which may help developers who want to use SwiftData but still have concerns about limited functionality.