← Wszystkie wpisy

Live Activities to maszyna stanów, a nie odznaka

Live Activity w aplikacji Return wygląda jak liczba odliczająca czas na ekranie blokady i w Dynamic Island.12 To nie jest liczba. To pięciostanowa maszyna cyklu życia z trzema zewnętrznymi ścieżkami zamknięcia oraz jedną wielobieżną ścieżką uruchomienia, która musi bronić się przed samą sobą. Wzorce opisane poniżej to te, które przetrwały w produkcji. Brutalnie szczera stopka na końcu mówi, czego jeszcze nie wiem.

Wysłałem v1, która traktowała Live Activity jako odznakę. „Aktualny pozostały czas” był danymi; reszta była dekoracją. Ta wersja miała trzy błędy, które wykryłem w TestFlight, oraz jeden, który złapałem w produkcji:

  1. Naciśnięcie przycisku start, gdy uruchomienie było już w toku, tworzyło drugą aktywność, która osierocała pierwszą.
  2. Odliczanie renderowało się poprawnie w Dynamic Island, ale widok ekranu blokady trafiał w endTime <= Date() dla wstrzymanych liczników i pokazywał 0:00, dopóki użytkownik nie wznowił.
  3. Live Activity pozostawała widoczna długo po zresetowaniu licznika przez użytkownika, ponieważ polityka zamknięcia była ustawiona na .default, którą Apple utrzymuje widoczną przez pewien czas — do czterech godzin.
  4. (Produkcja). W lokalizacjach języków pisanych od prawej do lewej (arabski, hebrajski) cyfry renderowały się w odwrotnej kolejności w obszarze compact-trailing Dynamic Island. Cyfry łacińskie, układ RTL. Poprawka miała jedną linię.

Każdy z nich był błędem maszyny stanów. Liczba odliczania była w porządku. Liczba nie jest produktem. Produktem jest stan.

Maszyna stanów poniżej to ta, która przetrwała te błędy.

TL;DR

  • Wysłany LiveActivityManager udostępnia 5 metod przejścia (startActivity, updateActivity, showCycleComplete, showFinalCompletion, endActivity) plus 1 odczyt (hasActiveActivity). 224 linie kodu produkcyjnego strzegą jednego konkretnego zagrożenia wewnątrz startActivity: równoczesnych wywołań startu plus sprawdzeń anulowania na każdej granicy await w tej metodzie.3
  • ContentState niesie 6 pól: endTime, currentCycle, totalCycles, isPaused, isCompleted, remainingSeconds. Pierwsze pięć to etykiety maszyny stanów. Szóste (remainingSeconds) to statyczne wyświetlanie awaryjne, którego ActivityKit timerInterval na żywo nie potrafi obsłużyć.
  • Decyzja o polityce zamknięcia to prawdziwa decyzja produktowa. .immediate przy resecie użytkownika, .after(Date().addingTimeInterval(3)) przy zakończeniu, nigdy domyślna systemowa.
  • Obszar compact-trailing Dynamic Island wymaga .environment(\.layoutDirection, .leftToRight) na tekście licznika, aby cyfry łacińskie pozostały LTR w lokalizacjach systemowych RTL.

Maszyna stanów

Wysłana Live Activity ma jeden stan bezczynny, trzy stany aktywne, które użytkownik może obserwować, jeden stan końcowy oraz jedną wielobieżną bramkę, którą musi obserwować deweloper:

┌──────────────────────────────────────────────────────────────────┐
│                  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.

Ścieżka renderowania sprawdza najpierw isPaused; ta kolejność powstrzymuje wstrzymany licznik przed renderowaniem jako CYCLE_END, gdy czas zegara ściennego przekroczył endTime.7

Nazwy stanów to nie etykiety na liczbie. Nazwy stanów to kontrakt między LiveActivityManager (strona aplikacji, gdzie żyją moje widoki SwiftUI) a ReturnLiveActivity (rozszerzenie widgetu, gdzie proces Apple renderuje powierzchnię).

