← Todos os Posts

O runtime do watchOS é um contrato, não uma tarefa em background

O Watch app do Return executa um timer de meditação multiciclo que precisa continuar contando quando o usuário abaixa o pulso.1 O padrão que sobrevive a essa restrição é o WKExtendedRuntimeSession somado a um delegate global, com escopo de app. Tudo o mais morre no momento em que o relógio adormece.

O watchOS não é um iOS com tela menor. O modelo de runtime é diferente. O iOS dá ao app um orçamento generoso em foreground e um runtime em background que diminui mas é real, por meio de sessões de áudio, atualizações de localização, BGTaskScheduler e algumas outras facilidades.2 O watchOS dá ao app em foreground um orçamento medido em segundos depois que o pulso é abaixado, e depois disso o app é suspenso, a menos que tenha assinado um contrato de runtime com o sistema. Não existe um recurso do tipo “estou só fazendo uma coisa em background”. Existe “estou executando um workout, sessão de mindfulness, smart alarm, rota de navegação ou tarefa de monitoramento de saúde”, e nada além disso.3

O target Watch do Return é um timer de mindfulness. O contrato da sessão é WKBackgroundModes: mindfulness. A API de runtime é o WKExtendedRuntimeSession. O padrão que tirou o Watch app de quebrado ao abaixar o pulso para sobrevive a uma meditação de 25 minutos é o que este post descreve.

TL;DR

  • O watchOS não tem um background no estilo do iOS. O runtime em foreground termina pouco depois de abaixar o pulso, e somente tipos de sessão registrados continuam rodando.
  • O WKExtendedRuntimeSession é a superfície da API de runtime. A Apple oferece quatro tipos de sessão: self-care, mindfulness, physical-therapy e alarm. Para um timer de meditação, o tipo de sessão é mindfulness, declarado via WKBackgroundModes no Info.plist.
  • O gerenciador de sessão precisa viver no escopo do app, não no escopo da view. O ciclo de vida das views do SwiftUI desaloca objetos pertencentes à view ao navegar; um delegate de sessão desalocado é uma sessão morta, mesmo que a sessão em si ainda esteja rodando.
  • Os callbacks do WKExtendedRuntimeSessionDelegate são o contrato: didStart, willExpire, didInvalidateWith. O callback de expiração dispara antes que o sistema force a invalidação; o sample da Apple em “Using extended runtime sessions” o enquadra como o lugar para “finalizar e limpar quaisquer tarefas antes que a sessão termine.”3
  • Abaixar o pulso sem uma sessão estendida ativa pausa o timer. Abaixar o pulso com uma sessão estendida ativa mantém o timer rodando. A sessão é a diferença entre “produto em produção” e “quebrado no segundo uso.”

O problema do background que o watchOS não resolve do jeito do iOS

Os apps de iOS recorrem a vários recursos de background quando precisam que o app continue rodando com a tela apagada:2

  • Um AVAudioSession com a categoria .playback mantém um app de áudio vivo enquanto a música toca.
  • As atualizações em background do CLLocationManager mantêm um app de navegação vivo, com a barra azul.
  • O BGTaskScheduler enfileira trabalhos curtos de manutenção que o sistema agenda no seu próprio relógio.
  • Uma extensão de UI em foreground (Live Activity, CallKit, PushKit) faz a ponte entre o processo do app e uma superfície de renderização controlada pelo sistema.

Nada disso ajuda no watchOS da forma que você poderia supor. Os Watch apps não têm o mesmo agendador de tarefas em background. Eles não têm um modo AVAudioSession.playback em background que mantenha o timer contando em silêncio. Eles têm uma única primitiva estrutural para “quero continuar rodando depois que o usuário abaixar o pulso”, e essa primitiva é o WKExtendedRuntimeSession com um tipo de sessão declarado.3

Os tipos de sessão que a Apple suporta para o WKExtendedRuntimeSession são propositalmente restritos:3

  • self-care (atividades breves de bem-estar, runtime em foreground, limite de 10 minutos)
  • mindfulness (meditação silenciosa, runtime em foreground, limite de 1 hora)
  • physical-therapy (alongamento e amplitude de movimento, runtime em background, limite de 1 hora)
  • alarm (smart alarms, runtime em background, limite de 30 minutos, agendável até 36 horas à frente via start(at:))

