Static enforcement is (broadly) the main advantage of language-integrated concurrency. Of course, if you manually check that everything which needs it does run on the main actor, you won't encounter data races - but in that case Swift concurrency is doing nothing for you; you could do that in Objective-C.
async/await allows you to more easily work with data across various concurrency/isolation domains - some of it may be @MainActor
, while other parts may be owned by a background thread or a custom actor
instance - but that doesn't mean you should always split everything up in to the smallest possible units of concurrency and throw await
and Task { ... }
around too liberally. Again - things which the UI displays should live on the main actor, and the majority updates should happen synchronously.
The main issue that I have found with regards to architecture is that, when the system behaves inconsistently, it becomes impossible to talk about architecture because less-experienced developers don't feel they understand what is going on. You want to start by introducing principles such as "any data which the UI needs must live on the main thread", but they can trivially defeat that, and it requires a certain amount of experience and confidence in your understanding before you are even willing to entertain the idea that perhaps the compiler and/or frameworks are the ones at fault.
For example:
struct MyView: View {
@MainActor
class ViewModel: ObservableObject {
var value: Int
init() { self.value = 0 }
func increment() { self.value += 1 }
}
@StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Text(String(viewModel.value))
Button(
action: { viewModel.increment() }, // <---
label: { Text("Increment") }
)
}
}
}
The button's action closure is not annotated @MainActor
, and yet we're able to access main-actor-isolated data and functions without so much as a warning. This applies to essentially every closure in SwiftUI.
To illustrate that something is going wrong here, and that truly our understanding is correct, let's replace the action closure with an unapplied method reference. This should be exactly equivalent to the above, except that now we do get a warning:
var body: some View {
VStack {
Text(String(viewModel.value))
Button(
action: viewModel.increment, // Warning: Converting function value of type '@MainActor () -> ()' to '() -> Void' loses global actor 'MainActor'
label: { Text("Increment") }
)
}
}
It is difficult to express how much of a roadblock this is for many developers - it seems like the first version "works", but really the compiler isn't checking things properly, and when you try to show them that, they tend to get very confused (because it's a bug; of course it doesn't make sense). If you finally get them to accept that, the only conclusion is that SwiftUI is basically unusable with Swift concurrency, which is also difficult for many to accept. Surely that can't be the case -- they must just not understand it, right?
They were told back at WWDC 2021 that their ObservableObjects
should be annotated with @MainActor
, and yet if you do that, basically SwiftUI doesn't work (unless you rely on this compiler bug, in which case anything that works is a coincidence). In the years since, it has not been fixed. Fingers crossed that 2023 will be the year...
That's why I say it's "unusable". IMO, architecture patterns are basically moot until the system achieves a baseline level of consistency which it has not yet achieved.
I don't mean to vent here (I hope it doesn't come across like that). Swift concurrency is very nice and exciting, and I have hope that it will one day achieve its aims -- but we're not there yet, and so I think it is not really possible to experiment with interesting architectures that leverage it.