Reputation: 10558
I'm building a SwiftUI app using SwiftData @Query and struggling quite a bit with redraws and slow inserts.
Here's a simplified and representative version of my app:
// MARK: - Complete Copy+Paste Example:
import SwiftUI
import SwiftData
// MARK: - Entry
@main
struct SampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: Show.self)
}
}
// MARK: - View
struct ContentView: View {
@Environment(\.modelContext) private var modelContext: ModelContext
@State private var path = NavigationPath()
@Query private var shows: [Show]
var body: some View {
NavigationStack(path: $path) {
List {
ForEach(shows) { show in
Row(show: show)
}
}
.navigationDestination(for: Show.self) { show in
ShowView(show: show)
}
.toolbar {
Button {
// mimic 1st network call for basic show info
// works fine
let newShow = Show(name: .random(length: 5))
modelContext.insert(newShow)
} label: {
Image(systemName: "plus")
}
}
}
}
struct Row: View {
var show: Show
var body: some View {
NavigationLink(value: show) {
VStack(alignment: .leading) {
Text(show.name)
if let date = show.nextDate {
Text(date.formatted(date: .abbreviated, time: .shortened))
}
}
}
}
}
}
struct ShowView: View {
@Environment(\.modelContext) private var modelContext: ModelContext
@Bindable var show: Show
var body: some View {
VStack(alignment: .leading) {
Text(show.name)
if !show.episodes.isEmpty {
Text("Episodes: \(show.episodes.count)")
}
if let date = show.nextDate {
Text(date.formatted(date: .abbreviated, time: .shortened))
}
Button {
// 1. ISSUE: doesn't automatically force a SwiftUI redraw on this ShowView, or the main ContentView?
Task {
let actor = ShowActor(modelContainer: modelContext.container)
try await actor.update(show.persistentModelID)
}
} label: {
Text("Update")
}
}
}
}
// MARK: - ModelActor
@ModelActor
actor ShowActor {}
extension ShowActor {
func update(_ identifier: PersistentIdentifier) async throws {
guard let show = modelContext.model(for: identifier) as? Show else { return }
// mimics 2nd network call to add nextDate + episode info adds
show.nextDate = .randomDateInNext7Days()
// ISSUE: inserts are very slow, how to speed up?
for _ in 0...Int.random(in: 10...100) {
let episode = Episode(name: .random(length: 10))
modelContext.insert(episode) // crashes if episode isn't first insert before adding show?
episode.show = show
}
try modelContext.save()
}
}
// MARK: - Models
@Model
class Show {
var name: String
var nextDate: Date?
@Relationship(deleteRule: .cascade, inverse: \Episode.show) var episodes: [Episode]
init(name: String, nextDate: Date? = nil, episodes: [Episode] = []) {
self.name = name
self.nextDate = nextDate
self.episodes = episodes
}
}
@Model
class Episode {
var show: Show?
var name: String
init(show: Show? = nil, name: String) {
self.show = show
self.name = name
}
}
// MARK: - Helpers
extension String {
static func random(length: Int) -> String {
let letters = "abcdefghijklmnopqrstuvwxyz"
return String((0..<length).map{ _ in letters.randomElement()! })
}
}
extension Date {
static func randomDateInNext7Days() -> Date {
Calendar.current.date(byAdding: .day, value: Int.random(in: 1...7), to: .now)!
}
}
// MARK: - Preview
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Show.self, configurations: config)
return ContentView()
.modelContainer(container)
}
Thanks in advance for your help!
Upvotes: 4
Views: 1732
Reputation: 1557
This was a little mind-boggling at first, because the inserting/updating approach seemed overly complicated. That's because it is.
To update a show (and force a redraw), all you need to do is simply update the show's episodes:
for _ in 0...Int.random(in: 10...100) {
show.episodes.append(Episode(show: show, name: .random(length: 10)))
}
This is simply because of SwiftData magic. For more insight on this, see Apple's tutorial on working with SwiftData.
So unless I am missing something, as to why you'd want/need to use a task/actor/insert/save/notify/subscribe/receive, leave aside all the lines of code required, that one line above is all you need.
As an alternative to @JoakimDanielson's answer above regarding performance, maybe you can also try grouping the insert in a transaction block, as per the answer here.
Wrap Inserts in a Single Transaction: Use modelContext.transaction {} to group your insertions into a single transaction. This can significantly improve performance compared to multiple transactions if you're dealing with large quantity of objects.
modelContext.transaction {
for obj in objects {
modelContext.insert(obj)
}
do {
try modelContext.save()
} catch {
// Handle error
}
}
See below the revised full code, that includes an additional button to add episodes using Joakim's approach, which is probably simpler than the transaction approach (although the two may achieve the same thing).
// MARK: - Complete Copy+Paste Example:
import SwiftUI
import SwiftData
// MARK: - Entry
@main
struct SampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: Show.self)
}
}
// MARK: - View
struct ContentView: View {
@Environment(\.modelContext) private var modelContext: ModelContext
@State private var path = NavigationPath()
@Query private var shows: [Show]
var body: some View {
NavigationStack(path: $path) {
List {
ForEach(shows) { show in
Row(show: show)
}
}
.navigationDestination(for: Show.self) { show in
ShowView(show: show)
}
.toolbar {
Button {
// mimic 1st network call for basic show info
// works fine
let newShow = Show(name: .random(length: 5))
modelContext.insert(newShow)
} label: {
Image(systemName: "plus")
}
}
}
}
struct Row: View {
var show: Show
var body: some View {
NavigationLink(value: show) {
VStack(alignment: .leading) {
Text(show.name)
if let date = show.nextDate {
Text(date.formatted(date: .abbreviated, time: .shortened))
}
}
}
}
}
}
struct ShowView: View {
@Environment(\.modelContext) private var modelContext: ModelContext
@Bindable var show: Show
var body: some View {
VStack(alignment: .leading) {
Text(show.name)
if !show.episodes.isEmpty {
Text("Episodes: \(show.episodes.count)")
}
if let date = show.nextDate {
Text(date.formatted(date: .abbreviated, time: .shortened))
}
Button {
updateLoopedAppend(show)
} label: {
Text("Update - using looped append (slower)")
}
Button {
updateSingleAppend(show)
} label: {
Text("Update - using single append (faster")
}
}
}
//SLOWER
private func updateLoopedAppend(_ show: Show) {
show.nextDate = .randomDateInNext7Days()
for _ in 0...Int.random(in: 10...100) {
show.episodes.append(Episode(show: show, name: .random(length: 10)))
}
}
//FASTER
private func updateSingleAppend(_ show: Show) {
show.nextDate = .randomDateInNext7Days()
var episodes = [Episode]()
for _ in 0...Int.random(in: 10...100) {
let episode = Episode(name: .random(length: 10))
episodes.append(episode)
}
show.episodes.append(contentsOf: episodes)
}
}
// MARK: - ModelActor
@ModelActor
actor ShowActor {}
extension ShowActor {
func update(_ identifier: PersistentIdentifier) async throws {
guard let show = modelContext.model(for: identifier) as? Show else { return }
// mimics 2nd network call to add nextDate + episode info adds
show.nextDate = .randomDateInNext7Days()
// ISSUE: inserts are very slow, how to speed up?
for _ in 0...Int.random(in: 10...100) {
let episode = Episode(name: .random(length: 10))
modelContext.insert(episode) // crashes if episode isn't first insert before adding show?
episode.show = show
}
try modelContext.save()
}
}
// MARK: - Models
@Model
class Show {
var name: String
var nextDate: Date?
@Relationship(deleteRule: .cascade, inverse: \Episode.show) var episodes: [Episode]
init(name: String, nextDate: Date? = nil, episodes: [Episode] = []) {
self.name = name
self.nextDate = nextDate
self.episodes = episodes
}
}
@Model
class Episode {
var show: Show?
var name: String
init(show: Show? = nil, name: String) {
self.show = show
self.name = name
}
}
// MARK: - Helpers
extension String {
static func random(length: Int) -> String {
let letters = "abcdefghijklmnopqrstuvwxyz"
return String((0..<length).map{ _ in letters.randomElement()! })
}
}
extension Date {
static func randomDateInNext7Days() -> Date {
Calendar.current.date(byAdding: .day, value: Int.random(in: 1...7), to: .now)!
}
}
// MARK: - Preview
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Show.self, configurations: config)
return ContentView()
.modelContainer(container)
}
Upvotes: 1
Reputation: 52043
Performance:
Don't update the relationship inside the loop, do it for all episodes afterwards.
var episodes = [Episode]()
for _ in 0...99 {
let episode = Episode(name: .random(length: 10))
episodes.append(episode)
}
show.episodes.append(contentsOf: episodes)
try modelContext.save()
Update UI:
You can post a notification from the actor when it's done
await MainActor.run {
NotificationQueue.default.enqueue(Notification(name: Notification.Name("ActorIsDone")),
postingStyle: .now)
}
and then use .onReceive
in your views to somehow trigger a refresh
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ActorIsDone")), perform: { _ in
// ...
})
Upvotes: 3