WWDC 21
Discover concurrency in SwiftUI
The SwiftUI run loop and Observable Objects
The SwiftUI engine runs a loop that receives some events, lets you update your models, checks them for changes, recreates necessary view objects and re-renders the parts of the UI that should be updated
The run loop runs in "ticks" happening in regular intervals, and all of it executes on the main thread / main actor
The events sent from ObservableObject
's @Published
also need to be sent on the main actor for this to work correctly
Let's see exactly why:
With such update method:
class Photos: ObservableObject { @Published private(set) var items: [SpacePhoto] = [] func updateItems() { let fetched = fetchPhotos() items = fetched } func fetchPhotos() -> [SpacePhoto] { ... } }
the update goes like this:
- when we assign the
items
property, which is@Published
, thePublished
wrapper automatically sends anobjectWillChange
event that is observed by SwiftUI, before the changes happen - at that point SwiftUI records a snapshot of the data before it changes
- then, the new value is written to the property
- at the next "tick" of the run loop – which might happen immediately afterwards, or a bit later – SwiftUI compares the current value of the property to the saved snapshot, and this tells it what exactly was changed
- since some of the data was changed, the appropriate parts of the UI are re-rendered
If all of the above happens on the main thread, these steps are guaranteed to happen in order
However, if the code takes some time to run, this may cause the run loop to miss the next tick, because the main thread will be busy calculating the data – which will result in dropped frames and a possibly degraded user experience
So you may want to run the calculations on a background queue:
func updateItems() { DispatchQueue.global().async { let fetched = fetchPhotos() self.items = fetched } }
This way, the main thread isn't blocked, the run loop is free to run the next ticks there until the data is ready
However, if the assignment (and the resulting notification) happens outside of the main thread, this could mess up the order of operations and could result in the view not being updated
For example:
- the long-running operation finishes running on the background thread
- it sends the
objectWillChange
event - SwiftUI takes a snapshot of the data
- *but* because this is all happening on the background thread, the run loop is free to perform another tick on the main thread in the meantime, which it might possibly do at this very moment, right after
objectWillChange
- in this case, SwiftUI compares the snapshot to the current data, but the data hasn't changed yet, so there's nothing to update
- now, the value of the property is updated on the background thread
- but SwiftUI has already compared the snapshots, so it has forgotten about the previous state and will not compare the data again…
To make sure the order of operations is always right:
- 1.
objectWillChange
, - 2. the state changes,
- 3. and the run loop runs a tick,
all of these need to be done on the same thread – the main thread
Previously, the solution would have been to jump back to the main thread before saving the data:
func updateItems() { DispatchQueue.global().async { let fetched = fetchPhotos() DispatchQueue.main.async { self.items = fetched } } }
With Swift 5.5, a better approach is to instead make the fetching an asynchronous operation and use await
to wait for the result
By using await
we "yield" the current (main) thread instead of blocking it and it's free to perform next run loop ticks in the meantime, and when the response is received, the method continues execution – on the same thread where it started, automatically:
func updateItems() async { let fetched = await fetchPhotos() items = fetched }
To make sure that all of the updates to properties in the Photos
class happen on the main thread and that we don't make a mistake somewhere, we can mark the class with the global actor @MainActor
:
@MainActor class Photos: ObservableObject { @Published private(set) var items: [SpacePhoto] = [] ... }
This way the Swift compiler guarantees that the code in this class runs on the main thread, at compile time
Associating async tasks with views
SwiftUI provides a new .task
modifier that can be used to associate an asynchronous task with a given view
The task is run at the beginning of the view's lifetime, like .onAppear
, but the closure your provide to it is asynchronous, which makes it easier to make asynchronous calls with await
inside
In this app, we can use the .task
modifier to run the method which prepares the photo data in the model that we've written above:
struct CatalogView: View { @StateObject private var photos = Photos() var body: some View { NavigationView { List { ... } } .task { await photos.updateItems() } } }
What's more, the task's lifetime is tied to the view's lifetime, so the task is also automatically cancelled when the view is removed from the UI
You can use this to e.g. create a task that continuously reads data from an async sequence for the whole duration of the view's lifetime
Async images
SwiftUI now includes an AsyncImage()
view that automatically loads the contents of the image for you from a given URL:
AsyncImage(url: photo.url)
You can customize both the look of the resulting image and the placeholder that is displayed while the image is loading:
AsyncImage(url: photo.url) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { ProgressView() }
You can also customize the error handling – for that, check out the AsyncImage(url:scale:transaction:content:)
initializer of this view
Running async code from SwiftUI action handlers
To call asynchronous code from inside a button handler closure, which is synchronous, use a Task
block to execute a piece of asynchronous code in synchronous context:
struct SavePhotoButton: View { var photo: SpacePhoto @State private var isSaving = false var body: some View { Button { Task { isSaving = true await photo.save() isSaving = false } } label: { Text("Save") .opacity(isSaving ? 0 : 1) .overlay { if isSaving { ProgressView() } } } .disabled(isSaving) .buttonStyle(.bordered) } }
💡 Tip: to show a spinner inside a button like this, use .opacity()
to hide its label while the spinner is displayed – since the label is still there, just with 0 opacity, it makes sure that the button stays at the same size in the loading state
ℹ️ In the talk, the asynchronous code was instead wrapped in a call to an async {}
function, which was later replaced with the Task
initializer
Reloading the data using pull-to-refresh
SwiftUI adds a new .refreshable
modifier this year which automatically implements a pull-to-refresh behavior in your view, where scrolling the contents of the view beyond the top edge triggers a reload of the data
Inside the closure passed to .refreshable
, provide the reloading code that should be run on refresh – the code can be asynchronous, so it can use await
:
List { ForEach(photos.items) { item in ... } } .refreshable { await photos.updateItems() }