wandres.dev
WIDGETS Y APP INTENTS · fuera de la app

Compartir datos con la app: grupo, contenedor e invalidación

La app y su widget son dos procesos con dos cajas de arena distintas que no comparten absolutamente nada por defecto. Esta lección construye el puente completo: qué es exactamente un app group y qué frontera de seguridad establece, cómo se materializa el contenedor compartido para preferencias, archivos, bases de datos y secretos, y por qué la pieza que más veces falla no es la escritura del dato sino el aviso que debería seguirla.

⏱ 17 min

Un widget que no ve los datos de su app no está roto: está funcionando exactamente como el sistema promete. La app y la extensión son dos binarios distintos, en dos procesos distintos, con dos contenedores de datos distintos, y ninguna de las dos puede leer el sistema de archivos de la otra. El puente no se improvisa con una ruta absoluta ni con un singleton compartido, porque no existe memoria común; se declara como una capacidad, se materializa como un directorio que ambos identificadores pueden abrir y se recorre con las mismas cautelas que cualquier recurso concurrente entre procesos. Y una vez construido, queda la mitad que casi todo el mundo olvida: escribir el dato nuevo no hace que el widget lo muestre. Hay que avisar.

🎯 Al terminar esta lección sabrás
  • Explicar qué frontera de seguridad establece un app group y cómo se resuelve su contenedor en tiempo de ejecución.
  • Elegir con criterio entre preferencias, archivos, base de datos y llavero para el material compartido.
  • Invalidar la línea temporal desde la app en el momento correcto y con el alcance correcto.
  • Diseñar una carga útil pequeña y versionada en lugar de exponer el modelo completo a la extensión.

Dos cajas de arena y una puerta declarada

Cada proceso de una app de iOS vive en un contenedor propio cuya raíz solo él conoce. Una extensión, aunque se distribuya dentro del mismo paquete y lleve el mismo prefijo de identificador, tiene el suyo. El app group es una capacidad que se solicita en el portal de desarrollo, se activa en ambos objetivos del proyecto y se materializa como un tercer contenedor que el sistema abre a todo binario firmado por el mismo equipo que declare ese identificador. La confianza no la da la ruta: la dan la firma y el perfil de aprovisionamiento.

La regla operativa que se sigue de ahí es que la ruta jamás se escribe a mano. Se pregunta al sistema, siempre, y desde ambos lados con la misma constante compartida, porque un error de una letra no falla en compilación sino en ejecución y solo en el dispositivo.

enum Compartido {
    static let grupo = "group.com.ejemplo.finanzas"

    static var contenedor: URL {
        FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: grupo)!
    }

    static let ajustes = UserDefaults(suiteName: grupo)!
}

Conviene entender qué queda dentro y qué queda fuera de esa puerta. Dentro caben preferencias con nombre de suite, archivos bajo el contenedor devuelto, bases de datos alojadas en él y —con una llave aparte, el grupo de acceso al llavero— los secretos. Fuera queda todo lo demás: el contenedor privado de cada objetivo, la caché del sistema, los archivos temporales y cualquier ruta obtenida antes de conceder la capacidad. Y hay un matiz que muerde en producción: el contenedor compartido es accesible mientras el dispositivo esté desbloqueado o según la clase de protección del archivo, de modo que un widget de la pantalla de bloqueo puede encontrarse leyendo un archivo que aún no se puede descifrar.

Qué guardar y en qué forma

La tentación es dar a la extensión acceso al mismo modelo que usa la app. Casi siempre es mala idea. El widget dispone de segundos y de un techo de memoria estrecho; abrir una base de datos grande, migrar un esquema o deserializar miles de objetos para pintar dos cifras es un desperdicio que además puede terminar en terminación por memoria. La estrategia sólida es escribir, desde la app, una carga útil de widget: una estructura mínima, versionada y ya calculada, que la extensión solo tiene que leer y dibujar.

struct CargaWidget: Codable {
    let version: Int          // sube al cambiar la forma
    let saldo: Decimal
    let etiqueta: String
    let capturadoEn: Date
}

func publicar(_ carga: CargaWidget) throws {
    let url = Compartido.contenedor.appending(path: "widget.json")
    let datos = try JSONEncoder().encode(carga)
    try datos.write(to: url, options: [.atomic, .completeFileProtectionUnlessOpen])
    WidgetCenter.shared.reloadTimelines(ofKind: "SaldoWidget")
}

