← Tous les articles

Le runtime watchOS est un contrat, pas une tâche en arrière-plan

L’app Watch de Return exécute un minuteur de méditation multi-cycles qui doit continuer à compter quand l’utilisateur baisse le poignet.1 Le pattern qui survit à cette contrainte, c’est WKExtendedRuntimeSession associé à un délégué global, dont la portée couvre toute l’app. Tout le reste meurt à l’instant où la montre s’endort.

watchOS n’est pas iOS avec un écran plus petit. Le modèle de runtime est différent. iOS accorde à une app un budget généreux en avant-plan ainsi qu’un runtime en arrière-plan, qui s’amenuise mais reste réel, à travers les sessions audio, les mises à jour de localisation, BGTaskScheduler et quelques autres mécanismes.2 watchOS, lui, accorde à l’app en avant-plan un budget mesuré en secondes après le baisser de poignet, et passé ce délai, l’app est suspendue à moins d’avoir signé un contrat de runtime avec le système. Il n’existe aucun mécanisme du genre « je fais juste un truc en arrière-plan ». Il y a « j’exécute une séance d’entraînement, une séance de pleine conscience, une session de smart alarm, un itinéraire de navigation ou une tâche de surveillance de santé », et rien d’autre.3

La cible Watch de Return est un minuteur de pleine conscience. Le contrat de session est WKBackgroundModes: mindfulness. Le API de runtime est WKExtendedRuntimeSession. Le pattern qui a fait passer l’app Watch de cassée au baisser de poignet à survit à 25 minutes de méditation est celui que décrit cet article.

TL;DR

  • watchOS n’a pas d’arrière-plan à la manière d’iOS. Le runtime en avant-plan se termine peu après le baisser de poignet, et seuls les types de sessions enregistrés continuent à s’exécuter.
  • WKExtendedRuntimeSession est la surface du API. Apple prend en charge quatre types de sessions : self-care, mindfulness, physical-therapy et alarm. Pour un minuteur de méditation, le type de session est mindfulness, déclaré via WKBackgroundModes dans Info.plist.
  • Le gestionnaire de session doit vivre à l’échelle de l’app, pas à celle de la vue. Le cycle de vie des vues SwiftUI désalloue les objets détenus par les vues lors de la navigation ; un délégué de session désalloué, c’est une session morte, même si la session elle-même tourne encore.
  • Les callbacks de WKExtendedRuntimeSessionDelegate constituent le contrat : didStart, willExpire, didInvalidateWith. Le callback d’expiration se déclenche avant que le système ne force l’invalidation ; l’exemple de code d’Apple dans « Using extended runtime sessions » le présente comme l’endroit où « terminer et nettoyer toutes les tâches avant la fin de la session ».3
  • Un baisser de poignet sans session étendue active met le minuteur en pause. Un baisser de poignet avec une session étendue active fait continuer le minuteur. La session, c’est la différence entre « produit livré » et « cassé à la deuxième utilisation ».

Le problème de l’arrière-plan que watchOS ne résout pas à la manière iOS

Les apps iOS ont recours à plusieurs mécanismes d’arrière-plan quand elles ont besoin de continuer à tourner avec l’écran éteint :2

  • Une AVAudioSession avec la catégorie .playback maintient une app audio en vie pendant la lecture musicale.
  • Les mises à jour en arrière-plan de CLLocationManager maintiennent une app de navigation en vie avec une barre bleue.
  • BGTaskScheduler met en file des travaux de maintenance courts que le système programme selon sa propre horloge.
  • Une extension UI en avant-plan (Live Activity, CallKit, PushKit) fait le pont entre le processus de l’app et une surface de rendu contrôlée par le système.

Aucun de ces mécanismes ne fonctionne sur watchOS comme on pourrait le supposer. Les apps Watch ne disposent pas du même planificateur de tâches en arrière-plan. Elles n’ont pas de mode AVAudioSession.playback en arrière-plan capable de maintenir le minuteur en train de compter en silence. Elles disposent d’une seule primitive structurelle pour exprimer « je veux continuer à tourner après que l’utilisateur a baissé le poignet », et cette primitive, c’est WKExtendedRuntimeSession avec un type de session déclaré.3

