Protocolos y @MainActor en Swift: dónde colocar el aislamiento
Arturo Rivas Arias
La adopción de la concurrencia estricta de Swift ha convertido el aislamiento en una parte esencial del diseño de una API. Ya no basta con saber que una implementación termina actualizando la interfaz: el compilador también necesita conocer desde qué dominio de aislamiento puede invocarse cada operación.
Esta diferencia se vuelve especialmente importante al trabajar con protocolos. Colocar @MainActor sobre un protocolo completo, sobre uno de sus requisitos o sobre una conformidad no expresa lo mismo. Aunque las tres formas se parecen visualmente, cada una establece un contrato distinto para los tipos que adopten ese protocolo.
@MainActor forma parte del contrato
MainActor es un actor global que representa el dominio de aislamiento utilizado por la interfaz de usuario. En las plataformas de Apple, su ejecutor está integrado con el hilo principal. Anotar una declaración con @MainActor permite que el compilador compruebe que solo se accede a ella de forma síncrona desde ese mismo actor; desde otro contexto será necesario realizar un salto a otro dominio de aislamiento mediante una llamada asíncrona.
Un protocolo también puede aislarse por completo:
@MainActor
protocol PlaybackControlsRendering: AnyObject {
func showPlaying(title: String)
func showPaused()
}
En este caso, @MainActor no afecta únicamente a los dos métodos. El propio protocolo está aislado y la conformidad exige ese aislamiento. Si un tipo declara la conformidad en su definición principal, Swift puede inferir que el tipo completo pertenece al actor principal:
final class MiniPlayerController: PlaybackControlsRendering {
private var currentTitle = ""
func showPlaying(title: String) {
currentTitle = title
// Actualización de la interfaz
}
func showPaused() {
// Actualización de la interfaz
}
}
Aunque MiniPlayerController no contiene la anotación de forma visible, su conformidad con PlaybackControlsRendering provoca que infiera el aislamiento de MainActor. Sus inicializadores, propiedades y métodos de instancia quedan sujetos a ese actor, no solo las dos funciones requeridas por el protocolo.
Esta inferencia es segura, pero puede resultar poco evidente al leer una lista larga de conformidades. Cuando el tipo es conceptualmente parte de la capa de interfaz, declarar su aislamiento de forma explícita suele comunicar mejor la intención:
@MainActor
final class MiniPlayerController: PlaybackControlsRendering {
// Toda la implementación pertenece deliberadamente a MainActor.
}
La conformidad en una extensión cambia el alcance
Si el tipo debe continuar siendo no aislado, la conformidad puede declararse en una extensión. De este modo, el protocolo sigue exigiendo que sus requisitos se ejecuten en el actor principal, pero el resto del tipo no hereda ese aislamiento:
final class DownloadCoordinator: Sendable {
func beginDownload() async throws {
// Trabajo que no necesita ejecutarse en MainActor
}
}
extension DownloadCoordinator: PlaybackControlsRendering {
@MainActor
func showPlaying(title: String) {
// Solo este requisito salta al actor principal
}
@MainActor
func showPaused() {
// Solo este requisito salta al actor principal
}
}
La extensión actúa como una frontera: DownloadCoordinator continúa siendo un tipo no aislado y únicamente los métodos y/o propiedades de la conformidad quedan ligados a MainActor. Las anotaciones de los métodos pueden inferirse a partir del protocolo, pero escribirlas explícitamente hace que la restricción sea visible sin necesidad de consultar su declaración.
Desde Swift 6.1 también puede utilizarse nonisolated para deshacer de forma explícita la inferencia de un actor global. Esta opción es especialmente útil cuando la conformidad debe aparecer en la declaración principal o cuando se quiere evitar que un cambio posterior en la organización del código altere el aislamiento del tipo:
nonisolated final class DownloadCoordinator: PlaybackControlsRendering, Sendable {
@MainActor
func showPlaying(title: String) {
// Actualización de la interfaz
}
@MainActor
func showPaused() {
// Actualización de la interfaz
}
func beginDownload() async throws {
// El tipo no ha heredado MainActor del protocolo.
}
}
El conflicto con los actores personalizados
Un actor protege su estado mediante su propio ejecutor. Por eso no puede adoptar sin más un protocolo completamente aislado a otro actor global:
actor MediaImportService {
private var importedFiles = 0
}
// Error: MediaImportService no puede adoptar un protocolo
// completamente aislado a MainActor.
extension MediaImportService: PlaybackControlsRendering {
// ...
}
MediaImportService ya está aislado a cada una de sus instancias, mientras que PlaybackControlsRendering exige que el tipo conforme pertenezca a MainActor. No se trata simplemente de ejecutar dos métodos en el hilo principal: ambas declaraciones intentan establecer dominios de aislamiento incompatibles para el tipo.
La solución más flexible consiste en no aislar el protocolo completo y marcar únicamente los requisitos que realmente interactúan con la interfaz.
Aislar requisitos individuales
Un protocolo no aislado puede combinar operaciones disponibles desde cualquier contexto con otras que deban ejecutarse en MainActor:
struct ImportProgress: Sendable {
let completedItems: Int
let totalItems: Int
}
protocol ImportFeedback: Sendable {
func shouldReportProgress() async -> Bool
@MainActor
func display(_ progress: ImportProgress)
@MainActor
func displayCompletion(importedItems: Int)
}
Ahora un actor personalizado puede adoptar el protocolo. Conserva su aislamiento para consultar su estado y cambia al actor principal únicamente al ejecutar las operaciones de presentación:
actor PhotoImportCoordinator: ImportFeedback {
private var reportingEnabled = true
func shouldReportProgress() async -> Bool {
reportingEnabled
}
@MainActor
func display(_ progress: ImportProgress) {
ImportOverlay.shared.update(
completed: progress.completedItems,
total: progress.totalItems
)
}
@MainActor
func displayCompletion(importedItems: Int) {
ImportOverlay.shared.finish(count: importedItems)
}
}
La llamada conserva las restricciones definidas por el protocolo. Un consumidor puede consultar al actor y, si corresponde, saltar después a MainActor para actualizar la interfaz:
func report(
_ progress: ImportProgress,
using feedback: some ImportFeedback
) async {
guard await feedback.shouldReportProgress() else {
return
}
await feedback.display(progress)
}
La presencia de await no significa necesariamente que vaya a crearse un hilo nuevo. Indica que la ejecución puede suspenderse para entrar en el dominio de aislamiento correcto: primero el del actor que implementa shouldReportProgress() y después el de MainActor para display(_:).
Por qué un requisito async aporta flexibilidad
Los requisitos síncronos de un protocolo no aislado se consideran invocables desde un contexto no aislado. Un actor no puede satisfacer uno de esos requisitos con un método que necesite acceder a su estado protegido:
protocol ImportPolicy {
func canImportMoreItems() -> Bool
}
actor ImportQuota: ImportPolicy {
private var remainingItems = 100
// Error: un método aislado al actor no puede satisfacer
// un requisito síncrono y no aislado.
func canImportMoreItems() -> Bool {
remainingItems > 0
}
}
Una posible solución sería declarar la implementación como nonisolated, pero entonces no podría leer remainingItems. Si la decisión depende del estado del actor, el contrato correcto es asíncrono:
protocol ImportPolicy {
func canImportMoreItems() async -> Bool
}
actor ImportQuota: ImportPolicy {
private var remainingItems = 100
func canImportMoreItems() -> Bool {
remainingItems > 0
}
}
El requisito async permite que cada implementación determine su aislamiento. Un tipo ordinario podrá responder directamente, mientras que un actor podrá ejecutar el método en su propio dominio y el consumidor utilizará siempre await de forma uniforme.
Un protocolo aislado no es una conformidad aislada
Swift 6.2 incorporó las conformidades aisladas mediante SE-0470. Esta herramienta resuelve el problema inverso: un tipo aislado a MainActor necesita adoptar un protocolo que, por sí mismo, no está aislado.
@MainActor
final class AlbumRowModel: @MainActor Equatable {
let identifier: UUID
var isSelected = false
init(identifier: UUID) {
self.identifier = identifier
}
static func == (lhs: AlbumRowModel, rhs: AlbumRowModel) -> Bool {
lhs.identifier == rhs.identifier &&
lhs.isSelected == rhs.isSelected
}
}
Equatable continúa siendo un protocolo no aislado. Lo que está restringido a MainActor es la conformidad concreta de AlbumRowModel, por lo que solo puede utilizarse desde ese dominio. Esto permite que la implementación de == acceda de forma segura a propiedades aisladas sin convertir Equatable en un protocolo de interfaz.
Las dos formas expresan contratos diferentes:
| Declaración | Significado |
|---|---|
@MainActor protocol P | El protocolo y todos sus requisitos pertenecen a MainActor; una conformidad directa puede propagar ese aislamiento al tipo. |
protocol P { @MainActor func f() } | Solo el requisito f() está aislado; el tipo conforme puede pertenecer a otro actor. |
Type: @MainActor P | P no tiene por qué estar aislado, pero esa conformidad concreta solo puede usarse desde MainActor. |
nonisolated Type: P | El tipo corta la inferencia de actor global que podría recibir a través de sus conformidades. |
El efecto de Default Actor Isolation
Desde Swift 6.2, un módulo puede configurar MainActor como aislamiento predeterminado. En un paquete Swift puede expresarse en el manifiesto:
.target(
name: "GalleryFeature",
swiftSettings: [
.defaultIsolation(MainActor.self)
]
)
Con esta configuración, muchas declaraciones que antes eran no aisladas pasan a inferir MainActor. Esto reduce anotaciones en aplicaciones centradas en la interfaz, pero también puede ocultar el origen de una conformidad o hacer que modelos, servicios y utilidades terminen ligados al actor principal sin necesitarlo.
La configuración predeterminada no sustituye al diseño de la API. Los protocolos de una biblioteca o de una capa de dominio deberían expresar su aislamiento según su semántica, no según el ajuste del módulo donde se definieron. nonisolated, los requisitos async y el aislamiento granular permiten conservar esas fronteras incluso cuando la mayor parte de la aplicación se ejecuta en MainActor.
Qué estrategia elegir
Un protocolo completamente aislado a MainActor encaja cuando todas sus conformidades representan objetos de interfaz: renderizadores, controladores, coordinadores visuales o tipos cuya identidad solo tiene sentido en el actor principal.
Cuando el protocolo mezcla lógica de dominio y presentación, resulta preferible aislar únicamente los requisitos que lo necesiten. Esta decisión permite que clases no aisladas y actores personalizados compartan la misma abstracción sin trasladar todo su trabajo al actor principal.
Los requisitos síncronos no aislados deben reservarse para operaciones que puedan ejecutarse realmente desde cualquier contexto. Si una implementación necesita consultar el estado de un actor, convertir el requisito en async expresa el coste de aislamiento en la propia API y evita recurrir a saltos internos difíciles de trazar.
Por último, una conformidad aislada es adecuada cuando el protocolo es general, pero una implementación concreta solo puede utilizarlo dentro de MainActor. Diferenciar estas tres decisiones —aislamiento del protocolo, del requisito y de la conformidad— evita gran parte de los diagnósticos confusos que aparecen al activar la concurrencia estricta en Swift 6.