You get a similar sort of thing here, where the async code is all pretty much visible in one spot, but now you are using the published variable as the signal for the view to do something such as update internal state. I’d consider the fact that you have a variable being updated as a side-effect a part of the problem. So either leverage that pattern appropriately so that any updates to the variable trigger the behaviors, or realize the variable doesn’t need to exist and have fetchFooVar return the value, which forces you to address the async code that way.
Yes, reorganizing the code to be reactive will solve many of the problems caused by having too many in-flight tasks. I still think there are many other, subtler problems that will arose if this kind of pattern is abused. Acting on value changes instead of getting/setting variables manually is one part of it —and would prevent many, many errors—, but ultimately I'd still want the dispatch of the functions to be specified at the call site rather than inside the function itself.
Actually, I think reactivity in general and SwiftUI in particular are partially responsible of this pattern of putting single tasks inside non-async functions: as long as you are updating a
@Published
variable or something like that inside the function, you'll see no adverse effects of using that pattern (not at first, at least). Things start to get *very* messy once those functions update more than one or two variables: while a properly written async/await code might have awaited for the full set of variables to update (and then update the view with a full set of correct values), trying to do that with functions that internally dispatch
Task
s may be a disaster.
Writing this reminded me of something. I tried to write a minimal-example of it. Below are two versions of a code
with the same subtle bug:
With async/await all the way:
Swift:
class FooViewModel {
@Published var position: CGPoint
@Published var size: CGFloat
func getPosition() async -> CGPoint {
return await networkFunctionToGetPosition()
}
func getSize() async -> CGFloat {
return await networkFunctionToGetSize()
}
@MainActor func updateRectangle() async {
self.position = await getPosition()
self.size = await getSize()
}
}
struct FooView: View {
@StateObject var viewModel = FooViewModel()
var body: some View {
ZStack {
Rectangle()
.offset(
x: viewModel.position.x,
y: viewModel.position.y
)
.frame(
width: viewModel.size,
height: viewModel.size
)
}
}
}
With async/await bridged to
Task
s too soon:
Swift:
class FooViewModel {
@Published var position: CGPoint
@Published var size: CGFloat
func getPosition() {
Task { @MainActor in
self.position = await networkFunctionToGetPosition()
}
}
func getSize() {
Task { @MainActor in
self.size = await networkFunctionToGetSize()
}
}
func updateRectangle() {
getPosition()
getSize()
}
}
struct FooView: View {
@StateObject var viewModel = FooViewModel()
var body: some View {
ZStack {
Rectangle()
.offset(
x: viewModel.position.x,
y: viewModel.position.y
)
.frame(
width: viewModel.size,
height: viewModel.size
)
}
}
}
Both have the same problem. They will work great
most of the time some part of the codebase calls the
updatePosition()
method of the view model. But every once in a while, the UI will jump around because the rectangle size was updated with a new size a frame before the position updated its value. The example is trivial to solve if you use async/await all the way up:
Swift:
class FooViewModel {
@Published var position: CGPoint
@Published var size: CGFloat
func getPosition() async -> CGPoint {
return await networkFunctionToGetPosition()
}
func getSize() async -> CGFloat {
return await networkFunctionToGetSize()
}
@MainActor func updateRectangle() async {
let newPosition = await getPosition()
let newSize = await getSize()
self.position = newPosition
self.size = newSize
}
}
struct FooView: View {
@StateObject var viewModel = FooViewModel()
var body: some View {
ZStack {
Rectangle()
.offset(
x: viewModel.position.x,
y: viewModel.position.y
)
.frame(
width: viewModel.size,
height: viewModel.size
)
}
}
}
It's also easier to catch: you don't have to look up
getSize
nor
getPosition
to see whether they update
size
and
position
synchronously or not. The compiler will error out if you forget the
await
, and once you write the
await
you'll immediately see that there's a suspension point between the update of the
position
and the update of the
size
. It would be very hard IMHO to have it work in sync just by reacting to the changes of
position
and
size
.
This example is a minimal version of something I actually saw when someone else tried to fix a weird iOS-15-only main thread crash by peppering around Tasks on some imperative code that computed a complex layout. Sometimes the frame updates landed between updates and the layout had both new values for some variables and old values for others, causing unpredictable jittering and jumping around. Avoiding this intermediate states required getting rid of all the Tasks and moving the synchronous to asynchronous boundary to the outermost call site (the one function that computed the whole layout, which I changed from synchronous to async).
In any case, if someone new starts working with the codebase, knowing nothing about it, they shouldn't have to go through every function's implementation to see whether those functions have immediate effects or not. Writing a single
Task
inside a function instead of going async all the way also disallows the call site to explicitly detach the task with
Task.detached
, or to suspend the execution until the task is finished... you'd have to refactor the function if you ever need to do that.