WidgetKit: el modelo de timeline
Un widget no es tu app en miniatura ejecutándose en la pantalla de inicio: es un archivo de vistas ya dibujadas, cada una con su fecha de validez, que el sistema muestra sin ejecutar una línea de tu código. Esta lección reconstruye ese modelo desde el ciclo de vida real de la extensión, desmonta el papel de cada método del proveedor, explica el presupuesto de recargas que nadie controla y muestra cómo diseñar entradas que envejezcan bien en lugar de pedir refrescos que jamás llegarán.
La pregunta que hunde a casi todo el mundo la primera vez es por qué el widget sigue mostrando el dato de hace tres horas. La respuesta exige abandonar una intuición cómoda: un widget no es una vista de tu app ejecutándose en la pantalla de inicio. Es un archivo de vistas ya renderizadas, cada una etiquetada con la fecha a partir de la cual es válida, que el sistema guarda y dibuja sin arrancar tu código. Tu extensión vive unos segundos, produce ese archivo y muere. Todo lo demás —cuándo se dibuja cada entrada, cuánto dura, cuándo se te vuelve a llamar— lo decide WidgetKit con un presupuesto que no controlas y que no negocia. Programar widgets es, por tanto, programar en tiempo diferido: escribir ahora lo que se verá dentro de seis horas, sin estar presente cuando ocurra.
- Explicar el ciclo de vida real de una extensión de widget y por qué excluye todo trabajo continuo.
- Construir un proveedor correcto distinguiendo marcador, instantánea y línea temporal.
- Razonar sobre las políticas de recarga y sobre el presupuesto diario que impone el sistema.
- Diseñar entradas que envejezcan bien tratando el tiempo como contenido y no como evento.
Un proceso que nace, dibuja y muere
La extensión de widget es un binario aparte, con su propio identificador, su propio sandbox y su propio proceso. No comparte memoria con la app, no hereda su estado y no puede verla. El sistema la arranca cuando necesita material nuevo, le concede unos pocos segundos de ejecución y un techo de memoria de apenas unas decenas de megabytes, recoge el resultado y la termina. No hay ciclo de ejecución que sobreviva, ni temporizadores que sigan disparando, ni observadores que despierten cuando cambie una base de datos.
De esa arquitectura se derivan, sin excepciones, las tres prohibiciones que definen el oficio. No puedes mantener una conexión abierta: cualquier trabajo asíncrono debe terminar dentro de la llamada o se pierde. No puedes suscribirte a nada: no hay nadie escuchando cuando el proceso ya no existe. Y no puedes depender del reloj para actuar: si quieres que algo cambie a las nueve y cuarto, esa vista tiene que estar dibujada y archivada mucho antes de las nueve y cuarto.
La consecuencia positiva es que el cuerpo de la vista se vuelve trivial de razonar. Una vista de widget debe ser una función pura de su entrada: mismos datos, mismo dibujo, sin acceso a singletons, sin lecturas perezosas y sin efectos. Si necesita un valor, ese valor viaja dentro del tipo que conforma TimelineEntry, y si el valor no cabe ahí es señal de que estás intentando calcular en el momento equivocado.
struct Entrada: TimelineEntry {
let date: Date // desde cuando es valida esta vista
let saldo: Decimal
let capturadoEn: Date // cuando se leyo el dato de verdad
let estaObsoleto: Bool
}
El campo date no significa cuándo se generó la entrada sino desde qué instante debe mostrarse; confundir esas dos fechas es el error más frecuente del principiante y produce widgets que parecen adelantarse o retrasarse sin patrón. Guardar aparte el momento real de captura, como en capturadoEn, permite además ser honesto en pantalla cuando el dato ya no es fresco, algo mucho más valioso que fingir actualidad.
La anatomía de una línea temporal
El proveedor responde a tres preguntas distintas y cada una tiene su contrato. El marcador contesta qué dibujar mientras no hay nada: se pide de forma síncrona, debe devolverse al instante y se usa para el esqueleto difuminado que aparece al añadir el widget o al reconstruir la pantalla. La instantánea contesta qué enseñar en la galería y en cualquier vista previa: debe ser rápida, representativa y bonita, y cuando context.isPreview es verdadero conviene servir datos de ejemplo en lugar de esperar a la red. La línea temporal contesta lo único que importa de verdad: qué se verá, en qué orden y hasta cuándo.
struct Proveedor: AppIntentTimelineProvider {
func placeholder(in context: Context) -> Entrada {
Entrada(date: .now, saldo: 0, capturadoEn: .now, estaObsoleto: false)
}
func snapshot(for configuracion: Ajustes, in context: Context) async -> Entrada {
if context.isPreview { return .ejemplo }
return await Entrada.actual(cuenta: configuracion.cuenta)
}
func timeline(for configuracion: Ajustes, in context: Context) async -> Timeline<Entrada> {
let base = await Entrada.actual(cuenta: configuracion.cuenta)
let ahora = Date.now
let entradas = (0..<6).map { paso in
base.proyectada(a: ahora.addingTimeInterval(Double(paso) * 900))
}
return Timeline(entries: entradas, policy: .after(ahora.addingTimeInterval(5400)))
}
}
Ese código concentra la idea central: una sola lectura de datos produce seis vistas futuras. En lugar de pedir seis refrescos —que el sistema no concedería—, se calcula por adelantado lo que se sabrá en cada tramo y se deja archivado. La política cierra el contrato: .atEnd pide una nueva línea cuando se agote la última entrada, .after fija un instante mínimo antes del cual no hace falta molestar al sistema, y .never renuncia por completo al ritmo automático porque la invalidación llegará desde la app.
Escribe cada entrada preguntándote qué será verdad en ese instante, no qué es verdad ahora. Un contador de días que faltan, una racha, un horario de transporte o una cuenta atrás son perfectamente predecibles y admiten decenas de entradas de una sola vez. Un saldo bancario o un marcador deportivo no lo son, y ahí la única estrategia honesta es una entrada con la fecha de captura visible y una invalidación empujada desde fuera.
El presupuesto que no controlas
Cada widget instalado tiene asignado un presupuesto diario de recargas que el sistema reparte a lo largo de las veinticuatro horas, del orden de varias decenas para un widget visible y en uso. El reparto no es uniforme ni negociable: pesa con qué frecuencia mira esa pantalla la persona, si el widget está realmente visible, la batería, el modo de bajo consumo y el aprendizaje del sistema sobre los hábitos. Pedir una recarga cada minuto no acelera nada; simplemente agota el presupuesto antes y deja el resto del día en silencio.
Hay una excepción decisiva que cambia el diseño de la app entera: las recargas que solicita la app contenedora mientras está en primer plano no se descuentan del presupuesto. Ese es el motivo por el que la arquitectura sana casi siempre es la inversa de la intuitiva: el widget pide poco y tarde, y es la app —al guardar, al sincronizar, al recibir una notificación silenciosa o al terminar una tarea en segundo plano— quien avisa de que hay material nuevo.
flowchart TB
a[El sistema decide que toca material nuevo] --> b[Arranca el proceso de la extension]
b --> c[Se invoca al proveedor de linea temporal]
c --> d[Se devuelven varias entradas con fecha]
d --> e[El proceso termina de inmediato]
e --> f[El sistema archiva las vistas dibujadas]
f --> g{Llega la fecha de la siguiente entrada}
g -->|si| h[Se muestra sin ejecutar codigo]
g -->|se agoto la politica| c
i[La app avisa de un cambio] --> catEnd
Pide línea nueva al consumirse la última entrada. Es el ritmo natural para datos periódicos y predecibles cuya cadencia ya conoces.
after
Fija un suelo temporal explícito. Útil cuando sabes que antes de cierta hora no habrá nada distinto que contar y quieres ahorrar presupuesto.
never
Renuncia al ritmo automático. Obliga a que la invalidación llegue desde la app y convierte el widget en un consumidor pasivo de avisos.
Diseñar con el tiempo dentro
La técnica que más recargas ahorra no es una optimización sino un cambio de encuadre: dejar que el texto se actualice solo. Un Text construido con un intervalo de temporizador, con un rango de fechas o con estilo relativo se redibuja en el sistema cada segundo sin consumir una sola recarga, porque el trabajo lo hace el proceso que dibuja, no el tuyo. Una cuenta atrás, un cronómetro de partida o un tiempo restante de trayecto no necesitan línea temporal alguna: necesitan una entrada y una fecha de fin.
Text(entry.finPrevisto, style: .timer) // se refresca solo, cada segundo
Text(entry.inicio ... entry.fin) // rango vivo
Text(entry.capturadoEn, style: .relative) // hace tres minutos
if entry.estaObsoleto {
Label("Sin conexion", systemImage: "wifi.slash")
.font(.caption2)
}
El otro hábito que separa un widget profesional de uno amateur es aceptar que el dato envejece y decirlo. Un valor de hace cuatro horas presentado como si fuera actual es peor que un valor viejo marcado como viejo: destruye la confianza en la cifra y, con ella, la razón de existir del widget. Marcar la antigüedad, atenuar el número o mostrar la hora de captura son intervenciones de una línea que convierten una limitación del sistema en información útil.
Queda un detalle de contexto que conviene fijar pronto. La misma línea temporal alimenta familias distintas —pequeña, mediana, grande, las de la pantalla de bloqueo y las del escritorio— y el mismo dibujo se reinterpreta en modo acentuado o monocromo según dónde esté. La vista debe leer el entorno y adaptarse, no clonar el diseño; y todo lo sensible debe declararse como tal para que el sistema lo oculte cuando el dispositivo esté bloqueado o el modo de privacidad lo exija.
@Environment(\.widgetFamily) private var familia
@Environment(\.widgetRenderingMode) private var modo
var body: some View {
switch familia {
case .systemSmall: Resumen(entry: entry) // una sola cifra
case .systemMedium: ResumenConTendencia(entry: entry)
default: Detalle(entry: entry)
}
}
Text(entry.saldo, format: .currency(code: "EUR"))
.privacySensitive()
Ese reparto por familia no es una concesión al espacio disponible sino una decisión editorial. Una familia pequeña no es una grande recortada: es una pregunta distinta, y responderla bien exige elegir el único dato que justifica ocupar una casilla de la pantalla de inicio. Los widgets que fracasan casi nunca fallan por técnica; fallan porque intentaron caber en lugar de decidir qué merecía mirarse sin abrir nada.
Hay un motivo por el que este modelo desconcierta y no es la API: es que rompe el supuesto implícito de casi todo el código de interfaz que hemos escrito nunca, a saber, que el programa está presente cuando el usuario mira. Una app se ejecuta mientras se observa; un widget se observa mientras no se ejecuta. Eso obliga a invertir la dirección del cálculo. En una app, ante la pregunta de qué mostrar, se consulta el estado actual; en un widget hay que responder por adelantado a la pregunta de qué será verdad más tarde, y esa reformulación es exactamente la que hace un sistema de renderizado diferido, un plan de consulta materializado o un caché con invalidación explícita. Por eso el ejercicio es tan formativo: obliga a separar lo predecible de lo impredecible dentro de tu propio dominio, una distinción que la mayoría de las bases de código jamás hace explícita. Todo lo que es función del reloj —cuentas atrás, calendarios, rachas, horarios— resulta ser barato y precalculable; todo lo que depende de un tercero —un precio, un marcador, un mensaje— resulta ser caro y necesita invalidación empujada. Cuando terminas de clasificar tu dominio en esas dos categorías descubres que has documentado sus dependencias temporales mejor que ningún diagrama, y que el widget era solo la excusa para mirarlas de frente.
La extensión vive segundos: produce vistas ya dibujadas con fecha de validez y muere. El marcador es instantáneo, la instantánea es para la galería, la línea temporal es el archivo real. Precalcula varias entradas por llamada en lugar de pedir refrescos, elige política según la naturaleza del dato y recuerda que solo la app en primer plano invalida sin coste. Usa textos con tiempo vivo para todo lo que sea cuenta atrás y admite en pantalla cuando el dato ya no es fresco.
- Toma un dato de tu app que creas que necesita actualizarse cada minuto y clasifícalo: cuánto de él es función del reloj y cuánto depende de un tercero.
- Escribe una línea temporal que cubra las próximas seis horas con entradas precalculadas para la parte predecible, sin ninguna llamada de red intermedia.
- Sustituye toda cuenta atrás por texto con estilo de temporizador o rango de fechas y comprueba que se mueve sin consumir recargas.
- Añade a la entrada la fecha real de captura y una señal visible de obsolescencia a partir de un umbral que tú definas.
- Instrumenta la extensión con registro y observa durante un día completo cuántas veces se te llama de verdad; compara ese número con el que habías supuesto.