← Alle Beitrage

Live Activities sind eine Zustandsmaschine, kein Badge

Die Live Activity in Return sieht aus wie eine Countdown-Zahl auf dem Lock Screen und auf der Dynamic Island.12 Sie ist keine Zahl. Sie ist eine Zustandsmaschine mit fünf Lebenszyklus-Zuständen, drei externen Beendigungspfaden und einem reentranten Startpfad, der sich gegen sich selbst verteidigen muss. Die nachfolgenden Muster sind diejenigen, die in der Produktion überlebt haben. Der brutal-ehrliche Schlussabschnitt am Ende sagt aus, was ich noch nicht weiß.

Ich habe eine v1 ausgeliefert, die die Live Activity wie ein Badge behandelt hat. Die “aktuell verbleibende Zeit” war Daten; der Rest war Dekoration. Diese Version hatte drei Bugs, die ich in TestFlight gefangen habe, und einen, den ich in der Produktion gefangen habe:

  1. Ein Tippen auf Start, während der Start bereits in Bearbeitung war, erzeugte eine zweite Activity, die die erste verwaisen ließ.
  2. Der Countdown wurde auf der Dynamic Island korrekt gerendert, aber die Lock-Screen-Ansicht traf bei pausierten Timern auf endTime <= Date() und zeigte 0:00 an, bis der Benutzer fortsetzte.
  3. Die Live Activity blieb lange sichtbar, nachdem der Benutzer den Timer zurückgesetzt hatte, weil die Beendigungsrichtlinie .default war, die Apple einige Zeit – bis zu vier Stunden – sichtbar hält.
  4. (Produktion.) Bei rechts-nach-links-Sprachgebietsschemata (Arabisch, Hebräisch) wurden die Ziffern in der Compact-Trailing-Region der Dynamic Island rückwärts gerendert. Lateinische Ziffern, RTL-Layout. Der Fix war eine Zeile.

Jeder dieser Fehler war ein Zustandsmaschinen-Bug. Die Countdown-Zahl war in Ordnung. Die Zahl ist nicht das Produkt. Das Produkt ist der Zustand.

Die nachstehende Zustandsmaschine ist das, was diese Bugs überlebt hat.

TL;DR

  • Der ausgelieferte LiveActivityManager stellt 5 Übergangsmethoden bereit (startActivity, updateActivity, showCycleComplete, showFinalCompletion, endActivity) plus 1 Lesezugriff (hasActiveActivity). Die 224 Produktionszeilen schützen vor einer spezifischen Gefahr innerhalb von startActivity: nebenläufige Start-Aufrufe plus Cancellation-Checks an jeder await-Grenze in dieser Methode.3
  • Der ContentState trägt 6 Felder: endTime, currentCycle, totalCycles, isPaused, isCompleted, remainingSeconds. Die ersten fünf sind die Bezeichner der Zustandsmaschine. Das sechste (remainingSeconds) ist ein statischer Anzeige-Fallback, den das Live-timerInterval von ActivityKit nicht bedienen kann.
  • Die Entscheidung über die Beendigungsrichtlinie ist die eigentliche Produktentscheidung. .immediate für das Zurücksetzen durch den Benutzer, .after(Date().addingTimeInterval(3)) für den Abschluss, niemals der Systemstandard.
  • Die Compact-Trailing-Region der Dynamic Island benötigt .environment(\.layoutDirection, .leftToRight) auf dem Timer-Text, damit lateinische Ziffern unter RTL-Systemgebietsschemata LTR bleiben.

Die Zustandsmaschine

Die ausgelieferte Live Activity hat einen Idle-Zustand, drei Live-Zustände, die der Benutzer beobachten kann, einen Endzustand und ein reentrantes Tor, das der Entwickler beobachten muss:

┌──────────────────────────────────────────────────────────────────┐
│                  Lifecycle states                                 │
├──────────────────────────────────────────────────────────────────┤
│  IDLE          currentActivity == nil; no Live Activity present   │
│  RUNNING       isPaused=false, endTime > Date()                   │
│  PAUSED        isPaused=true, remainingSeconds=N                  │
│  CYCLE_END     isPaused=false, endTime <= Date(), isCompleted=false│
│  COMPLETE      isCompleted=true (terminal; transitions to IDLE)   │
└──────────────────────────────────────────────────────────────────┘
              │
              ↓
┌──────────────────────────────────────────────────────────────────┐
│             Dismissal policies (Apple)                            │
├──────────────────────────────────────────────────────────────────┤
│  .immediate            user reset                                  │
│  .after(now + 3s)      completion display window                   │
│  .default              system decides; can stay up to 4 hours      │
└──────────────────────────────────────────────────────────────────┘

Reentrancy gate inside startActivity():
  isStartingActivity flag + cancellable startActivityTask
  prevents two concurrent startActivity() calls from creating
  two Live Activities for one timer. Cancellation checks across
  each await keep the in-flight task safe to abort.

Der Render-Pfad prüft zuerst isPaused; diese Reihenfolge ist es, die einen pausierten Timer davon abhält, als CYCLE_END gerendert zu werden, wenn die Wanduhrzeit endTime überschritten hat.7

Die Zustandsnamen sind keine Beschriftungen auf der Zahl. Die Zustandsnamen sind der Vertrag zwischen LiveActivityManager (der App-Seite, wo meine SwiftUI-Views leben) und ReturnLiveActivity (der Widget-Extension, wo Apples Prozess die Oberfläche rendert).

Der Vertrag ist TimerActivityAttributes.ContentState, alle 6 Felder:3

public struct ContentState: Codable, Hashable {
    var endTime: Date
    var currentCycle: Int
    var totalCycles: Int?
    var isPaused: Bool
    var isCompleted: Bool = false
    var remainingSeconds: Int = 0
}

Jeder Zustandsübergang mutiert dieses Struct und bittet ActivityKit, es über Prozessgrenzen hinweg an die Widget-Extension auszuliefern. Das Widget rendert dann neu. Es gibt keinen geteilten Speicher. Es gibt keinen Callback. Es gibt ein Codable-Struct, das bei jedem Übergang eine Prozessgrenze überquert.

Diese Tatsache schließt alles aus, was ich mit Closures, View-Models, Observable-Objekten oder berechneten Properties tun könnte. Der Zustand muss als serialisierbare Daten ausdrückbar sein. Wenn er nicht kodiert werden kann, kann er nicht übergehen.

Der reentrante Start

Live Activities haben ein hartes Limit für gleichzeitige Activities und ein weiches Limit für das, was passiert, wenn Sie Activity.request zweimal in Bearbeitung aufrufen. Das harte Limit ist gut dokumentiert.4 Das weiche Limit lautet “der zweite Aufruf könnte erfolgreich sein und ein Waisenkind erzeugen.” Das Waisenkind ist die Live Activity, die nicht mehr mit currentActivity in Ihrem Manager assoziiert ist. Sie überlebt. Sie hat keinen Weg zurück in Ihren Code. Sie verschwindet schließlich von selbst durch ihren Veralterungs-Timer. Bis dahin sieht der Benutzer einen doppelten Timer.

Das Waisenkind war der v1-Bug, den Return ausgeliefert hat. Der Fix ist das reentrante Tor plus ein abbrechbarer Task in LiveActivityManager.swift:3

private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?

func startActivity(...) {
    #if os(iOS)
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
    guard !isStartingActivity else { return }
    isStartingActivity = true

    startActivityTask?.cancel()

    startActivityTask = Task {
        defer {
            isStartingActivity = false
            startActivityTask = nil
        }
        guard !Task.isCancelled else { return }

        await endActivity()  // explicit cleanup of any prior state

        guard !Task.isCancelled else { return }

        // ... build attributes + contentState ...

        do {
            let activity = try Activity.request(...)
            guard !Task.isCancelled else { return }
            currentActivity = activity
        } catch {
            // log; flag clears via defer
        }
    }
    #endif
}

