Distrito Telefónica. Hub de Innovación y Talento

Volver
Development

Guía de supervivencia para migrar a Swift 6 sin romper tu app

Durante los últimos meses, nuestro equipo ha trabajado en la migración a Swift 6 de un proyecto complejo escrito en Swift 5 activando la comprobación maxima de concurrencia estricta. Ha sido un viaje técnico exigente, lleno de aprendizajes que aquí comparto con la intención de ayudar a otros equipos en este mismo proceso. En este artículo encontrarás reflexiones prácticas, trampas comunes, y ejemplos reales de código que ilustran lo aprendido.

Nuestra migración en números

El proyecto que migramos es una aplicación con más de 1.000.000 de líneas de código Swift, dividida en 24 módulos, con una fuerte dependencia de patrones de concurrencia legacy como DispatchQueue, singletons compartidos y controladores no aislados. La migración no fue inmediata: requirió semanas de planificación, una revisión completa de dependencias y la refactorización de código debido a usos incompatibles con Sendable o MainActor.        
Durante el proceso:      
  • Añadimos más de 3.000 anotaciones de tipo @MainActor o Sendable, y marcamos con @unchecked Sendablemás de 400 tipos que ya estaban correctamente sincronizados manualmente.
  • Tuvimos que aislar módulos enteros usando @preconcurrency para poder avanzar gradualmente sin bloquear otros desarrollos.
  • Y adaptamos nuestro entorno de testing, incorporando Swift Testing para los nuevos módulos, mientras manteníamos XCTest actualizado con Swift Concurrency para los existentes.
El cambio fue progresivo, duro y lleno de “gotchas”, pero al final logramos migrar todo nuestro código del proyecto a Swift 6 sin interrumpir el desarrollo diario ni romper funcionalidades existentes. 

Las dificultades estructurales al migrar

Migrar a Swift 6 es especialmente complejo en proyectos de gran tamaño que todavía dependen de patrones de concurrencia heredados como DispatchQueue, NSOperationQueue o sistemas personalizados de gestión de hilos. Estos proyectos suelen tener miles de líneas de código que no han sido adaptadas al nuevo modelo estructurado de concurrencia basado en async/await o actores. Reescribir todo de golpe no es realista, y tratar de hacerlo puede romper flujos de trabajo existentes, configuraciones de tests o APIs internas. Además, estos patrones legacy no se integran de forma natural con los nuevos requisitos de Sendable o la aislamiento con @MainActor, lo que provoca una avalancha de errores y advertencias de compilación. En estos casos, equilibrar seguridad y pragmatismo se vuelve fundamental — y esta guía está pensada precisamente para ayudarte a transitar ese camino.

Cabe recalcar que Swift 6 es un lenguaje en continua evolución y es posible que en el futuro algunos problemas o ventajas señaladas queden obsoletos por nuevas interacciones del lenguaje. Este articulo se refiere en todo momento a una migración a la version 6.0 de Swift.   @MainActor no es siempre Main Thread

Uno de los errores más comunes al migrar a Swift 6 con concurrencia estricta es pensar que @MainActorimplica automáticamente que nuestro código se ejecuta en el hilo principal (main thread).
 Spoiler: no es así.

¿Entonces qué hace exactamente @MainActor?

@MainActor garantiza acceso seguro y exclusivo al actor principal, pero no obliga a que el código se ejecute en el hilo principal inmediatamente.

Esto significa que, aunque estés dentro de una función @MainActor, podrías no estar en el hilo principal, dependiendo del contexto.  
Ejemplo 1: llamada simple desde una cola en background

final class UIUpdater {
   @MainActor func showResult() {
        print("🔵 En UIUpdater.showResult — ¿main thread?:", Thread.isMainThread)
    }
} final class Worker {
    let updater = UIUpdater()     func doWork() {
        DispatchQueue.global().async {
            print("⚪️ Empezamos en background — ¿main thread?:", Thread.isMainThread)             Task {
                await self.updater.showResult()
            }
        }
    }
}
Salida posible:

⚪️ Empezamos en background — ¿main thread?: false  
🔵 En UIUpdater.showResult — ¿main thread?: false

Aunque showResult() está en un actor, no se cambia automáticamente al hilo principal. El sistema puede ejecutarlo en la cola actual si no hay otras tareas pendientes en el actor que esten utilizando main thread.  

Ejemplo 2: completion handler con @MainActor

final class Worker {
    let updater = UIUpdater()     func doWork() {
            DispatchQueue.global().async {
                  simulateAsyncWork { result in
                        print("🔴 Completion handler — ¿main thread?:", Thread.isMainThread)
                        self.updater.showResult()
                  }
            }
    }     func simulateAsyncWork(completion: @escaping @MainActor (String) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            completion("Trabajo terminado")
        }
    }
}

Salida posible:

🔴 Completion handler — ¿main thread?: false
🟢 showResult() — ¿main thread?: false

Al igual que en el ejemplo anterior, aunque completion está encapsulado en un @MainActor, puede no cambiar automáticamente al hilo principal y tratar de ejecutarse en el hilo background actual.    ¿Y qué pasa con DispatchQueue.main.async?

A diferencia de @MainActor, la llamada explícita a DispatchQueue.main.async sí garantiza que el código se ejecutará en el hilo principal. Este enfoque es muy común antes de que Swift Concurrency llegase y sigue siendo una herramienta muy válida.

De hecho, cuando el compilador detecta esta sintaxis, asume que se está en el hilo principal, y por eso permite acceder a propiedades o métodos @MainActor sin advertencias, aunque el closure no esté marcado explícitamente como tal. Esto ocurre gracias a una optimización interna: el compilador marca estos closures como "implicitly isolated to MainActor" cuando están envueltos por DispatchQueue.main.async, RunLoop.main.perform, o similares. Esto evita errores de concurrencia sin necesidad de añadir Task { @MainActor in } ni await.

Como explica Ole Begemann, esta es una "promesa del programador" al compilador de que el contexto es seguro. Pero no es magia: el compilador no verifica en tiempo de ejecución si efectivamente estás en el hilo principal, solo confía en que DispatchQueue.main garantiza eso. Hay que asegurarse que efectivamente tu DispatchQueue esta correctamente configurado y siempre se lanza en main thread.

Como contrapartida, DispatchQueue.main.async no respeta el cooperativismo de los actores, lo que puede generar bloqueos o deadlocks si se abusa para tareas pesadas. No suspende la tarea actual ni libera el actor hasta que termina su ejecución.

Conclusión: es útil como herramienta puntual para saltar al hilo principal, especialmente dentro de código legacy que no esta adaptado al nuevo ecosistéma de actores o de async/await.   MainActor.assumeIsolated: una herramienta útil (pero peligrosa)

Aunque Swift 6 refuerza la seguridad de concurrencia, muchas APIs nativas de Apple —incluidos métodos críticos como awakeFromNib() o deinit de vistas aisladas en @MainActor— no están marcados con @MainActor, a pesar de que siempre se ejecutan en el hilo principal en la práctica.

Esto genera fricción: si intentamos acceder a una propiedad aislada por MainActor desde uno de estos métodos, el compilador lo marcará como error, incluso si sabemos que siempre se debería ejecutar en main thread.

Para estos casos, Swift ofrece una herramienta para que el compilador no se queje: MainActor.assumeIsolated { ... }. Esta función nos permite decir explícitamente:

“Sé que estoy en el MainActor, confía en mí”. O mejor dicho, sé que estoy en main thread y que no va a haber problemas para acceder a este recurso o instancia.

@IBOutlet weak var label: UILabel!

override func awakeFromNib() {
    MainActor.assumeIsolated {
        label.text = "¡Hola desde el MainActor!"
    }
}

