tva
← Artículos

Concurrencia en Swift 6: guía práctica de migración

Swift 5 era permisivo con las condiciones de carrera sobre los datos. El compilador advertía sobre algunas e ignoraba otras, lo que llevó a adoptar hábitos en el código —llamar a DispatchQueue.main.async en algunos lugares y olvidarlo en otros— que funcionaban hasta que dejaban de hacerlo. Swift 6 cambia las reglas: todas las advertencias sobre aislamiento de actores y conformidad con Sendable se convierten en errores que bloquean la compilación, que no puede continuar hasta que se elimine o se reconozca explícitamente el riesgo de condiciones de carrera.

En realidad, la mayoría de las apps existentes de SwiftUI no esconden condiciones de carrera catastróficas. Lo que tienen es un conjunto de patrones que eran seguros en la práctica, pero técnicamente incorrectos según el modelo de concurrencia de Swift. La migración consiste menos en corregir errores reales que en hacer que el compilador pueda reconocer las garantías de seguridad del código. Es un ejercicio valioso, pero exige entender qué indican realmente los errores.

Activar la comprobación estricta de concurrencia

Antes de pasar al modo de lenguaje Swift 6, conviene consultar los errores como advertencias. En Xcode, establezca el ajuste del compilador de Swift SWIFT_STRICT_CONCURRENCY en complete para su target. Así verá todo lo que Swift 6 rechazaría sin que falle la compilación. Revise las advertencias de forma sistemática antes de cambiar la versión del lenguaje.

Para activar el modo de lenguaje Swift 6 en los ajustes de compilación de su target, establezca SWIFT_VERSION en 6. Esto activa implícitamente la comprobación estricta de concurrencia complete y convierte todas las advertencias en errores. En un proyecto con varios paquetes, lo mejor es actualizar un target a la vez, empezando por los paquetes con menos dependencias de la interfaz de usuario: primero los de lógica pura y redes, y al final los de interfaz.

El problema de los límites de MainActor

El tipo de error más habitual al migrar SwiftUI tiene este aspecto:

// Error: Main actor-isolated property 'items' can not be
// mutated from a nonisolated context
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() {
        Task {
            let result = await API.load()
            self.items = result  // ← error
        }
    }
}

Las propiedades @Published de un ObservableObject no quedan aisladas automáticamente con @MainActor, pero SwiftUI las observa en el hilo principal. Cuando un Task {} se ejecuta en un ejecutor no especificado e intenta escribir en items, Swift 6 detecta la posible condición de carrera. Para la clase exactamente como está escrita, el compilador de Swift 6 informa de un riesgo de carrera de datos en el cierre de Task que captura self; el mensaje sobre el aislamiento en el actor principal que aparece en el comentario solo se muestra cuando items se marca con @MainActor.

La solución más clara es anotar toda la clase con @MainActor:

@MainActor
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() async {
        let result = await API.load()
        items = result  // ✓ — entire class is MainActor-isolated
    }
}

Esto hace explícito el aislamiento de la clase. Quienes llamen a fetch() desde un contexto en segundo plano tendrán que usar await, como corresponde: deben estar en el actor principal para interactuar con una clase que controla las actualizaciones de la interfaz. Si no puede anotar toda la clase, la alternativa es cambiar de actor explícitamente:

func fetch() {
    Task {
        let result = await API.load()
        await MainActor.run {
            self.items = result
        }
    }
}

Ambos enfoques son válidos. La anotación @MainActor de la clase es más clara cuando su finalidad principal es controlar vistas de SwiftUI, como ocurre casi siempre con las clases ObservableObject en una app bien estructurada.

PhotosPicker y el aislamiento de los delegados

PHPickerViewControllerDelegate es una fuente habitual de errores en Swift 6. En los SDK actuales, el propio método del delegado está aislado en el actor principal, pero el callback de carga del proveedor de elementos que se usa dentro de él llega en una cola en segundo plano, y el código de ese callback suele necesitar actualizar la interfaz. Este es el patrón clásico de Swift 5:

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    dismiss(animated: true)
    guard let provider = results.first?.itemProvider else { return }

    provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
        if let image = object as? UIImage {
            self?.imageView.image = image  // ← Swift 6 error
        }
    }
}

En Swift 6, el cierre que se pasa a loadObject es @Sendable, lo que significa que no puede capturar self si self no es Sendable. Un UIViewController cumple ese requisito porque las clases aisladas en el actor principal son implícitamente Sendable; el error real es que se accede a imageView.image, una propiedad aislada con @MainActor, desde un contexto en segundo plano.

La solución es cambiar al actor principal dentro del cierre:

provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
    guard let image = object as? UIImage else { return }
    Task { @MainActor [weak self] in
        self?.imageView.image = image
    }
}

La sintaxis Task { @MainActor in } crea una tarea aislada explícitamente en el actor principal. La captura de image es válida porque UIImage cumple con Sendable. La captura de self mediante [weak self] es válida porque un controlador de vista está aislado en el actor principal y, por tanto, es Sendable; weak solo evita un ciclo de retención y no haría segura la captura de un tipo que no sea Sendable.

Con PhotosPicker de SwiftUI, la API más reciente, el patrón es más claro porque utiliza Transferable:

// In a @MainActor view model:
func handlePickerSelection(_ item: PhotosPickerItem?) async {
    guard let item else { return }
    if let data = try? await item.loadTransferable(type: Data.self) {
        selectedImage = UIImage(data: data)
    }
}