Apps de workout usam HKWorkoutSession com o background mode workout-processing separado; esse caminho está documentado para workouts de verdade e não é um tipo de WKExtendedRuntimeSession.4 O background mode underwater-depth suporta apps de mergulho e rastreamento de profundidade via o caminho da API de dive-session, também não via WKExtendedRuntimeSession. Um app pode combinar workout-processing com um tipo de extended-runtime session, mas não pode escolher mais de um tipo de extended-runtime por app.4

Apps que não se encaixam em nenhuma dessas categorias não podem usar o WKExtendedRuntimeSession para rodar depois que o pulso é abaixado. Apps de áudio recorrem à categoria AVAudioSession.Category.playback e à integração com Now Playing por um caminho de código diferente; apps de navegação usam as atualizações em background do CLLocationManager. O Watch não é um computador de uso geral; é um dispositivo com restrições de bateria que o modelo de runtime impõe.

Um timer de meditação se encaixa em mindfulness. O contrato: declarar o background mode no Info.plist, requisitar um WKExtendedRuntimeSession, tratar os callbacks do delegate, encerrar a sessão quando o timer terminar. A Apple documenta o limite da sessão de mindfulness como uma hora, com discrição do sistema para encurtar esse tempo sob pressão térmica ou de bateria.3

O padrão usado em produção no Return

O padrão começa com a declaração no Info.plist:4

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

A declaração do mode é o que torna o tipo de sessão válido. Sem ela, chamar WKExtendedRuntimeSession().start() falha silenciosamente e o app é suspenso ao abaixar o pulso, igual a um Watch app sem nenhum background mode.

O próprio gerenciador de sessão precisa viver no escopo do app. O ciclo de vida das views do SwiftUI é hostil a objetos com estado de longa vida: @StateObject e @State têm escopo restrito à view que os possui, e um navigation push que substitui a view derruba o estado junto. Um WKExtendedRuntimeSession cujo delegate é desalocado no meio da sessão não causa crash; a sessão continua rodando, mas os callbacks do delegate (willExpire, didInvalidateWith) chegam a um objeto liberado, ou seja, a limpeza nunca acontece, ou seja, a próxima chamada de startSession() acha que não há sessão ativa e inicia uma duplicada.

O padrão em produção é um singleton no escopo do app. O snippet abaixo é a forma estrutural; produção adiciona logging dentro de cada método para observabilidade:

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

Três detalhes estruturais importam, além da conformidade com o protocolo.

A instância static let shared é retida durante todo o ciclo de vida do processo do Watch app por meio de armazenamento estático; o ARC não a desalocará. O que o binding no nível do App proporciona não é retenção extra, mas um ponto de observação estável. O bug que isso previne: um gerenciador de sessão mantido somente por uma view transitória que é removida no meio da sessão, em que a view morre mas o static let shared sobrevive, com o efeito colateral de qualquer manager encapsulado em @StateObject perder seu ciclo de observação e parar de re-renderizar corretamente. Use o singleton com um accessor @Observable no nível do App, para que a UI continue observando a instância canônica.

A propriedade session é a proteção contra sessões duplicadas. Um timer com um botão de “começar de novo” pode chamar startSession() por múltiplos caminhos; o guard session == nil é o lock. Duas sessões estendidas concorrentes geram comportamento imprevisível: às vezes a segunda tem sucesso e a primeira fica órfã, às vezes a chamada de start falha silenciosamente. A invariante de sessão única previne a classe inteira.

Os callbacks do delegate fazem log mas raramente agem. O callback didStart dispara uma vez por sessão e é um hook útil para observabilidade; o willExpire dispara antes que o sistema force a invalidação e é onde o sample da Apple espera que o app “finalize e limpe quaisquer tarefas antes que a sessão termine”; o didInvalidateWith é onde a referência da sessão é limpa para que a próxima chamada de startSession() funcione. O padrão em produção é callbacks atualizam estado, a state machine faz o trabalho, não callbacks fazem o trabalho diretamente.

O timer manager chama o session manager em toda transição que muda se o timer está contando ativamente:

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

A sessão termina ao pausar, ao resetar e na conclusão do ciclo final. O raciocínio do produto: uma meditação pausada não precisa continuar reivindicando o orçamento de runtime que o sistema concede sob mindfulness; retomar do pause readquire uma sessão fresca. O custo do produto é que uma pausa com pulso abaixado não pode ser retomada apenas levantando o pulso; o usuário precisa trazer o app de volta para o foreground para retomar. O ganho do produto é que o custo de bateria do timer pausado vai a zero e o sistema não vê uma sessão obsoleta.