Drei Dinge zu diesem Muster, die die Dokumentation nicht hervorhebt:

Das isStartingActivity-Flag ist der aktive Schutz; startActivityTask?.cancel() ist defensives Aufräumen. Das Flag schließt jeden zweiten startActivity-Aufruf kurz, während der erste in Bearbeitung ist, sodass Sie den öffentlichen Pfad gar nicht erst um die Wette laufen lassen. Der Cancel-und-Ersetz-Tanz ist trotzdem wichtig, weil der laufende Task asynchron ist und einen kurzlebigen Aufrufer überdauern kann; die Aufhebung verhindert, dass ein veralteter Task weiterläuft, nachdem der Aufrufer weitergezogen ist.

Die guard !Task.isCancelled-Prüfungen an jeder await-Grenze. Cancellation ist in Swift kooperativ. Selbst wenn cancel aufgerufen wird, läuft der Task weiter, bis er explizit prüft. Jedes await ist eine Gelegenheit zur Prüfung. Ohne die Post-Await-Checks baut ein abgebrochener Task weiterhin Activity-Zustand auf, ruft Activity.request auf und erzeugt bei Erfolg stillschweigend ein Waisenkind.

Das defer setzt das Flag zurück, bevor der Task-Body abschließt. Ohne defer lässt ein frühes return (aus der Cancellation-Prüfung) isStartingActivity = true permanent zurück, und die Activity startet nie wieder, bis die App neu gestartet wird. Das Flag ist eine Sperre; die Sperre muss bei jedem Ausgangspfad freigegeben werden.

Das pushType: nil-Argument. Return verwendet keine APNs-pushgesteuerten Live-Activity-Updates. Die App aktualisiert die Activity lokal über activity.update. Wenn Sie pushgesteuerte Updates benötigen (Lieferverfolgung, Sportergebnisse, Echtzeitdaten), ist der Typ pushType: .token und der Vertrag ist dramatisch komplexer.5 Lokale Updates sind einfacher und decken jeden Timer-/Zähler-/Einzel-App-Workflow ab.

Das Pause-Problem

ActivityKit liefert eine wunderschöne Text(timerInterval: Date()...endTime, countsDown: true)-View aus, die einen Live-Countdown ohne Update von der App rendert.6 Sie setzen die Endzeit, das System rendert einen Live-Timer. Kein Timer.publish, keine Widget-Aktualisierung, kein Akkuverbrauch.

Das ist fantastisch, wenn der Timer läuft. Es ist falsch, wenn der Timer pausiert ist.

Der timerInterval-Text zählt unabhängig von einem “Pause”-Signal im Zustand auf endTime zu. Es gibt in Apples API keinen “Eingefroren bei 10:23”-Modus. Wenn Sie endTime = Date().addingTimeInterval(623) übergeben und der Benutzer bei der 10:23-Marke pausiert, zählt der Timer-Text im Widget weiter auf null herunter. Das Zustandsfeld sagt pausiert. Das Widget rendert laufend.

Der Fix besteht darin, zwei verschiedene Views aus demselben Zustand zu rendern:7

if context.state.isPaused {
    // static text
    Text(formatTime(context.state.remainingSeconds))
        .monospacedDigit()
} else if context.state.endTime > Date() {
    // live countdown
    Text(timerInterval: Date()...context.state.endTime, countsDown: true)
        .monospacedDigit()
} else {
    // post-end static
    Text("0:00")
        .monospacedDigit()
}

Das zweigleisige Rendern ist der Grund, warum der ContentState remainingSeconds als separates Feld trägt. Es ist redundant, wenn der Timer läuft (das System berechnet es aus endTime). Es ist die einzige Wahrheitsquelle, wenn der Timer pausiert ist. Die zwei Hälften des Structs bedienen zwei verschiedene Render-Modi; der isPaused-Boolean wählt zwischen ihnen aus.