Les types de sessions qu’Apple prend en charge pour WKExtendedRuntimeSession sont volontairement étroits :3

  • self-care (brèves activités de bien-être, runtime au premier plan, limite de 10 minutes)
  • mindfulness (méditation silencieuse, runtime au premier plan, limite de 1 heure)
  • physical-therapy (étirements et amplitude de mouvement, runtime en arrière-plan, limite de 1 heure)
  • alarm (smart alarms, runtime en arrière-plan, limite de 30 minutes, programmable jusqu’à 36 heures à l’avance via start(at:))

Les apps de fitness utilisent HKWorkoutSession avec le mode d’arrière-plan workout-processing distinct ; ce chemin est documenté pour les véritables séances d’entraînement et n’est pas un type de WKExtendedRuntimeSession.4 Le mode d’arrière-plan underwater-depth prend en charge les apps de plongée et de suivi de profondeur via le chemin API de session de plongée, et là encore pas via WKExtendedRuntimeSession. Une app peut combiner workout-processing avec un seul type de session de runtime étendu, mais elle ne peut pas choisir plus d’un type de runtime étendu par app.4

Les apps qui n’entrent dans aucune de ces catégories ne peuvent pas utiliser WKExtendedRuntimeSession pour continuer à tourner après le baisser de poignet. Les apps audio passent par la catégorie de session audio AVAudioSession.Category.playback et l’intégration Now Playing sur un chemin de code différent ; les apps de navigation utilisent les mises à jour en arrière-plan de CLLocationManager. La Watch n’est pas un ordinateur généraliste ; c’est un appareil avec des contraintes de batterie que le modèle de runtime fait respecter.

Un minuteur de méditation correspond à mindfulness. Le contrat : déclarer le mode d’arrière-plan dans Info.plist, demander une WKExtendedRuntimeSession, gérer les callbacks du délégué, terminer la session quand le minuteur se termine. Apple documente la limite de la session mindfulness à une heure, le système se réservant le droit de la raccourcir sous pression thermique ou de batterie.3

Le pattern que Return livre

Le pattern commence par la déclaration dans Info.plist :4

<key>WKBackgroundModes</key>
<array>
    <string>mindfulness</string>
</array>

C’est cette déclaration de mode qui rend le type de session valide. Sans elle, l’appel à WKExtendedRuntimeSession().start() échoue silencieusement et l’app est suspendue au baisser de poignet, exactement comme une app Watch sans aucun mode d’arrière-plan.

Le gestionnaire de session lui-même doit vivre à l’échelle de l’app. Le cycle de vie des vues SwiftUI est hostile aux objets statefuls de longue durée : @StateObject et @State ont une portée limitée à la vue qui les possède, et un push de navigation qui remplace la vue emporte l’état avec elle. Une WKExtendedRuntimeSession dont le délégué se fait désallouer en pleine session ne plante pas ; la session continue à tourner, mais les callbacks du délégué (willExpire, didInvalidateWith) atteignent un objet libéré, ce qui veut dire que le nettoyage n’a jamais lieu, ce qui veut dire que le prochain appel à startSession() croit qu’il n’y a pas de session active et en démarre une autre en doublon.

Le pattern livré, c’est un singleton à l’échelle de l’app. L’extrait ci-dessous donne la forme structurelle ; en production, on ajoute du logging à l’intérieur de chaque méthode pour l’observabilité :

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
    }
}

Trois détails structurels comptent au-delà de la conformité au protocole.

L’instance static let shared est conservée pour toute la durée de vie du processus de l’app Watch grâce au stockage statique ; ARC ne la désallouera pas. Ce que le binding au niveau App apporte, ce n’est pas une rétention supplémentaire mais un point d’observation stable. Le pattern de bug que cela évite : un gestionnaire de session détenu uniquement par une vue transitoire qui se fait dépiler en pleine session, où la vue meurt mais où static let shared survit, avec pour effet de bord qu’un gestionnaire enveloppé dans @StateObject perd son cycle d’observation et cesse de se rerendre correctement. Utilisez le singleton plus un accesseur @Observable au niveau App pour que l’UI continue d’observer l’instance canonique.