Abaixar o pulso é o teste

O teste de watchOS no simulador é uma ficção polida. O simulador não impõe o modelo de runtime do pulso abaixado da maneira que um Apple Watch real impõe. O simulador mantém o app em foreground enquanto a janela do simulador estiver com o foco; uma extended runtime session no simulador parece idêntica a nenhuma sessão, porque o app em foreground continua rodando de qualquer jeito.

O teste real é em um Apple Watch de verdade:5

  1. Inicie o timer.
  2. Abaixe o pulso (ou pressione o botão lateral para travar a tela).
  3. Espere 30 segundos.
  4. Levante o pulso de novo.

Sem uma extended runtime session ativa, o Watch app é suspenso; o estado do timer congela no momento em que o pulso é abaixado e retoma daquele estado congelado. Para uma meditação de 5 minutos em que o usuário fecha os olhos, o bug é invisível até o timer estar errado pelo tempo em que os olhos ficaram fechados.

Com uma extended runtime session ativa, o timer continua contando. Levantar o pulso revela o timer na posição correta de tempo decorrido. O cue de áudio (se o timer toca um na conclusão) dispara no horário de relógio correto, não no horário em que o pulso é levantado.

O cenário do pulso abaixado foi o bug com que o primeiro build do Watch app do Return foi para produção, e o refactor para singleton corrigiu. A correção é o padrão de singleton acima; o bug era uma instância de WatchSessionManager mantida por uma view do SwiftUI que foi desalocada num navigation push. A sessão estava tecnicamente rodando do lado do sistema, mas o delegate foi liberado; a próxima chamada de start de sessão era silenciosamente um no-op porque a propriedade session do manager havia sido definida em um objeto agora morto. O teste em hardware real expõe a falha em segundos. O teste no simulador nunca expõe.

O que os callbacks do delegate realmente dizem para você

O WKExtendedRuntimeSessionInvalidationReason enumera as formas como uma sessão termina:6

Razão Quando acontece
none A sessão foi explicitamente invalidada pelo app chamando invalidate()
sessionInProgress Uma sessão do mesmo tipo já está rodando
expired O limite de tempo imposto pelo sistema foi atingido
resignedFrontmost Um app diferente passou a ser o frontmost enquanto a sessão rodava
suppressedBySystem O sistema suprimiu a sessão (bateria baixa, pressão térmica)
error Ocorreu um erro irrecuperável; verifique o parâmetro error

As razões que importam para o design do produto:

expired significa que o limite de tempo imposto pelo sistema foi atingido. A Apple documenta o limite da sessão de mindfulness como uma hora;3 a duração mais longa de meditação no Return é de 60 minutos, que é o teto documentado. Uma meditação de 90 minutos não pode ser concluída dentro de uma única sessão de mindfulness: o timer morreria no meio da sessão na marca de uma hora. A decisão de produto é limitar as durações disponíveis ao que o modelo de runtime está documentado para entregar, e não apostar na tolerância do sistema.

resignedFrontmost significa que o usuário abriu outro Watch app e sua sessão perdeu. Os usuários de Watch são bons em deslizar para outro app e depois esquecer. A decisão de produto é, ou pausar-no-resign (estado preservado, o usuário pode voltar) ou encerrar-no-resign (sessão acabou, o usuário recebe um sinal de “você parou cedo”). O Return escolhe pausar-no-resign para que o usuário possa atender uma ligação no meio da meditação e voltar.

suppressedBySystem é a versão polida de “o relógio está quente.” Um dispositivo watchOS sob pressão térmica ou bateria baixa pode revogar uma extended runtime session mesmo sem mau uso pelo app. O session manager precisa lidar com o caso de forma graciosa: limpar a referência, expor um aviso não bloqueante e não entrar num estado em que tente reiniciar uma sessão que o sistema acabou de recusar.

O callback willExpire dispara quando a sessão está prestes a expirar; o sample da Apple o enquadra como o momento de “finalizar e limpar quaisquer tarefas antes que a sessão termine.”3 O callback é onde um app pode gravar um snapshot final de estado, tocar um cue de áudio de fechamento, ou apresentar uma UI de “sessão terminando em breve”. O Return hoje só registra o callback no log; uma limpeza mais rica (entrada de log no HealthKit, fade-out de áudio) acontece nos caminhos de reset e conclusão do timer e está na lista o que eu construiria de forma diferente para a janela do willExpire.