Die Beendigungsrichtlinien

activity.end(_:dismissalPolicy:) nimmt einen von drei ActivityUIDismissalPolicy-Werten, und die falsche Wahl ist es, was meine v1 nach einem Reset für eine gefühlte Ewigkeit auf dem Lock Screen des Benutzers verweilen ließ:13

Richtlinie Wann verwenden Was Sie erhalten
.immediate Benutzer-Reset, Fehler, App in den Hintergrund geschickt ohne zu verfolgende Activity Activity verschwindet jetzt. Kein Schonfenster
.after(date) Abschlussanzeige: “Ihre Meditation ist abgeschlossen” muss einen Moment lesbar sein. Das Datum muss innerhalb des von Apple erlaubten Vier-Stunden-Fensters liegen Activity zeigt den finalen Zustand, dann verschwindet sie zum date
.default Wenn Sie wirklich wollen, dass Apples Heuristik entscheidet System hält sie “einige Zeit” sichtbar (Apples Wortlaut), bis zu vier Stunden, nachdem end aufgerufen wurde

Return verwendet .after(Date().addingTimeInterval(3)) für den natürlichen Abschlusspfad:3

await activity.end(
    .init(state: contentState, staleDate: nil),
    dismissalPolicy: .after(Date().addingTimeInterval(3))
)

Drei Sekunden ist die Zeit, die ein Benutzer benötigt, um auf den Lock Screen zu schauen, zu registrieren, dass der Timer beendet ist, und die Befriedigung des Häkchens zu spüren. Weniger als drei ist hektisch. Mehr als drei fühlt sich an, als wüsste die Activity nicht, dass sie fertig ist.

Bei einem benutzergesteuerten Reset lautet der Aufruf dismissalPolicy: .immediate. Kein Fenster. Der Benutzer weiß bereits Bescheid.

Die falsche Wahl in v1 war .default. Bei einem abgeschlossenen Meditations-Timer hielt das System die Activity lange genug sichtbar, dass Benutzer dachten, die App habe den Abschluss überhaupt nicht registriert. Apples Dokumentation sagt, .default halte die beendete Activity “einige Zeit” sichtbar, bis zu vier Stunden;13 die korrekte Haltung für einen Timer ist es, die Beendigung explizit zu machen.

Die Compact-Region der Dynamic Island

Die Dynamic Island hat drei Render-Modi, und Sie brauchen alle drei selbst für einen einfachen Timer:2

  • Compact (Standard-Form der Dynamic Island): Leading-Icon + Trailing-Timer
  • Minimal (wenn eine andere Live Activity um dieselbe Dynamic Island konkurriert): nur Leading-Icon
  • Expanded (langes Drücken): vier benannte Regionen (leading, trailing, center, bottom)

Das Muster, das sich seinen Platz in Return verdient hat, besteht darin, die Expanded-Ansicht nahezu identisch zur Compact-Ansicht zu gestalten:8

DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        Image("AppIconSmall")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 16, height: 16)
            .clipShape(RoundedRectangle(cornerRadius: 4))
    }
    DynamicIslandExpandedRegion(.trailing) {
        TimerText(...)
    }
    DynamicIslandExpandedRegion(.center) { EmptyView() }
    DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
    Image("AppIconSmall")...
} compactTrailing: {
    TimerText(...)
} minimal: {
    Image("AppIconSmall")...
}

Die meisten Live-Activity-Tutorials lehnen sich an die Expanded-Ansicht als das “echte” Design an, mit reichhaltigem Inhalt in der bottom-Region. Für einen Meditations-Timer ist die Expansion totes Gewicht. Der Benutzer öffnet die Expanded-Ansicht durch langes Drücken, und das lange Drücken gibt ihm bereits die haptische Rückmeldung, dass etwas passiert ist. Inhalt hinzuzufügen lässt die Expansion etwas sagen, was der Benutzer nicht erfragt hat. Leere Regionen im Expanded-Modus sind kein Designversagen; sie sind das Design.