La propriété session est la protection contre les sessions en doublon. Un minuteur avec un bouton « recommencer » peut appeler startSession() depuis plusieurs chemins ; le guard session == nil est le verrou. Deux sessions étendues concurrentes provoquent un comportement imprévisible : parfois la seconde réussit et la première devient orpheline, parfois l’appel de démarrage échoue silencieusement. L’invariant de session unique prévient toute la classe de problèmes.

Les callbacks du délégué loggent mais agissent rarement. Le callback didStart se déclenche une fois par session et constitue un point d’accroche utile pour l’observabilité ; le callback willExpire se déclenche avant que le système ne force l’invalidation et c’est l’endroit où l’exemple d’Apple s’attend à ce que l’app « termine et nettoie toutes les tâches avant la fin de la session » ; le callback didInvalidateWith est l’endroit où la référence de session est nettoyée afin que le prochain appel à startSession() fonctionne. Le pattern livré, c’est les callbacks mettent à jour l’état, la machine à états fait le travail, et non les callbacks font le travail directement.

Le gestionnaire de minuteur appelle le gestionnaire de session à chaque transition qui modifie le fait que le minuteur compte activement ou non :

@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 session se termine à la pause, à la réinitialisation et à la fin du dernier cycle. Le raisonnement produit : une méditation en pause n’a pas besoin de continuer à réclamer le budget de runtime que le système accorde sous mindfulness ; reprendre depuis la pause récupère une nouvelle session. Le coût produit, c’est qu’une pause prise au baisser de poignet ne peut pas être reprise par le seul fait de relever le poignet ; l’utilisateur doit ramener l’app au premier plan pour reprendre. Le gain produit, c’est que le coût en batterie d’un minuteur en pause tombe à zéro et que le système ne voit pas de session obsolète.

Le baisser de poignet, c’est le test

Tester sur le simulateur watchOS, c’est une fiction polie. Le simulateur n’applique pas le modèle de runtime du baisser de poignet comme le ferait une véritable Apple Watch. Le simulateur garde l’app au premier plan tant que la fenêtre du simulateur a le focus ; une session de runtime étendu dans le simulateur est indiscernable d’une absence de session, parce que l’app au premier plan continue de tourner dans les deux cas.

Le vrai test se fait sur une véritable Apple Watch :5

  1. Lancer le minuteur.
  2. Baisser le poignet (ou appuyer sur le bouton latéral pour verrouiller l’écran).
  3. Attendre 30 secondes.
  4. Relever le poignet.

Sans session de runtime étendu active, l’app Watch est suspendue ; l’état du minuteur est figé à l’instant du baisser de poignet et reprend à partir de cet état figé. Pour une méditation de 5 minutes pendant laquelle l’utilisateur a les yeux fermés, le bug est invisible jusqu’à ce que le minuteur soit faux d’autant de temps que les yeux sont restés fermés.

Avec une session de runtime étendu active, le minuteur continue de compter. Le relevé de poignet révèle le minuteur à la position écoulée correcte. Le signal sonore (si le minuteur en joue un à la fin) se déclenche à l’heure murale correcte, et non à l’heure du relevé de poignet.

Le scénario du baisser de poignet, c’était le bug avec lequel le premier build Watch de Return a été livré et que le refactor en singleton a corrigé. Le correctif, c’est le pattern singleton ci-dessus ; le bug, c’était une instance de WatchSessionManager détenue par une vue SwiftUI qui se faisait désallouer lors d’un push de navigation. La session tournait techniquement côté système, mais le délégué était libéré ; l’appel suivant de démarrage de session était silencieusement un no-op parce que la propriété session du gestionnaire avait été assignée sur un objet désormais mort. Le test sur appareil réel fait surface la défaillance en quelques secondes. Le test sur simulateur, jamais.