Kontraktem jest TimerActivityAttributes.ContentState, wszystkie 6 pól: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
}

Każde przejście stanu modyfikuje tę strukturę i prosi ActivityKit o dostarczenie jej przez granice procesów do rozszerzenia widgetu. Widget następnie się ponownie renderuje. Nie ma pamięci współdzielonej. Nie ma callbacku. Jest struktura Codable, która przekracza granicę procesu przy każdym przejściu.

Ten fakt wyklucza wszystko, co mógłbym chcieć zrobić z domknięciami, modelami widoków, obserwowalnymi obiektami lub właściwościami obliczanymi. Stan musi być wyrażalny jako dane serializowalne. Jeśli nie da się go zakodować, nie może przejść.

Wielobieżne uruchomienie

Live Activities mają twardy limit współbieżnych aktywności i miękki limit tego, co dzieje się, gdy wywoła się Activity.request dwukrotnie w locie. Twardy limit jest dobrze udokumentowany.4 Miękki limit to: „drugie wywołanie może się powieść i utworzyć osierocony obiekt”. Osieroconym obiektem jest Live Activity, która nie jest już skojarzona z currentActivity w menedżerze. Przeżywa. Nie ma drogi powrotnej do kodu. Z czasem zamyka się sama na własnym liczniku przeterminowania. Użytkownik widzi do tego momentu zduplikowany licznik.

Osierocenie było błędem v1, który wysłałem w Return. Poprawką jest wielobieżna bramka plus anulowalne Task w 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
}

Trzy rzeczy w tym wzorcu, których dokumentacja nie wskazuje:

Flaga isStartingActivity to aktywna ochrona; startActivityTask?.cancel() to defensywne sprzątanie. Flaga zwarciowo blokuje każde drugie wywołanie startActivity, gdy pierwsze jest w locie, więc faktycznie nie ścigają się ścieżki publiczne. Taniec anuluj-i-zamień nadal ma znaczenie, ponieważ Task w locie jest asynchroniczny i może przetrwać krótkotrwałego wywołującego; anulowanie zapobiega kontynuacji nieaktualnego Task po tym, jak wywołujący ruszył dalej.

Sprawdzenia guard !Task.isCancelled na każdej granicy await. Anulowanie w Swift jest kooperacyjne. Nawet jeśli wywołane zostanie cancel, Task nadal działa, dopóki jawnie nie sprawdzi. Każde await to okazja do sprawdzenia. Bez sprawdzeń po-await anulowany Task nadal buduje stan aktywności, wywołuje Activity.request i po cichu tworzy osierocony obiekt przy sukcesie.

defer czyści flagę przed zakończeniem ciała Task. Bez defer wczesny return (z sprawdzenia anulowania) pozostawia isStartingActivity = true na stałe i aktywność nigdy więcej nie startuje aż do ponownego uruchomienia aplikacji. Flaga jest blokadą; blokada musi się zwolnić na każdej ścieżce wyjścia.

Argument pushType: nil. Return nie używa aktualizacji Live Activity wypychanych przez APNs. Aplikacja aktualizuje aktywność lokalnie poprzez activity.update. Jeśli potrzebne są aktualizacje sterowane pushem (śledzenie dostawy, wyniki sportowe, dane czasu rzeczywistego), typem jest pushType: .token, a kontrakt jest dramatycznie bardziej złożony.5 Aktualizacje lokalne są prostsze i pokrywają każdy przepływ pracy z licznikiem / licznikiem cyklicznym / pojedynczą aplikacją.

Problem pauzy

ActivityKit dostarcza piękny widok Text(timerInterval: Date()...endTime, countsDown: true), który renderuje odliczanie na żywo bez żadnej aktualizacji ze strony aplikacji.6 Ustawia się czas końcowy, system renderuje licznik na żywo. Bez Timer.publish, bez odświeżania widgetu, bez drenażu baterii.