La escritura atómica no es un adorno: sin ella, la extensión puede despertar justo en mitad de un guardado y leer un archivo truncado. El campo de versión tampoco lo es, porque la app y la extensión se actualizan juntas pero el archivo sobrevive a la actualización: una extensión nueva puede encontrarse una carga vieja escrita antes de que el usuario abriera la app. Leer con tolerancia y degradar a un estado vacío es más barato que cualquier migración.

🧩

Preferencias de suite

Para escalares, banderas y ajustes pequeños. Cómodas y rápidas, pero sin garantías transaccionales y con un tamaño que no conviene forzar.

📄

Archivo en el contenedor

La opción por defecto para una carga útil. Escritura atómica, formato versionado, coste de lectura constante y control total sobre la protección de datos.

🗄️

Base de datos compartida

Cuando la extensión necesita consultar de verdad. Exige alojar el almacén en el grupo y asumir el coste de abrirlo dentro del presupuesto del widget.

🔑

Llavero con grupo de acceso

El único sitio legítimo para credenciales. Recuerda que la clase de protección decide si el widget podrá leerlas con el dispositivo bloqueado.

Si el proyecto usa un almacén moderno de persistencia, compartirlo es cuestión de configurar el contenedor del grupo al construirlo, y entonces la extensión puede ejecutar consultas acotadas en lugar de leer un archivo. Es la opción correcta cuando el widget es configurable y la consulta depende de la elección del usuario; es la incorrecta cuando el dato es único y podría haberse precalculado. La pregunta que decide no es cuál es más elegante sino cuánta memoria y cuántos milisegundos consume la primera lectura en frío.

El eslabón que falla: invalidar

Escribir en el contenedor no despierta a nadie. No hay observadores entre procesos, no hay notificaciones automáticas y el widget no vigila el sistema de archivos. La única forma de que el sistema pida una línea temporal nueva antes de lo previsto es que alguien lo solicite explícitamente, y ese alguien casi siempre debe ser la app.

WidgetCenter.shared.reloadTimelines(ofKind: "SaldoWidget")   // preciso
WidgetCenter.shared.reloadAllTimelines()                     // caro, ultimo recurso

let instalados = await WidgetCenter.shared.currentConfigurations()

Pedir la recarga por tipo concreto es siempre preferible: recargar todo obliga al sistema a ejecutar todos los proveedores del paquete y consume presupuesto de widgets que ni siquiera cambiaron. Consultar las configuraciones instaladas antes de trabajar añade otro ahorro nada trivial: si nadie ha añadido tu widget, todo el cálculo de la carga útil sobra.

flowchart LR
a[La app cambia un dato] --> b[Escribe carga util atomica en el contenedor]
b --> c[Pide recarga por tipo]
c --> d[El sistema arranca la extension]
d --> e[El proveedor lee la carga util]
e --> f[Devuelve entradas ya dibujables]
g[Push silencioso o tarea en segundo plano] --> b
h[Intent ejecutado desde el widget] --> b

El momento del aviso importa tanto como el aviso. Los puntos naturales son el guardado de una edición, el final de una sincronización, la recepción de una notificación silenciosa, el cierre de una tarea en segundo plano y la ejecución de una acción desde el propio widget. En cambio, invalidar dentro de un bucle de escritura o en cada pulsación de teclado es una forma eficaz de gastar el presupuesto del día antes de comer. Y conviene recordar la asimetría clave del sistema: las recargas solicitadas mientras la app está en primer plano no se descuentan del presupuesto, las demás sí.

⚠️
Tres fallos que parecen bugs del sistema y no lo son

El widget muestra datos viejos porque nadie llamó a la invalidación tras escribir. El widget aparece vacío en el dispositivo pero funciona en el simulador porque el grupo no está en el perfil de aprovisionamiento de la extensión, solo en el de la app. Y el widget de pantalla de bloqueo falla justo por la mañana porque la carga útil se guardó con una protección que exige el dispositivo desbloqueado. Ninguno de los tres produce un error visible: producen silencio.

De vuelta a la app: enlaces y coherencia

