In the WWDC 23 lounge, this exchange with @Dave N (Apple) indicated that using ModelActor is the right way to do background work using swift concurrency. https://developer.apple.com/forums/thread/731338
I've figured out how to use this approach to do background work using swift concurrency (inside a Task), and example code is below for those who find it useful, however I'm not seeing view updates when the background work is complete.
There seems to be no way to trigger a view update based on a @Query when inserts happen in a ModelActor’s context. However, if a delete happens on the ModelActor context, this DOES trigger a view redraw. I believe this is a bug because I expect the behavior to be the same for inserts and deletes. I've submitted this as Feedback FB12689036.
Below is a minimal project which is the default SwiftData template, where the Add and Delete buttons point to a ModelActor instead of the main view ModelContext. You will see that using the addItem function in the ModelActor does not trigger a UI update in ContentView. However if you relaunch the app the added Item will be present. In contrast, using the delete function on the ModelActor context does trigger an immediate view update in ContentView.
If this is intended behavior, we need a way to merge changes from background contexts, similar to what is described in the Core Data document “Loading and Displaying a Large Data Feed”:
https://developer.apple.com/documentation/swiftui/loading_and_displaying_a_large_data_feed
In Core Data we have automaticallyMergesChangesFromParent and mergeChanges(fromContextDidSave:) to do this manually. There seems to be no equivalent for Swift Data.
If anyone has solved this problem of merging changes from other contexts, or can confirm that this is a bug, please let me know.
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
@State private var simpleModelActor: SimpleModelActor!
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
.onAppear {
simpleModelActor = SimpleModelActor(modelContainer: modelContext.container)
}
}
private func addItem() {
Task {
await simpleModelActor.addItem()
}
}
private func deleteItems(offsets: IndexSet) {
Task {
for index in offsets {
await simpleModelActor.delete(itemWithID: items[index].objectID)
}
}
}
}
import Foundation
import SwiftData
final actor SimpleModelActor: ModelActor {
let executor: any ModelExecutor
init(modelContainer: ModelContainer) {
let modelContext = ModelContext(modelContainer)
executor = DefaultModelExecutor(context: modelContext)
}
func addItem() {
let newItem = Item(timestamp: Date())
context.insert(newItem)
try! context.save() // this does not impact a re-display by the @Query in ContentView. I would have expected it to cause a view redraw.
}
func delete(itemWithID itemID: Item.ID) {
let item = context.object(with: itemID)
context.delete(object: item) // this DOES cause a view redraw in ContentView. It triggers an update by @Query.
// try! context.save() // this makes do difference to view redraw behavior.
}
}
Selecting any option will automatically load the page
Post
Replies
Boosts
Views
Activity
I've already submitted this as a bug report to Apple, but I am posting here so others can save themselves some troubleshooting. This is submitted as FB14337982 with an attached complete Xcode project to replicate.
In iOS 17 we use a ModelActor to download data which is saved as an Event, and then save it to SwiftData with a relationship to a Location. In iOS 18 22A5307d we are seeing that this code no longer persists the relationship to the Location, but still saves the Event. If we put a breakpoint in that ModelActor we see that the object graph is correct within the ModelActor stack trace at the time we call modelContext.save(). However, after saving, the relationship is missing from the default.store SQLite file, and of course from the app UI.
Here is a toy example showing how inserting an Employee into a Company using a ModelActor gives unexpected results in iOS 18 22A5307d but works as expected in iOS 17.
It appears that no relationships data survives being saved in a ModelActor.ModelContext.
Also note there seems to be a return of the old bug that saving this data in the ModelActor does not update the @Query in the UI in iOS 18 but does so in iOS 17.
Models
@Model
final class Employee {
var uuid: UUID = UUID()
@Relationship(deleteRule: .nullify) public var company: Company?
/// For a concise display
@Transient var name: String {
self.uuid.uuidString.components(separatedBy: "-").first ?? "NIL"
}
init(company: Company?) {
self.company = company
}
}
@Model
final class Company {
var uuid: UUID = UUID()
@Relationship(deleteRule: .cascade, inverse: \Employee.company)
public var employees: [Employee]? = []
/// For a concise display
@Transient var name: String {
self.uuid.uuidString.components(separatedBy: "-").first ?? "NIL"
}
init() { }
}
ModelActor
import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SimpleModelActor")
@ModelActor
final actor SimpleModelActor {
func addEmployeeTo(CompanyWithID companyID: PersistentIdentifier?) {
guard let companyID,
let company: Company = self[companyID, as: Company.self] else {
logger.error("Could not get a company")
return
}
let newEmployee = Employee(company: company)
modelContext.insert(newEmployee)
logger.notice("Created employee \(newEmployee.name) in Company \(newEmployee.company?.name ?? "NIL")")
try! modelContext.save()
}
}
ContentView
import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "View")
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var companies: [Company]
@Query private var employees: [Employee]
@State private var simpleModelActor: SimpleModelActor!
var body: some View {
ScrollView {
LazyVStack {
DisclosureGroup("Instructions") {
Text("""
Instructions:
1. In iOS 17, tap Add in View. Observe that an employee is added with Company matching the shown company name.
2. In iOS 18 beta (22A5307d), tap Add in ModelActor. Note that the View does not update (bug 1). Note in the XCode console that an Employee was created with a relationship to a Company.
3. Open the default.store SQLite file and observe that the created Employee does not have a Company relationship (bug 2). The relationship was not saved.
4. Tap Add in View. The same code is now executed in a Button closure. Note in the XCode console again that an Employee was created with a relationship to a Company. The View now updates showing both the previously created Employee with NIL company, and the View-created employee with the expected company.
""")
.font(.footnote)
}
.padding()
Section("**Companies**") {
ForEach(companies) { company in
Text(company.name)
}
}
.padding(.bottom)
Section("**Employees**") {
ForEach(employees) { employee in
Text("Employee \(employee.name) in company \(employee.company?.name ?? "NIL")")
}
}
Button("Add in View") {
let newEmployee = Employee(company: companies.first)
modelContext.insert(newEmployee)
logger.notice("Created employee \(newEmployee.name) in Company \(newEmployee.company?.name ?? "NIL")")
try! modelContext.save()
}
.buttonStyle(.bordered)
Button("Add in ModelActor") {
Task {
await simpleModelActor.addEmployeeTo(CompanyWithID: companies.first?.persistentModelID)
}
}
.buttonStyle(.bordered)
}
}
.onAppear {
simpleModelActor = SimpleModelActor(modelContainer: modelContext.container)
if companies.isEmpty {
let newCompany = Company()
modelContext.insert(newCompany)
try! modelContext.save()
}
}
}
}