PlaybackManager: Incremental @MainActor adoption from the UI side – Part 2 - #4948
PlaybackManager: Incremental @MainActor adoption from the UI side – Part 2#4948kean wants to merge 1 commit into
Conversation
Promotes the three per-method placeholders from Part 1 to type-level isolation and closes the ring of callers that pulled in.
|
Claude finished @kean's task in 4m 36s —— View job Review: incremental
|
| Task { | ||
| let upNextEpisodes = dataManager.allUpNextEpisodes() | ||
| let newEpisodes = dataManager.findNewReleaseEpisodes(limit: 12).map { episode in | ||
| makeRowViewModel(for: episode) | ||
| } | ||
| let newVideoReleases = dataManager.findNewVideoReleaseEpisodes(limit: 12).map { episode in | ||
| makeRowViewModel(for: episode) | ||
| } | ||
| await MainActor.run { [weak self, newEpisodes] in | ||
| let currentlyPlaying = upNextEpisodes.first.map { withPodcast($0) } | ||
| let upNextEntries = Array(upNextEpisodes.dropFirst().prefix(12)).map { withPodcast($0) } | ||
| let newEpisodes = dataManager.findNewReleaseEpisodes(limit: 12).map { withPodcast($0) } | ||
| let newVideoReleases = dataManager.findNewVideoReleaseEpisodes(limit: 12).map { withPodcast($0) } |
There was a problem hiding this comment.
The "keep the DB work off-main" property here is entirely implicit: it holds only because HomeViewModel is nonisolated, so Task {} picks up no isolation and lands on the generic executor. The moment this class gets the @MainActor the PR description says is coming, allUpNextEpisodes(), findNewReleaseEpisodes, findNewVideoReleaseEpisodes and up to 25 parentPodcast(dataManager:) queries silently move onto the main thread — no compiler diagnostic, no test failure, just a hitchy Home screen.
The three sibling view models this PR touches (Starred, ListeningHistory, UpNext) already use the pattern that survives that promotion: Task.detached + a nonisolated load function. Applying it here too would make the future annotation a compile error instead of a silent regression:
func load() {
Task.detached { [dataManager] in
let entries = Self.loadEntries(using: dataManager)
await MainActor.run { [weak self] in ... }
}
}
nonisolated private static func loadEntries(using dataManager: DataManager) -> (...)Same argument applies to PodcastDetailViewModel.load() and its fetchEpisodes call.
| if let currentlyPlaying { | ||
| self.currentPlaying = self.makeRowViewModel(for: currentlyPlaying) | ||
| } |
There was a problem hiding this comment.
Pre-existing, but you're rewriting this exact line so it's cheap to fix: currentPlaying is only ever assigned, never cleared. If the user empties Up Next, load() re-runs with upNextEpisodes.first == nil and the stale row sticks around — shouldShowNowPlayingRow keeps returning true until playback starts.
| if let currentlyPlaying { | |
| self.currentPlaying = self.makeRowViewModel(for: currentlyPlaying) | |
| } | |
| self.currentPlaying = currentlyPlaying.map { self.makeRowViewModel(for: $0) } |
| @MainActor @Observable | ||
| class EpisodeRowViewModel: Identifiable { |
There was a problem hiding this comment.
Isolating the class makes id (line 17, var id: String { episode.uuid }) main-actor-isolated, which means it no longer cleanly satisfies Identifiable's nonisolated id requirement. Under Swift 5 mode that's a warning at worst, but it becomes an error under Swift 6 — and it is the direct cause of the extra Task { @MainActor in } you had to add in PodcastDetailViewModel.setupObservers.
Storing it instead makes the conformance honest and lets that observer stay synchronous:
nonisolated let id: String
// in init: self.id = episode.uuidThis is safe: the two archive/play-status observers below (lines 159 and 173) only replace episode when uuid == episode.uuid, so the uuid is effectively immutable for the lifetime of the view model.
Two smaller things while you're in here:
static func ==(line 10) is now main-actor-isolated too, and the class doesn't conform toEquatable— nothing in the tvOS target calls it. Same forMockEpisodeRowViewModel.==at line 239. Both look like dead code that could go.@MainActoronEpisodeUpNextActionsis the right call — it's purePlaybackManager+ToastManagermutation.
| Task { @MainActor in | ||
| guard self.episodes.contains(where: { $0.id == uuid }) else { | ||
| return | ||
| } | ||
| self.load() | ||
| } |
There was a problem hiding this comment.
Worth being precise about what this costs: it's redundant thread-wise (the publisher is already .receive(on: .main)), but it is not redundant timing-wise. load() now fires one runloop turn later than the notification, and because the contains check moved inside the task, two archive notifications delivered in the same turn now enqueue two tasks that both pass the check and both call load() — two concurrent load Tasks racing to write self.episodes.
The nonisolated let id change suggested on EpisodeRowViewModel removes the need for this hop entirely and restores the synchronous behaviour.
Separately (pre-existing, but this PR is about exactly this): load() at line 83 reads self.podcast, self.showArchived and self.isDiscover from the generic executor while the MainActor.run block on line 93 writes self.podcast. With setShowArchived / setSortOrder / this observer all able to call load() in quick succession, that's a genuine unsynchronised read/write on a nonisolated class. Isolating PodcastDetailViewModel (once TVDataManager.loadPodcast/fetchEpisodes are async, as you note) is the fix.
| } | ||
|
|
||
| private func loadEpisodeViewModels(using dataManager: DataManager) -> [EpisodeRowViewModel] { | ||
| private func loadEpisodes(using dataManager: DataManager) -> [(episode: BaseEpisode, podcast: Podcast?)] { |
There was a problem hiding this comment.
Nit / consistency: the two sibling versions of this method that the same commit rewrites are declared nonisolated private func (StarredEpisodesViewModel.swift:58, ListeningHistoryViewModel.swift:40); this one isn't. It makes no difference today since the class is nonisolated, but the explicit keyword is what keeps the query off-main once UpNextViewModel itself becomes @MainActor.
| private func loadEpisodes(using dataManager: DataManager) -> [(episode: BaseEpisode, podcast: Podcast?)] { | |
| nonisolated private func loadEpisodes(using dataManager: DataManager) -> [(episode: BaseEpisode, podcast: Podcast?)] { |
| import Combine | ||
|
|
||
| @MainActor | ||
| class SearchResultCellModel: ObservableObject, MainEpisodeActionViewDelegate { |
There was a problem hiding this comment.
This is correct, and the two Task { @MainActor in } become plain Task {} cleanly because the closures inherit the class isolation.
One follow-up worth considering: MainEpisodeActionViewDelegate (podcasts/Common Components/MainEpisodeActionView.swift:5) is a plain non-isolated protocol, so playTapped()/pauseTapped() are now main-actor-isolated witnesses satisfying nonisolated requirements. It works — MainEpisodeActionView is a UIView and only ever invokes the delegate from button handlers — but it's accidentally correct rather than checked. Marking that protocol @MainActor is a one-line change that makes it enforced, and it's the sort of thing that would otherwise bite the next class to adopt isolation here.
|
Closing in favor of #4953 |
Stacked on #4947.
Part 1 left three per-method
@MainActorannotations as placeholders where the whole type should be isolated. This promotes them to type-level and closes the ring of callers that pulled in.SearchResultCellModel,EpisodeRowViewModel(tvOS) andEpisodeUpNextActionsare now@MainActor. The redundant@MainActoron the innerTasks and onplay()goes away with it.EpisodeRowViewModeloff-main insideTask.detached. Rather than weaken the annotation, each one now keeps the database work off-main and constructs the view models on main.HomeViewModelgets slightly faster out of this — it was callingparentPodcast(dataManager:)for the Up Next rows insideMainActor.run, so that query moves off-main.One latent off-main call surfaced and is fixed:
DiscoveryEpisodeMenuButtons.load(_:)ran its completion on the generic executor, so Play Next / Play Last in the Discover and Search context menus mutated the Up Next queue, wrote a@Binding, and showed a toast off the main thread.The
Task { @MainActor in }added toPodcastDetailViewModel's archive observer is redundant at runtime — the publisher is already.receive(on: DispatchQueue.main). It is there only because the class itself isn't isolated yet.PodcastDetailViewModel,HomeViewModelandUpNextViewModelshould follow, which would delete theMainActor.rundance in all three, but that first needsTVDataManager.loadPodcastandfetchEpisodesto move off-main or the podcast-detail query lands on the main thread.To test
tvOS — the episode lists still populate and act correctly:
iOS:
Checklist
CHANGELOG.mdif necessary.