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 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.