emehex
emehex

Reputation: 10558

SwiftData @Query not triggering automatic SwiftUI redraw after update/insert

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!

Upvotes: 4

Views: 1732

Answers (2)

Andrei G.
Andrei G.

Reputation: 1557

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

Upvotes: 1

Joakim Danielson
Joakim Danielson

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

Related Questions