O que eu construiria de forma diferente

Duas coisas, se o Return estivesse começando do zero.

Use HKWorkoutSession para qualquer sessão cujo valor aumenta com a integração ao HealthKit. Um timer de meditação fica na borda entre mindfulness e workout-processing. Mindfulness foi a escolha certa para a v1 porque o modelo de dados é mais simples e a expectativa do usuário é “isto é meditação, não um workout.” O HKWorkoutSession carrega uma integração mais granular com o HealthKit (início de sessão, fim de sessão, segments, events) e oferece uma interface LiveWorkoutBuilder mais rica para acumular dados. O julgamento arquitetural, não uma garantia documentada da Apple: para um app cujo valor depende de telemetria detalhada da sessão, a rota da workout-session lida com estrutura que o WKExtendedRuntimeSession não lida.

Adicione uma superfície de observabilidade do estado da sessão desde o primeiro dia. A primeira versão do Return registrava eventos de sessão no console. A segunda versão adicionou visibilidade do estado da sessão no dispositivo para depuração. A terceira exporia um toggle de modo de desenvolvedor que mostra o histórico de razões da sessão para o usuário quando algo dá errado, em vez de tratar a invalidação da sessão como uma caixa-preta. O runtime do watchOS é opaco; a superfície de debug precisa compensar isso.

Quando o WKExtendedRuntimeSession é a resposta errada

Três casos em que o tipo de sessão não se encaixa:

Workouts que precisam de marcadores de segments, streams de frequência cardíaca ou rastreamento ativo de calorias. Use HKWorkoutSession diretamente com um HKLiveWorkoutBuilder. A API de Workout é o caminho documentado pela Apple para workouts de verdade (e meditações caminhadas ou atividades extenuantes); o WKExtendedRuntimeSession é o caminho documentado para sessões não relacionadas a workout, como mindfulness ou alarmes. Um app de meditação não precisa de um workout; um app Couch-to-5K precisa.

Reprodução de áudio que precisa de uma superfície Now Playing. Use um AVAudioSession configurado para playback em conjunto com os entitlements de sessão de áudio do watchOS; a integração com Now Playing somada à superfície de playback do sistema é o que apps de áudio querem, e o caminho de áudio é totalmente separado do WKExtendedRuntimeSession. O WKExtendedRuntimeSession não dá Now Playing nem o roteamento de áudio do sistema.

Sincronização de dados de longa duração sem percepção do usuário. Use WKApplicationRefreshBackgroundTask para janelas de refresh periódicas que o sistema agenda. O usuário não está no app; o app não precisa continuar rodando; ele precisa acordar brevemente e fazer o refresh. Os dois modelos, background-task e extended-runtime-session, atendem a necessidades muito diferentes.

O que esse padrão significa para apps que entregam em watchOS 11+

Três pontos.

  1. O modelo de runtime do Watch é opt-in. Escolha um tipo de sessão e viva dentro de suas regras. Apps que tentarem fazer “trabalho em background de uso geral” no watchOS perderão. Escolha mindfulness, workout-processing, self-care, physical-therapy, alarm ou underwater-depth, e desenhe a experiência do usuário em torno do orçamento de runtime que vem com o tipo de sessão escolhido.

  2. O delegate da sessão precisa viver no escopo do app. O ciclo de vida das views do SwiftUI não protege objetos com estado de longa vida. Um singleton static let shared ligado no nível do @main App é o menor padrão que sobrevive a navigation pushes, substituições de view e ao comportamento normal de desalocação do SwiftUI.

  3. Teste em hardware real. O simulador não impõe o modelo de runtime do pulso abaixado. O bug que um Watch app não consegue testar no simulador é o bug que ele entrega para os usuários.

Combine este post com meus textos anteriores sobre a mesma família de apps: SwiftUI cross-platform shipping (o Return entrega em iPhone, iPad, Watch, Mac e Apple TV); a state machine de Live Activities (a superfície do iOS para o mesmo timer); padrões de HealthKit (onde as sessões de mindfulness do Watch aterrissam nos dados de Saúde do usuário). O conjunto completo está no hub da Apple Ecosystem Series. Para um contexto mais amplo de iOS com agentes de IA, veja o iOS Agent Development guide.

