Live Activities sind eine State Machine, kein Badge
Genre: shipped-code. Der Beitrag dokumentiert die Live Activity, die ich in Return eingebaut habe – den SwiftUI-Meditationstimer, den meine Frau nutzt, meine Mutter nutzt und einige tausend Fremde nutzen.1 Die Patterns sind diejenigen, die in der Produktion überlebt haben. Der Brutal-Honesty-Footer sagt, was ich noch nicht weiß.
Die Live Activity in Return sieht aus wie eine Countdown-Zahl auf dem Sperrbildschirm und auf der Dynamic Island.2 Sie ist keine Zahl. Sie ist eine State Machine mit fünf Lifecycle-Zuständen, drei externen Dismissal-Pfaden und einem reentranten Start-Pfad, der sich gegen sich selbst verteidigen muss.
Ich habe eine v1 ausgeliefert, die die Live Activity wie ein Badge behandelte. Die „aktuelle verbleibende Zeit” war Daten; der Rest war Dekoration. Diese Version hatte drei Bugs, die ich in TestFlight entdeckt habe, und einen, den ich in der Produktion entdeckt habe:
- Wenn man auf Start tippte, während der Start bereits in Flight war, wurde eine zweite Activity erstellt, die die erste zur Waise machte.
- Der Countdown wurde auf der Dynamic Island korrekt gerendert, aber die Sperrbildschirm-Ansicht traf bei pausierten Timern auf
endTime <= Date()und zeigte0:00an, bis der Benutzer fortfuhr. - Die Live Activity blieb lange sichtbar, nachdem der Benutzer den Timer zurückgesetzt hatte, weil die Dismissal-Policy
.defaultwar, die Apple für einige Zeit bis zu vier Stunden sichtbar hält. - (Produktion.) Auf Right-to-Left-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 davon war ein State-Machine-Bug. Die Countdown-Zahl war in Ordnung. Die Zahl ist nicht das Produkt. Das Produkt ist der Zustand.
Die State Machine unten ist das, was diese Bugs überlebt hat.
TL;DR
- Der ausgelieferte
LiveActivityManagerexponiert 5 Transition-Methoden (startActivity,updateActivity,showCycleComplete,showFinalCompletion,endActivity) plus 1 Read (hasActiveActivity). Die 224 Produktionszeilen schützen vor einer spezifischen Gefahr instartActivity: gleichzeitigen Start-Aufrufen 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 Labels der State Machine. Das sechste (remainingSeconds) ist ein Static-Display-Fallback, den das LivetimerIntervalvon ActivityKit nicht liefern kann. - Die Entscheidung über die Dismissal-Policy ist der eigentliche Produkt-Call.
.immediatefür einen User-Reset,.after(Date().addingTimeInterval(3))für Completion, niemals der System-Default. - Die Compact-Trailing-Region der Dynamic Island braucht
.environment(\.layoutDirection, .leftToRight)auf dem Timer-Text, damit lateinische Ziffern unter RTL-System-Locales LTR bleiben.
Die State Machine
Die ausgelieferte Live Activity hat einen Idle-Zustand, drei Live-Zustände, die der Benutzer beobachten kann, einen Terminal-Zustand und ein reentrantes Gate, 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 Wanduhr-Zeit endTime überschritten hat.7
Die Zustandsnamen sind keine Labels 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 Surface 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
}
Jede Zustandsübergang mutiert dieses Struct und bittet ActivityKit, es über Prozessgrenzen hinweg an die Widget-Extension zu liefern. Das Widget rendert dann erneut. Es gibt keinen geteilten Speicher. Es gibt keinen Callback. Es gibt ein Codable-Struct, das bei jeder Transition eine Prozessgrenze überschreitet.
Diese Tatsache schließt alles aus, was ich mit Closures, View-Models, Observable Objects oder Computed Properties tun könnte. Der Zustand muss als serialisierbare Daten ausdrückbar sein. Wenn er nicht enkodiert werden kann, kann er nicht übergehen.
Der reentrante Start
Live Activities haben ein hartes Limit für gleichzeitige Activities und ein weiches Limit dafür, was passiert, wenn man Activity.request zweimal in Flight aufruft. Das harte Limit ist gut dokumentiert.4 Das weiche Limit ist „der zweite Aufruf könnte erfolgreich sein und eine Waise erzeugen”. Die Waise ist die Live Activity, die nicht mehr mit currentActivity in Ihrem Manager assoziiert ist. Sie überlebt. Sie hat keinen Pfad zurück in Ihren Code. Sie löst sich irgendwann durch ihren eigenen Staleness-Timer auf. Der Benutzer sieht bis dahin einen doppelten Timer.
Die Waise war der v1-Bug, den Return ausgeliefert hat. Der Fix ist das reentrante Gate plus ein cancelbarer 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 Pattern, die die Docs nicht herausstellen:
Das isStartingActivity-Flag ist der aktive Schutz; startActivityTask?.cancel() ist defensives Cleanup. Das Flag schließt jeden zweiten startActivity-Aufruf kurz, während der erste in Flight ist, sodass Sie auf dem öffentlichen Pfad nicht tatsächlich rennen. Der Cancel-then-Replace-Tanz ist trotzdem wichtig, weil der in-flight Task asynchron ist und einen kurzlebigen Caller überleben kann; die Cancellation verhindert, dass ein veralteter Task weiterläuft, nachdem der Caller bereits weiter ist.
Die guard !Task.isCancelled-Checks an jeder await-Grenze. Cancellation ist in Swift kooperativ. Auch wenn Cancel aufgerufen wird, läuft der Task weiter, bis er explizit prüft. Jedes await ist eine Gelegenheit zum Prüfen. Ohne die Post-await-Checks baut ein gecancelter Task weiter Activity-State auf, ruft Activity.request auf und erzeugt bei Erfolg stillschweigend eine Waise.
Das defer löscht das Flag, bevor der Task-Body abschließt. Ohne defer lässt ein frühes return (vom Cancellation-Check) isStartingActivity = true permanent stehen, und die Activity startet bis zum App-Neustart nie wieder. Das Flag ist ein Lock; der Lock muss auf jedem Exit-Pfad freigegeben werden.
Das Argument pushType: nil. Return verwendet keine via APNs gepushten Live-Activity-Updates. Die App aktualisiert die Activity lokal über activity.update. Wenn Sie push-getriebene Updates benötigen (Lieferverfolgung, Sportscores, Echtzeitdaten), ist der Type pushType: .token und der Vertrag ist drastisch komplexer.5 Lokale Updates sind einfacher und decken jeden Timer-/Counter-/Single-App-Workflow ab.
Das Pause-Problem
ActivityKit liefert eine wunderschöne Text(timerInterval: Date()...endTime, countsDown: true)-View, die einen Live-Countdown rendert, ohne dass die App ein Update durchführen muss.6 Sie setzen die Endzeit, das System rendert einen Live-Timer. Kein Timer.publish, kein Widget-Refresh, 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 keinen „eingefroren bei 10:23”-Modus in Apples API. 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 State-Feld 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 Two-Track-Rendering 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 Quelle der Wahrheit, wenn der Timer pausiert ist. Die zwei Hälften des Structs dienen zwei verschiedenen Render-Modi; der isPaused-Boolean wählt zwischen ihnen aus.
Die Dismissal-Policies
activity.end(_:dismissalPolicy:) nimmt einen von drei ActivityUIDismissalPolicy-Werten, und die falsche Wahl ist es, die meine v1 nach einem Reset auf dem Sperrbildschirm des Benutzers für eine gefühlte Ewigkeit stehen ließ:13
| Policy | Wann zu verwenden | Was Sie bekommen |
|---|---|---|
.immediate |
User-Reset, Fehler, App im Hintergrund ohne zu trackende Activity | Activity verschwindet jetzt. Kein Grace-Window |
.after(date) |
Completion-Display: „Ihre Meditation ist abgeschlossen” muss einen Moment lesbar sein. Das Datum muss innerhalb des Vier-Stunden-Fensters liegen, das Apple erlaubt | Activity zeigt den finalen Zustand und löst sich dann bei date auf |
.default |
Wenn Sie wirklich Apples Heuristiken entscheiden lassen wollen | System hält sie „für einige Zeit” sichtbar (Apples Wortlaut), bis zu vier Stunden nachdem end aufgerufen wurde |
Return verwendet .after(Date().addingTimeInterval(3)) für den natürlichen Completion-Pfad:3
await activity.end(
.init(state: contentState, staleDate: nil),
dismissalPolicy: .after(Date().addingTimeInterval(3))
)
Drei Sekunden sind die Zeit, die ein Benutzer braucht, um auf den Sperrbildschirm zu schauen, zu registrieren, dass der Timer geendet hat, und die Befriedigung des Häkchens zu spüren. Weniger als drei wirkt ruckelig. Mehr als drei fühlt sich an, als wüsste die Activity nicht, dass sie fertig ist.
Für einen vom Benutzer ausgelösten Reset lautet der Aufruf dismissalPolicy: .immediate. Kein Window. Der Benutzer weiß es bereits.
Die falsche Wahl in v1 war .default. Bei einem abgeschlossenen Meditationstimer hielt das System die Activity lange genug sichtbar, sodass Benutzer dachten, die App habe die Completion gar nicht registriert. Apples Dokumentation sagt, .default halte die beendete Activity „für einige Zeit” sichtbar, bis zu vier Stunden;13 die korrekte Haltung für einen Timer ist, das Dismissal 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 (Long-Press): vier benannte Regionen (
leading,trailing,center,bottom)
Das Pattern, das sich seinen Platz in Return verdient hat, ist, die Expanded-View nahezu identisch zu Compact zu machen: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 auf die Expanded-View als das „echte” Design, mit reichhaltigem Inhalt in der bottom-Region. Für einen Meditationstimer ist die Expansion totes Gewicht. Der Benutzer öffnet die Expanded-View durch Long-Press, und der Long-Press gibt ihm bereits das haptische Feedback, dass etwas passiert ist. Inhalt hinzuzufügen lässt die Expansion etwas sagen, was der Benutzer nicht angefragt hat. Leere Regionen im Expanded-Modus sind kein Versagen des Designs; sie sind das Design.
Der RTL-Bug
Der Produktions-Bug. Arabische und hebräische Benutzer auf iOS meldeten, dass der Compact-Trailing-Timer der Dynamic Island die Ziffern rückwärts renderte. Der lateinische Ziffernstring 5:23 wurde als 32:5 gerendert, weil die Layout-Richtung des Compact-Trailing die RTL-Einstellung des System-Locales geerbt hat.
SwiftUI erbt die System-Layout-Richtung innerhalb des Widget-Prozesses, sodass der Dynamic-Island-Timer-Text RTL übernahm, wenn das Telefon des Benutzers auf Arabisch oder Hebräisch eingestellt war. Lateinische Ziffern sollten LTR gerendert werden, auch innerhalb einer ansonsten RTL-UI. Der Fix besteht darin, die Layout-Richtung auf den numerischen Text-Views zu fixieren:7
.environment(\.layoutDirection, .leftToRight)
Der Override gehört auf die numerischen Text-Views innerhalb von TimerText (Dynamic Island compact / expanded) und innerhalb der Sperrbildschirm-View, nicht auf die ganze View. Lateinische Ziffern lesen sich von links nach rechts, unabhängig vom System-Locale des Benutzers; Cycle-Labels wie „Cycle 2 of 3” bleiben lokalisiert, sodass sie der System-Layout-Richtung folgen.
Der Bug taucht in TestFlight mit inländischen Locales nicht auf. Er taucht in dem Moment auf, in dem ein echter RTL-Benutzer den Timer öffnet. Die Lehre: Liefern Sie den LTR-fixierten Environment-Override auf jeder Latin-Digit-Text-View in jeder Live Activity aus, die in RTL-Locales laufen könnte.
Die Lokalisierungs-Story
TimerActivityAttributes trägt ein Feld languageCode: String, das von der App bei der Activity-Erstellung gesetzt wird: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 System-Locale, nicht das ausgewählte Locale der App. Wenn ein Benutzer Return auf Koreanisch eingestellt hat, während sein iPhone auf Englisch ist, würde das Widget ohne diesen Override Englisch sprechen. Die Sprachpräferenz der App reist in den Activity-Attributen mit; das Widget honoriert 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, auch 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 Mangel eines Pause-Modus in timerInterval zu umgehen. Wenn ich neu anfangen würde, würde ich ein einzelnes displayMode-Enum tragen (running, paused(remainingSeconds: Int), cycleEnd, complete) und den Render-Code auf den Case dispatchen lassen. Sechs Felder sind schwerer korrekt mutiert über fünf Transition-Methoden zu halten als vier Cases es sind.
Interaktive Live-Activity-Buttons hinzufügen (iOS 17+). Return exponiert derzeit keine Pause-/Resume-Controls in der Dynamic Island. Der Benutzer muss die App öffnen, um zu pausieren. iOS 17 hat Button(intent:) für App Intents innerhalb von Live Activities hinzugefügt.10 Eine interaktive Pause-Control ist die offensichtliche Erweiterung und das nächste, was ich für Return ausliefern werde.
Push-Update-Live-Activities für Cross-Device-Timer-Sync. Return synct Sessions ü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 das auf dem iPhone in Echtzeit reflektiert. APNs-Push zur Live Activity ist der Pfad.5 Habe ich nicht gebaut.
Wann Live Activities nicht zu verwenden sind
One-Shot-Transient-State. 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 einem klaren temporalen Anker (ein Timer, eine Liefer-ETA, eine Spieluhr, eine Anrufdauer). Aktien-Ticker und Sportscores funktionieren, weil sie ein Session-Window haben. Ein Allzweck-Dashboard nicht.
Apps ohne Sperrbildschirm-/Standby-Use-Case. Live Activities erfordern echtes Engineering-Investment (Target-Setup, ContentState-Design, Dismissal-Policy-Entscheidungen, RTL-Handling, Localization-Plumbing). Apps, die der Benutzer direkt öffnet, ohne während der Verwendung jemals den Sperrbildschirm zu konsultieren, sind nicht die richtige Form. Ein Foto-Editor braucht keine. Ein Workout-Tracker schon.
Auf Nicht-iOS-Surfaces, mit Einschränkungen. Returns LiveActivityManager liefert seine Implementierung hinter #if os(iOS) aus, weil der Timer von der iPhone- oder iPad-App gestartet wird. ActivityKit selbst beschreibt Sperrbildschirm-Banner, Dynamic Island, Apple Watch Smart Stack, Mac und CarPlay als Präsentations-Surfaces; iOS 26 hat einige davon erweitert.4 watchOS hat immer noch seine eigenen Complications 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)-Guards über eine 224-Zeilen-Datei.
Was das Pattern für Apps bedeutet, die auf iOS 26+ ausgeliefert werden
Zwei Lehren.
-
Behandeln Sie die Live Activity als State Machine, nicht als Zahl. Die State Machine hat klare Zustände, klare Übergänge und klare Dismissal-Regeln. Die Zahl auf dem Bildschirm ist ein Rendering eines Zustands. Bringen Sie zuerst die Zustände in Ordnung.
-
Der Reentrancy-Guard ist der Bug, den Sie noch nicht getroffen haben. Jeder Live-Activity-Manager, den ich in der Wildnis gesehen habe und der
isStartingActivity+ cancelbaren Task nicht implementiert, hat mindestens einen Orphan-Activity-Bug ausgeliefert. Der Guard sind 6 Zeilen. Schreiben Sie ihn einmal.
Kombinieren Sie diesen Beitrag mit meinen vorherigen Texten zur selben App-Familie: typisierte App Intents für Apple Intelligence; MCP-Server für Cross-LLM-Agenten; Liquid Glass Patterns für die visuelle Schicht; Multi-Platform-Shipping für Cross-Device-Reichweite. Live Activities sind die iOS-Sperrbildschirm-und-Dynamic-Island-Schicht desselben Stacks. Das vollständige Set lebt im Apple Ecosystem Series Hub. Für den breiteren iOS-mit-AI-Agenten-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 werden; das System entscheidet, wann aktualisiert wird, und das Widget rendert aus einer statischen Timeline.11 Live Activities rendern als Reaktion auf spezifische app-getriebene activity.update(...)-Aufrufe und leben für die Dauer der zugrunde liegenden Activity (ein Timer, eine Lieferung, ein Workout). Beide werden im Widget-Extension-Target ausgeliefert; der Unterschied ist das Trigger-Modell.
Funktionieren Live Activities auf dem iPad?
Ja, in iPadOS 17+. Das Sperrbildschirm-Banner ist die primäre Render-Surface; das iPad hat keine Dynamic Island. Derselbe ActivityConfiguration-Code funktioniert; erwarten Sie nur, dass die Dynamic-Island-Regionen auf dem iPad nie rendern.
Kann eine Live Activity meinen App-Prozess überleben?
Ja. Sobald Activity.request erfolgreich ist, besitzt ActivityKit die Activity. Der App-Prozess kann vom System terminiert werden; die Activity rendert weiterhin auf dem Sperrbildschirm und der Dynamic Island, bis Sie sie explizit beenden (oder bis die System-Staleness-Regeln sie auflösen). Explizite endActivity()-Aufrufe sind aus diesem Grund wichtig; ohne ein explizites End beim App-Reset überlebt die Activity den Timer.
Warum behandelt der Beitrag keine Push-aktualisierten Live Activities?
Ich habe keine Push-aktualisierten Live Activities in Return ausgeliefert. Gemäß der Genre-Regel für diesen Cluster: Shipped-Code-Beiträge dokumentieren nur, was der Produktionscode tut. Push-Updates sind unter „Was ich anders bauen würde” gelistet; ein zukünftiger Beitrag wird sie behandeln, nachdem ich sie ausgeliefert habe.
Was ist das tatsächliche File-Layout für Live Activities in einer SwiftUI-App?
- Im Haupt-App-Target:
LiveActivityManager.swift(verwaltet den Activity-Lifecycle),TimerActivityAttributes.swift(dasActivityAttributes-Struct, das mit dem Widget geteilt wird; beide Targets kompilieren diese Datei). - In einem Widget-Extension-Target:
ReturnLiveActivity.swift(dieWidget-Conformance 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 Sperrbildschirm. Sie ist eine State Machine, die bei jedem Übergang eine Prozessgrenze überschreitet. Bringen Sie die Zustände in Ordnung, schützen Sie die Reentrancy, wählen Sie die Dismissal-Policy bewusst und fixieren Sie die Layout-Richtung. Um die Zahl kümmert sich der Rest von selbst.
Referenzen
-
Vom Autor Return, ein SwiftUI-Meditationstimer, veröffentlicht im App Store am 21. April 2026, verfügbar für iPhone, iPad, Mac, Apple Watch und Apple TV. Live Activities werden nur auf dem iOS-Target ausgeliefert. ↩
-
Apple Developer, „ActivityKit framework”. Sperrbildschirm-Banner, Dynamic Island Compact-/Minimal-/Expanded-Modi, Activity-Lifecycle. Verfügbar iOS 16.1+; Dynamic Island verfügbar ab iPhone 14 Pro. ↩↩
-
Produktionscode in
Return/Return/LiveActivityManager.swift(224 Zeilen, 8#if os(iOS)-Blöcke) undReturn/Return/TimerActivityAttributes.swift(43 Zeilen). Geteilt zwischen App-Target und Widget-Extension-Target via Target-Mitgliedschaft. ↩↩↩↩↩ -
Apple Developer, „Displaying live data with Live Activities”. Concurrency-Limits, unterstützte Plattformen (iOS 16.1+, iPadOS 17+),
NSSupportsLiveActivities-Info.plist-Key. ↩↩ -
Apple Developer, „Updating and ending your Live Activity with ActivityKit push notifications”. Der
pushType: .token-Pfad erfordert einen separaten APNs-Auth-Key, serverseitige Push-Token-Registrierung und ein anderes Update-Protokoll als lokaleactivity.update(...)-Aufrufe. ↩↩ -
Apple Developer, „Text(timerInterval:pauseTime:countsDown:showsHours:)”. Live system-gerenderter Countdown-Timer; rendert ohne App-Updates, während die Activity läuft. ↩
-
Produktionscode in
Return/ReturnWidgets/ReturnLiveActivity.swift(232 Zeilen). DieWidget-Conformance der Widget-Extension mitActivityConfiguration<TimerActivityAttributes>-Body. DieTimerText-View in den Zeilen 61-102 behandelt das Three-State-Rendering paused / running / post-end. ↩↩↩↩ -
Apple Developer, „DynamicIsland”. Die vier benannten Expanded-Regionen (
leading,trailing,center,bottom) plus drei Compact-Mode-Views (compactLeading,compactTrailing,minimal). ↩ -
Die Widget-Extension läuft in ihrem eigenen Prozess und erbt das System-Locale, nicht das ausgewählte Locale der App. Apps, die In-App-Sprachumschaltung unterstützen (Return unterstützt 27 Sprachen), müssen den Sprachcode durch
ActivityAttributesübergeben, damit das Widget in der vom Benutzer gewählten Sprache rendern kann. Pattern:Locale(identifier: context.attributes.languageCode)stattLocale.current. ↩ -
Apple Developer, „Button(intent:)”. Verfügbar in Widget- und Live-Activity-Views ab iOS 17+. Verbindet App Intents mit Sperrbildschirm-/Dynamic-Island-Controls, ohne dass die App in den Vordergrund treten muss. ↩
-
Apple Developer, „TimelineProvider”. Das Widget-Refresh-Modell, das Live Activities vorausgeht; vorberechnete Einträge mit system-verwalteten Reload-Windows. ↩
-
Produktionscode in
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16 Zeilen). Das@main WidgetBundle, dasReturnLiveActivityals das einzige Widget der Widget-Extension registriert. Erforderliches Pattern für Widget-Extensions; das Bundle ist das, was das System lädt. ↩ -
Apple Developer, „ActivityUIDismissalPolicy”. Drei Cases:
.default,.immediate,.after(_:). Apple gibt an, dass.defaulteine beendete Live Activity „für einige Zeit” sichtbar hält, bis zu vier Stunden, und.after(_:)ein Datum innerhalb desselben Vier-Stunden-Fensters akzeptiert. ↩↩