Jest to fantastyczne, gdy licznik jest uruchomiony. Jest błędne, gdy licznik jest wstrzymany.

Tekst timerInterval odlicza w stronę endTime niezależnie od jakiegokolwiek sygnału „pauzy” w stanie. W API Apple nie ma trybu „zamrożone na 10:23”. Jeśli przekaże się endTime = Date().addingTimeInterval(623) i użytkownik wstrzyma na znaku 10:23, tekst licznika nadal odlicza do zera w widgecie. Pole stanu mówi: wstrzymane. Widget renderuje: uruchomione.

Poprawka polega na renderowaniu dwóch różnych widoków z tego samego stanu: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()
}

Dwutorowe renderowanie jest powodem, dla którego ContentState niesie remainingSeconds jako oddzielne pole. Jest ono nadmiarowe, gdy licznik działa (system oblicza je z endTime). Jest jedynym źródłem prawdy, gdy licznik jest wstrzymany. Dwie połowy struktury obsługują dwa różne tryby renderowania; boolean isPaused wybiera między nimi.

Polityki zamknięcia

activity.end(_:dismissalPolicy:) przyjmuje jedną z trzech wartości ActivityUIDismissalPolicy, a wybór niewłaściwej spowodował, że moja v1 pozostawała na ekranie blokady użytkownika przez coś, co wydawało się wiecznością po resecie:13

Polityka Kiedy używać Co się otrzymuje
.immediate Reset użytkownika, błąd, aplikacja przeniesiona w tło bez aktywności do śledzenia Aktywność znika natychmiast. Brak okna karencji
.after(date) Wyświetlanie zakończenia: „twoja medytacja jest ukończona” musi być czytelne przez chwilę. Data musi mieścić się w czterogodzinnym oknie dozwolonym przez Apple Aktywność pokazuje stan końcowy, następnie zamyka się o date
.default Gdy naprawdę chce się, aby heurystyki Apple decydowały System utrzymuje to widoczne „przez pewien czas” (sformułowanie Apple), do czterech godzin po wywołaniu end

Return używa .after(Date().addingTimeInterval(3)) dla naturalnej ścieżki zakończenia:3

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

Trzy sekundy to czas, jakiego użytkownik potrzebuje, aby spojrzeć na ekran blokady, zarejestrować, że licznik się zakończył, i poczuć satysfakcję ze znaku potwierdzenia. Mniej niż trzy jest skokowe. Więcej niż trzy sprawia wrażenie, jakby aktywność nie wiedziała, że jest skończona.

Dla resetu wywołanego przez użytkownika wywołaniem jest dismissalPolicy: .immediate. Brak okna. Użytkownik już wie.

Niewłaściwym wyborem w v1 było .default. Dla zakończonego licznika medytacji system utrzymywał aktywność widoczną wystarczająco długo, że użytkownicy myśleli, iż aplikacja w ogóle nie zarejestrowała zakończenia. Dokumentacja Apple mówi, że .default utrzymuje zakończoną aktywność „widoczną przez pewien czas” do czterech godzin;13 poprawną postawą dla licznika jest uczynienie zamknięcia jawnym.

Obszar compact w Dynamic Island

Dynamic Island ma trzy tryby renderowania i potrzebne są wszystkie trzy nawet dla prostego licznika:2

  • Compact (domyślny kształt Dynamic Island): wiodąca ikona + zamykający licznik
  • Minimal (gdy inna Live Activity konkuruje o ten sam Dynamic Island): tylko wiodąca ikona
  • Expanded (długie naciśnięcie): cztery nazwane regiony (leading, trailing, center, bottom)

Wzorzec, który zasłużył na swoje miejsce w Return, polega na tym, aby widok rozszerzony był prawie identyczny z compact: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")...
}

