MacKuba

🍎 Kuba Suder's blog on Mac & iOS development

WWDC 21

Discover concurrency in SwiftUI

Categories: SwiftUI 0 comments Watch the video

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, the Published wrapper automatically sends an objectWillChange 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. 1. objectWillChange,
  2. 2. the state changes,
  3. 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()
}