Ce que les callbacks du délégué vous disent vraiment

WKExtendedRuntimeSessionInvalidationReason énumère les façons dont une session se termine :6

Raison Quand elle se produit
none La session a été explicitement invalidée par l’app appelant invalidate()
sessionInProgress Une session du même type tourne déjà
expired La limite de temps imposée par le système a été atteinte
resignedFrontmost Une autre app est passée au premier plan pendant que la session tournait
suppressedBySystem Le système a supprimé la session (basse consommation, pression thermique)
error Une erreur irrécupérable s’est produite ; vérifiez le paramètre error

Les raisons qui comptent pour la conception produit :

expired veut dire que la limite de temps imposée par le système a été atteinte. Apple documente la limite de session mindfulness à une heure ;3 la durée maximale de méditation dans Return est de 60 minutes, ce qui correspond au plafond documenté. Une méditation de 90 minutes ne peut pas se terminer à l’intérieur d’une seule session mindfulness : le minuteur mourrait au milieu de la session, à la barre de l’heure. La décision produit, c’est de plafonner les durées disponibles à ce que le modèle de runtime est documenté pour fournir, et non de parier sur la tolérance du système.

resignedFrontmost veut dire que l’utilisateur a ouvert une autre app Watch et que votre session a perdu. Les utilisateurs de la Watch sont doués pour swiper vers une autre app puis oublier. La décision produit, c’est soit de mettre en pause au resign (état préservé, l’utilisateur peut revenir), soit de terminer au resign (session terminée, l’utilisateur reçoit un signal « vous vous êtes arrêté tôt »). Return choisit la pause-au-resign pour que l’utilisateur puisse prendre un appel téléphonique en pleine méditation et revenir.

suppressedBySystem, c’est la version polie de « la montre est chaude ». Un appareil watchOS sous pression thermique ou en faible batterie peut révoquer une session de runtime étendu même sans mauvais usage de la part de l’app. Le gestionnaire de session doit gérer le cas avec élégance : nettoyer la référence, faire surface un avertissement non bloquant, et ne pas entrer dans un état où il essaie de redémarrer une session que le système vient juste de refuser.

Le callback willExpire se déclenche quand la session est sur le point d’expirer ; l’exemple d’Apple le présente comme le moment de « terminer et nettoyer toutes les tâches avant la fin de la session ».3 Le callback est l’endroit où une app peut écrire un instantané d’état final, jouer un signal sonore de clôture ou présenter une UI « la session se termine bientôt ». Aujourd’hui, Return ne fait que logger le callback ; un nettoyage plus riche (entrée de log HealthKit, fondu audio) se produit sur les chemins de réinitialisation et de complétion du minuteur et figure sur la liste des choses que je construirais différemment pour la fenêtre willExpire.

Ce que je construirais différemment

Deux choses, si Return repartait de zéro.

Utiliser HKWorkoutSession pour toute session dont la valeur augmente avec l’intégration HealthKit. Un minuteur de méditation se trouve à la frontière entre mindfulness et workout-processing. Mindfulness était le bon choix pour la v1 parce que le modèle de données est plus simple et que l’attente utilisateur est « c’est de la méditation, pas un entraînement ». HKWorkoutSession apporte une intégration HealthKit plus granulaire (début de session, fin de session, segments, événements) et offre une interface LiveWorkoutBuilder plus riche pour accumuler des données. Jugement architectural, pas garantie documentée par Apple : pour une app dont la valeur dépend d’une télémétrie de session détaillée, la voie de la session de workout gère une structure que WKExtendedRuntimeSession ne gère pas.

Ajouter une surface d’observabilité de l’état de session dès le premier jour. La première version de Return loggait les événements de session dans la console. La deuxième version a ajouté la visibilité de l’état de session sur l’appareil pour le débogage. La troisième exposerait un toggle de mode développeur qui ferait surface l’historique des raisons de session à l’utilisateur quand quelque chose ne va pas, au lieu de traiter l’invalidation de session comme une boîte noire. Le runtime watchOS est opaque ; la surface de débogage doit compenser.