El puente tiene dirección inversa. Tocar el widget debe llevar a la app al sitio exacto que muestra, y el mecanismo es una URL propia: un enlace en toda la superficie para las familias pequeñas, o zonas distintas con destinos distintos en las medianas y grandes. Ese enlace no es decorativo, es parte del contrato de datos: la carga útil debe llevar el identificador necesario para reconstruir el destino, porque la app recibirá una URL y nada más.

// En la vista del widget
VStack { ... }
    .widgetURL(URL(string: "finanzas://cuenta/\(entry.cuentaID)"))

// En la app
.onOpenURL { url in ruta.abrir(url) }

Hay una asimetría de arranque que conviene prever. Cuando la app se abre desde un enlace del widget, puede estar arrancando en frío: no hay pila de navegación, no hay sesión validada y quizá ni siquiera datos cargados. Guardar el destino y aplicarlo cuando el estado esté listo es más robusto que intentar navegar dentro del propio manejador, que es donde suelen aparecer las pantallas en blanco y los saltos dobles.

@Observable final class Rutas {
    var pendiente: Destino?
    func aplicar() { /* se llama cuando la app ya esta lista */ }
}

Queda una obligación de coherencia que se olvida al desinstalar funciones. Si el usuario cierra sesión, borra la cuenta o revoca un permiso, el contenedor compartido sigue conteniendo lo que había: el widget seguirá mostrando el saldo de una sesión cerrada hasta que alguien lo limpie. Todo flujo de salida debe borrar la carga útil, escribir un estado neutro y volver a invalidar, en ese orden. Tratar el contenedor como parte del ciclo de vida de la sesión, y no como un caché anónimo, es lo que evita capturas de pantalla incómodas y filtraciones tontas en dispositivos compartidos.

El app group es una frontera de confianza disfrazada de carpeta

Merece la pena mirar el app group por lo que realmente es, porque el nombre engaña: parece una carpeta y en realidad es una superficie de interfaz entre procesos con distinta autoridad. En el momento en que escribes ahí un archivo dejas de estar en un programa y pasas a estar en un protocolo: hay un productor, hay uno o varios consumidores que se ejecutan cuando tú ya no existes, hay versiones que se cruzan porque cada binario se actualiza en un instante distinto, hay concurrencia real sin bloqueos compartidos y hay una política de cifrado que puede negar la lectura según el estado del dispositivo. Todas las disciplinas que se aplican a una API pública se aplican aquí y por las mismas razones: forma explícita y versionada, escrituras atómicas, lectores tolerantes que degradan en lugar de fallar, y ningún supuesto sobre quién escribió último. La razón por la que este tema aparece en un temario de widgets y no en uno de arquitectura es puramente accidental: es el primer sitio donde una app de iOS se ve obligada a admitir que no es un único programa sino una pequeña federación de procesos que comparten un contrato. Los equipos que lo entienden aquí escriben mejores extensiones de compartir, mejores controles, mejores intents y mejores actividades en vivo, porque todo eso es el mismo problema con otra fachada.

📝
Lo esencial

La app y el widget no comparten nada hasta que un app group lo declara; la ruta se pregunta al sistema y nunca se escribe a mano. Publica una carga útil pequeña, versionada y escrita de forma atómica en lugar de exponer el modelo completo, y elige preferencias, archivo, base de datos o llavero según el coste de la primera lectura en frío. Escribir no basta: hay que invalidar por tipo concreto y en los momentos correctos. Y limpia el contenedor al cerrar sesión, porque el widget sobrevive a la sesión que lo llenó.

⚔️ Construir el puente completo y romperlo a propósito
  1. Declara el app group en ambos objetivos, resuelve el contenedor desde una única constante compartida y verifica la ruta en dispositivo real, no solo en simulador.
  2. Diseña una carga útil versionada con lo estrictamente necesario para dibujar el widget y publícala de forma atómica desde el punto de guardado de tu app.
  3. Simula una carga escrita por una versión anterior y comprueba que la extensión degrada a un estado neutro en lugar de fallar.
  4. Instrumenta las invalidaciones y mide cuántas emites en una sesión típica; elimina las que no correspondan a un cambio observable en pantalla.
  5. Cierra sesión y confirma que el widget queda en estado neutro; si sigue mostrando datos, añade el borrado del contenedor al flujo de salida.