Der RTL-Bug

Der Produktions-Bug. Arabische und hebräische Benutzer auf iOS berichteten, dass der Compact-Trailing-Timer der Dynamic Island die Ziffern rückwärts rendere. Die lateinische Ziffernfolge 5:23 wurde als 32:5 gerendert, weil die Compact-Trailing-Layout-Richtung die RTL-Einstellung des Systemgebietsschemas erbte.

SwiftUI erbt die System-Layout-Richtung innerhalb des Widget-Prozesses, sodass der Dynamic-Island-Timer-Text RTL aufnahm, wenn das Telefon des Benutzers auf Arabisch oder Hebräisch eingestellt war. Lateinische Ziffern sollten LTR gerendert werden, selbst innerhalb einer ansonsten RTL-UI. Der Fix besteht darin, die Layout-Richtung auf den numerischen Text-Views festzunageln:7

.environment(\.layoutDirection, .leftToRight)

Die Überschreibung gehört auf die numerischen Text-Views innerhalb von TimerText (Dynamic Island Compact / Expanded) und innerhalb der Lock-Screen-View, nicht auf die gesamte View. Lateinische Ziffern werden unabhängig vom Systemgebietsschema des Benutzers von links nach rechts gelesen; Zyklus-Beschriftungen wie “Cycle 2 of 3” bleiben lokalisiert, sodass sie der System-Layout-Richtung folgen.

Der Bug zeigt sich nicht im inländischen TestFlight. Er zeigt sich in dem Moment, in dem ein echter RTL-Benutzer den Timer öffnet. Die Lehre: Liefern Sie die LTR-festgenagelte Environment-Überschreibung auf jeder lateinischen Ziffern-Text-View in jeder Live Activity aus, die in RTL-Gebietsschemata laufen könnte.

Die Lokalisierungsgeschichte

TimerActivityAttributes trägt ein languageCode: String-Feld, das die App bei der Activity-Erstellung setzt:9

let attributes = TimerActivityAttributes(
    timerDuration: duration,
    languageCode: settings.appLanguage  // app's selected language, not system's
)

Die Widget-Extension liest dies, um lokalisierte Strings zu rendern:

private var locale: Locale {
    let code = context.attributes.languageCode
    return code.isEmpty ? .current : Locale(identifier: code)
}

private func localized(_ key: String.LocalizationValue) -> String {
    String(localized: key, locale: locale)
}

Warum die App ihren eigenen Sprachcode übergibt, anstatt das Widget Locale.current lesen zu lassen: Die Widget-Extension läuft in ihrem eigenen Prozess. Ihr Locale.current ist das Systemgebietsschema, nicht das von der App ausgewählte Gebietsschema. Wenn ein Benutzer Return auf Koreanisch eingestellt hat, während sein iPhone auf Englisch ist, würde das Widget ohne diese Überschreibung Englisch sprechen. Die Sprachpräferenz der App reist in den Activity-Attributen mit; das Widget respektiert sie.

Localizable.xcstrings lebt im Widget-Target neben dem der App, aber sie sind separate Dateien. Strings, die im Widget verwendet werden, müssen in ReturnWidgets/Localizable.xcstrings existieren, selbst wenn derselbe String in Return/Localizable.xcstrings existiert. Das zu vergessen bedeutet, dass das Widget auf die Entwicklungssprache zurückfällt, während die App Koreanisch spricht.

Was ich anders bauen würde

