4

I'm building a SwiftUI app using SwiftData @Query and struggling quite a bit with redraws and slow inserts.

  1. How can I ensure that redraws are automatically triggered on my Views (both ShowView and ContentView) after data is updated?
  2. How can I speed up my model update 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!

5
  • Try using the modifier .task instead of Task.detached Commented Mar 1, 2024 at 18:35
  • I can't use .task as it's not something that runs automatically. It's triggers by an action/button on a completely separate view :( Commented Mar 1, 2024 at 18:43
  • Use .task(id:) you can trigger by changing the id. It is a common pattern Commented Mar 1, 2024 at 18:49
  • You can't use Task because View structs are value types that have no lifetime so must use the .task(id:) modifier in SwiftUI. Commented Mar 2, 2024 at 16:49
  • Did you try to use List(shows) instead of a ForEach(shows) { ... ? I recently learned that ForEach does not properly refresh its body when the result of the @Query update, i.e. the Row body is not reevaluated. Commented Dec 3, 2024 at 16:38

2 Answers 2

3

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
   // ...
})
Sign up to request clarification or add additional context in comments.

2 Comments

This is all tremendously helpful. Thank you! For .onReceive, would it be kosher to do something like: 1. add @State var refreshCount = 0; 2. put .id("refresh-\(refreshCount)") on the bottom of the "body VStack"; 3. increment inside onReceive refreshCount +=1? Or is there a better way to accomplish this?
It might be one way to get a refresh, I did some research earlier but didn’t really find a “best” way to do it.
1

1. How can I ensure that redraws are automatically triggered on my Views (both ShowView and ContentView) after data is updated?

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.

2. How can I speed up my model update inserts?

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)
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.