Esta solución no cambia de hilo ni lo aísla por ti. Solo es válida si realmente estás en el hilo principal, como ocurre al inicializar vistas desde nibs o storyboards y el compilador no es capaz de inferirlo.

Advertencia: usar assumeIsolated es una promesa al compilador. Si te equivocas y efectivamente no estas en MainActor o MainThread entonces tendrás problemas en runtime y la app puede darte crash al ejecutar ese bloque de codigo.
Aislar lo justo: el truco de lazy var con MainActor

Una de las limitaciones al usar @MainActor es que si declaramos una propiedad de una clase como:

@MainActor let updater = UIUpdater()

el compilador nos obliga a que el init de la clase también esté aislado:

@MainActor init() { ... }

Esto puede ser un problema, especialmente en clases que no disponen de metodos asincronos y son dependencias que no pueden aislarse completamente en @MainActor ya que implicaría aislar otras clases que la usen y propagarlo por toda tu app.

Una solución práctica es declarar la propiedad como lazy var. De esta forma, su inicialización se retrasa hasta que se accede, y solo el método que la usa deberá estar en el contexto del MainActor:

class ViewModel {
    @MainActor lazy var updater = UIUpdater()
    
    func init() {}     @MainActor
    func updateUI() {
        updater.showResult()
    }
}

Este enfoque mantiene la arquitectura limpia y minimiza las anotaciones de actor donde no son necesarias.   Cuando no puedes reescribir todo: @unchecked Sendable al rescate

En muchos proyectos hay clases que ya gestionan el acceso concurrente con DispatchQueue, NSLock o OperationQueue. Estas clases suelen tener propiedades mutables compartidas, como caches, servicios o delegados, y adaptarlas a Sendable puede ser casi imposible sin reescribir gran parte del código.

Ahí es donde entra @unchecked Sendable: una forma de decirle al compilador “confía en mí, ya me encargo yo de hacerlo seguro”. No asegura nada de forma concurrente, pero permite que el código compile cuando sabemos que ya estamos gestionando correctamente el acceso:

final class SessionManager:

@unchecked Sendable {
    private let syncQueue = DispatchQueue(label: "SessionManagerQueue")
    private var sessions: [String: Session] = [:]     func save(_ session: Session) {
        syncQueue.async {
            self.sessions[session.id] = session
        }
    }
}

Este tipo de código es común en muchas aplicaciones actuales. En lugar de rediseñar toda la estructura para hacerla automáticamente segura con Sendable, se puede documentar y marcar como @unchecked Sendable, manteniendo el control sobre la sincronización manual y asegurando la concurrencia.  

Migración progresiva con @preconcurrency

Cuando trabajamos en una app dividida en múltiples módulos o frameworks, migrar todo a la vez al nuevo modelo de concurrencia estricta es inviable, la lista de errores puede ser interminable. Aquí es donde @preconcurrency se vuelve una herramienta fundamental.

Este atributo permite declarar que un protocolo, tipo o importación está pensado para ser usado con código que aún no sigue las reglas estrictas de concurrencia. Al aplicarlo, evitamos que el compilador nos obligue a anotar todo con Sendable o @MainActor de golpe.

Por ejemplo, puedes marcar la importación de un framework que aún no has migrado, para que no contamine tu código más moderno:

@preconcurrency import LegacyNetworking

También puedes usarlo en protocolos que se usan desde clases nuevas, pero que todavía no pueden migrarse del todo:

@preconcurrency
protocol LegacyDelegate: AnyObject {
    func didFinish()
}

Esto es especialmente útil si tienes clases que sí están en @MainActor, pero deben conformar a protocolos antiguos. Sin @preconcurrency, el compilador te forzaría a anotar todo el protocolo como @MainActor también, lo cual puede no ser viable todavía.

Otra estrategia frecuente es usar @preconcurrency en extensiones donde se implementan estos protocolos:

extension ViewController:

@preconcurrency LegacyDelegate {
    @MainActor func didFinish() {
        // ejecución en MainActor
    }
}

De esta forma, el compilador permite la implementación dentro de un contexto @MainActor, aunque el protocolo no lo esté.

En resumen, @preconcurrency es una herramienta clave para migrar por capas: puedes ir migrando modulo a modulo para no romper nada y asegurar la concurrencia de forma gradual.  

XCTest y los límites con MainActor

Uno de los grandes obstáculos al migrar a concurrencia estricta es el sistema de testing tradicional. XCTest fue diseñado mucho antes del modelo de actores, y eso genera conflictos serios cuando trabajamos con tipos anotados con @MainActor.

Problemas comunes con XCTest

Algunas limitaciones frecuentes:

.   No puedes anotar el setUp() o tearDown() con @MainActor, porque son override de métodos heredados de XCTestCase.

.   No puedes acceder directamente desde esos métodos a propiedades marcadas como @MainActor, ni crear instancias que lo requieran.

.   No puedes esperar await dentro de setUp(), a menos que uses hacks como Task.runDetached o expectation.  

Entra Swift Testing: diseñado para la concurrencia moderna

Con Swift 6 y el nuevo Swift Testing framework resolvemos estos problemas:

.   Puedes marcar toda la prueba o el setUp() con @MainActor, sin hacks.

.   Puedes usar async let y await de forma nativa en todas las fases del test.

.   Puedes definir tests como funciones async, sin necesidad de expectation ni wait().

Ejemplo:

@MainActor
@testable import MyApp struct MyTests: Test {
    @MainActor var updater: UIUpdater
    
    @MainActor
    func setUp() {
        updater = UIUpdater()
    }     func testButtonTitle() async {
        await updater.load()
        XCTAssertEqual(updater.title, "Welcome")
    }
}

Esto no solo es más limpio: el compilador puede verificar las reglas de aislamiento desde el principio, sin inconsistencias. Al ser parte del nuevo modelo, Swift Testing permite una integración nativa con @MainActor, Sendable, isolated, y otras restricciones de Swift Concurrency.   Conclusiones: un camino estricto hacia un futuro más seguro (¿y flexible?)

Apple ha apostado fuerte por la concurrencia segura en Swift 6. Con la introducción de Sendable, MainActor y el enforcement estricto del compilador, el lenguaje se ha convertido en uno de los más exigentes a la hora de detectar errores en tiempo de compilación. Y eso, aunque beneficioso a largo plazo, ha hecho que migrar proyectos reales sea una tarea complicada, poco flexible y en muchos casos frustrante.

Las herramientas como @unchecked Sendable, @preconcurrency, o incluso MainActor.assumeIsolated existen como parches temporales para poder avanzar sin reescribir toda una base de código desde cero. Pero no dejan de ser soluciones reactivas, y muchas veces dificiles de encontrar, ante una transición que muchos equipos no pueden abordar de forma global.

Swift 6 se ha diseñado como si partiéramos de una base ideal: sin mutabilidad compartida, sin controladores, sin singletons. Pero la realidad es distinta, especialmente en apps con años de historia.  
¿El futuro? Todo apunta a que será más suave.

Es probable viendo la evolución de Swift 6 que en el futuro se adopte una filosofía inversa a la actual: en lugar de asumir que todo es concurrente y aislado, se dará por hecho que el código UI corre en el main thread salvo que se indique lo contrario. Esto aligeraría muchísimo la carga de anotar código, haría más natural la escritura de tests, y acercaría Swift a lo que otros lenguajes ya permiten con menos fricción.  
Mientras tanto, el camino es exigente, pero vale la pena. Las recompensas —apps más estables, menos errores de concurrencia, y herramientas más potentes para asegurar la concurrencia— compensan el esfuerzo. Solo necesitamos que Apple también piense en los equipos que todavía caminan, no solo en los que ya corren.