Como la llamada a loadTransferable es async, se utiliza await y la asignación se realiza en el actor en el que esté aislada la función: @MainActor si el modelo de vista lleva esa anotación. No hace falta cambiar de actor manualmente.

Tipos Sendable y no Sendable

Un tipo es Sendable si puede transferirse de forma segura entre los límites de aislamiento de los actores; a grandes rasgos, si es un tipo por valor o una clase que protege su estado mediante sincronización. En Swift 6, el compilador exige esto siempre que un valor cruza un límite de aislamiento.

El patrón de error que aparece con más frecuencia es este:

// Error: Capture of 'config' with non-sendable type 'RequestConfig'
// in a '@Sendable' closure
struct RequestConfig {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

Task {
    let result = await API.fetch(config)  // config captured here
}

La solución suele ser sencilla: añadir la conformidad con Sendable al tipo por valor:

struct RequestConfig: Sendable {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

Para las estructuras cuyos miembros son todos Sendable, lo que incluye String, Data y todos los tipos por valor estándar, esta conformidad se satisface de forma trivial. El compilador la infiere implícitamente para las estructuras no públicas, por lo que, en la práctica, este error aparece en tipos public utilizados desde otro módulo; el compilador comprueba una declaración explícita : Sendable. Para las clases es más complejo: el compilador solo puede comprobar la conformidad con Sendable de una clase final cuyas propiedades almacenadas sean todas constantes let de tipos Sendable, o de una clase aislada en un actor global como @MainActor. Una clase que protege el estado mutable mediante su propia sincronización interna necesita @unchecked Sendable, porque el compilador no puede verificar los bloqueos. Para un ObservableObject al que siempre se accede desde el actor principal, prefiera @MainActor, que lo convierte automáticamente en Sendable; trate @unchecked Sendable como último recurso, no como primera respuesta.

SharedDataProvider y el estado compartido entre modelos de vista

Un patrón habitual en apps grandes de SwiftUI es un proveedor de datos compartido que se inyecta en varias vistas mediante el entorno. En Swift 6, este objeto necesita un aislamiento de actor explícito porque se accede a él desde los cuerpos de las vistas, que se ejecutan en el actor principal:

@MainActor
class SharedDataProvider: ObservableObject {
    static let shared = SharedDataProvider()

    @Published var currentUser: User?
    @Published var sessionToken: String?

    func refreshSession() async {
        let token = await AuthClient.refresh()
        sessionToken = token
    }
}

La anotación @MainActor cumple aquí una función concreta: garantiza que todo acceso a currentUser y sessionToken se serialice a través del actor principal, que es el aislamiento adecuado para las propiedades que controlan las actualizaciones de las vistas de SwiftUI. Las tareas en segundo plano que necesiten escribir en esta clase deben usar await, como corresponde: deben esperar a que el actor principal esté disponible en lugar de escribir de forma concurrente.

El detalle es que static let shared en una clase @MainActor solo puede consultarse desde el actor principal. Si alguna ruta de ejecución intenta acceder a SharedDataProvider.shared desde un contexto en segundo plano, Swift 6 la rechazará. La solución es pasar la instancia explícitamente por la cadena de funciones (el acceso a sus propiedades desde fuera del actor principal sigue requiriendo await) o envolver el acceso en await MainActor.run { ... }.

La vía de escape de @unchecked Sendable

En la práctica, algunas clases no pueden hacerse completamente Sendable: pueden encapsular bibliotecas de C, servir de puente a tipos de Objective-C o tener un estado interno gestionado de forma segura pero opaca. En estos casos, @unchecked Sendable le dice al compilador: «Confíe en mí, me he encargado de la sincronización»:

final class AudioPlayer: @unchecked Sendable {
    private let queue = DispatchQueue(label: "audio.player")
    private var _isPlaying = false

    var isPlaying: Bool {
        queue.sync { _isPlaying }
    }

    func play() {
        queue.async { self._isPlaying = true }
    }
}

Esto no es un atajo para evitar el trabajo de migración. Es una declaración explícita de que ha analizado la semántica de concurrencia y asume la responsabilidad. Úselo para clases que realmente necesiten sincronización interna y añada un comentario que explique qué mecanismo proporciona la garantía de seguridad.

Un orden práctico de migración

Active primero SWIFT_STRICT_CONCURRENCY = complete y resuelva todas las advertencias antes de cambiar al modo de lenguaje Swift 6. Empiece por los paquetes hoja del grafo de dependencias: los que no dependen de otros módulos internos. Corríjalos, actualícelos al modo de lenguaje Swift 6 y pase a la siguiente capa. Así evitará que corregir un paquete dependiente exija modificar al mismo tiempo otro paquete del que depende y que todavía está en modo de advertencias.

En una base de código típica de SwiftUI, la mayor parte de la migración consiste en anotar las clases ObservableObject con @MainActor, añadir conformidad con Sendable a los tipos por valor que se pasan entre tareas y cambiar de actor explícitamente en los callbacks de los delegados. El volumen de cambios parece grande en una comparación del código, pero cada cambio individual es mecánico. El compilador señala todos los lugares que necesitan atención: ese es el objetivo de la comprobación estricta de concurrencia.

Artículos relacionados

Artículos relacionados