Quand WKExtendedRuntimeSession est la mauvaise réponse

Trois cas où le type de session ne convient pas :

Les entraînements qui ont besoin de marqueurs de segments, de flux de fréquence cardiaque ou de suivi des calories actives. Utilisez HKWorkoutSession directement avec un HKLiveWorkoutBuilder. Le API Workout est le chemin documenté par Apple pour les véritables entraînements (et les méditations en marchant ou les activités intenses) ; WKExtendedRuntimeSession est le chemin documenté pour les sessions hors entraînement comme la pleine conscience ou les alarmes. Une app de méditation n’a pas besoin d’un entraînement ; une app Couch-to-5K, si.

La lecture audio qui a besoin d’une surface Now Playing. Utilisez une AVAudioSession configurée pour la lecture aux côtés des entitlements de session audio watchOS ; l’intégration Now Playing plus la surface de lecture du système, c’est ce que veulent les apps audio, et le chemin audio est entièrement séparé de WKExtendedRuntimeSession. WKExtendedRuntimeSession ne vous donne pas Now Playing ni le routage audio du système.

La synchronisation de données de longue durée sans que l’utilisateur en soit conscient. Utilisez WKApplicationRefreshBackgroundTask pour les fenêtres de rafraîchissement périodiques que le système programme. L’utilisateur n’est pas dans l’app ; l’app n’a pas besoin de continuer à tourner ; elle a besoin de se réveiller brièvement et de se rafraîchir. Les deux modèles, tâches d’arrière-plan et sessions de runtime étendu, répondent à des besoins très différents.

Ce que ce pattern signifie pour les apps livrées sur watchOS 11+

Trois enseignements.

  1. Le modèle de runtime de la Watch est sur opt-in. Choisissez un type de session et vivez à l’intérieur de ses règles. Les apps qui essaient de faire du « travail générique en arrière-plan » sur watchOS perdront. Choisissez mindfulness, workout-processing, self-care, physical-therapy, alarm ou underwater-depth, et concevez l’expérience utilisateur autour du budget de runtime qui accompagne le type de session que vous avez choisi.

  2. Le délégué de session doit vivre à l’échelle de l’app. Le cycle de vie des vues SwiftUI ne protège pas les objets statefuls de longue durée. Un singleton static let shared lié au niveau @main App, c’est le plus petit pattern qui survit aux pushs de navigation, aux remplacements de vues et au comportement normal de désallocation de SwiftUI.

  3. Testez sur du vrai matériel. Le simulateur n’applique pas le modèle de runtime du baisser de poignet. Le bug qu’une app Watch ne peut pas tester dans le simulateur, c’est le bug qu’elle livre aux utilisateurs.

Mettez cet article en regard de mes précédentes publications sur la même famille d’apps : le shipping SwiftUI cross-plateforme (Return est livré sur iPhone, iPad, Watch, Mac et Apple TV) ; la machine à états des Live Activities (la surface côté iOS pour le même minuteur) ; les patterns HealthKit (où les sessions de pleine conscience de la Watch atterrissent dans les données Health de l’utilisateur). L’ensemble complet vit sur le hub de la série Apple Ecosystem. Pour un contexte plus large iOS-avec-agents-AI, consultez le guide iOS Agent Development.

FAQ

Qu’est-ce qu’une session de runtime étendu watchOS ?

Une session de runtime étendu watchOS (WKExtendedRuntimeSession) est le API qu’une app Watch utilise pour continuer à tourner après que l’utilisateur a baissé le poignet. La session doit déclarer un type (mindfulness, workout-processing, alarm, etc.) via WKBackgroundModes dans Info.plist. Sans session étendue active, watchOS suspend l’app peu après le baisser de poignet.

Pourquoi mon minuteur watchOS s’arrête-t-il de compter quand l’utilisateur baisse le poignet ?