ContentState kleiner machen. Sechs Felder sind zu viele. Die Redundanz zwischen endTime und remainingSeconds ist der Preis dafür, den fehlenden Pause-Modus in timerInterval zu umgehen. Würde ich von vorne beginnen, würde ich ein einzelnes displayMode-Enum tragen (running, paused(remainingSeconds: Int), cycleEnd, complete) und den Render-Code auf den Fall verzweigen lassen. Sechs Felder sind schwerer korrekt mutiert über fünf Übergangsmethoden hinweg zu halten, als vier Fälle es sind.

Interaktive Live-Activity-Buttons hinzufügen (iOS 17+). Return stellt derzeit keine Pause-/Fortsetzen-Steuerelemente in der Dynamic Island bereit. Der Benutzer muss die App öffnen, um zu pausieren. iOS 17 fügte Button(intent:) für App Intents innerhalb von Live Activities hinzu.10 Eine interaktive Pause-Steuerung ist die offensichtliche Erweiterung und das Nächste, was ich für Return ausliefern werde.

Push-Update Live Activities für geräteübergreifende Timer-Synchronisation. Return synchronisiert Sitzungen über iPhone, iPad, Watch und Apple TV via NSUbiquitousKeyValueStore (behandelt in Five Apple Platforms, Three Shared Files). Heute wird die Activity lokal von der iPhone- oder iPad-App gestartet und lokal aktualisiert. Ein Benutzer, der einen Timer auf der Apple Watch startet, könnte idealerweise sehen, wie die Live Activity dies in Echtzeit auf dem iPhone widerspiegelt. APNs-Push zur Live Activity ist der Weg.5 Habe ich noch nicht gebaut.

Wann Live Activities nicht zu verwenden sind

Einmalige flüchtige Zustände. Ein “Gespeichert!”-Toast verdient keine Live Activity. Das System hat ein Banner. Verwenden Sie es.

Häufig wechselnde Daten ohne Timer-Dimension. Live Activities funktionieren am besten für Dinge mit klarem zeitlichen Anker (ein Timer, eine Liefer-ETA, eine Spieluhr, eine Telefongesprächsdauer). Aktien-Ticker und Sportergebnisse funktionieren, weil sie ein Sitzungsfenster haben. Ein Allzweck-Dashboard nicht.

Apps ohne Lock-Screen-/Standby-Anwendungsfall. Live Activities erfordern echten Engineering-Aufwand (Target-Setup, ContentState-Design, Beendigungsrichtlinien-Entscheidungen, RTL-Behandlung, Lokalisierungs-Sanitärinstallation). Apps, die der Benutzer direkt öffnet, ohne den Lock Screen während der Nutzung jemals zu konsultieren, haben nicht die richtige Form. Ein Foto-Editor braucht keine. Ein Workout-Tracker schon.

Auf Nicht-iOS-Oberflächen, mit Vorbehalten. Returns LiveActivityManager liefert seine Implementierung hinter #if os(iOS) aus, weil der Timer von der iPhone- oder iPad-App gestartet wird. ActivityKit selbst beschreibt Lock-Screen-Banner, Dynamic Island, Apple Watch Smart Stack, Mac und CarPlay als Präsentationsoberflächen; iOS 26 erweiterte mehrere davon.4 watchOS hat noch eigene Komplikationen API für Vollbild-Rendering. macOS hat Menüleisten-Apps. iPadOS unterstützt Live Activities seit iPadOS 17 ohne Dynamic-Island-Region. Returns Manager hat 8 #if os(iOS)-Schutze über eine 224-zeilige Datei verteilt.

Was das Muster für Apps bedeutet, die auf iOS 26+ ausgeliefert werden

Zwei Erkenntnisse.

  1. Behandeln Sie die Live Activity als Zustandsmaschine, nicht als Zahl. Die Zustandsmaschine hat klare Zustände, klare Übergänge und klare Beendigungsregeln. Die Zahl auf dem Bildschirm ist eine Darstellung eines Zustands. Bringen Sie zuerst die Zustände richtig hin.

  2. Der Reentranzschutz ist der Bug, den Sie noch nicht getroffen haben. Jeder Live-Activity-Manager, den ich in freier Wildbahn gesehen habe, der isStartingActivity + abbrechbarer Task nicht implementiert, hat mindestens einen Waisen-Activity-Bug ausgeliefert. Der Schutz besteht aus 6 Zeilen. Schreiben Sie ihn einmal.