Większość tutoriali Live Activity opiera się na widoku rozszerzonym jako „prawdziwym” projekcie, z bogatą zawartością w regionie bottom. Dla licznika medytacji rozszerzenie to martwy ciężar. Użytkownik otwiera widok rozszerzony długim naciśnięciem, a samo długie naciśnięcie daje już mu sprzężenie haptyczne, że coś się wydarzyło. Dodanie zawartości sprawia, że rozszerzenie mówi coś, o co użytkownik nie prosił. Puste regiony w trybie rozszerzonym nie są porażką projektu; one są projektem.

Błąd RTL

Błąd produkcyjny. Użytkownicy arabskiego i hebrajskiego na iOS zgłaszali, że licznik compact-trailing w Dynamic Island renderował cyfry w odwrotnej kolejności. Łaciński ciąg cyfr 5:23 renderował się jako 32:5, ponieważ kierunek układu compact-trailing dziedziczył ustawienie RTL z lokalizacji systemowej.

SwiftUI dziedziczy systemowy kierunek układu wewnątrz procesu widgetu, więc tekst licznika Dynamic Island podchwycił RTL, gdy telefon użytkownika był ustawiony na arabski lub hebrajski. Cyfry łacińskie powinny renderować się LTR nawet wewnątrz UI, które jest poza tym RTL. Poprawka polega na przypięciu kierunku układu na widokach tekstowych z liczbami:7

.environment(\.layoutDirection, .leftToRight)

Nadpisanie idzie na widoki numeryczne Text wewnątrz TimerText (Dynamic Island compact / expanded) oraz wewnątrz widoku ekranu blokady, a nie na cały widok. Cyfry łacińskie czyta się od lewej do prawej niezależnie od lokalizacji systemowej użytkownika; etykiety cyklu, takie jak „Cykl 2 z 3”, pozostają zlokalizowane, więc podążają za systemowym kierunkiem układu.

Błąd nie ujawnia się w krajowym TestFlight. Ujawnia się w momencie, gdy prawdziwy użytkownik RTL otworzy licznik. Lekcja: wysyłaj nadpisanie środowiska przypięte do LTR na każdym widoku tekstowym z cyframi łacińskimi w każdej Live Activity, która może działać w lokalizacjach RTL.

Historia lokalizacji

TimerActivityAttributes niesie pole languageCode: String ustawiane przez aplikację przy tworzeniu aktywności:9

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

Rozszerzenie widgetu odczytuje to, aby renderować zlokalizowane ciągi:

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

Dlaczego aplikacja przekazuje własny kod języka, zamiast pozwolić widgetowi odczytać Locale.current: rozszerzenie widgetu działa we własnym procesie. Jego Locale.current jest lokalizacją systemową, a nie wybraną przez aplikację. Jeśli użytkownik ustawi Return na koreański, podczas gdy iPhone jest w angielskim, widget mówiłby po angielsku bez tego nadpisania. Preferencja językowa aplikacji podróżuje w atrybutach aktywności; widget ją honoruje.

Localizable.xcstrings mieszka w celu widgetu obok celu aplikacji, ale są to osobne pliki. Ciągi używane w widgecie muszą istnieć w ReturnWidgets/Localizable.xcstrings, nawet jeśli ten sam ciąg istnieje w Return/Localizable.xcstrings. Zapomnienie o tym oznacza, że widget cofa się do języka deweloperskiego, podczas gdy aplikacja mówi po koreańsku.

Co zbudowałbym inaczej

Zmniejszyć ContentState. Sześć pól to za dużo. Nadmiarowość między endTime a remainingSeconds to cena obejścia braku trybu pauzy w timerInterval. Gdybym zaczynał od nowa, niósłbym pojedynczy enum displayMode (running, paused(remainingSeconds: Int), cycleEnd, complete) i pozwoliłbym kodowi renderującemu kierować przypadkami. Sześć pól trudniej utrzymywać poprawnie zmutowanymi w pięciu metodach przejścia niż cztery przypadki.

