El runtime de watchOS es un contrato, no una tarea en segundo plano
Género: shipped-code. La publicación documenta el patrón de runtime de watchOS que Return tiene en producción. Return es el temporizador de meditación en SwiftUI disponible en la App Store; la app de Watch ejecuta un temporizador de múltiples ciclos que tiene que seguir contando cuando el usuario baja la muñeca.1 El patrón que sobrevive a esa restricción es WKExtendedRuntimeSession más un delegado global, con alcance a nivel de app. Todo lo demás muere en el momento en que el reloj se suspende.
watchOS no es iOS con una pantalla más pequeña. El modelo de runtime es diferente. iOS le da a una app un presupuesto generoso en primer plano y un runtime en segundo plano —decreciente pero real— a través de sesiones de audio, actualizaciones de ubicación, BGTaskScheduler y un puñado de otras facilidades.2 watchOS le da a la app en primer plano un presupuesto medido en segundos después de que se baja la muñeca, y luego de eso, la app queda suspendida a menos que haya firmado un contrato de runtime con el sistema. No existe la facilidad de “solo estoy haciendo algo en segundo plano”. Existe “estoy ejecutando un entrenamiento, una sesión de mindfulness, una alarma inteligente, una ruta de navegación o una tarea de monitoreo de salud”, y nada más.3
El target de Watch de Return es un temporizador de mindfulness. El contrato de sesión es WKBackgroundModes: mindfulness. La API de runtime es WKExtendedRuntimeSession. El patrón que llevó a la app de Watch de romperse al bajar la muñeca a sobrevivir una meditación de 25 minutos es el que describe esta publicación.
TL;DR
- watchOS no tiene un segundo plano al estilo de iOS. El runtime en primer plano termina poco después de bajar la muñeca, y solo los tipos de sesión registrados continúan ejecutándose.
WKExtendedRuntimeSessiones la superficie de API. La sesión debe declarar un tipo de sesión; para un temporizador de meditación, el tipo es implícito a través deWKBackgroundModes: mindfulnessenInfo.plist.- El gestor de sesiones tiene que vivir con alcance a nivel de app, no a nivel de vista. El ciclo de vida de las vistas en SwiftUI desasigna los objetos pertenecientes a vistas durante la navegación; un delegado de sesión desasignado es una sesión muerta, incluso si la sesión en sí sigue ejecutándose.
- Los callbacks de
WKExtendedRuntimeSessionDelegateson el contrato:didStart,willExpire,didInvalidateWith. El callback de expiración se dispara antes de que el sistema fuerce la invalidación; Apple lo describe como la ventana para que la app “termine y haga limpieza”. - Bajar la muñeca sin una sesión extendida activa pausa el temporizador. Bajar la muñeca con una sesión extendida activa continúa el temporizador. La sesión es la diferencia entre “producto en producción” y “roto en el segundo uso”.
El problema de fondo que watchOS no resuelve a la manera de iOS
Las apps de iOS recurren a varias facilidades de segundo plano cuando necesitan que la app siga ejecutándose con la pantalla apagada:2
- Una
AVAudioSessioncon la categoría.playbackmantiene viva una app de audio mientras se reproduce música. - Las actualizaciones en segundo plano de
CLLocationManagermantienen viva una app de navegación con una barra azul. BGTaskSchedulerencola trabajo de mantenimiento corto que el sistema agenda según su propio reloj.- Una extensión de UI en primer plano (Live Activity, CallKit, PushKit) conecta el proceso de la app a una superficie de renderizado controlada por el sistema.
Ninguna de estas ayuda en watchOS de la forma que podrías suponer. Las apps de Watch no tienen el mismo programador de tareas en segundo plano. No tienen un modo AVAudioSession.playback en segundo plano que mantenga el temporizador contando en silencio. Tienen una primitiva estructural para “quiero seguir ejecutándome después de que el usuario baje la muñeca”, y esa primitiva es WKExtendedRuntimeSession con un tipo de sesión declarado.3
Los tipos de sesión que Apple admite a través de WKBackgroundModes son intencionadamente acotados:4
workout-processing(conHKWorkoutSessionpara entrenamientos reales)mindfulness(para temporizadores de meditación y ejercicios de respiración)self-care(para rutinas guiadas)physical-therapy(para apps de sesiones de terapia)alarm(para alarmas de despertar basadas en tiempo)underwater-depth(para apps de buceo y seguimiento de profundidad)
Las apps que no encajan en una de esas categorías no pueden usar WKExtendedRuntimeSession para ejecutarse después de bajar la muñeca. Las apps de audio recurren a la categoría mediaPlayback de la sesión de audio y a la integración con Now Playing en una ruta de código diferente; las apps de navegación usan las actualizaciones en segundo plano de CLLocationManager. El Watch no es una computadora de propósito general; es un dispositivo con restricciones de batería que el modelo de runtime hace cumplir.
Un temporizador de meditación encaja en mindfulness. El contrato: declarar el modo de segundo plano en Info.plist, solicitar una WKExtendedRuntimeSession, manejar los callbacks del delegado, finalizar la sesión cuando el temporizador termine. El sistema otorga aproximadamente hasta una hora de runtime por sesión, con la discreción del propio sistema para acortar ese tiempo bajo presión térmica o de batería.3
El patrón que Return tiene en producción
El patrón comienza con la declaración en Info.plist:4
<key>WKBackgroundModes</key>
<array>
<string>mindfulness</string>
</array>
La declaración del modo es lo que hace válido el tipo de sesión. Sin ella, llamar a WKExtendedRuntimeSession().start() falla silenciosamente y la app se suspende al bajar la muñeca igual que una app de Watch sin ningún modo en segundo plano.
El gestor de sesiones en sí tiene que vivir con alcance a nivel de app. El ciclo de vida de las vistas en SwiftUI no es amigable con los objetos con estado de larga duración: @StateObject y @State están limitados al alcance de la vista que los posee, y un push de navegación que reemplaza la vista descarta el estado junto con ella. Una WKExtendedRuntimeSession cuyo delegado se desasigna a mitad de la sesión no genera un crash; la sesión sigue ejecutándose, pero los callbacks del delegado (willExpire, didInvalidateWith) llegan a un objeto liberado, lo que significa que la limpieza nunca ocurre, lo que significa que la siguiente llamada a startSession() cree que no hay sesión activa e inicia una duplicada.
El patrón en producción es un singleton con alcance a nivel de app. El siguiente fragmento muestra la forma estructural; en producción se agrega logging dentro de cada método para observabilidad:
import SwiftUI
import WatchKit
final class WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate {
static let shared = WatchSessionManager()
private var session: WKExtendedRuntimeSession?
private override init() {
super.init()
}
var isSessionActive: Bool {
session != nil
}
func startSession() {
guard session == nil else { return }
let newSession = WKExtendedRuntimeSession()
newSession.delegate = self
newSession.start()
session = newSession
}
func endSession() {
guard let existing = session else { return }
existing.invalidate()
session = nil
}
// MARK: - WKExtendedRuntimeSessionDelegate
func extendedRuntimeSessionDidStart(_ session: WKExtendedRuntimeSession) {}
func extendedRuntimeSessionWillExpire(_ session: WKExtendedRuntimeSession) {
// Apple's "about to expire / finish and clean up" hook
}
func extendedRuntimeSession(
_ session: WKExtendedRuntimeSession,
didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason,
error: Error?
) {
self.session = nil
}
}
Tres detalles sobre ese singleton que la documentación no menciona:
El static let shared más @State private var sessionManager = WatchSessionManager.shared a nivel de @main App mantienen al gestor vivo durante toda la vida del proceso de la app de watch. SwiftUI no retiene singletons solo porque una vista los tenga; el binding anterior es lo que le dice al runtime que mantenga la referencia. Sin el binding a nivel de App, ARC puede descartar el gestor cuando ninguna vista lo retiene.
La propiedad session es la protección contra sesiones duplicadas. Un temporizador con un botón de “comenzar de nuevo” puede llamar a startSession() desde múltiples rutas; la verificación guard session == nil es el bloqueo. Dos sesiones extendidas concurrentes causan comportamiento impredecible: a veces la segunda tiene éxito y la primera queda huérfana, a veces la llamada de inicio falla silenciosamente. La invariante de sesión única previene toda esta clase de problemas.
Los callbacks del delegado registran logs pero rara vez actúan. El callback didStart se dispara una vez por sesión y es un gancho útil para observabilidad; el callback willExpire se dispara antes de que el sistema fuerce la invalidación y es donde Apple espera que la app “termine y haga limpieza”; el callback didInvalidateWith es donde se limpia la referencia de la sesión para que la siguiente llamada a startSession() funcione. El patrón en producción es los callbacks actualizan estado, la máquina de estados hace el trabajo, no los callbacks hacen el trabajo directamente.
El gestor del temporizador llama al gestor de sesiones en cada transición que cambia si el temporizador está contando activamente:
final class WatchTimerManager: ObservableObject {
func start() {
startExtendedSession() // -> WatchSessionManager.shared.startSession()
// ... start the timer state machine ...
}
func pause() {
timer?.invalidate()
isRunning = false
endExtendedSession() // -> WatchSessionManager.shared.endSession()
}
func reset() {
// ... clear timer state ...
endExtendedSession()
}
private func completeCycle() {
// ... last cycle handling ...
endExtendedSession() // ends on final completion
}
}
La sesión termina al pausar, al reiniciar y al completarse el último ciclo. El razonamiento de producto: una meditación pausada no necesita seguir reclamando el presupuesto de runtime que el sistema otorga bajo mindfulness; reanudar desde una pausa adquiere una sesión nueva. El costo de producto es que una pausa con la muñeca bajada no puede reanudarse solo levantando la muñeca; el usuario tiene que traer la app de vuelta al primer plano para reanudar. La ganancia de producto es que el costo de batería del temporizador pausado llega a cero y el sistema no ve una sesión obsoleta.
Bajar la muñeca es la prueba
Probar watchOS en el simulador es una ficción cortés. El simulador no aplica el modelo de runtime de bajar la muñeca de la forma en que lo hace un Apple Watch real. El simulador mantiene la app en primer plano siempre que la ventana del simulador tenga el foco; una sesión de runtime extendida en el simulador se ve idéntica a no tener sesión en absoluto, porque la app en primer plano sigue ejecutándose en cualquier caso.
La prueba real es en un Apple Watch real:5
- Inicia el temporizador.
- Baja la muñeca (o presiona el botón lateral para bloquear la pantalla).
- Espera 30 segundos.
- Levanta la muñeca de nuevo.
Sin una sesión de runtime extendida activa, la app de watch queda suspendida; el estado del temporizador se congela en el momento de bajar la muñeca y se reanuda desde ese estado congelado. Para una meditación de 5 minutos en la que el usuario cierra los ojos, el bug es invisible hasta que el temporizador esté equivocado por la cantidad de tiempo que los ojos estuvieron cerrados.
Con una sesión de runtime extendida activa, el temporizador sigue contando. Levantar la muñeca revela el temporizador en la posición transcurrida correcta. La señal de audio (si el temporizador reproduce una al completarse) se dispara en el momento correcto del reloj de pared, no en el momento de levantar la muñeca.
El escenario de bajar la muñeca fue el bug que Return lanzó en v1 y parchó en v2. La solución es el patrón de singleton anterior; el bug era una instancia de WatchSessionManager retenida por una vista de SwiftUI que se desasignaba en un push de navegación. La sesión técnicamente se ejecutaba en el lado del sistema, pero el delegado estaba liberado; la siguiente llamada de inicio de sesión era silenciosamente un no-op porque la propiedad session del gestor había sido establecida en un objeto ahora muerto. Las pruebas en dispositivos reales revelan la falla en segundos. Las pruebas en el simulador nunca la revelan.
Lo que los callbacks del delegado realmente te dicen
WKExtendedRuntimeSessionInvalidationReason enumera las maneras en que termina una sesión:6
| Razón | Cuándo ocurre |
|---|---|
none |
La sesión fue invalidada explícitamente por la app llamando a invalidate() |
sessionInProgress |
Una sesión del mismo tipo ya está en ejecución |
expired |
Se alcanzó el límite de tiempo impuesto por el sistema |
resignedFrontmost |
Una app diferente se volvió la primera en pantalla mientras la sesión se ejecutaba |
suppressedBySystem |
El sistema suprimió la sesión (poca batería, presión térmica) |
error |
Ocurrió un error irrecuperable; revisa el parámetro error |
Las razones que importan para el diseño de producto:
expired significa que el usuario obtuvo la sesión completa que pediste. La sesión se ejecutó hasta su fin natural. La duración más larga de meditación de Return es de 60 minutos, lo cual está justo en el borde de lo que típicamente se otorga a las sesiones de mindfulness. Una meditación de 90 minutos rutinariamente alcanzaría expired y el temporizador moriría a mitad de sesión. La decisión de producto es limitar las duraciones disponibles a lo que el modelo de runtime puede entregar realmente.
resignedFrontmost significa que el usuario abrió otra app de Watch y tu sesión perdió. Los usuarios de Watch son buenos para deslizar a una app diferente y luego olvidar. La decisión de producto es o pausar al ceder (estado preservado, el usuario puede volver) o terminar al ceder (sesión terminada, el usuario recibe una señal de “te detuviste antes”). Return elige pausar al ceder para que el usuario pueda atender una llamada telefónica a mitad de la meditación y volver.
suppressedBySystem es la versión cortés de “el reloj está caliente”. Un dispositivo watchOS bajo presión térmica o con batería baja puede revocar una sesión de runtime extendida incluso sin uso indebido por parte de la app. El gestor de sesiones tiene que manejar el caso con elegancia: limpiar la referencia, mostrar una advertencia no bloqueante y no entrar en un estado donde intente reiniciar una sesión que el sistema acaba de rechazar.
El callback willExpire se dispara cuando la sesión está a punto de expirar y está documentado como el momento para que la app “termine y haga limpieza”.3 El callback es donde una app puede escribir una instantánea final del estado, reproducir una señal de audio de cierre o presentar una UI de “la sesión está por terminar”. Hoy Return solo registra el callback en logs; una limpieza más rica (entrada en el log de HealthKit, fade-out de audio) ocurre en las rutas de reinicio y completitud del temporizador y está en la lista de qué construiría diferente para la ventana de willExpire.
Qué construiría diferente
Dos cosas, si Return empezara desde cero.
Usar HKWorkoutSession para cualquier sesión cuyo valor aumenta con la integración de HealthKit. Un temporizador de meditación se ubica en el borde entre mindfulness y workout-processing. Mindfulness fue la opción correcta para v1 porque el modelo de datos es más simple y la expectativa del usuario es “esto es meditación, no un entrenamiento”. HKWorkoutSession lleva una integración con HealthKit más granular (inicio de sesión, fin de sesión, segmentos, eventos) y ofrece una interfaz LiveWorkoutBuilder más rica para acumular datos. El juicio arquitectónico, no una garantía documentada de Apple: para una app cuyo valor depende de telemetría detallada de la sesión, la ruta de workout-session maneja una estructura que WKExtendedRuntimeSession no maneja.
Agregar una superficie de observabilidad del estado de la sesión desde el primer día. La primera versión de Return registraba los eventos de sesión en consola. La segunda versión agregó visibilidad del estado de la sesión en el dispositivo para depuración. La tercera expondría un toggle de modo desarrollador que muestre el historial de razones de la sesión al usuario cuando algo salga mal, en lugar de tratar la invalidación de la sesión como una caja negra. El runtime de watchOS es opaco; la superficie de depuración tiene que compensarlo.
Cuándo WKExtendedRuntimeSession es la respuesta equivocada
Tres casos donde el tipo de sesión no encaja:
Entrenamientos que necesitan marcadores de segmento, flujos de frecuencia cardíaca o seguimiento de calorías activas. Usa HKWorkoutSession directamente con un HKLiveWorkoutBuilder. La API de Workout es la ruta documentada de Apple para entrenamientos reales (y meditaciones caminando o actividad extenuante); WKExtendedRuntimeSession es la ruta documentada para sesiones que no son entrenamientos como mindfulness o alarmas. Una app de meditación no necesita un entrenamiento; una app de Couch-to-5K sí.
Reproducción de audio que necesita una superficie de Now Playing. Usa una AVAudioSession configurada para reproducción junto con los entitlements de sesión de audio de watchOS; la integración con Now Playing más la superficie de reproducción del sistema es lo que las apps de audio quieren, y la ruta de audio está separada por completo de WKExtendedRuntimeSession. WKExtendedRuntimeSession no te da Now Playing ni el enrutamiento de audio del sistema.
Sincronización de datos de larga duración sin que el usuario lo sepa. Usa WKApplicationRefreshBackgroundTask para ventanas de actualización periódicas que el sistema agenda. El usuario no está en la app; la app no necesita seguir ejecutándose; necesita despertarse brevemente y actualizarse. Los dos modelos de tarea-en-segundo-plano y sesión-de-runtime-extendida sirven necesidades muy diferentes.
Lo que el patrón significa para apps que se lanzan en watchOS 11+
Tres conclusiones.
-
El modelo de runtime de Watch es opt-in. Elige un tipo de sesión y vive dentro de sus reglas. Las apps que intenten hacer “trabajo general en segundo plano” en watchOS perderán. Elige
mindfulness,workout-processing,self-care,physical-therapy,alarmounderwater-depth, y diseña la experiencia de usuario alrededor del presupuesto de runtime que viene con el tipo de sesión que elegiste. -
El delegado de sesión tiene que vivir con alcance a nivel de app. El ciclo de vida de las vistas en SwiftUI no protege a los objetos con estado de larga duración. Un singleton
static let sharedenlazado en el nivel de@main Appes el patrón más pequeño que sobrevive a los pushes de navegación, los reemplazos de vista y el comportamiento normal de desasignación de SwiftUI. -
Prueba en hardware real. El simulador no aplica el modelo de runtime de bajar la muñeca. El bug que una app de Watch no puede probar en el simulador es el bug que termina con los usuarios.
Combina esta publicación con mis escritos anteriores sobre la misma familia de apps: el lanzamiento de SwiftUI multiplataforma (Return se lanza en iPhone, iPad, Watch, Mac y Apple TV); la máquina de estados de Live Activities (la superficie del lado de iOS para el mismo temporizador); los patrones de HealthKit (donde aterrizan las sesiones de mindfulness del Watch en los datos de Salud del usuario). El conjunto completo vive en el hub de la Serie del Ecosistema Apple. Para un contexto más amplio sobre iOS con agentes de IA, consulta la guía de Desarrollo de Agentes en iOS.
Preguntas frecuentes
¿Qué es una sesión de runtime extendida en watchOS?
Una sesión de runtime extendida en watchOS (WKExtendedRuntimeSession) es la API que una app de Watch usa para seguir ejecutándose después de que el usuario baja la muñeca. La sesión debe declarar un tipo (mindfulness, workout-processing, alarm, etc.) a través de WKBackgroundModes en Info.plist. Sin una sesión extendida activa, watchOS suspende la app poco después de bajar la muñeca.
¿Por qué mi temporizador de watchOS deja de contar cuando el usuario baja la muñeca?
La app de Watch se suspende poco después de bajar la muñeca a menos que esté ejecutándose una WKExtendedRuntimeSession activa de un tipo admitido. Un gestor de temporizador que no inicie tal sesión verá su runtime en segundo plano cortado, y el estado del temporizador se congelará en el momento de bajar la muñeca hasta que el usuario la levante de nuevo.
¿Cuál es la diferencia entre WKExtendedRuntimeSession y HKWorkoutSession?
WKExtendedRuntimeSession es la API de runtime extendida de propósito general para sesiones que no son entrenamientos como mindfulness, alarm o self-care. HKWorkoutSession es la API para entrenamientos reales; se integra con HealthKit, admite marcadores de segmento y es la ruta documentada para meditaciones caminando o actividad extenuante. Las apps de mindfulness sin telemetría a nivel de entrenamiento usan la primera; las apps de entrenamiento usan la segunda.
¿Puede el sistema revocar mi sesión de runtime extendida?
Sí. WKExtendedRuntimeSessionInvalidationReason incluye expired (límite de tiempo del sistema alcanzado), resignedFrontmost (otra app de Watch se volvió la primera en pantalla) y suppressedBySystem (poca batería o presión térmica). El gestor de sesiones tiene que manejar cada caso de forma limpia: la referencia se limpia, el estado del temporizador reacciona apropiadamente y la siguiente llamada de inicio de sesión funciona correctamente.
¿Dónde debe vivir el gestor de sesiones en una app de watchOS con SwiftUI?
A nivel de app, como un singleton enlazado desde el struct @main App. El estado con alcance de vista de SwiftUI (@State, @StateObject) se desasigna en pushes de navegación, reemplazos de vista o cuando la app pasa a segundo plano. Un delegado de sesión perteneciente a una vista que se libera a mitad de sesión causa que la referencia de la sesión se filtre e impide que las sesiones posteriores inicien limpiamente.
Referencias
-
Del autor, Return, un temporizador de meditación en SwiftUI publicado en la App Store el 21 de abril de 2026, disponible para iPhone, iPad, Mac, Apple Watch y Apple TV. La app de Watch usa
WKExtendedRuntimeSessioncon el modo en segundo planomindfulnesspara el runtime del temporizador de ciclos. ↩ -
Apple Developer, “About the background execution sequence”. Facilidades de runtime en segundo plano del lado de iOS (sesiones de audio, ubicación, BGTaskScheduler) y cómo difieren de watchOS. ↩↩
-
Apple Developer, “WKExtendedRuntimeSession”. Tipos de sesión, ciclo de vida, callbacks del delegado, límites de runtime y la clave
WKBackgroundModesde Info.plist. ↩↩↩↩ -
Apple Developer, “Information Property List: WKBackgroundModes”. Cadenas de tipo de sesión admitidas:
workout-processing,mindfulness,self-care,physical-therapy,alarm,underwater-depth. ↩↩ -
Apple Developer, “Building a watchOS app” y la guía de pruebas de WatchKit. El comportamiento de runtime en dispositivos reales no es reproducible en el simulador de watchOS; el simulador no aplica la suspensión por bajar la muñeca. ↩
-
Apple Developer, “WKExtendedRuntimeSessionInvalidationReason”. Casos de la enumeración:
none,sessionInProgress,expired,resignedFrontmost,suppressedBySystem,error. ↩