© Andrés Ibañez Kautsch 2023
A. I. KautschModern Concurrency on Apple Platformshttps://doi.org/10.1007/978-1-4842-8695-1_5

5. Unstructured Concurrency

Andrés Ibañez Kautsch1  
(1)
La Paz, Bolivia
 

In the last chapter, we talked extensively about structured concurrency. We learned that structured concurrency follows the same ideas of structured programming. A natural counterpart for structured concurrency is unstructured concurrency.

Unstructured concurrency offers more flexibility in exchange for slightly less usability. When working with unstructured concurrency, your code will not necessarily follow the top-to-bottom structure we have come to love. Luckily, working with unstructured concurrency is still very easy, and the flexibility won’t increase the complexity of your program considerably.

To work with unstructured concurrency , we use an object we already know: Task.

Tasks in Depth

Task is a useful object that allows us to do two important things:
  1. 1.

    The moment a task is created with its initializer, it launches the code within the closure asynchronously.

     
  2. 2.

    It creates a concurrent context for us to be able to use concurrency in the way of calling async methods .

     

Creating Tasks

Task is an object that can store a value, an error, both, or neither. To create an asynchronous, unstructured task , we call Task and initialize it with a closure which is the code we want to run asynchronously. We have been doing this continuously throughout the book. For example, Listing 5-1 is a small snippet from Chapter 2 that calls some async method with Task.
func authenticateAndFetchProfile() {
     Task {
          //...
          let userProfile = try await fetchUserInfo()
          //...
     }
}
Listing 5-1

Using Task to call async code

In Listing 5-1, the fetchUserInfo() info method is called the moment authenticateAndFetchProfile() method . You cannot decide when the code within a Task will execute. It will always be done immediately upon initialization.

When you want to do concurrency using Task, the first bit of flexibility you have at your disposal is specifying the priority. The priority is of type Task.Priority, which is the same one you can use when working with task groups. Listing 5-2 shows the available priority options you have at your disposal when working with the new concurrency system.
static let background: TaskPriority
static let high: TaskPriority
static let low: TaskPriority
static var medium: TaskPriority
static let userInitiated: TaskPriority
static let utility: TaskPriority
Listing 5-2

Available priority options

A Task is an object, as such, you can store it in a variable. This is where part of the reason this is called “unstructured concurrency ” comes from. You can have multiple Task objects, store them in variables, or even in collections. And one thing you can do with unstructured concurrency that you can’t do with structured concurrency is cancelling tasks. Task cancellation is an important topic, and we will talk more about it when we talk about the Task Tree in this chapter. For now, keep in mind that you can cancel tasks by calling the cancel() method.

Listing 5-3 shows how we can declare a task, store it in a variable, and cancel it later.
func authenticate() {
    let authenticationTask = Task<Bool, Error> {
        let context = LAContext()
        let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics
        return try await context.evaluatePolicy(policy, localizedReason: "To log in")
    }
    authenticationTask.cancel()
}
Listing 5-3

Creating a Task, and explicitly cancelling it later

We created a variable of type Task<Bool, Error>. This is because the context.evaluatePolicy(_:localizedReason:) returns a Bool and can throw an error.

We can await on the Task’s value if we need it to continue doing some work, as seen in Listing 5-4.
Task {
    let authenticationSuccessful = try await authenticationTask.value
    if authenticationSuccessful {
        // Successfully authenticated.
    }
}
Listing 5-4

Awaiting on a Task’s value

We need to do try await because our task can return a value and throw an error. Note the lack of a do-catch statement in this newly created Task. This code will implicitly create a task that is of type Task<(), Error>, because it doesn’t return anything, but it can throw an error (the error thrown by context.evaluatePolicy). Tasks do not need an explicit do-catch block because they will be captured by closure itself but add these statements if it makes sense in the context of your application.

When you create a new task with Task {}, it will inherit contextual information from the task that spawned it. The information inherited includes the actor the task is running on (if you spawn a Task in something that runs on the @MainActor, then the task will also run on the @MainActor), and priority. If your newly spawned Task is a root task – it creates an asynchronous context but it itself is not running in one – it will inherit the actor of whatever thread it is running on.

Unstructured Concurrency in Action

Now that you have a better understanding of what unstructured concurrency is, and you finally learned what the Task object is, we can start writing some sample code to better understand how it all works.