Dodać interaktywne przyciski Live Activity (iOS 17+). Return obecnie nie udostępnia kontrolek pauzy/wznowienia w Dynamic Island. Użytkownik musi otworzyć aplikację, aby wstrzymać. iOS 17 dodał Button(intent:) dla App Intents wewnątrz Live Activities.10 Interaktywna kontrolka pauzy to oczywiste rozszerzenie i kolejna rzecz, jaką wyślę dla Return.

Live Activities aktualizowane pushem dla synchronizacji licznika między urządzeniami. Return synchronizuje sesje między iPhone, iPad, Watch i Apple TV poprzez NSUbiquitousKeyValueStore (omówione w Pięć platform Apple, trzy współdzielone pliki). Dziś aktywność jest uruchamiana lokalnie z aplikacji iPhone lub iPad i aktualizowana lokalnie. Użytkownik uruchamiający licznik na Apple Watch mógłby idealnie zobaczyć, jak Live Activity odzwierciedla to na iPhone w czasie rzeczywistym. Push APNs do Live Activity to ścieżka.5 Nie zbudowałem tego.

Kiedy nie używać Live Activities

Jednorazowy stan przejściowy. Powiadomienie typu „zapisano!” nie zasługuje na Live Activity. System ma baner. Należy go używać.

Często zmieniające się dane bez wymiaru czasowego. Live Activities działają najlepiej dla rzeczy z jasnym kotwiczeniem czasowym (licznik, ETA dostawy, zegar gry, czas trwania połączenia). Tickery giełdowe i wyniki sportowe działają, ponieważ mają okno sesji. Dashboard ogólnego przeznaczenia nie.

Aplikacje bez przypadku użycia ekranu blokady / standby. Live Activities wymagają realnej inwestycji inżynierskiej (konfiguracja celu, projekt ContentState, decyzje o polityce zamknięcia, obsługa RTL, instalacja lokalizacji). Aplikacje, które użytkownik otwiera bezpośrednio, nigdy nie konsultując ekranu blokady podczas użytkowania, nie są właściwym kształtem. Edytor zdjęć go nie potrzebuje. Tracker treningowy tak.

Na powierzchniach poza iOS, z zastrzeżeniami. LiveActivityManager w Return wysyła swoją implementację za #if os(iOS), ponieważ licznik jest uruchamiany z aplikacji iPhone lub iPad. Sam ActivityKit opisuje baner ekranu blokady, Dynamic Island, Smart Stack Apple Watch, Mac i CarPlay jako powierzchnie prezentacji; iOS 26 rozszerzył kilka z nich.4 watchOS nadal ma własne komplikacje API dla pełnoekranowego renderowania. macOS ma aplikacje paska menu. iPadOS obsługuje Live Activities od iPadOS 17 bez regionu Dynamic Island. Menedżer Return ma 8 zabezpieczeń #if os(iOS) w jednym 224-liniowym pliku.

Co wzorzec oznacza dla aplikacji wysyłanych na iOS 26+

Dwa wnioski.

  1. Traktuj Live Activity jako maszynę stanów, a nie liczbę. Maszyna stanów ma jasne stany, jasne przejścia i jasne zasady zamykania. Liczba na ekranie to jedno renderowanie jednego stanu. Najpierw popraw stany.

  2. Strażnik wielobieżności to błąd, w który jeszcze nie trafiłeś. Każdy menedżer Live Activity, jaki widziałem na wolności, który nie implementuje isStartingActivity + anulowalnego Task, wysłał co najmniej jeden błąd osieroconej aktywności. Strażnik to 6 linii. Napisz go raz.

Sparuj ten post z moimi wcześniejszymi opracowaniami dla tej samej rodziny aplikacji: typowanymi App Intents dla Apple Intelligence; serwerami MCP dla agentów LLM-procesowych; wzorcami Liquid Glass dla warstwy wizualnej; wieloplatformowym wysyłaniem dla zasięgu między urządzeniami. Live Activities to warstwa ekranu blokady iOS i Dynamic Island tego samego stosu. Pełny zestaw mieszka w hubie serii Apple Ecosystem. Dla szerszego kontekstu iOS z agentami AI zobacz przewodnik iOS Agent Development.