FAQ

O que é uma extended runtime session no watchOS?

Uma extended runtime session no watchOS (WKExtendedRuntimeSession) é a API que um Watch app usa para continuar rodando depois que o usuário abaixa o pulso. A sessão precisa declarar um tipo (mindfulness, workout-processing, alarm, etc.) via WKBackgroundModes no Info.plist. Sem uma extended session ativa, o watchOS suspende o app pouco depois de o pulso ser abaixado.

Por que meu timer no watchOS para de contar quando o usuário abaixa o pulso?

O Watch app é suspenso pouco depois de o pulso ser abaixado, a menos que uma WKExtendedRuntimeSession ativa de um tipo suportado esteja rodando. Um timer manager que não inicia tal sessão verá seu runtime em background ser cortado, e o estado do timer congela no momento em que o pulso é abaixado, até que o usuário levante o pulso novamente.

Qual a diferença entre WKExtendedRuntimeSession e HKWorkoutSession?

O WKExtendedRuntimeSession é a API de runtime estendido de uso geral para sessões não relacionadas a workout, como mindfulness, alarm ou self-care. O HKWorkoutSession é a API para workouts de verdade; ele se integra ao HealthKit, suporta marcadores de segments e é o caminho documentado para meditações caminhadas ou atividades extenuantes. Apps de mindfulness sem telemetria de nível workout usam o primeiro; apps de workout usam o segundo.

O sistema pode revogar minha extended runtime session?

Sim. O WKExtendedRuntimeSessionInvalidationReason inclui expired (limite de tempo do sistema atingido), resignedFrontmost (outro Watch app passou a ser frontmost) e suppressedBySystem (bateria baixa ou pressão térmica). O session manager precisa lidar com cada um de forma limpa: a referência é limpa, o estado do timer reage adequadamente e a próxima chamada de start de sessão funciona corretamente.

Onde o session manager deve viver em um app SwiftUI de watchOS?

No escopo do app, como um singleton ligado a partir da struct @main App. O estado com escopo de view do SwiftUI (@State, @StateObject) é desalocado em navigation pushes, substituições de view ou quando o app vai para background. Um delegate de sessão pertencente à view, liberado no meio da sessão, faz a referência da sessão vazar e impede que sessões subsequentes sejam iniciadas de forma limpa.

Referências


  1. Return, do autor, um timer de meditação em SwiftUI publicado na App Store em 21 de abril de 2026, disponível para iPhone, iPad, Mac, Apple Watch e Apple TV. O Watch app usa WKExtendedRuntimeSession com background mode mindfulness para o runtime do timer de ciclos. 

  2. Apple Developer, “About the background execution sequence”. Recursos de runtime em background no lado iOS (sessões de áudio, localização, BGTaskScheduler) e como diferem do watchOS. 

  3. Apple Developer, “WKExtendedRuntimeSession”. Tipos de sessão, ciclo de vida, callbacks do delegate, limites de runtime e a chave WKBackgroundModes do Info.plist. 

  4. Apple Developer, “Information Property List: WKBackgroundModes”. Strings de tipo de sessão suportadas: workout-processing, mindfulness, self-care, physical-therapy, alarm, underwater-depth

  5. Apple Developer, “Building a watchOS app” e a orientação de testes do WatchKit. O comportamento de runtime em dispositivo real não é reproduzível no simulador do watchOS; o simulador não impõe a suspensão por pulso abaixado. 

  6. Apple Developer, “WKExtendedRuntimeSessionInvalidationReason”. Casos da enumeração: none, sessionInProgress, expired, resignedFrontmost, suppressedBySystem, error

Artigos relacionados

HealthKit + SwiftUI no iOS 26: autorização, tipos de amostra e padrões multiplataforma a partir do envio de dois apps

Padrões reais de produção do Water (rastreamento de água, HKQuantitySample) e do Return (sessões mindful, HKCategorySamp…

16 min de leitura

Do que o SwiftUI é feito

O SwiftUI é uma DSL de result builder em cima de uma árvore de Views com tipo por valor. Quando o substrato fica visível…

15 min de leitura

A camada de limpeza é o verdadeiro mercado de agentes de IA

A Charlie Labs pivotou de construir agentes para limpar o que eles deixam para trás. O mercado de agentes de IA está sai…

14 min de leitura