We will work and modify the project we created in the last chapter – the ImageDownloader project .

The project worked perfectly fine, but it had two main issues that I’d like to address:
  1. 1.

    When the app launched, it showed a ProgressView in the center until all images were done downloading. This means if we had a lot of images or they were really big, the ProgressView would be shown for a very long time. Once the images were done downloading, the view would refresh and show all the images at once.

     
  2. 2.

    There was no guaranteed to the order the images would be displayed in. Images would be added to the array in a first-downloaded basis. This is not harmful buy itself, but if you had an app that displayed a chronological order of elements, it would matter.

     

We will modify the original project and address these two issues. If you would like to download the modifications directly, feel free to download the “Chapter 4 - ImageDownloaderUnstructured” project.

Modifying the ImageManager Class

Start by adding the code in Listing 5-5 to the top of the file.
enum ImageDownloadStatus {
    case downloading(_ task: Task<Void, Never>)
    case error(_ error: Error)
    case downloaded(_ localImageURL: URL)
}
struct ImageDownload: Identifiable {
    let id: UUID = UUID()
    let status: ImageDownloadStatus
}
Listing 5-5

Additional objects

Currently, ContentView reads an array of URLs from the ContentViewViewModel and displays them in the UI. We will change this, so the ImageManager is the actual data source of the images. We will also make the view read ImageDownload objects instead of URL objects. This will help us show a different UI depending on the download status of the image (the ImageDownloadStatus enum). We need to make ImageDownload conform to Identifiable so it plays nicely with SwiftUI.