FAQ

Jaka jest różnica między Live Activities a widgetami WidgetKit?

Widgety WidgetKit renderują się w odstępach zdefiniowanych przez TimelineProvider; system decyduje, kiedy odświeżyć, a widget renderuje się ponownie ze statycznej osi czasu.11 Live Activities renderują się w odpowiedzi na konkretne wywołania activity.update(...) sterowane przez aplikację i żyją przez czas trwania bazowej aktywności (licznik, dostawa, trening). Oba wysyłają się w celu rozszerzenia widgetu; różnica polega na modelu wyzwalacza.

Czy Live Activities działają na iPad?

Tak, w iPadOS 17+. Baner ekranu blokady jest podstawową powierzchnią renderowania; iPad nie ma Dynamic Island. Ten sam kod ActivityConfiguration działa; należy po prostu spodziewać się, że regiony Dynamic Island nigdy nie wyrenderują się na iPad.

Czy Live Activity może przeżyć proces mojej aplikacji?

Tak. Gdy Activity.request zakończy się sukcesem, ActivityKit posiada aktywność. Proces aplikacji może zostać zakończony przez system; aktywność nadal renderuje się na ekranie blokady i Dynamic Island, dopóki jawnie się jej nie zakończy (lub dopóki systemowe reguły przeterminowania jej nie zamkną). Jawne wywołania endActivity() mają z tego powodu znaczenie; bez jawnego zakończenia przy resecie aplikacji, aktywność przeżywa licznik.

Dlaczego post nie omawia Live Activities aktualizowanych pushem?

Nie wysłałem Live Activities aktualizowanych pushem w Return. Zgodnie z regułą gatunku dla tego klastra: posty z kodem produkcyjnym dokumentują tylko to, co robi kod produkcyjny. Aktualizacje push są wymienione w „Co zbudowałbym inaczej”; przyszły post omówi je po tym, jak je wyślę.

Jaki jest faktyczny układ plików dla Live Activities w aplikacji SwiftUI?

Trzy elementy:3712

  • W głównym celu aplikacji: LiveActivityManager.swift (zarządza cyklem życia aktywności), TimerActivityAttributes.swift (struktura ActivityAttributes współdzielona z widgetem; oba cele kompilują ten plik).
  • W celu rozszerzenia widgetu: ReturnLiveActivity.swift (zgodność Widget z ciałem ActivityConfiguration), ReturnWidgetsBundle.swift (@main WidgetBundle).
  • Konfiguracja: Info.plist z NSSupportsLiveActivities = YES w celu aplikacji.

Cel rozszerzenia widgetu potrzebuje importów ActivityKit i WidgetKit. TimerActivityAttributes to jedyny plik współdzielony między oboma celami; cała reszta jest izolowana w obrębie celu.


Live Activity to nie liczba na ekranie blokady. To maszyna stanów, która przekracza granicę procesu przy każdym przejściu. Popraw stany, ochroń wielobieżność, wybierz politykę zamknięcia z rozmysłem i przypnij kierunek układu. Liczba zatroszczy się o siebie sama.

