DEV Community

GoyesDev
GoyesDev

Posted on

SC #8: Cancelando un Task

Cancelar un Task no necesariamente provoca su cancelación, debido a que cada Task es responsable de validar las cancelaciones manualmente.

Consideremos el siguiente método para descargar una imagen:

func fetchImage() async throws -> UIImage? {
  let imageTask = Task { () -> UIImage? in
    let imageURL = URL(string: "https://httpbin.org/image")!
    var imageRequest = URLRequest(url: imageURL)
    imageRequest.allHTTPHeaderFields = ["accept": "image/jpeg"]

    print("Starting network request...")
    let (imageData, _) = try await URLSession.shared.data(for: imageRequest)

    return UIImage(data: imageData)
  }
  imageTask.cancel()
  return try await imageTask.value
}
Enter fullscreen mode Exit fullscreen mode

Aquí se puede ver lo siguiente:

  1. Se envolvió el llamado a try await URLSession.shared.data(for:) con un Task que devuelve un UIImage?.
  2. Al final del método se espera por el valor retornado por el Task (i.e. try await imageTask.value).
  3. Teniendo la referencia de la tarea (i.e. imageTask), se la puede cancelar (i.e. imageTask.cancel()).

Cancelar la tarea no implica que no se ejecuta, pues esta se ejecuta tan pronto se define.

Aunque al principio se mencionó que cancelar la tarea no necesariamente basta para que se detenga, en este caso sí funciona así porque URLSession se encarga de hacer las validaciones de cancelación pertinentes.

En la consola se ve el siguiente mensaje:

Starting network request...
Image loading failed: Error Domain=NSURLErrorDomain Code=-999 "cancelled"
Enter fullscreen mode Exit fullscreen mode

Provocando un error con Task.checkCancellation()

Task.checkCancellation() arroja un error de tipo CancellationError si la tarea fue cancelada.

func fetchImage() async throws -> UIImage? {
  let imageTask = Task { () -> UIImage? in
    let imageURL = URL(string: "https://httpbin.org/image")!
    // ...

    try Task.checkCancellation()

    let (imageData, _) = try await URLSession.shared.data(for: imageRequest)

    // ...
  }
  imageTask.cancel()
  return try await imageTask.value
}
Enter fullscreen mode Exit fullscreen mode

El código anterior produce:

Image loading failed: CancellationError()

Lo que significa que la petición web no se llevó a cabo.

Validando un booleano con Task.isCancelled

isCancelled permite validar el estado de cancelación y manejarlo manualmente, en lugar de arrojar un error como checkCancellation().

func fetchImage() async throws -> UIImage? {
  let imageTask = Task { () -> UIImage? in
    let imageURL = URL(string: "https://httpbin.org/image")!
    // ...

    guard Task.isCancelled == false else {
      print("Image request was cancelled")
      return nil
    }

    let (imageData, _) = try await URLSession.shared.data(for: imageRequest)

    // ...
  }
  imageTask.cancel()
  return try await imageTask.value
}
Enter fullscreen mode Exit fullscreen mode

El código anterior solo imprime:

Image request was cancelled

Descartando el wrapper Task

Definir explícitamente un wrapper Task dentro de una función async solo es necesario para cancelar la tarea de forma deliberada. Sin embargo, si esto no es necesario, entonces se puede omitir el wrapper Task { () -> UIImage? in ....

func fetchImage() async throws -> UIImage? {
  let imageURL = URL(string: "https://httpbin.org/image")!
  var imageRequest = URLRequest(url: imageURL)
  imageRequest.allHTTPHeaderFields = ["accept": "image/jpeg"]

  try Task.checkCancellation()

  let (imageData, _) = try await URLSession.shared.data(for: imageRequest)

  try Task.checkCancellation()

  return UIImage(data: imageData)
}
Enter fullscreen mode Exit fullscreen mode

Cancelando la tarea en SwiftUI

Forma manual

Se podría cancelar un Task de forma manual en SwiftUI guardando una referencia a ella (e.g. imageDownloadingTask) y luego cancelándola donde sea necesario (e.g. onDisappear).

@State var image: UIImage?
@State var imageDownloadingTask: Task<Void, Never>?

var body: some View {
  VStack {
    if let image {
      Image(uiImage: image)
    } else {
      Text("Loading...")
    }
  }.onAppear {
    imageDownloadingTask = Task {
      do {
        image = try await fetchImage()
        print("Image loading completed")
      } catch {
        print("Image loading failed: \(error)")
      }
    }
  }.onDisappear {
    imageDownloadingTask?.cancel()
  }
}
Enter fullscreen mode Exit fullscreen mode

Forma automática

El modificador task(priority:_:) ejecuta una tarea asíncrona antes de que la vista aparezca. SwiftUI cancelará automáticamente el Task en algún momento después de que la vista desaparezca.

Teniendo en cuenta lo anterior, se puede usar task(priority:_:) en el ejemplo así:

@State var image: UIImage?

var body: some View {
  VStack {
    if let image {
      Image(uiImage: image)
    } else {
      Text("Loading...")
    }
  }.task {
    do {
      image = try await fetchImage()
      print("Image loading completed")
    } catch {
      print("Image loading failed: \(error)")
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Tener en cuenta que el código ejecutado en .task corre antes que una tarea despachada en .onAppear. Sin embargo, el tiempo de ejecución de una tarea en .task no se puede determinar, lo que significa que un código síncrono en .onAppear podría ejecutarse antes que una tarea despachada en .task.

Cancelación de subtareas

Si una "supertarea" es cancelada, todas las "subtareas" reciben la señal de cancelación y deben verificar si están canceladas usando isCancelled o checkCancellation().

Si una subtarea no verifica explícitamente si fue cancelada, entonces puede continuar corriendo. Sin embargo, cualquier subtarea que internamente valide la cancelación (e.g. try await algunaFuncionQueArroja()), puede arrojar un error CancellationError cuando sea ejecutada.

func parentTask() async {
  let handler = Task {
    print("Parent task started")
    async let child1 = childTask(id: 1)
    async let child2 = childTask(id: 2)
    let ids = try await [child1, child2]
  }
  try? await Task.sleep(nanoseconds: 200_000_000)
  handler.cancel()
  do {
    _ = try await handler.value
  } catch let error as CancellationError {
    print("La tarea fue cancelada")
  } catch {
    print("Hubo otro error")
  }
  print("Parent task started")
}

func childTask(id: Int) async throws -> Int {
  for i in 1..<4 {
    try Task.checkCancellation()
    try await Task.sleep(nanoseconds: 100_000_000)
    print("Subtarea: \(id), paso: \(i)")
  }
  print("Terminé la subtarea: \(id)")
  return id
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)