Also, since the ImageManager class will be a data source for SwiftUI now, mark it as @MainActor, as seen in Listing 5-6.
@MainActor
class ImageManager: ObservableObject {
Listing 5-6

Marking ImageManager as @MainActor

Now go to the TaskGroupsApp.swift file and change the declaration of the ImageManager class variable there as well to add the @MainActor attribute, as seen in Listing 5-7.
@MainActor
fileprivate let imageManager = ImageManager()
Listing 5-7

Marking the imageManager variable as @MainActor

Go back to the ImageManager.swift file , and this time modify the images variable to be of type [ImageDownload]. Also, add the @Published attribute. Both changes are shown in Listing 5-8.
@Published private(set) var images: [ImageDownload] = []
Listing 5-8.

Creating a published property for remote images

Because SwiftUI will use this as a data source, whenever this array changes, our UI will update.

Finally, we will modify the download(serverImages:) function as per Listing 5-9.
func download(serverImages: [ServerImage]) {
    for imageIndex in (0..<serverImages.count) {
        let downloadTask = Task<Void, Never> {
            do {
                let imageUrl = try await download(serverImages[imageIndex])
                self.images[imageIndex] = ImageDownload(status: .downloaded(imageUrl))
            } catch {
                self.images[imageIndex] = ImageDownload(status: .error(error))
            }
        }
        images.append(ImageDownload(status: .downloading(downloadTask)))
    }
}
Listing 5-9

A new version of download(serverImages:)

This is where part of the magic will happen. We will start by iterating over all the ServerImages we have downloaded. For each server image, we will create an explicit Task. We will also add this task immediately to the images array with the .downloading(_) status. The enum tracks the task itself because we can cancel it later if we want.

The task body will attempt to download the image. If the download fails, we will replace its entry in the images array with the .error(_) value. If it succeeds, we will replace it with the .downloaded(url) value. In short, the body of the task will replace its place in the array with a different enum value depending on whether the download was successful or not. And because this is a @Published property, SwiftUI can observe its changes and be notified in real time of whatever happens with this array.

Modifying the ContentViewViewModel Class

The main modification in the ContentViewViewModel.swift file is the downloadImages() function as in Listing 5-10. We have also eliminated the imageUrls array from this file as that work has been delegated to the ImageManager class.
func downloadImages() {
    Task {
        guard let manager = imageManager else { return }
        do {
            let imageList = try await manager.fetchImageList()
            manager.download(serverImages: imageList)
        } catch {
            print(error)
        }
    }
}
Listing 5-10

The new downloadImages() method

The view model is now a very light class, containing only this method. We could delegate everything to the ImageManager class, but I prefer to keep it as a separate logic class.

Modifying the ContentView Object

The body property is modified as per Listing 5-11.
var body: some View {
    ScrollView {
        LazyVStack {
            if let manager = viewModel.imageManager {
                ForEach(manager.images) { imageDownload in
                    switch imageDownload.status {
                    case .error(_): imageErrorView()
                    case .downloading(_): imageDownloadView()
                    case .downloaded(let url): imageView(for: url)
                    }
                }
            }
        }
    }
    .onAppear {
        viewModel.imageManager = imageManager
        viewModel.downloadImages()
    }
}
Listing 5-11

The new body property

We will display a new view depending on the ImageDownloadStatus property of the ImageDownload object. If we have an error, we will show an “x” mark. If we are currently downloading it, we will show a photo placeholder. If we have the image, we will show the image itself. Listing 5-12 shows the implementation of these additional methods.
@ViewBuilder
func imageDownloadView() -> some View {
    VStack {
        Image(systemName: "photo")
            .resizable()
            .frame(width: 300, height: 300)
            .foregroundColor(.gray)
    }
}
@ViewBuilder
func imageErrorView() -> some View {
    VStack {
        Image(systemName: "xmark")
            .resizable()
            .frame(width: 300, height: 300)
    }
}
@ViewBuilder
func imageView(for url: URL) -> some View {
    Image(uiImage: UIImage(data: viewModel.data(for: url))!)
        .resizable()
        .frame(width: 300, height: 300)
}
Listing 5-12

Helper properties to create the views

And that’s it! If you run the app now, you will see a UI with placeholders for the images. Figure 5-1 shows our basic UI.

A screenshot represents a mobile screen with three photos about to load, one below the other, and time, cellular, and battery symbols on the top.

Figure 5-1

Showing placeholders for images that will be downloaded

As the images load, Figure 5-2 shows the placeholders getting replaced with images as they are downloaded.

A screenshot depicts a mobile screen with three photos of cats one below the other, and time, cellular, and battery on the top.

Figure 5-2

The images replace the placeholders one by one as they are downloaded

This improved version of the ImageDownloader project shows placeholders for images as they are downloaded instead of showing a ProgressView while waiting for all the images until they are downloaded, and the images will always show up in the same order. Neat!

The Task Tree

The new concurrency system in Swift is driven by an underlying structure called the task tree . Like the name states, the task tree gives us a mental model of how concurrency works behind the scenes. This task tree dictates how your tasks are going to run in relation to others. It also controls how data is inherited to child tasks. Earlier, we mentioned that a task inherits the actor, priority, and task local variables from the task that launched it. This is thanks to the task tree. Tasks spawn child tasks and these child tasks inherit all these attributes from their parents.

To better understand this concept, let’s explore with some of the code we have written previously. Listing 5-13 brings back a function we had in the async let version of our Social Media App.
func fetchData() async throws -> (userInfo: UserInfo, followingFollowersInfo: FollowerFollowingInfo) {
    let userApi = UserAPI()
    async let userInfo = userApi.fetchUserInfo()
    async let followers = userApi.fetchFollowingAndFollowers()
    return (try await userInfo, try await followers)
}
Listing 5-13

Asynchronously fetching data from a server

This code has two async let calls that retrieve the user info and followers info, respectively. Because they are async let , the system will try to run them concurrently.

Intuitively, you may think that each async let call launches a new task implicitly, but this is not the case. Each async let call will launch a new child task. The distinction is important because Tasks can only be created explicitly.

Each child task will inherit the information we received earlier from the parent (in this case, the parent is the fetchData() function itself) – actor, priority, and local variables. When you create Task within another Task, it will also inherit the parent Tasks attributes, but unlike implicit tasks, you can tweak it a bit according to your necessities, like spawning it with a different priority. Nested tasks are not child tasks, strictly speaking.

If you were to draw the fetchData() task as a tree, it would look like Figure 5-3.

A flow diagram depicts the visual representation with fetchData, userApi dot fetchUserInfo, and userApi dot fetchFollowingandFollowers.

Figure 5-3

A visual representation of a task tree

When fetchData is executed, the same task is used to launch fetchUserInfo and fetchFollowingAndFollowers. This needs to be repeated and it’s very important you don’t forget. Tasks are always created explicitly. Unless we manually make those calls wrapped within a Task, they will be run under the same Task. One task can run multiple concurrent child tasks, with the same actor, priority, and task local variables.

Another important aspect of the task tree you need to consider is that a parent task can only finish its work when all its children have finished their work. Even if such termination is not successful (i.e., an error is thrown), it’s still a termination, and a task can be completed when such abnormal situations occur.

There are two other aspects that are also governed by the task tree . They are important enough to warrant their own sections: Error propagation and task cancellation.

Error Propagation

Let’s go back to the fetchData tree . fetchData itself can throw, and so can all the child tasks it spawns. Suppose an error occurs in fetchUserInfo. If the function throws an error, execution of the function will end at that point, throw the error up to the caller, and it will not execute anything underneath the point the error took place. So far, that’s exactly what would happen if we weren’t using the new concurrency system at all. That’s how error propagation works in Swift (and in many other programming languages, in fact).

But when we are working with the new concurrency system, things are not so straightforward. Sure, fetchUserInfo could throw an error, but fetchFollowingAndFollowers could complete normally and return an expected value.

If an error is thrown, it will be propagated up the task tree until it reaches the caller. Any other child tasks that are either awaiting or running concurrently will be marked as cancelled.

Task Cancellation

Task cancellation is very important, yet it does not work like you would expect at first glance. If you reread the last sentence in the previous paragraph, you will notice that we said that the tasks “will be marked as cancelled.”

With this new concurrency system, task cancellation is cooperative. Cancelling a task simply means that you are notifying it that you intend it to be cancelled. It is up to the task to actually find an appropriate time to cancel itself, and you need to implement this logic by yourself. The reason task cancellation is cooperative is because there are sensitive yet important tasks that may not be acceptable to forcefully stop their execution. If you are writing data to a database, or you are modifying a user file in a concurrent task, abruptly cancelling it can lead to user data corruption (and many 1-star reviews).

When a Task is cancelled , it also marks any children subtasks as cancelled, attempting to ensure no unnecessary work will be done.

To explain this concept better, I have provided a project, “Chapter 5 – Counter App” that we will work through.

When you launch the app, you will see a simple button, as seen in Figure 5-4.

A screenshot represents a mobile screen with start counting on it along with time, cellular, and battery symbols on the top.

Figure 5-4

A simple UI with a button to start a counter

Tapping the button will start showing the numbers 1 to 10 and 11 to 20 getting printed in two different sections, at the same time. Figure 5-5 shows this action in progress.

A screenshot represents a mobile screen with numbers 1, 2, 11, and 12 encircled separately, cancel at the bottom, along with time, cellular, and battery symbols on the top.

Figure 5-5

Showing numbers in the UI. The UI is updated every second

The functions that output the numbers to draw are in Listing 5-14.
func countFrom1To10() async throws -> Bool {
    for i in (1...10) {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        values1To10 += [i]
    }
    return true
}
func countFrom11To20() async throws -> Bool {
    for i in (11...20) {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        values11to20 += [i]
    }
    return true
}
Listing 5-14

The methods output the numbers to show in the UI

Listing 5-15 shows the method that launches these two functions asynchronously.
func startCounting() {
    isCounting = true
    counterTask = Task {
        async let counter1To10DidFinish = countFrom1To10()
        async let counter11To20DidFinish = countFrom11To20()
        do {
            let _ = try await (counter1To10DidFinish, counter11To20DidFinish)
        } catch {
            self.error = error
        }
    }
}
Listing 5-15

Launching both counter methods asynchronously

counterTask is a variable of type Task<Void, Never>. Storing the task in a variable will allow us to cancel it later, as seen in Listing 5-16.
Button("Cancel") {
    viewModel.counterTask?.cancel()
}
Listing 5-16

Giving the user manual control over the task

You can see that the counterTask variable acts as a parent task for countFrom1to10 and countFrom11to20. What do you think will happen when you tap the “Cancel” button? Will the number stop printing altogether and the UI display the ones it managed to print? Will it erase all the numbers? Feel free to take a minute to think about it.

But the right answer is neither of those options. The moment you tap the Cancel button, all the numbers will be displayed in the UI instantly (unlike one number at a time, like it had been doing) and the task will appear like it finished without an issue. What’s going on here?

Cancellation is cooperative. This means that the tasks will not be explicitly and automatically cancelled by anyone. Earlier, we said that tasks are marked as cancelled. Cancellation being cooperative means that tasks need to check their cancellation status and cancel execution when it is appropriate. It is not correct to cancel tasks instantly, because your code could be doing something essential that cannot be unexpectedly interrupted. So instead of having someone else cancel your code, your code needs to wait for an appropriate time to stop executing itself.

To check for cancellation, you need to place cancellation checks where it makes sense to do so. Swift provides you with the Task.checkCancellation()static method and the Task.isCancelled static property . Use the former when you are within an async context that can throw, and the latter when your context is not marked as throws.

With this in mind, we now need our countFrom1To10() and countFrom11To20() methods to check their cancellation status at some point and return when it makes sense. One way we can do this is to place a check before we append the value to the variable. Listing 5-17 shows how this is done.
func countFrom1To10() async throws -> Bool {
    for i in (1...10) {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        if Task.isCancelled { return false }
        values1To10 += [i]
    }
    return true
}
Listing 5-17

Adding a task cancellation check to countFrom1To10()

It would also be acceptable to add the check after you append the value to the array. And even better, if it makes sense to have the cancellation check in both places, you can do that too. Listing 5-18 shows the method with some additional checks.
func countFrom1To10() async throws -> Bool {
    for i in (1...10) {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        if Task.isCancelled { return false }
        values1To10 += [i]
        if Task.isCancelled { return false }
    }
    return true
}
Listing 5-18

Adding additional cancellation checks

There is no formula to identify where the cancellation checks should go, but it is important that you design your code with cancellation in mind, because you don’t want consumers of your code to have unnecessarily running operations. Many of Apple’s async methods are already accounting for cancellation. If you cancel a task that has a URLSession data task running, for example, it will react to cancellations automatically.

If you run the code now and tap the Cancel button, the first numbers will stop printing in place, whereas the ones that go from 11 to 20 will still print to the end. Figure 5-6 shows this behavior in action.

A screenshot represents a mobile screen with numbers 1, 11 to 20 encircled separately, cancel at the bottom, along with time, cellular, and battery symbols on the top.

Figure 5-6

Only one task is checking for cancellation, therefore cancellation is not working as expected

Before we fix countFrom11to20 , you may be wondering why exactly the numbers show up instantly when cancelling, instead of showing one number per second like it does before the “Cancel” button is tapped. The reason is that the try? await Task.sleep(nanoseconds: 1_000_000_000) call has the error silenced with try?. The code was written like this because the sleep(nanoseconds:) method actually throws an error when the Task it’s running on has been cancelled. Because the error is silenced, the task doesn’t sleep for one second, and the loop gets executed immediately. Essentially, cancellation is an error that gets thrown, and you can catch it and handle it differently than the others.

To provide an example of cooperative task cancellation , I had to silence the error and manually add the cancellation checks. In general, you can expect that any method of the new concurrency system is already checking for cancellation at some point, alongside Apple’s own async methods. You can avoid checking for cancellation in this specific method if you remove the question mark after the try.

Listing 5-19 fixes the countFrom11to20 method .
func countFrom11To20() async throws -> Bool {
    for i in (11...20) {
        if Task.isCancelled { return false }
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        values11to20 += [i]
        if Task.isCancelled { return false }
    }
    return true
}
Listing 5-19

Fixing the countFrom11To20 method

And now, whenever you tap the “Cancel” button , the numbers will not print from top to bottom like they were doing before we added the cancellation checks. It’s interesting to note that due to the nature of asynchronous code, you may see different outputs in the UI depending on when you cancel. For example, the app tries to print two numbers every second (1 and 11, 2 and 12, etc.), but depending on when you cancel you may get only one number of a pair. Figure 5-7 shows what happened when I cancelled the task when it was showing the third pair.

A screenshot represents a mobile screen with numbers 1, 2, 11, 12, and 13 encircled separately, cancel at the bottom, along with time, cellular, and battery symbols on the top.

Figure 5-7

The tasks were cancelled at the same time, but one of them was slightly ahead of the other

Task Cancellation and Task Groups

It is very important to understand that structured concurrency with task groups can make your code return partial results to the caller instead of a complete collection. Consider the code in Listing 5-20, which is from the ImageDownloader project from Chapter 4.
func download(serverImages: [ServerImage]) async throws -> [URL] {
    var urls: [URL] = []
    try await withThrowingTaskGroup(of: URL.self) { group in
        for image in serverImages {
            group.addTask(priority: .userInitiated) {
                let imageUrl = try await self.download(image)
                return imageUrl
            }
        }
        for try await imageUrl in group {
            urls.append(imageUrl)
        }
    }
    return urls
}
Listing 5-20

Downloading multiple images in a Task Group

If you have 10 images to download, and an error were to occur (or you cancel the group calling group.cancelAll()) within the withThrowingTaskGroupCall before all images are downloaded, the URLs array will contain a partial result consisting of only the images it managed to download before the unusual exit took place. Make sure you document this behavior, as callers to your code may not be aware of this and may cause their programs to have unexpected behavior.

Unstructured Concurrency with Detached Tasks

We have mentioned that the task tree ensures tasks inherit some properties from their parents such as priority, actor, and local variables. It is entirely possible to launch a task from another task that doesn’t inherit anything from its parent. These are called Detached Tasks , and they are useful when you need concurrency, but this concurrency is not strictly related. For example, downloading images from the internet could be one task. You may later want to store those images in a local cache. The process of storing the images in a local cache can be a detached task, that way if the task is cancelled but the image is downloaded, the cache-saving operation will proceed without an issue.

To launch a detached task , you simply use the detached static method of task . Listing 5-21 shows how we can create one.
Task.detached(priority: .low) {
    imageManager.writeImageToCache(image)
}
Listing 5-21

Creating a detached task

Syntactically, there’s not much difference when it comes to standard tasks but be aware of the task tree and inheritance properties when you work with them. Specifying the priority is optional, just like when working with normal tasks.

Summary

This chapter was packed with a discussion of unstructured concurrency and the task tree. We gave a lot of coverage to the Task object and how it behaves when it comes to creating concurrency. This type of concurrency is called unstructured concurrency , and it gives us more control, allowing us to cancel the tasks manually or passing them around as arguments.

We talked about how structured concurrency works when used alongside Tasks. We said that calling async methods within a Task will make the methods child tasks of the parent task. These child tasks inherit all the properties of the parent including priority, actor, and local variables. Launching async methods does not mean they run in a different task. Tasks are always explicitly created with the Task {} object.

We also talked about the task tree and how it governs the execution of your tasks. The task tree gives you a mental model of what kind of information is being inherited by child tasks. Also, it governs how cancellation takes place.

We learned that task cancellation is not straightforward. “Cancelling” a task simply means that will be marked as cancelled, but the tasks themselves are responsible for finding an appropriate time to stop execution, as forcefully stopping asynchronous code can have disastrous consequences for your users, such as data corruption. We also touched on how task cancellation works with task groups and how it can cause them to deliver partial results, which may be unexpected.

Finally, we talked about detached tasks , which are a form of unstructured concurrency that don’t inherit anything from a parent task. These tasks are completely independent.

Exercises

Improve the Project
Download the “Chapter 4 - ImageDownloader” project and do the following changes:
  1. 1.

    Change it so the images are no longer downloaded with a task group. Instead, it should use unstructured concurrency with Task to download each image. Hint: You can create a separate view, called ServerImageView, with an accompanying view model to download a single image.

     
  2. 2.

    Add the ability to let users cancel an independent image download task if they tap the “Cancel” button.

     
  3. 3.

    SwiftUI includes a view modifier called .task, which will execute its contents when a view appears, and it will cancel the execution when the view goes out of view, essentially replacing the behavior for the onAppear and onDisappear modifiers. Use it to automatically download an image when its placeholder view (which can be a ProgressView) appears.

     
By the end of this exercise, your program should look similar to Figure 5-8.

A screenshot depicts a mobile screen with three photos loading, along with time, cellular, and battery symbols on the top.

Figure 5-8

The UI that shows when images are downloading

If you tap the “Cancel” button (or if an error occurs), the UI should look similar to Figure 5-9.

A screenshot depicts a mobile screen with three error-loading photos, along with time, cellular, and battery symbols on the top.

Figure 5-9

An image download has been cancelled, or another error has occurred

And naturally, if no errors occur, the images should appear like they originally did in the base exercise.

You can find my solution in the “Chapter 4 - ImageDownloaderWithTask” project.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.142.156.255