Bibliografia


  1. Autorska aplikacja Return, licznik medytacji w SwiftUI opublikowany w App Store 21 kwietnia 2026, dostępny dla iPhone, iPad, Mac, Apple Watch i Apple TV. Live Activities wysyłają się tylko na cel iOS. 

  2. Apple Developer, „ActivityKit framework”. Baner ekranu blokady, tryby compact / minimal / expanded Dynamic Island, cykl życia aktywności. Dostępne od iOS 16.1+; Dynamic Island dostępny na iPhone 14 Pro i nowszych. 

  3. Kod produkcyjny w Return/Return/LiveActivityManager.swift (224 linie, 8 bloków #if os(iOS)) i Return/Return/TimerActivityAttributes.swift (43 linie). Współdzielony między celem aplikacji a celem rozszerzenia widgetu poprzez członkostwo w celach. 

  4. Apple Developer, „Displaying live data with Live Activities”. Limity współbieżności, obsługiwane platformy (iOS 16.1+, iPadOS 17+), klucz Info.plist NSSupportsLiveActivities

  5. Apple Developer, „Updating and ending your Live Activity with ActivityKit push notifications”. Ścieżka pushType: .token wymaga oddzielnego klucza autoryzacyjnego APNs, rejestracji tokenu push po stronie serwera oraz innego protokołu aktualizacji niż lokalne wywołania activity.update(...)

  6. Apple Developer, „Text(timerInterval:pauseTime:countsDown:showsHours:)”. Licznik odliczający na żywo renderowany przez system; renderuje się bez aktualizacji aplikacji, gdy aktywność jest uruchomiona. 

  7. Kod produkcyjny w Return/ReturnWidgets/ReturnLiveActivity.swift (232 linie). Zgodność Widget rozszerzenia widgetu z ciałem ActivityConfiguration<TimerActivityAttributes>. Widok TimerText w liniach 61-102 obsługuje renderowanie trzech stanów: paused / running / post-end. 

  8. Apple Developer, „DynamicIsland”. Cztery nazwane rozszerzone regiony (leading, trailing, center, bottom) plus trzy widoki trybu compact (compactLeading, compactTrailing, minimal). 

  9. Rozszerzenie widgetu działa we własnym procesie i dziedziczy lokalizację systemową, a nie wybraną przez aplikację. Aplikacje, które obsługują przełączanie języka w aplikacji (Return obsługuje 27 języków), muszą przekazać kod języka przez ActivityAttributes, aby widget mógł renderować w wybranym przez użytkownika języku. Wzorzec: Locale(identifier: context.attributes.languageCode) zamiast Locale.current

  10. Apple Developer, „Button(intent:)”. Dostępne w widokach widgetów i Live Activity od iOS 17+. Łączy App Intents z kontrolkami ekranu blokady / Dynamic Island bez konieczności wysuwania aplikacji na pierwszy plan. 

  11. Apple Developer, „TimelineProvider”. Model odświeżania widgetów poprzedzający Live Activities; wstępnie obliczone wpisy z oknami przeładowania zarządzanymi przez system. 

  12. Kod produkcyjny w Return/ReturnWidgets/ReturnWidgetsBundle.swift (16 linii). @main WidgetBundle, który rejestruje ReturnLiveActivity jako jedyny widget rozszerzenia widgetu. Wymagany wzorzec dla rozszerzeń widgetów; bundle to to, co system ładuje. 

  13. Apple Developer, „ActivityUIDismissalPolicy”. Trzy przypadki: .default, .immediate, .after(_:). Apple stwierdza, że .default utrzymuje zakończoną Live Activity widoczną „przez pewien czas” do czterech godzin, a .after(_:) przyjmuje datę w tym samym czterogodzinnym oknie. 

Powiązane artykuły

Powierzchnia widżetów iOS 26: jeden App Intent, wiele miejsc

Widżety iOS 26, elementy sterujące w Centrum sterowania i Live Activities to wszystko powierzchnie App Intents. Jeden in…

8 min czytania

Liquid Glass w SwiftUI: trzy wzorce z wdrożenia Return na iOS 26

Liquid Glass od Apple to jednoliniowe API SwiftUI. Trzy wzorce z aplikacji Return wykraczają poza `.glassEffect()`: szkł…

15 min czytania

Loop Engineering: Loops Win Where Verification Is Cheap

Loop engineering, checked against Boris Cherny's full transcripts: every loop he names has cheap verification. That cons…

19 min czytania