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:
- Ein Tippen auf Start, während der Start bereits in Bearbeitung war, erzeugte eine zweite Activity, die die erste verwaisen ließ.
- Der Countdown wurde auf der Dynamic Island korrekt gerendert, aber die Lock-Screen-Ansicht traf bei pausierten Timern auf
endTime <= Date()und zeigte0:00an, bis der Benutzer fortsetzte. - Die Live Activity blieb lange sichtbar, nachdem der Benutzer den Timer zurückgesetzt hatte, weil die Beendigungsrichtlinie
.defaultwar, die Apple einige Zeit – bis zu vier Stunden – sichtbar hält. - (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
LiveActivityManagerstellt 5 Übergangsmethoden bereit (startActivity,updateActivity,showCycleComplete,showFinalCompletion,endActivity) plus 1 Lesezugriff (hasActiveActivity). Die 224 Produktionszeilen schützen vor einer spezifischen Gefahr innerhalb vonstartActivity: nebenläufige Start-Aufrufe plus Cancellation-Checks an jederawait-Grenze in dieser Methode.3 - Der
ContentStateträ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-timerIntervalvon ActivityKit nicht bedienen kann. - Die Entscheidung über die Beendigungsrichtlinie ist die eigentliche Produktentscheidung.
.immediatefü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.
-
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.
-
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?
- Im Haupt-App-Target:
LiveActivityManager.swift(verwaltet den Activity-Lebenszyklus),TimerActivityAttributes.swift(dasActivityAttributes-Struct, das mit dem Widget geteilt wird; beide Targets kompilieren diese Datei). - In einem Widget-Extension-Target:
ReturnLiveActivity.swift(dieWidget-Konformanz mitActivityConfiguration-Body),ReturnWidgetsBundle.swift(das@main WidgetBundle). - Konfiguration:
Info.plistmitNSSupportsLiveActivities = YESim 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
-
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. ↩
-
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. ↩↩
-
Production code in
Return/Return/LiveActivityManager.swift(224 lines, 8#if os(iOS)blocks) andReturn/Return/TimerActivityAttributes.swift(43 lines). Shared between the app target and the widget extension target via target membership. ↩↩↩↩↩ -
Apple Developer, “Displaying live data with Live Activities”. Concurrency limits, supported platforms (iOS 16.1+, iPadOS 17+),
NSSupportsLiveActivitiesInfo.plist key. ↩↩ -
Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. The
pushType: .tokenpath requires a separate APNs auth key, server-side push token registration, and a different update protocol from localactivity.update(...)calls. ↩↩ -
Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. Live system-rendered countdown timer; renders without app updates while the activity is running. ↩
-
Production code in
Return/ReturnWidgets/ReturnLiveActivity.swift(232 lines). The widget extension’sWidgetconformance withActivityConfiguration<TimerActivityAttributes>body. TheTimerTextview at lines 61-102 handles the paused / running / post-end three-state rendering. ↩↩↩↩ -
Apple Developer, “DynamicIsland”. The four named expanded regions (
leading,trailing,center,bottom) plus three compact-mode views (compactLeading,compactTrailing,minimal). ↩ -
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
ActivityAttributesso the widget can render in the user’s chosen language. Pattern:Locale(identifier: context.attributes.languageCode)rather thanLocale.current. ↩ -
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. ↩
-
Apple Developer, “TimelineProvider”. The widget refresh model that predates Live Activities; pre-computed entries with system-managed reload windows. ↩
-
Production code in
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16 lines). The@main WidgetBundlethat registersReturnLiveActivityas the widget extension’s only widget. Required pattern for widget extensions; the bundle is what the system loads. ↩ -
Apple Developer, “ActivityUIDismissalPolicy”. Three cases:
.default,.immediate,.after(_:). Apple states.defaultkeeps an ended Live Activity visible “for some time” up to four hours, and.after(_:)accepts a date within the same four-hour window. ↩↩