Lesen Sie diesen Beitrag zusammen mit meinen vorherigen Schriften für dieselbe App-Familie: typisierte App Intents für Apple Intelligence; MCP-Server für plattformübergreifende LLM-Agenten; Liquid-Glass-Patterns für die visuelle Schicht; Multi-Platform-Auslieferung für geräteübergreifende Reichweite. Live Activities sind die iOS-Lock-Screen-und-Dynamic-Island-Schicht desselben Stacks. Die vollständige Sammlung lebt im Apple Ecosystem Series Hub. Für den breiteren iOS-mit-AI-Agents-Kontext siehe den iOS Agent Development Guide.

FAQ

Was ist der Unterschied zwischen Live Activities und WidgetKit-Widgets?

WidgetKit-Widgets rendern in Intervallen, die durch TimelineProvider definiert sind; das System entscheidet, wann eine Aktualisierung erfolgt, und das Widget rendert aus einer statischen Timeline neu.11 Live Activities rendern als Reaktion auf spezifische app-getriebene activity.update(...)-Aufrufe und leben für die Dauer der zugrundeliegenden Activity (ein Timer, eine Lieferung, ein Workout). Beide werden im Widget-Extension-Target ausgeliefert; der Unterschied ist das Auslösemodell.

Funktionieren Live Activities auf dem iPad?

Ja, in iPadOS 17+. Das Lock-Screen-Banner ist die primäre Render-Oberfläche; das iPad hat keine Dynamic Island. Derselbe ActivityConfiguration-Code funktioniert; erwarten Sie nur, dass die Dynamic-Island-Regionen auf dem iPad nie gerendert werden.

Kann eine Live Activity meinen App-Prozess überleben?

Ja. Sobald Activity.request erfolgreich ist, besitzt ActivityKit die Activity. Der App-Prozess kann vom System beendet werden; die Activity rendert weiterhin auf dem Lock Screen und der Dynamic Island, bis Sie sie explizit beenden (oder bis die System-Veralterungsregeln sie verwerfen). Explizite endActivity()-Aufrufe sind aus diesem Grund wichtig; ohne ein explizites Ende beim App-Reset überlebt die Activity den Timer.

Warum behandelt der Beitrag keine pushaktualisierten Live Activities?

Ich habe in Return keine pushaktualisierten Live Activities ausgeliefert. Gemäß der Genre-Regel für diesen Cluster: Ausgelieferter-Code-Beiträge dokumentieren nur, was der Produktionscode tut. Push-Updates sind in “Was ich anders bauen würde” aufgeführt; ein zukünftiger Beitrag wird sie behandeln, nachdem ich sie ausgeliefert habe.

Wie ist das tatsächliche Datei-Layout für Live Activities in einer SwiftUI-App?

Drei Teile:3712

  • Im Haupt-App-Target: LiveActivityManager.swift (verwaltet den Activity-Lebenszyklus), TimerActivityAttributes.swift (das ActivityAttributes-Struct, das mit dem Widget geteilt wird; beide Targets kompilieren diese Datei).
  • In einem Widget-Extension-Target: ReturnLiveActivity.swift (die Widget-Konformanz mit ActivityConfiguration-Body), ReturnWidgetsBundle.swift (das @main WidgetBundle).
  • Konfiguration: Info.plist mit NSSupportsLiveActivities = YES im App-Target.

Das Widget-Extension-Target benötigt ActivityKit- und WidgetKit-Imports. TimerActivityAttributes ist die einzige Datei, die über beide Targets hinweg geteilt wird; alles andere ist target-isoliert.