L’app Watch est suspendue peu après le baisser de poignet à moins qu’une WKExtendedRuntimeSession active d’un type pris en charge ne soit en cours d’exécution. Un gestionnaire de minuteur qui ne démarre pas une telle session verra son runtime en arrière-plan coupé, et l’état du minuteur sera figé à l’instant du baisser de poignet jusqu’à ce que l’utilisateur relève à nouveau le poignet.

Quelle est la différence entre WKExtendedRuntimeSession et HKWorkoutSession ?

WKExtendedRuntimeSession est le API généraliste de runtime étendu pour les sessions hors entraînement comme la pleine conscience, l’alarme ou le self-care. HKWorkoutSession est le API pour les véritables entraînements ; il s’intègre à HealthKit, prend en charge les marqueurs de segments et constitue le chemin documenté pour les méditations en marchant ou les activités intenses. Les apps de pleine conscience sans télémétrie de niveau entraînement utilisent le premier ; les apps de fitness utilisent le second.

Le système peut-il révoquer ma session de runtime étendu ?

Oui. WKExtendedRuntimeSessionInvalidationReason inclut expired (limite de temps système atteinte), resignedFrontmost (une autre app Watch est passée au premier plan) et suppressedBySystem (basse consommation ou pression thermique). Le gestionnaire de session doit gérer chacun proprement : la référence se nettoie, l’état du minuteur réagit de manière appropriée, et le prochain appel de démarrage de session fonctionne correctement.

Où le gestionnaire de session doit-il vivre dans une app watchOS SwiftUI ?

À l’échelle de l’app, en tant que singleton lié depuis la struct @main App. L’état à portée de vue de SwiftUI (@State, @StateObject) se fait désallouer lors des pushs de navigation, des remplacements de vues ou du passage de l’app en arrière-plan. Un délégué de session détenu par une vue qui est libéré en pleine session provoque une fuite de la référence de session et empêche les sessions suivantes de démarrer proprement.

Références


  1. Return de l’auteur, un minuteur de méditation SwiftUI publié sur l’App Store le 21 avril 2026, disponible pour iPhone, iPad, Mac, Apple Watch et Apple TV. L’app Watch utilise WKExtendedRuntimeSession avec le mode d’arrière-plan mindfulness pour le runtime du minuteur de cycles. 

  2. Apple Developer, « About the background execution sequence ». Mécanismes de runtime en arrière-plan côté iOS (sessions audio, localisation, BGTaskScheduler) et comment ils diffèrent de watchOS. 

  3. Apple Developer, « WKExtendedRuntimeSession ». Types de sessions, cycle de vie, callbacks du délégué, limites de runtime et clé WKBackgroundModes d’Info.plist. 

  4. Apple Developer, « Information Property List: WKBackgroundModes ». Chaînes de types de sessions prises en charge : workout-processing, mindfulness, self-care, physical-therapy, alarm, underwater-depth

  5. Apple Developer, « Building a watchOS app » et la documentation de test WatchKit. Le comportement du runtime sur appareil réel n’est pas reproductible dans le simulateur watchOS ; le simulateur n’applique pas la suspension liée au baisser de poignet. 

  6. Apple Developer, « WKExtendedRuntimeSessionInvalidationReason ». Cas d’énumération : none, sessionInProgress, expired, resignedFrontmost, suppressedBySystem, error

Articles connexes

HealthKit + SwiftUI sur iOS 26 : autorisation, types d'échantillons et patterns multi-plateformes tirés de la mise en production de deux apps

Patterns de production réels issus de Water (suivi de l'eau, HKQuantitySample) et Return (sessions de pleine conscience,…

16 min de lecture

De quoi est faite SwiftUI

SwiftUI est un DSL à result builder posé sur un arbre de vues à types valeur. Une fois le substrat visible, AnyView, Gro…

16 min de lecture

La couche de nettoyage est le véritable marché des agents IA

Charlie Labs a pivoté de la construction d'agents au nettoyage derrière eux. Le marché des agents IA passe de la générat…

15 min de lecture