Aby niezależnie anulować oczekiwanie jednego odbiorcy timera, przydziel każdemu odbiorcy własny AsyncStream i uchwyt zadania. Udostępnij timer Foundation, a następnie rozsyłaj jego wartości do osobnych strumieni. Anulowanie odbiorcy A może wtedy zakończyć oczekiwanie A, podczas gdy odbiorca B będzie działać dalej.
W tym samouczku zbudujemy niewielką aplikację desktopową na Maca z dwoma wyświetlaczami syntetycznych liczb i przyciskiem Cancel A. Oba wyświetlacze otrzymują wartości z jednego timera. Celem jest zatrzymanie asynchronicznego odbierania danych przez A bez zatrzymywania B ani unieważniania współdzielonego timera.
Dlaczego anulowanie jednego oczekiwania może wpływać na innych odbiorców?
AsyncSequence opisuje asynchroniczną iterację; zachowanie podczas anulowania zależy od konkretnej implementacji sekwencji. W aktualnym kodzie źródłowym biblioteki standardowej opisano, że anulowanie zadania zawieszonego w next() kończy strumień. Wywołanie może natychmiast zwrócić nil lub zrobić to przy kolejnych wywołaniach. Nowy iterator tego samego strumienia nie tworzy niezależnej subskrypcji. Kod źródłowy biblioteki standardowej Swift: AsyncStream.swift.
Dlatego ważne jest, kto zarządza zasobami. Jeśli dwa zadania korzystają z tego samego strumienia, traktowanie anulowania jako prywatnej operacji na jednym iteratorze nie spełnia wymagań tego zadania. Zamiast tego utwórz dwa strumienie z osobnymi kontynuacjami i jawnie wysyłaj wygenerowane wartości do obu. Apple opisuje AsyncStream jako pomost od wywołań zwrotnych do asynchronicznej iteracji, wykorzystujący yield(_:) do dostarczania elementów oraz finish() do zakończenia strumienia. AsyncStream.
Anulowanie zadań jest też kooperacyjne: wywołanie cancel() oznacza zadanie jako anulowane i uruchamia jego procedury obsługi anulowania, ale nie przerywa dowolnego kodu. Odbiorca powinien sprawdzić stan anulowania przed przetworzeniem odebranej wartości. Anulowanie jest też propagowane do zadań podrzędnych, więc anulowanie wspólnego zadania nadrzędnego może mieć szerszy skutek niż anulowanie uchwytu zadania konkretnego odbiorcy. Task.cancel().
W tej aplikacji obowiązują następujące zasady zarządzania zasobami:
| Operacja | Zamierzony skutek |
|---|---|
| Anulowanie zadania A | Zakończenie odbierania danych przez A i usunięcie subskrypcji A |
| Zakończenie kontynuacji A | Normalne zakończenie strumienia A |
| Anulowanie zadania B | Niezależne zakończenie odbierania danych przez B |
| Zatrzymanie centrum timerów | Unieważnienie timera i zakończenie wszystkich pozostałych strumieni |
Jakie wartości powinny otrzymywać odbiorcy timera?
Przykład wykorzystuje celowo prostą konwencję wartości. Każde przetworzone wywołanie zwrotne timera zwiększa liczbę całkowitą od 1 do 1,000, po czym zaczyna ponownie od 1. Każdy wyświetlacz pokazuje tę liczbę i jej kwadrat. Na przykład przykładowa wartość 7 daje n=7, square=49. Ograniczenie utrzymuje obliczenia na małą skalę podczas kolejnych cykli.
Każda subskrypcja buforuje tylko najnowszą oczekującą wartość. To odpowiednie dla wyświetlacza, którego zadaniem jest pokazywanie najnowszego stanu syntetycznego: wolniejszy wyświetlacz może pomijać wartości pośrednie. Nie nadaje się to do sytuacji, gdy trzeba przetworzyć każde wygenerowane zdarzenie. Apple opisuje zarówno ograniczone buforowanie, jak i domyślny nieograniczony bufor AsyncStream. Buforowanie AsyncStream.
Licznik zlicza przetworzone wywołania zwrotne, a nie upływające sekundy. Timery Foundation zależą od pętli zdarzeń, mogą uruchamiać się z opóźnieniem i nie odtwarzają każdego pominiętego wywołania. Dwusekundowy interwał poniżej ułatwia obserwację interakcji anulowania; nie zapewnia precyzyjnego pomiaru czasu. Timer.
Jak utworzyć niezależne subskrypcje?
Użyj w Xcode celu aplikacji SwiftUI dla macOS, toolchaina Swift 6 i minimalnej wersji wdrożeniowej macOS 14 lub nowszej. Dodaj trzy poniższe pliki, zastępując szablonowy punkt wejścia aplikacji i widok. Razem tworzą kompletny przykład; nie wymagają zewnętrznego pakietu, dostępu do sieci ani przechowywania danych. Kod został przejrzany, ale nie skompilowano go w tym środowisku badawczym.
Utwórz współdzielone centrum timerów
Centrum zarządza timerem i słownikiem kontynuacji subskrypcji. Każde wywołanie subscribe() tworzy nowy strumień. Procedura wywoływana przy jego zakończeniu usuwa tylko subskrypcję o zgodnym identyfikatorze. Apple dokumentuje, że anulowanie aktywnej iteracji strumienia wywołuje onTermination i kończy iterację wartością nil. AsyncStream.Continuation.onTermination.
Umieść ten kod w pliku TickHub.swift:
import Foundation
@MainActor
final class TickHub {
private var timer: Timer?
private var number = 0
private var continuations:
[UUID: AsyncStream<Int>.Continuation] = [:]
func subscribe() -> (id: UUID, stream: AsyncStream<Int>) {
let id = UUID()
let pair = AsyncStream<Int>.makeStream(
bufferingPolicy: .bufferingNewest(1)
)
pair.continuation.onTermination = { [weak self] _ in
Task { @MainActor [weak self] in
self?.unsubscribe(id)
}
}
continuations[id] = pair.continuation
return (id, pair.stream)
}
func start() {
guard timer == nil else { return }
let newTimer = Timer(
timeInterval: 2,
repeats: true
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.emit()
}
}
newTimer.tolerance = 0.2
timer = newTimer
RunLoop.main.add(newTimer, forMode: .common)
}
private func emit() {
guard timer != nil else { return }
number = number == 1_000 ? 1 : number + 1
for continuation in continuations.values {
_ = continuation.yield(number)
}
}
func unsubscribe(_ id: UUID) {
let continuation = continuations.removeValue(forKey: id)
continuation?.finish()
}
func stop() {
timer?.invalidate()
timer = nil
let remaining = Array(continuations.values)
continuations.removeAll()
for continuation in remaining {
continuation.finish()
}
}
}
Inicjalizator timera tworzy nieaktywny timer, dlatego centrum jawnie dodaje go do głównej pętli zdarzeń. Wywołanie zwrotne uruchamia zadanie na głównym aktorze, zanim uzyska dostęp do stanu centrum. Sprawdzenie timer != nil zapobiega wysłaniu wartości przez wywołanie zwrotne, które oczekiwało już w kolejce, po zamknięciu centrum. Timer.init(timeInterval:repeats:block:) oraz RunLoop.add(_:forMode:).
Cały stan centrum jest odizolowany na MainActor, którego executor jest równoważny głównej kolejce GCD. Procedura wywoływana przy zakończeniu planuje czyszczenie na tym aktorze zamiast bezpośrednio uzyskiwać dostęp do słownika z kontekstu wykonania wywołania zwrotnego. MainActor.
Przydziel każdemu odbiorcy osobny uchwyt zadania
Umieść poniższy kod w pliku TimerDemoModel.swift. Dwa wywołania subscribe() są niezbędne: tworzą niezależne cykle życia strumieni. Bloki defer również anulują subskrypcję po wyjściu odbiorcy, zarówno przy jawnie zakończonej pętli, jak i przy anulowaniu.
import Combine
@MainActor
final class TimerDemoModel: ObservableObject {
@Published private(set) var aText = "Waiting"
@Published private(set) var bText = "Waiting"
@Published private(set) var aCancelled = false
@Published private(set) var stopped = false
private let hub = TickHub()
private var aTask: Task<Void, Never>?
private var bTask: Task<Void, Never>?
private var started = false
func start() {
guard !started else { return }
started = true
let a = hub.subscribe()
let b = hub.subscribe()
let source = hub
aTask = Task { @MainActor [weak self] in
defer { source.unsubscribe(a.id) }
for await number in a.stream {
guard !Task.isCancelled else { break }
self?.aText = "n=\(number), square=\(number * number)"
}
}
bTask = Task { @MainActor [weak self] in
defer { source.unsubscribe(b.id) }
for await number in b.stream {
guard !Task.isCancelled else { break }
self?.bText = "n=\(number), square=\(number * number)"
}
}
hub.start()
}
func cancelA() {
guard !aCancelled, !stopped else { return }
aCancelled = true
aTask?.cancel()
}
func stopAll() {
guard !stopped else { return }
stopped = true
aTask?.cancel()
bTask?.cancel()
hub.stop()
aTask = nil
bTask = nil
}
}
Zwróć uwagę, że cancelA() wykonuje dokładnie jedną operację anulowania. Nie wywołuje hub.stop() ani nie dotyka zadania B. Pętla sprawdza Task.isCancelled po odebraniu liczby, a przed zmianą wyświetlacza, zgodnie z udokumentowanym przez Swift modelem kooperacyjnego anulowania. Język programowania Swift — współbieżność.
Obie ścieżki czyszczenia mogą próbować usunąć A: procedura wywoływana przy zakończeniu oraz defer zadania. Jest to zamierzone. unsubscribe(_:) najpierw usuwa wpis, a następnie kończy zwróconą kontynuację. Kolejna próba usunięcia niczego nie znajduje, więc pozostałe subskrypcje nie zostają naruszone.
Dodaj elementy sterujące aplikacji desktopowej
Umieść ten kod w pliku TimerWaiterDemoApp.swift. W celu pozostaw tylko jedną deklarację aplikacji @main.
import SwiftUI
@main
@MainActor
struct TimerWaiterDemoApp: App {
var body: some Scene {
WindowGroup {
TimerDemoView()
}
}
}
@MainActor
struct TimerDemoView: View {
@StateObject private var model = TimerDemoModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Independent timer consumers").font(.headline)
Text("A: \(model.aText)")
Text(model.aCancelled ? "A cancelled" : "A active")
Text("B: \(model.bText)")
HStack {
Button("Cancel A") { model.cancelA() }
.disabled(model.aCancelled || model.stopped)
Button("Stop all") { model.stopAll() }
.disabled(model.stopped)
}
}
.padding(24)
.frame(minWidth: 420)
.onAppear { model.start() }
.onDisappear { model.stopAll() }
}
}
@StateObject zapewnia modelowi stabilne miejsce przechowywania danych przez cały okres życia kontenera widoku, a zmiany opublikowanych właściwości aktualizują zależne widoki tekstowe. Akcja wykonywana przy zniknięciu widoku zapewnia czyszczenie na poziomie widoku; jej dokładne wywołanie zależy od widoku. Bezpośrednią operację zamknięcia zapewnia też jawny przycisk Stop all. StateObject i onDisappear(perform:).
Jak sprawdzić, czy anulowanie pozostaje lokalne?
Uruchom aplikację i wykonaj poniższą sekwencję. Podane liczby są przykładowymi wartościami, na które warto zwrócić uwagę, a nie zapisanymi wynikami testów.
- Poczekaj, aż oba wyświetlacze zaczną się aktualizować. Zwykle powinny pokazywać tę samą najnowszą liczbę i jej kwadrat.
- Kliknij
Cancel Amiędzy wywołaniami timera. Status A zmieni się naA cancelled, a wyświetlana wartość pozostanie bez zmian. - Poczekaj na co najmniej dwa kolejne wywołania. Wartość B powinna się zmieniać, podczas gdy A pozostanie bez zmian.
- Kliknij
Stop all. Wartość B również powinna przestać się zmieniać. - Uruchom aplikację ponownie i od razu anuluj A, zanim pojawi się jego pierwsza wartość. A powinno pozostać w stanie
Waiting, podczas gdy B zacznie otrzymywać liczby.
Ta sekwencja sprawdza anulowanie zarówno po rozpoczęciu odbierania danych, jak i przed otrzymaniem pierwszej użytecznej wartości. W tym projekcie akcja anulowania i aktualizacje wyświetlacza są serializowane na głównym aktorze. Aktualizacja zakończona przed akcją przycisku pozostaje widoczna; sprawdzenie anulowania zapobiega zastosowaniu kolejnej wartości przez wznowioną iterację A.
Jeśli zachowanie jest inne, przed zmianą interwału timera sprawdź granice odpowiedzialności za zasoby:
| Objaw | Pierwsza rzecz do sprawdzenia |
|---|---|
| B zatrzymuje się po anulowaniu A | Poszukaj współdzielonego strumienia lub czyszczenia wywołującego hub.stop() |
| Wyświetlacze otrzymują różne podzbiory wartości | Upewnij się, że każdy odbiorca otrzymuje osobny wynik subscribe() |
| Nie pojawiają się żadne wartości | Upewnij się, że timer został uruchomiony i dodany do działającej głównej pętli zdarzeń aplikacji |
| Wartości czasami są pomijane | Sprawdź, czy polityka buforowania najnowszej wartości odpowiada wymaganiom wyświetlacza |
| Zadania działają po zniknięciu widoku | Upewnij się, że zamknięcie obejmuje oba uchwyty zadań i centrum |
Co się zmienia w przypadku oczekiwania jednorazowego lub ponownego uruchomienia?
W przypadku oczekiwania jednorazowego zachowaj subskrypcję jako prywatną, przetwórz pierwszą odpowiednią wartość i zakończ pętlę. Pozostaw operację anulowania subskrypcji defer: jasno określa ona zamierzony cykl życia subskrypcji, nawet jeśli pętla zakończy się przez break. Normalne zakończenie oczekiwania jednorazowego powinno usunąć tę subskrypcję, pozostawiając współdzielone źródło dostępne dla B.
Aby ponownie uruchomić A, utwórz nową subskrypcję i nowe zadanie po zakończeniu starego. Nie próbuj resetować anulowanego zadania ani ponownie używać zakończonego strumienia. W interfejsie, który można uruchamiać ponownie, poczekaj na zakończenie starego zadania przed zainstalowaniem zamiennika albo w inny sposób rozróżniaj kolejne cykle życia subskrypcji, aby stare czyszczenie nie mogło usunąć nowego stanu.
Przykład celowo udostępnia jedno uruchomienie dla każdej instancji widoku. Globalne zamknięcie unieważnia powtarzający się timer na głównym aktorze, na którym został zainstalowany. Apple dokumentuje, że unieważnionych timerów nie można używać ponownie; ponowne uruchomienie wymaga utworzenia kolejnego timera. Cykl życia timera.
Niezależne anulowanie wynika bezpośrednio z niezależnego zarządzania subskrypcjami. Przechowuj timer w centrum, przydziel każdemu odbiorcy osobny strumień i uchwyt zadania, a po zakończeniu usuwaj tylko subskrypcję danego odbiorcy. Dzięki temu element sterujący aplikacji desktopowej może zatrzymać jeden oczekujący wyświetlacz, podczas gdy drugi nadal otrzymuje te same syntetyczne obliczenia.
Źródła
- AsyncStream.swift — projekt Swift.
- AsyncStream — Apple.
- onTermination — Apple.
- cancel() — Apple.
- Concurrency — projekt Swift.
- Timer — Apple.
- init(timeInterval:repeats:block:) — Apple.
- add(_:forMode:) — Apple.
- MainActor — Apple.
- StateObject — Apple.
- onDisappear(perform:) — Apple.