skip to Main Content

I’m learning async/await and trying to fetch an image from a URL off the main thread to avoid overloading it, while updating the UI afterward. Before starting the fetch, I want to show a loading indicator (UI-related work). I’ve implemented this in two different ways using Task and Task.detached, and I have some doubts:

  1. Is using Task { @MainActor the better approach?
    I added @MainActor because, after await, the resumed execution might not return to the Task’s original thread. Is this the right way to ensure UI updates are done safely?

  2. Does calling fetchImage() on @MainActor force it to run entirely on the main thread?
    I used an async data fetch function (not explicitly marked with any actor). If I were to use a completion handler instead, would the function run on the main thread?

  3. Is using Task.detached overkill here?
    I tried Task.detached to ensure the fetch runs on a non-main actor. However, it seems to involve unnecessary actor hopping since I still need to hop back to the main actor for UI updates. Is there any scenario where Task.detached would be a better fit?

Any guidance on best practices for these scenarios would be greatly appreciated. Thanks in advance!

class MyViewController : UIViewController{
    override func viewDidLoad() {
        super.viewDidLoad()

        //MARK: First approch
        Task{@MainActor in
            showLoading()
            let image = try? await fetchImage() //Will the image fetch happen on main thread?
            updateImageView(image:image)
            hideLoading()
        }

        //MARK: 2nd approch
        Task{@MainActor in
            showLoading()
            let detachedTask = Task.detached{
                try await self.fetchImage()
            }
            updateImageView(image:try? await detachedTask.value)
            hideLoading()
        }
    }

    func fetchImage() async throws -> UIImage {
        let url = URL(string: "https://via.placeholder.com/600x400.png?text=Example+Image")!

        //Async data function call
        let (data, response) = try await URLSession.shared.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }

        guard let image = UIImage(data: data) else {
            throw URLError(.cannotDecodeContentData)
        }

        return image
    }

    func showLoading(){
        //Show Loader handling
    }

    func hideLoading(){
        //Hides the loader
    }

    func updateImageView(image:UIImage?){
        //Image view updated
    }
}

2

Answers


  1. Yes you want to use detached task in this case to get the other task out of the main actor. per Apple documentation unstructured concurrency

    To create an unstructured task that’s not part of the current actor, known more specifically as a detached task, call the Task.detached(priority:operation:) class method.

    Login or Signup to reply.
  2. I know this was answered on the Apple Developer Forums, but just to capture the salient parts of Quinn’s answer here:

    1. The @MainActor in Task { @MainActor in … } is unnecessary. UIViewController is isolated to the main actor, and therefore so are MyViewController and its methods, including viewDidLoad.

      Thus it is just:

      class MyViewController: UIViewController {
          override func viewDidLoad() {
              super.viewDidLoad()
      
              Task {                              // no explicit `@MainActor` needed here
                  showLoading()
                  let image = try? await fetchImage()
                  updateImageView(image:image)
                  hideLoading()
              }
          }
      }
      
    2. The fetchImage method is isolated to the main actor (for reasons outlined above), but the fetching of the image is OK because you await an async function:

      let (data, response) = try await URLSession.shared.data(from: url)
      

      This will await the data(from:) call and will not block the main thread. No problem here.

    3. What is potentially problematic in fetchImage is the call to UIImage(data:):

      guard let image = UIImage(data: data) else {
          throw URLError(.cannotDecodeContentData)
      }
      

      That can block the main actor momentarily. So we would generally want to move that off the current actor. So, you could theoretically use a detached task:

      func fetchImage() async throws -> UIImage {
          guard let url = URL(string: "https://via.placeholder.com/600x400.png?text=Example+Image") else {
              throw URLError(.badURL)
          }
      
          let (data, response) = try await URLSession.shared.data(from: url)
      
          guard
              let httpResponse = response as? HTTPURLResponse,
              200 ..< 300 ~= httpResponse.statusCode
          else {
              throw URLError(.badServerResponse)
          }
      
          return try await Task.detached {
              guard let image = UIImage(data: data) else {
                  throw URLError(.cannotDecodeContentData)
              }
      
              return image
          }.value
      }
      

      That having been said, we generally try to avoid unstructured concurrency, so this is a common pattern:

      func fetchImage() async throws -> UIImage {
          guard let url = URL(string: "https://via.placeholder.com/600x400.png?text=Example+Image") else {
              throw URLError(.badURL)
          }
      
          let (data, response) = try await URLSession.shared.data(from: url)
      
          guard
              let httpResponse = response as? HTTPURLResponse,
              200 ..< 300 ~= httpResponse.statusCode
          else {
              throw URLError(.badServerResponse)
          }
      
          return try await image(from: data)
      }
      
      // Even though the work is synchronous, we can declare this function as both
      // `nonisolated` and `async` and that will get it off the main actor
      
      nonisolated func image(from data: Data) async throws -> UIImage {
          guard let image = UIImage(data: data) else {
              throw URLError(.cannotDecodeContentData)
          }
      
          return image
      }
      

      This nonisolated async pattern is a nice, concise way to move work off the main actor. Other structured concurrency approaches include using a separate actor for this work.

      In this case (with non-cancellable work), the difference between the detached task and the structured concurrency is not terribly critical. (We remain with structured concurrency to enjoy, amongst other things, automatic cancelation propagation.) But unstructured concurrency has a hint of code smell, and you might consider, as a rule, remaining with structured concurrency where you can.


    Two unrelated observation:

    • I suspect you were simplifying the example for the sake of a MRE, but we would generally avoid try? unless we really wanted to ignore errors. So, perhaps:

      override func viewDidLoad() {
          super.viewDidLoad()
      
          Task {
              showLoading()
      
              do {
                  updateImageView(image: try await fetchImage())
              } catch {
                  // update UI to reflect error
              }
      
              hideLoading()
          }
      }
      
    • I might advise against manually throwing URLError types of badServerResponse and cannotDecodeContentData. I confess that I have done this myself in throw-away examples, but these error codes mean something different than what you have here. We would generally use our own custom errors, e.g.:

      enum FetchError: LocalizedError {
          case badURL
          case notValidResponse(URLResponse)
          case notOk(HTTPURLResponse, Data)
          case notImage(Data)
      
          var errorDescription: String? {
              return switch self {
              case .badURL:           String(localized: "Bad URL")
              case .notValidResponse: String(localized: "Remote server error")
              case .notOk:            String(localized: "Image not found successfully")
              case .notImage:         String(localized: "Remote service did not return a valid image")
              }
          }
      }
      
      func fetchImage() async throws -> UIImage {
          guard let url = URL(string: "https://via.placeholder.com/600x400.png?text=Example+Image") else {
              throw FetchError.badURL
          }
      
          let (data, response) = try await URLSession.shared.data(from: url)
      
          guard let httpResponse = response as? HTTPURLResponse else {
              throw FetchError.notValidResponse(response)
          }
      
          guard 200 ..< 300 ~= httpResponse.statusCode else {
              throw FetchError.notOk(httpResponse, data)
          }
      
          return try await image(from: data)
      }
      
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search