El runtime de watchOS es un contrato, no una tarea en segundo plano
La app Watch de Return ejecuta un temporizador de meditación multiciclo 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 de app. Todo lo demás muere en el momento en que el reloj se duerme.
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 cada vez menor —pero real— a través de sesiones de audio, actualizaciones de ubicación, BGTaskScheduler y un puñado de otras prestaciones.2 watchOS le da a la app en primer plano un presupuesto medido en segundos después de bajar la muñeca, y después de eso, la app se suspende a menos que haya firmado un contrato de runtime con el sistema. No hay una prestación del tipo “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 Watch de Return es un temporizador de mindfulness. El contrato de sesión es WKBackgroundModes: mindfulness. El API de runtime es WKExtendedRuntimeSession. El patrón que llevó a la app Watch de rota 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 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 del API. Apple admite cuatro tipos de sesión:self-care,mindfulness,physical-therapyyalarm. Para un temporizador de meditación, el tipo de sesión esmindfulness, declarado a través deWKBackgroundModesenInfo.plist.- El gestor de sesión tiene que vivir con alcance de app, no de vista. El ciclo de vida de las vistas en SwiftUI desaloja los objetos propiedad de la vista al navegar; un delegado de sesión desalojado 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; el código de muestra de Apple en “Using extended runtime sessions” lo enmarca como el lugar para “terminar y limpiar cualquier tarea antes de que la sesión termine.”3 - 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 enviado” y “roto al segundo uso.”
El problema del segundo plano que watchOS no resuelve a la manera de iOS
Las apps de iOS recurren a varias prestaciones 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. BGTaskSchedulerpone en cola trabajo de mantenimiento corto que el sistema programa 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 esas ayuda en watchOS de la manera 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 la primitiva es WKExtendedRuntimeSession con un tipo de sesión declarado.3
Los tipos de sesión que Apple admite para WKExtendedRuntimeSession son estrechos a propósito:3
self-care(actividades breves de bienestar, runtime al frente, límite de 10 minutos)mindfulness(meditación silenciosa, runtime al frente, límite de 1 hora)physical-therapy(estiramientos y rango de movimiento, runtime en segundo plano, límite de 1 hora)alarm(alarmas inteligentes, runtime en segundo plano, límite de 30 minutos, programable hasta 36 horas por adelantado mediantestart(at:))
Las apps de entrenamiento usan HKWorkoutSession con el modo de segundo plano separado workout-processing; esa ruta está documentada para entrenamientos reales y no es un tipo de WKExtendedRuntimeSession.4 El modo de segundo plano underwater-depth admite apps de buceo y de seguimiento de profundidad a través de la ruta del API de sesión de buceo, también no a través de WKExtendedRuntimeSession. Una app puede combinar workout-processing con un tipo de sesión de runtime extendido, pero no puede elegir más de un tipo de runtime extendido por app.4
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 de sesión de audio AVAudioSession.Category.playback 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 limitaciones de batería que el modelo de runtime impone.
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, terminar la sesión cuando el temporizador termine. Apple documenta el límite de la sesión de mindfulness como una hora, con discreción del sistema para acortarla bajo presión térmica o de batería.3
El patrón que envía Return
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 Watch sin ningún modo de segundo plano.
El gestor de sesión en sí tiene que vivir con alcance de app. El ciclo de vida de vistas en SwiftUI no es amigable con objetos con estado de larga duración: @StateObject y @State están limitados a la vista que los posee, y un push de navegación que reemplaza la vista descarta el estado con ella. Una WKExtendedRuntimeSession cuyo delegado se desaloja a mitad de sesión no falla; la sesión continúa 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 un duplicado.
El patrón implementado es un singleton con alcance de app. El fragmento siguiente es la forma estructural; producción 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 estructurales importan más allá de la conformidad con el protocolo.
La instancia static let shared se retiene durante el ciclo de vida del proceso de la app Watch a través del almacenamiento estático; ARC no la desalojará. Lo que aporta el binding a nivel de App no es retención adicional sino un punto de observación estable. El patrón de bug que esto previene: un gestor de sesión sostenido únicamente por una vista transitoria que se descarta a mitad de sesión, donde la vista muere pero static let shared sobrevive, con el efecto secundario de que cualquier gestor envuelto en @StateObject pierde su ciclo de observación y deja de re-renderizar correctamente. Usa el singleton más un accesor @Observable a nivel de App para que la UI siga observando la instancia canónica.
La propiedad session es la protección contra sesiones duplicadas. Un temporizador con un botón de “empezar de nuevo” puede llamar a startSession() desde múltiples rutas; el chequeo guard session == nil es el cerrojo. 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. El invariante de sesión única previene toda la clase de problemas.
Los callbacks del delegado registran 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 la muestra de Apple espera que la app “termine y limpie cualquier tarea antes de que la sesión termine”; el callback didInvalidateWith es donde se limpia la referencia de sesión para que la siguiente llamada a startSession() funcione. El patrón implementado es los callbacks actualizan el estado, la máquina de estados hace el trabajo, no los callbacks hacen el trabajo directamente.
El gestor del temporizador llama al gestor de sesión en cada transición que cambia si el temporizador está contando activamente:
@Observable final class WatchTimerManager {
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 completar el ciclo final. El razonamiento de producto: una meditación pausada no necesita seguir reclamando el presupuesto de runtime que el sistema concede bajo mindfulness; reanudar desde la pausa adquiere una sesión nueva. El costo de producto es que una pausa con la muñeca bajada no se puede reanudar 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 se va 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 impone el modelo de runtime al bajar la muñeca como lo hace un Apple Watch real. El simulador mantiene la app en primer plano mientras la ventana del simulador tenga foco; una sesión de runtime extendido en el simulador se ve idéntica a no tener sesión, porque la app en primer plano sigue ejecutándose de cualquier manera.
La prueba real es en un Apple Watch real:5
- Lanza el temporizador.
- Baja la muñeca (o presiona el botón lateral para bloquear la pantalla).
- Espera 30 segundos.
- Levanta la muñeca de vuelta.
Sin una sesión de runtime extendido activa, la app Watch se suspende; el estado del temporizador queda congelado 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 extendido activa, el temporizador sigue contando. El 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 la hora de reloj correcta, no en la hora de levantar la muñeca.
El escenario de bajar la muñeca fue el bug con el que se envió la primera build Watch de Return y que la refactorización al singleton arregló. La solución es el patrón singleton de arriba; el bug era una instancia de WatchSessionManager sostenida por una vista SwiftUI que se desalojaba en un push de navegación. La sesión técnicamente estaba ejecutándose del 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 dispositivo real revelan la falla en segundos. Las pruebas en simulador no la revelan nunca.
Qué te dicen realmente los callbacks del delegado
WKExtendedRuntimeSessionInvalidationReason enumera las formas 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 |
Ya está ejecutándose una sesión del mismo tipo |
expired |
Se alcanzó el límite de tiempo impuesto por el sistema |
resignedFrontmost |
Una app diferente pasó a primer plano mientras la sesión se ejecutaba |
suppressedBySystem |
El sistema suprimió la sesión (bajo consumo, 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 se alcanzó el límite de tiempo impuesto por el sistema. Apple documenta el límite de la sesión de mindfulness como una hora;3 la duración más larga de meditación en Return es de 60 minutos, que es el techo documentado. Una meditación de 90 minutos no puede completarse dentro de una sola sesión de mindfulness: el temporizador moriría a mitad de sesión en la marca de la hora. La decisión de producto es limitar las duraciones disponibles a lo que el modelo de runtime está documentado para entregar, no apostar por la tolerancia del sistema.
resignedFrontmost significa que el usuario abrió otra app de Watch y tu sesión perdió. Los usuarios de Watch son buenos deslizando a otra app y luego olvidándose. La decisión de producto es pausar al ceder el primer plano (estado preservado, el usuario puede volver) o terminar al ceder el primer plano (sesión terminada, el usuario recibe una señal de “terminaste antes”). Return elige pausar al ceder el primer plano para que el usuario pueda atender una llamada telefónica a mitad de 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 batería baja puede revocar una sesión de runtime extendido incluso sin mal uso de la app. El gestor de sesión tiene que manejar el caso elegantemente: 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; la muestra de Apple lo enmarca como el momento de “terminar y limpiar cualquier tarea antes de que la sesión termine.”3 El callback es donde una app puede escribir una instantánea de estado final, reproducir una señal de audio de cierre o presentar una UI de “la sesión termina pronto”. Return hoy solo registra el callback; la limpieza más rica (entrada de log en HealthKit, fade-out de audio) ocurre en las rutas de reset y completado 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 aumente con la integración de HealthKit. Un temporizador de meditación está en el borde entre mindfulness y workout-processing. Mindfulness fue la elecció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 da 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 sesión, la ruta de sesión de entrenamiento maneja una estructura que WKExtendedRuntimeSession no maneja.
Agregar una superficie de observabilidad del estado de sesión desde el día uno. La primera versión de Return registraba eventos de sesión a la consola. La segunda versión añadió visibilidad del estado de sesión en el dispositivo para depuración. La tercera expondría un toggle de modo desarrollador que muestra el historial de razones de sesión al usuario cuando algo sale mal, en lugar de tratar la invalidación de sesión como una caja negra. El runtime de watchOS es opaco; la superficie de depuración necesita compensar.
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 activo de calorías. Usa HKWorkoutSession directamente con un HKLiveWorkoutBuilder. El API 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 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 se entere. Usa WKApplicationRefreshBackgroundTask para ventanas periódicas de actualización que el sistema programa. El usuario no está en la app; la app no necesita seguir ejecutándose; necesita despertar brevemente y actualizar. Los dos modelos —tarea en segundo plano y sesión de runtime extendido— sirven a necesidades muy diferentes.
Qué significa el patrón para apps que se envían 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 de app. El ciclo de vida de vistas en SwiftUI no protege objetos con estado de larga duración. Un singleton
static let sharedligado al nivel@main Appes el patrón más pequeño que sobrevive a pushes de navegación, reemplazos de vista y al comportamiento normal de desalojo de SwiftUI. -
Prueba en hardware real. El simulador no impone el modelo de runtime al bajar la muñeca. El bug que una app Watch no puede probar en el simulador es el bug que envía a los usuarios.
Combina esta publicación con mis escritos anteriores sobre la misma familia de apps: el envío de SwiftUI multiplataforma (Return se envía en iPhone, iPad, Watch, Mac y Apple TV); la máquina de estados de Live Activities (la superficie del lado iOS para el mismo temporizador); 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 de iOS con agentes de IA, consulta la guía de Desarrollo de Agentes iOS.
Preguntas frecuentes
¿Qué es una sesión de runtime extendido en watchOS?
Una sesión de runtime extendido en watchOS (WKExtendedRuntimeSession) es el API que una app 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 en watchOS deja de contar cuando el usuario baja la muñeca?
La app 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 congela en el momento de bajar la muñeca hasta que el usuario levante la muñeca de nuevo.
¿Cuál es la diferencia entre WKExtendedRuntimeSession y HKWorkoutSession?
WKExtendedRuntimeSession es el API de runtime extendido de propósito general para sesiones que no son entrenamientos como mindfulness, alarm o self-care. HKWorkoutSession es el 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 el primero; las apps de entrenamiento usan el segundo.
¿Puede el sistema revocar mi sesión de runtime extendido?
Sí. WKExtendedRuntimeSessionInvalidationReason incluye expired (se alcanzó el límite de tiempo del sistema), resignedFrontmost (otra app de Watch pasó a primer plano) y suppressedBySystem (bajo consumo o presión térmica). El gestor de sesión tiene que manejar cada caso limpiamente: la referencia se limpia, el estado del temporizador reacciona apropiadamente y la siguiente llamada de inicio de sesión funciona correctamente.
¿Dónde debería vivir el gestor de sesión en una app SwiftUI de watchOS?
Con alcance de app, como un singleton ligado desde el struct @main App. El estado con alcance de vista de SwiftUI (@State, @StateObject) se desaloja en pushes de navegación, reemplazos de vista o cuando la app pasa a segundo plano. Un delegado de sesión propiedad de la vista que se libera a mitad de sesión causa que la referencia de sesión se filtre y previene que las sesiones subsiguientes inicien limpiamente.
Referencias
-
Return del autor, un temporizador de meditación SwiftUI publicado en la App Store el 21 de abril de 2026, disponible para iPhone, iPad, Mac, Apple Watch y Apple TV. La app Watch usa
WKExtendedRuntimeSessioncon el modo de segundo planomindfulnesspara el runtime del temporizador de ciclos. ↩ -
Apple Developer, “About the background execution sequence”. Prestaciones de runtime en segundo plano del lado iOS (sesiones de audio, ubicación, BGTaskScheduler) y cómo difieren de watchOS. ↩↩
-
Apple Developer, “WKExtendedRuntimeSession”. Tipos de sesión, ciclo de vida, callbacks de 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 dispositivo real no es reproducible en el simulador de watchOS; el simulador no impone la suspensión al bajar la muñeca. ↩
-
Apple Developer, “WKExtendedRuntimeSessionInvalidationReason”. Casos de enumeración:
none,sessionInProgress,expired,resignedFrontmost,suppressedBySystem,error. ↩