So, for this scenario, the code follows a model, view, view-model architecture, where @Observable class DiscoverAPIViewModel
is a View-Model that searches for some data by making an API call.
@Observable class DiscoverAPIViewModel {
private (set) var contentRecsVideosByKeyword: [VideoData] = [] // note: A
func contentRecsVideosByKeywordApi(accessToken: String, username: String, keyword: String, count: Int = 5)
{
// ....
// fetch data via an api call
// on success
DispatchQueue.main.async { // note: B
strongSelf.contentRecsVideosByKeyword = [] // try force-refresh (by design ids are unique, so re-assigning may not always reset the list)
strongSelf.contentRecsVideosByKeyword = result.data!
}
Please note the following above:
A. contentRecsVideosByKeyword
is stateful because the class uses @Observable
macro
B. When data is retrieved successfully, DispatchQueue.main.async
is used to set contentRecsVideosByKeyword
as it is used in the view part (seen later)
Model is just a simple struct with id
, name
, etc. primitive values. The View has warning
Main actor-isolated property 'discoverApiViewModel' can not be referenced from a Sendable closure; this is an error in Swift 6
because of the following code
struct ApiView: View {
@State private var discoverApiViewModel = DiscoverAPIViewModel()
private let keyword: String
private let username: String
private let accessToken: String
private let videoCountPerPage = 7
// ...
// other properties like durationMilliseconds, and methods
init(username: String, accessToken: String, keyword: String) {
// ... init values for one time use
}
var body: some View {
List(discoverApiViewModel.contentRecsVideosByKeyword) { item in
Text(item.name) // displaying data in the view
}
.task {
do {
try? await Task.sleep(for: durationMilliseconds)
DispatchQueue.global(qos: .background).async {
discoverApiViewModel // Main actor-isolated property 'discoverApiViewModel'...
.contentRecsVideosByKeywordApi(accessToken: accessToken, username: username, keyword: keyword, count: videoCountPerPage)
}
}
}
}
}
Please, can someone suggest how to properly call an API in the background and then update the view (that uses some stateful data), using SwiftUI
for View and Observable
for View-Model? Currently, most of the code is based on MVVM architecture, and modifying/refactoring it causes related main actor exception at runtime (in some cases, for e.g. when app becomes active from a background state, but it seems highly related).
loadingView
.onAppear {
apiTask = createApiTask(viewModel: discoverApiViewModel)
}
where createApiTask
is
func createApiTask(viewModel: DiscoverAPIViewModel) -> Task<Void,Error> {
Task(priority: .background) {
try await Task.sleep(for: durationMilliseconds)
DispatchQueue.global(qos: .background).async {
viewModel.contentRecsVideosByKeywordApi(accessToken: accessToken, username: username, keyword: keyword, count: videoCountPerPage)
}
}
}
The new Concurrency (async/await) replaces the "old" DispatchQueue
approaches and they shouldn't be mixed. The "Meet async/await" video from WWDC is quite informative.
Ideally, you will have a "Service" or "Manager" that handles the background work in a custom globalActor
or an actor
.
The View
and ViewModel
are meant to stay in the MainActor
because they deal with the UI.
The flow should be something like
Your code mixing the request and the UI.
Here is one approach...
import SwiftUI
// Isolated to Main Actor by default in iOS 18+
struct IsolatedSampleView: View {
@State private var model: IsolatedSampleViewModel = .init()
@State private var isProcessing: Bool = false
var body: some View {
VStack {
if isProcessing {
ProgressView()
} else {
Text(model.contentRecsVideosByKeyword.description)
}
}
.task { //trigger
isProcessing = true
await model.contentRecsVideosByKeywordApi(accessToken: "", username: "", keyword: "", count: 7)
isProcessing = false
}
}
}
#Preview {
IsolatedSampleView()
}
@MainActor //Update UI on MainActor
@Observable
class IsolatedSampleViewModel {
var contentRecsVideosByKeyword: [String] = []
func contentRecsVideosByKeywordApi(accessToken: String, username: String, keyword: String, count: Int) async {
//MainActor update //Detach from MainActor
contentRecsVideosByKeyword = await Task.detached(operation: {
// MainActor.assertIsolated("Not isolated!!") // Will cause a crash because we are no longer isolated to Main
try? await Task.sleep(for: .seconds(3))
//Return to the MainActor
return [accessToken, username, keyword, count.description]
}).value
}
}
https://developer.apple.com/documentation/swiftui/state
Note as I said above Task.detached
can easily be replaced by an actor
or globalActor
isolated context or another custom Sendable
type.
There are many ways to leave the MainActor
but the approach should be the same as I listed above, do the work somewhere else and return
the data to the main.
Some people would suggest something like this
@MainActor
@Observable
class IsolatedSampleViewModel {
var contentRecsVideosByKeyword: [String] = []
func contentRecsVideosByKeywordApi(accessToken: String, username: String, keyword: String, count: Int) async {
//Detach from MainActor
await Task.detached(operation: { [weak self] in
// MainActor.assertIsolated("Not isolated!!") // Will cause a crash because we are no londer isolated to Main
try? await Task.sleep(for: .seconds(3))
guard let self else {return}
//Update on the MainActor
await MainActor.run {
self.contentRecsVideosByKeyword = [accessToken, username, keyword, count.description]
}
}).value
}
}
Which mimics the DispatchQueue
approach but I find it unsightly and anti-pattern.