Una pantalla puede necesitar varias consultas para completar su contenido. Si esperamos a reunir todos los resultados antes de actualizarla, la petición más lenta determina cuánto tiempo permanece vacía. Aunque algunas secciones estén listas, el usuario sigue viendo un indicador de carga.
Una de las mejores maneras de acabar con esto, es organizar el trabajo por etapas con AsyncSequence. Este enfoque permite entregar resultados parciales y decidir qué contenido aparece primero. La mejora puede estar en el tiempo hasta el primer resultado útil, aunque completar toda la pantalla siga costando lo mismo o incluso más.
Los hilos no son un límite de peticiones
async let permite iniciar operaciones concurrentes. El problema surge cuando agrupamos todos sus resultados en una única devolución y retrasamos la actualización de la interfaz hasta reunirlos.
Conviene distinguir, además, tareas y hilos. Como explica Apple en Swift concurrency: Behind the scenes, una tarea suspendida puede liberar el hilo para ejecutar otro trabajo. Por tanto, limitar los hilos del ejecutor cooperativo no equivale a limitar las peticiones en curso: muchas operaciones pueden esperar simultáneamente una respuesta de red.
Si necesitamos controlar cuántas solicitudes iniciamos, debemos hacerlo explícitamente. Cargar los datos por etapas es una forma sencilla de organizar ese trabajo, siempre que los servicios llamados no lancen por su cuenta un número ilimitado de operaciones.
Una secuencia que entrega secciones
Imaginemos una app de museo. Primero queremos mostrar su descripción; después, las exposiciones; finalmente, las entradas disponibles. Cada sección tiene valor por separado.
AsyncSequence representa el acceso a valores que pueden llegar con el tiempo. Su iterador implementa next(): devuelve un elemento o nil cuando termina. En este ejemplo emitimos únicamente la sección recién cargada, y el consumidor conserva el estado acumulado:
import Foundation
protocol MuseumService: Sendable {
func overview() async throws -> String
func exhibitions() async throws -> [String]
func tickets() async throws -> [String]
}
enum MuseumUpdate: Sendable {
case overview(String)
case exhibitions([String])
case tickets([String])
}
struct MuseumLoading<Service: MuseumService>: AsyncSequence {
typealias Element = MuseumUpdate
let service: Service
func makeAsyncIterator() -> Iterator {
Iterator(service: service)
}
struct Iterator: AsyncIteratorProtocol, Sendable {
let service: Service
private var step = 0
init(service: Service) {
self.service = service
}
mutating func next() async throws -> MuseumUpdate? {
guard step < 3 else { return nil }
do {
try Task.checkCancellation()
let update: MuseumUpdate
switch step {
case 0:
update = .overview(try await service.overview())
case 1:
update = .exhibitions(try await service.exhibitions())
default:
update = .tickets(try await service.tickets())
}
try Task.checkCancellation()
step += 1
return update
} catch {
step = 3
throw error
}
}
}
}
El servicio concreto se encarga de consultar la red o la persistencia. Crear MuseumLoading no inicia esas consultas: cada llamada a next() ejecuta una etapa. En un bucle for try await, la siguiente empieza después de procesar el elemento anterior. Si salimos del bucle, las etapas pendientes no arrancan.
Ese comportamiento es propio de esta implementación. Otras implementaciones de AsyncSequence pueden cargar datos por adelantado o iniciar varias peticiones a la vez. Por tanto, usar este protocolo no garantiza que las peticiones se ejecuten de una en una.
Actualizar el estado según llegan los datos
El modelo puede recibir cada sección y exponerla a SwiftUI mediante Observation, disponible desde iOS 17:
import Observation
@MainActor @Observable
final class MuseumModel<Service: MuseumService> {
var overview: String?
var exhibitions: [String]?
var tickets: [String]?
var errorMessage: String?
private let service: Service
init(service: Service) {
self.service = service
}
func load() async {
errorMessage = nil
do {
for try await update in MuseumLoading(service: service) {
try Task.checkCancellation()
switch update {
case .overview(let text): overview = text
case .exhibitions(let items): exhibitions = items
case .tickets(let items): tickets = items
}
}
} catch {
guard !Task.isCancelled else { return }
errorMessage = error.localizedDescription
}
}
}
Los opcionales distinguen una sección pendiente (nil) de otra cargada sin resultados ([]). @MainActor aísla las mutaciones del estado de presentación. El ejemplo presupone una sola carga activa; si permitimos recargas simultáneas, debemos cancelar la anterior o identificar cada solicitud para evitar que resultados antiguos sobreescriban los nuevos.
Cancelar y conservar lo útil
La cancelación es cooperativa. Aquí comprobamos su estado antes de iniciar cada etapa y después de esperar su resultado. Eso evita continuar o entregar datos cuando la tarea ya está cancelada, pero interrumpir inmediatamente una operación en curso depende también del servicio.
Si una etapa falla, la secuencia se detiene, pero las secciones ya recibidas se mantienen. Según el contrato de AsyncIteratorProtocol, después de lanzar un error o devolver nil, el iterador debe devolver nil en todas las llamadas posteriores a next(). Si queremos que el fallo de una sección opcional no interrumpa la carga de las demás, podemos devolver un elemento que describa ese error y continuar con la siguiente etapa.
Elegir el orden con intención
Serializar todas las consultas puede aumentar el tiempo total. Podemos combinar etapas secuenciales con unas pocas operaciones independientes mediante async let dentro de cada etapa. El objetivo es equilibrar el contenido visible, las dependencias y la carga sobre las llamadas al servidor.
Para evaluar el resultado conviene medir tanto el tiempo hasta mostrar contenido útil como el tiempo de carga completa. Una interfaz que entrega información aprovechable pronto puede mejorar la experiencia incluso sin reducir la duración total.