This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start implementing a download manager as a sample project for exploring Swift's new concurrency model.

00:06 Today we'll build a download manager to get to know Swift's async/await APIs better — not because we think we should be using these APIs right now, but to explore how they work and to see what their benefits are and where they break.

Download Model and View

01:03 We start out with an array of two URLs:

let urls = [
    URL(string: "https://www.objc.io/index.html")!,
    URL(string: "http://ftp.acc.umu.se/mirror/wikimedia.org/dumps/enwiki/20211101/enwiki-20211101-abstract.xml.gz")!
]

01:10 From these URLs, we want to create instances of a model object. This object should be observable so we can show the state and progress of each download:

final class DownloadModel: ObservableObject {
   let url: URL
   init(_ url: URL) {
       self.url = url
   }
   
   func start() {
   }
}

02:15 We create a DownloadView that can display the state of a DownloadModel:

struct DownloadView: View {
    @ObservedObject var model: DownloadModel
    
    var body: some View {
        VStack {
            Text("\(model.url)")
        }
    }
}

02:49 In ContentView, we loop over the URLs, creating a DownloadView for each one:

struct ContentView: View {
    var body: some View {
        VStack {
            ForEach(urls, id: \.self) { url in
                DownloadView(model: DownloadModel(url))
            }
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

03:17 By instantiating a model in the ForEach's view builder, we're essentially recreating the model every time the ContentView is reexecuted — but this never happens, so this setup works for the sake of this demo.

03:41 We add a button to start the downloading:

struct DownloadView: View {
    @ObservedObject var model: DownloadModel
    
    var body: some View {
        VStack {
            Text("\(model.url)")
            Button("Start") {
                model.start()
            }
        }
    }
}

Task

03:49 In the model's start method, we call download(from:delegate:) on the shared session. We don't need a delegate, so we pass in nil for the second argument. The download method is both throwing and asynchronous, so we have to mark the call with try await. It returns a tuple containing both the local URL of the downloaded data and a URLResponse containing additional information (we're ignoring the latter):

final class DownloadModel: ObservableObject {
    let url: URL
    init(_ url: URL) {
        self.url = url
    }
    
    func start() {
        let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
    }
}

05:18 We now get some errors, because the start method itself doesn't provide an asynchronous context in which we can await the download. Nor do we handle any errors thrown by the download method. We fix both these things by marking the method with async throws. Finally, we print out the local URL to see if the method works:

final class DownloadModel: ObservableObject {
    let url: URL
    init(_ url: URL) {
        self.url = url
    }
    
    func start() async throws {
        let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
        print(localURL)
    }
}

06:20 We still get an error in DownloadView, because we're calling the model's start without awaiting its result. This is because the button's action closure isn't an asynchronous context where await can be used.

07:08 If we weren't using concurrency, but a callback-based API instead, we'd need to switch to another thread to perform the asynchronous work. In this case, we wrap the heavy work in a Task, which can be suspended by the system until the work is done:

struct DownloadView: View {
    @ObservedObject var model: DownloadModel
    
    var body: some View {
        VStack {
            Text("\(model.url)")
            Button("Start") {
                Task {
                    try await model.start()
                }
            }
        }
    }
}

08:31 The errors go away now that we're using await in the asynchronous context provided by the task. The compiler also stops complaining about not handling any errors, meaning if the start method throws an error, we wouldn't know about it.

09:14 We can't make any assumptions about where the task gets executed, nor can we specify a priority for the task, so the system probably assigns it some default priority.

Main Actor

09:54 Now that we can at least start the download task, we add a published property to the model — state — for observation by the DownloadView. We update state to reflect the finished download state at the end of the start method:

final class DownloadModel: ObservableObject {
    let url: URL
    init(_ url: URL) {
        self.url = url
    }
    
    enum State {
        case notStarted
        case done(URL)
    }
    
    @Published var state = State.notStarted
    
    func start() async throws {
        let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
        state = .done(localURL)
    }
}

11:02 When we run this and let a download finish, we get a warning about state being mutated off the main queue. We can add some assertions to check whether the start method is being executed on the main queue, both before and after downloading:

final class DownloadModel: ObservableObject {
    // ...

    func start() async throws {
        assert(Thread.isMainThread)
        let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
        assert(Thread.isMainThread)
        state = .done(localURL)
    }
}

11:50 When we start a download, the first assertion is passed. But execution stops on the second assertion, which means our method is moved onto a different queue in between statements. The first part executes on the main queue, but everything after the await runs on a different queue. We could think of this as being analogous to a completion callback running on the queue where the asynchronous work is done:

final class DownloadModel: ObservableObject {
    // ...

    func start() async throws {
        assert(Thread.isMainThread)
        //let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
        let (localURL, _) = try URLSession.shared.download(from: url, delegate: nil, completion: {
            assert(Thread.isMainThread)
            self.state = .done(localURL)
        })
    }
}

13:35 In addition to a cleaner syntax, the benefit of using async/await over a callback closure is that we can be sure the code after the await is executed exactly once (or, if an error is thrown, zero times).

13:56 In short, we can't make any assumptions about where our method gets executed. But we know that state must be mutated on the main queue, so we can mark it with @MainActor:

final class DownloadModel: ObservableObject {
    // ...
    
    @MainActor @Published var state = State.notStarted
    
    @MainActor
    func start() async throws {
        let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
        state = .done(localURL)
    }
}

14:24 Now we get a compiler error on the line where we try to mutate state in the start method. We could technically fix this error by dispatching onto the main queue:

final class DownloadModel: ObservableObject {
    // ...
    
    @MainActor @Published var state = State.notStarted
    
    func start() async throws {
        let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
        DispatchQueue.main.async {
            self.state = .done(localURL)
        }
    }
}

14:46 The compiler is smart enough to know that state is now being mutated on the main queue. But we don't like to use dispatch here, because it makes it possible for the start method to return before state is updated. Besides, we want to use concurrency APIs instead.

15:57 We can also add the @MainActor attribute to the start method. This has the effect that, after suspension, the execution of the method continues on the main queue. With that, the compiler error disappears:

final class DownloadModel: ObservableObject {
    // ...
    
    @MainActor @Published var state = State.notStarted

    @MainActor 
    func start() async throws {
        let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
        state = .done(localURL)
    }
}

16:40 In DownloadView, we check the model's state property to only show the start button before the download task runs, and the local URL after downloading is done:

struct DownloadView: View {
    @ObservedObject var model: DownloadModel
    
    var body: some View {
        VStack {
            Text("\(model.url)")
            switch model.state {
            case .notStarted:
                Button("Start") {
                    Task {
                        try await model.start()
                    }
                }
            case let .done(url):
                Text("Done: \(url)")
            }
        }
    }
}

17:24 The @MainActor annotation on the state property alone doesn't guarantee the property is only updated on the main queue; it just enables some compile-time checks. But when we annotate the start method with @MainActor, we tell the system to execute the method on the main queue. The system can guarantee this, because the method is asynchronous.

19:14 We could also annotate the entire model with @MainActor. This way, all properties and methods of the class are accessed on the main queue, as long as this access originates from an asynchronous context:

@MainActor
final class DownloadModel: ObservableObject {
    let url: URL
    init(_ url: URL) {
        self.url = url
    }
    
    enum State {
        case notStarted
        case done(URL)
    }
    
    @Published var state = State.notStarted
    
    func start() async throws {
        let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
        state = .done(localURL)
    }
}

Showing Progress

19:59 Before we finish, we add a third case to the download model's State enum to indicate the download is in progress. We set the state to inProgress before we start the download:

final class DownloadModel: ObservableObject {
    let url: URL
    init(_ url: URL) {
        self.url = url
    }
    
    enum State {
        case notStarted
        case inProgress
        case done(URL)
    }
    
    @MainActor @Published var state = State.notStarted
    
    @MainActor
    func start() async throws {
        state = .inProgress
        let (localURL, _) = try await URLSession.shared.download(from: url, delegate: nil)
        state = .done(localURL)
    }
}

20:29 In DownloadView, we show a ProgressView if the download is in progress:

struct DownloadView: View {
    @ObservedObject var model: DownloadModel
    
    var body: some View {
        VStack {
            Text("\(model.url)")
            switch model.state {
            case .notStarted:
                Button("Start") {
                    Task {
                        try await model.start()
                    }
                }
            case .inProgress:
                ProgressView()
                    .progressViewStyle(.linear)
            case let .done(url):
                Text("Done: \(url)")
            }
        }
    }
}

21:23 Perhaps it makes more sense to rename the start method to download or run — it doesn't just start the download task, but the method runs throughout the task and only returns after the download has completely finished (or failed).

22:11 That's because the start method is asynchronous, but it doesn't have to be. Next time, we'll see that the start method can be synchronous and just kick off the download task, and we can still observe the task's progress through the published state property. We'll also add proper progress reporting.

Resources

  • Sample Code

    Written in Swift 5.5

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

62 Episodes · 21h59min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes