Posts

Arquitectura SwiftUI y concurrencia: organiza tu código según el estado

Arturo Rivas Arias

Con Swift 6, organizar una aplicación SwiftUI ya no consiste únicamente en separar vistas, modelos y servicios. También hay que decidir qué código puede acceder a cada estado y desde qué contexto de concurrencia. Muchos errores relacionados con Sendable o @MainActor aparecen precisamente porque esas responsabilidades se han mezclado.

Una forma práctica de diseñar la arquitectura es hacerse una pregunta antes de crear cada tipo: ¿qué estado necesita conservar? A partir de la respuesta podemos distinguir el estado de la interfaz, el estado compartido que no pertenece a la interfaz y las operaciones que no necesitan guardar estado.

El estado de la interfaz pertenece a MainActor

Una vista necesita datos para mostrar contenido, indicar que se está cargando o alertar de un error. Si utilizamos un modelo observable para gestionar esos valores, tiene sentido aislarlo al actor principal.

@Observable permite que SwiftUI siga las propiedades utilizadas por una vista, pero no implica por sí mismo aislamiento a MainActor. Conviene declararlo expresamente cuando ese es el contrato del modelo, especialmente en proyectos que no tienen activado el aislamiento al actor principal por defecto.

Imaginemos una aplicación con una agenda de fenómenos astronómicos:

import Observation

@MainActor
@Observable
final class SkyAgenda {
    private(set) var events: [SkyEvent] = []
    private(set) var isLoading = false
    private(set) var errorMessage: String?

    private let source: any SkyEventSource

    init(source: any SkyEventSource) {
        self.source = source
    }

    func refresh() async {
        isLoading = true
        defer { isLoading = false }

        do {
            events = try await source.load()
            errorMessage = nil
        } catch is CancellationError {
            return
        } catch {
            errorMessage = "No se pudo actualizar la agenda"
        }
    }
}

El modelo gestiona únicamente información necesaria para presentar la pantalla. La obtención y el procesamiento de los datos se delegan en SkyEventSource. Así, la vista puede conservar una instancia con @State y ejecutar refresh() desde .task, aprovechando la cancelación asociada al ciclo de vida de la vista.

Servicios sin estado y valores Sendable

Un servicio que recibe datos, los transforma y devuelve un resultado no tiene por qué ser una clase con propiedades modificables. Una estructura sin estado compartido resulta mucho más sencilla de utilizar entre distintos dominios de aislamiento.

import Foundation

nonisolated struct SkyEvent: Identifiable, Decodable, Sendable {
    let id: UUID
    let title: String
    let date: Date
}

nonisolated protocol SkyEventSource: Sendable {
    func load() async throws -> [SkyEvent]
}

nonisolated struct JSONSkyEventSource: SkyEventSource {
    let data: Data

    @concurrent
    func load() async throws -> [SkyEvent] {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode([SkyEvent].self, from: data)
    }
}

Este ejemplo utiliza datos JSON recibidos previamente, por lo que podemos centrarnos en la operación de decodificación. SkyEvent cumple Sendable porque sus propiedades también lo cumplen; ser una estructura no garantiza automáticamente esa conformidad si contiene referencias mutables no seguras.

El protocolo exige Sendable a sus implementaciones, lo que facilita inyectar distintos servicios y sustituirlos por versiones de prueba o para las previews.

async no significa ejecutar fuera del actor principal

Aquí aparece un detalle importante introducido en Swift 6.2. Con el comportamiento nonisolated(nonsending) habilitado, una función async no aislada puede continuar ejecutándose en el actor de quien la invoca. Escribir await no traslada automáticamente un cálculo pesado a otro ejecutor.

Para operaciones que consumen CPU, como decodificar una respuesta especialmente grande o procesar miles de registros, @concurrent expresa que queremos ejecutar la función fuera del aislamiento del actor que la llama. No significa crear manualmente un hilo, ni es necesario aplicarlo indiscriminadamente a todas las operaciones de red.

Por eso interesa que las funciones de procesamiento reciban valores Sendable y devuelvan resultados del mismo tipo, sin acceder directamente a las propiedades del modelo observable.

¿Y si necesitamos conservar estado fuera de la interfaz?

Una caché compartida, un gestor de sesiones o un registro de operaciones pendientes sí necesitan mantener información modificable. Sin embargo, esa información no tiene por qué vivir en un modelo de la interfaz ni quedar aislada al actor principal.

En estos casos, un actor ofrece una frontera clara:

actor SkyEventCache {
    private var stored: [UUID: SkyEvent] = [:]

    func event(id: UUID) -> SkyEvent? {
        stored[id]
    }

    func save(_ event: SkyEvent) {
        stored[event.id] = event
    }
}

El actor coordina el acceso a su estado para evitar modificaciones simultáneas no seguras. Eso sí, un actor puede permitir que otras operaciones avancen cuando un método se suspende en un await; no debemos suponer que una secuencia completa de operaciones asíncronas es indivisible. Esto se conoce como re-entrada de actores.

Una arquitectura que refleja las reglas de concurrencia

La idea puede resumirse en tres decisiones: el estado visible para SwiftUI permanece en un modelo aislado a MainActor; el estado compartido independiente de la interfaz se protege mediante actores; y el trabajo sin estado se expresa mediante servicios y funciones que intercambian valores Sendable.

También importa la configuración del proyecto. En aplicaciones con aislamiento al actor principal por defecto, como las creadas con las opciones más modernas de Xcode, nonisolated sirve para excluir de ese aislamiento a los tipos que no necesitan pertenecer a él. En proyectos antiguos, esos modificadores pueden no hacer falta, pero las anotaciones @MainActor deben revisarse igualmente.

El objetivo no es silenciar advertencias añadiendo atributos hasta que compile. Es dar a cada estado un propietario y a cada operación un contexto de ejecución claro. Cuando la arquitectura expresa esas decisiones, las comprobaciones de concurrencia de Swift dejan de ser un obstáculo y se convierten en una ayuda para detectar errores antes de ejecutar la aplicación.