Die Live Activity ist keine Zahl auf dem Lock Screen. Sie ist eine Zustandsmaschine, die bei jedem Übergang eine Prozessgrenze überquert. Bringen Sie die Zustände richtig hin, schützen Sie die Reentranz, wählen Sie die Beendigungsrichtlinie bewusst und nageln Sie die Layout-Richtung fest. Um die Zahl kümmert sich das System.

References


  1. Author’s Return, a SwiftUI meditation timer published on the App Store on April 21, 2026, available for iPhone, iPad, Mac, Apple Watch, and Apple TV. Live Activities ship on the iOS target only. 

  2. Apple Developer, “ActivityKit framework”. Lock Screen banner, Dynamic Island compact / minimal / expanded modes, activity lifecycle. Available iOS 16.1+; Dynamic Island available iPhone 14 Pro and later. 

  3. Production code in Return/Return/LiveActivityManager.swift (224 lines, 8 #if os(iOS) blocks) and Return/Return/TimerActivityAttributes.swift (43 lines). Shared between the app target and the widget extension target via target membership. 

  4. Apple Developer, “Displaying live data with Live Activities”. Concurrency limits, supported platforms (iOS 16.1+, iPadOS 17+), NSSupportsLiveActivities Info.plist key. 

  5. Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. The pushType: .token path requires a separate APNs auth key, server-side push token registration, and a different update protocol from local activity.update(...) calls. 

  6. Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. Live system-rendered countdown timer; renders without app updates while the activity is running. 

  7. Production code in Return/ReturnWidgets/ReturnLiveActivity.swift (232 lines). The widget extension’s Widget conformance with ActivityConfiguration<TimerActivityAttributes> body. The TimerText view at lines 61-102 handles the paused / running / post-end three-state rendering. 

  8. Apple Developer, “DynamicIsland”. The four named expanded regions (leading, trailing, center, bottom) plus three compact-mode views (compactLeading, compactTrailing, minimal). 

  9. The widget extension runs in its own process and inherits the system locale, not the app’s selected locale. Apps that support in-app language switching (Return supports 27 languages) must pass the language code through ActivityAttributes so the widget can render in the user’s chosen language. Pattern: Locale(identifier: context.attributes.languageCode) rather than Locale.current

  10. Apple Developer, “Button(intent:)”. Available in widget and Live Activity views from iOS 17+. Bridges App Intents into Lock Screen / Dynamic Island controls without requiring app foregrounding. 

  11. Apple Developer, “TimelineProvider”. The widget refresh model that predates Live Activities; pre-computed entries with system-managed reload windows. 

  12. Production code in Return/ReturnWidgets/ReturnWidgetsBundle.swift (16 lines). The @main WidgetBundle that registers ReturnLiveActivity as the widget extension’s only widget. Required pattern for widget extensions; the bundle is what the system loads. 

  13. Apple Developer, “ActivityUIDismissalPolicy”. Three cases: .default, .immediate, .after(_:). Apple states .default keeps an ended Live Activity visible “for some time” up to four hours, and .after(_:) accepts a date within the same four-hour window. 

Verwandte Beiträge

Die Widget-Oberfläche von iOS 26: Ein App Intent, viele Orte

Widgets, Control-Center-Steuerelemente und Live Activities in iOS 26 sind allesamt Oberflächen für App Intents. Ein einz…

7 Min. Lesezeit

HealthKit + SwiftUI auf iOS 26: Autorisierung, Sample-Typen und plattformübergreifende Muster aus zwei ausgelieferten Apps

Echte Produktionsmuster aus Water (Wassertracking, HKQuantitySample) und Return (Achtsamkeitssitzungen, HKCategorySample…

13 Min. Lesezeit

Loop Engineering: Schleifen gewinnen dort, wo Verifikation billig ist

Loop Engineering, geprüft an Boris Chernys vollständigen Transkripten: Jede Schleife, die er nennt, hat billige Verifika…

15 Min. Lesezeit