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.