Damit sich ein einzelner Timer-Wartender unabhängig abbrechen lässt, benötigt jeder Verbraucher einen eigenen AsyncStream und einen eigenen Task-Handle. Teilen Sie den Foundation-Timer und verteilen Sie seine Werte anschließend auf separate Streams. So kann der Abbruch von Verbraucher A dessen Warten beenden, während Verbraucher B fortfährt.
Dieses Tutorial erstellt eine kleine Mac-Desktop-App mit zwei Anzeigen für synthetische Zahlen und einer Schaltfläche Cancel A. Beide Anzeigen erhalten Werte von einem Timer. Die konkrete Aufgabe besteht darin, As Verbindung asynchronen Konsums zu beenden, ohne B zu stoppen oder den gemeinsamen Timer ungültig zu machen.
Warum kann der Abbruch eines Wartenden andere Verbraucher beeinflussen?
AsyncSequence beschreibt die asynchrone Iteration; das Abbruchverhalten stammt von der konkreten Implementierung der Sequenz. Für AsyncStream dokumentiert der aktuelle Quellcode der Standardbibliothek, dass der Abbruch einer Task, die in next() suspendiert ist, den Stream beendet. Der Aufruf kann sofort oder bei nachfolgenden Aufrufen nil zurückgeben. Ein neuer Iterator über denselben Stream richtet kein unabhängiges Abonnement ein. Quellcode der Swift-Standardbibliothek: AsyncStream.swift.
Deshalb ist die Besitzverteilung wichtig. Wenn zwei Tasks denselben Stream konsumieren, entspricht es nicht dem gewünschten Vertrag dieser Aufgabe, den Abbruch als private Operation für einen Iterator zu behandeln. Erstellen Sie stattdessen zwei Streams mit separaten Continuations und senden Sie jeden generierten Wert ausdrücklich an beide. Apple beschreibt AsyncStream als Brücke von Callbacks zur asynchronen Iteration; dabei dient yield(_:) dazu, Elemente bereitzustellen, und finish(), um einen Stream zu beenden. AsyncStream.
Auch der Abbruch von Tasks ist kooperativ: Ein Aufruf von cancel() markiert die Task und führt ihre Abbruch-Handler aus, unterbricht aber keinen beliebigen Code. Der Verbraucher sollte vor der Verarbeitung eines empfangenen Werts den Abbruchstatus prüfen. Außerdem wird der Abbruch an untergeordnete Tasks weitergegeben, sodass der Abbruch eines gemeinsamen übergeordneten Tasks weiterreichende Auswirkungen haben kann als der Abbruch des Handles des gewünschten Verbrauchers. Task.cancel().
Verwenden Sie für diese App die folgenden Regeln zur Besitzverteilung:
| Vorgang | Beabsichtigte Wirkung |
|---|---|
| As Task abbrechen | As Konsum beenden und As Abonnement entfernen |
| As Continuation beenden | As Stream regulär beenden |
| Bs Task abbrechen | Bs Konsum unabhängig beenden |
| Timer-Hub stoppen | Timer ungültig machen und alle verbleibenden Streams beenden |
Welche Werte sollen die Timer-Verbraucher erhalten?
Das Beispiel verwendet bewusst einen einfachen Wertevertrag. Jeder verarbeitete Timer-Callback erhöht eine Ganzzahl von 1 bis 1.000 und beginnt danach wieder bei 1. Jede Anzeige zeigt diese Ganzzahl und ihr Quadrat. Zum Beispiel ergibt ein beispielhafter Wert von 7 n=7, square=49. Durch diese Begrenzung bleibt die Berechnung auch bei wiederholten Durchläufen klein.
Jedes Abonnement puffert nur den neuesten ausstehenden Wert. Das eignet sich für eine Anzeige, deren Aufgabe es ist, den neuesten synthetischen Zustand darzustellen: Eine langsame Anzeige kann Zwischenwerte überspringen. Es ist ungeeignet, wenn jedes generierte Ereignis verarbeitet werden muss. Apple dokumentiert sowohl begrenztes Puffern als auch den standardmäßig unbegrenzten Puffer von AsyncStream. AsyncStream-Pufferung.
Der Zähler misst verarbeitete Callbacks, nicht verstrichene Sekunden. Foundation-Timer hängen von ihrer Run Loop ab, können verspätet ausgelöst werden und holen nicht jede verpasste Auslösung nach. Das unten verwendete Intervall von zwei Sekunden erleichtert es, die Abbruchwirkung zu beobachten; es ist keine präzise Uhr. Timer.
Wie erstellt man unabhängige Abonnements?
Verwenden Sie in Xcode ein macOS-SwiftUI-App-Ziel mit einer Swift-6-Toolchain und einem Deployment-Ziel ab macOS 14. Fügen Sie die folgenden drei Dateien hinzu und ersetzen Sie den App-Einstiegspunkt und die View der Vorlage. Zusammen bilden sie ein vollständiges Beispiel. Sie benötigen weder externe Pakete noch Netzwerkzugriff oder gespeicherte Daten. Der Code wurde geprüft, aber in dieser Forschungsumgebung nicht kompiliert.
Den gemeinsamen Timer-Hub erstellen
Der Hub besitzt den Timer und ein Dictionary mit den Continuations der Abonnements. Jeder Aufruf von subscribe() erstellt einen neuen Stream. Der Termination-Callback entfernt nur die passende Kennung. Apple dokumentiert, dass der Abbruch einer aktiven Stream-Iteration onTermination aufruft und die Iteration mit nil beendet. AsyncStream.Continuation.onTermination.
Fügen Sie diesen Code in TickHub.swift ein:
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()
}
}
}
Der Timer-Initialisierer erstellt einen nicht eingeplanten Timer, daher fügt der Hub ihn ausdrücklich zur Main Run Loop hinzu. Der Callback wechselt in eine Main-Actor-Task, bevor er den Zustand des Hubs verändert. Die Prüfung timer != nil verhindert, dass ein bereits eingeplanter Callback nach dem Herunterfahren Werte ausgibt. Timer.init(timeInterval:repeats:block:) und RunLoop.add(_:forMode:).
Der gesamte Zustand des Hubs ist auf MainActor isoliert, dessen Executor der Main Dispatch Queue entspricht. Der Termination-Callback plant die Bereinigung dort ein, statt aus dem Ausführungskontext des Callbacks direkt auf das Dictionary zuzugreifen. MainActor.
Jedem Verbraucher ein separates Task-Handle geben
Fügen Sie den folgenden Code in TimerDemoModel.swift ein. Die beiden Aufrufe von subscribe() sind entscheidend: Sie erzeugen unabhängige Stream-Lebenszyklen. Die defer-Blöcke melden den Verbraucher auch beim Beenden ab und decken so sowohl einen expliziten Schleifenabbruch als auch einen Abbruch der Task ab.
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
}
}
Beachten Sie, dass cancelA() genau eine Abbruchoperation ausführt. Die Methode ruft weder hub.stop() auf noch greift sie auf Bs Task zu. Die Schleife prüft nach dem Empfang einer Zahl und vor der Aktualisierung der Anzeige Task.isCancelled und folgt damit dem dokumentierten kooperativen Abbruchmodell von Swift. Die Programmiersprache Swift — Nebenläufigkeit.
Beide Bereinigungspfade können versuchen, A zu entfernen: der Termination-Callback und das defer der Task. Das ist hier beabsichtigt. unsubscribe(_:) entfernt zunächst den Eintrag und beendet dann die zurückgegebene Continuation. Bei einem späteren Entfernen wird kein Eintrag gefunden, sodass andere Abonnements unberührt bleiben.
Die Desktop-Steuerelemente hinzufügen
Fügen Sie diesen Code in TimerWaiterDemoApp.swift ein. Im Ziel darf es nur eine @main-App-Deklaration geben.
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 gibt dem Modell während der Lebensdauer des View-Containers einen stabilen Speicherplatz, und veröffentlichte Änderungen aktualisieren abhängige Text-Views. Die Aktion beim Verschwinden bietet eine Bereinigung auf View-Ebene; ihr genauer Zeitpunkt hängt von der View ab. Die Schaltfläche Stop all bietet außerdem eine direkte Abschaltmöglichkeit. StateObject und onDisappear(perform:).
Wie prüft man, ob der Abbruch lokal bleibt?
Starten Sie die App und führen Sie die folgende Abnahmesequenz aus. Die Zahlen unten sind beispielhafte Beobachtungen, auf die Sie achten sollten, keine aufgezeichneten Testergebnisse.
- Warten Sie, bis beide Anzeigen weiterzählen. Normalerweise sollten sie dieselbe neueste Zahl und dasselbe Quadrat anzeigen.
- Klicken Sie zwischen zwei Timer-Auslösungen auf
Cancel A. As Status wechselt zuA cancelled, und der angezeigte Wert bleibt stehen. - Warten Sie mindestens zwei weitere Auslösungen. B sollte weiterzählen, während As Anzeige unverändert bleibt.
- Klicken Sie auf
Stop all. Auch B sollte nicht mehr weiterzählen. - Starten Sie die App neu und brechen Sie A sofort ab, bevor der erste Wert erscheint. As Anzeige sollte bei
Waitingbleiben, während B Zahlen empfängt.
Diese Sequenz prüft den Abbruch sowohl nach Beginn des Konsums als auch bevor der erste brauchbare Wert eintrifft. In diesem Entwurf werden die Abbruchaktion auf dem Main Actor und die Aktualisierungen der Anzeigen nacheinander ausgeführt. Eine Aktualisierung, die vor der Schaltflächenaktion abgeschlossen wurde, bleibt sichtbar; die Abbruchprüfung verhindert, dass eine später fortgesetzte Iteration von A noch einen Wert anwendet.
Wenn sich das Verhalten unterscheidet, prüfen Sie zunächst die Besitzgrenze, bevor Sie das Timer-Intervall ändern:
| Symptom | Zuerst prüfen |
|---|---|
| B stoppt, wenn A abgebrochen wird | Suchen Sie nach einem gemeinsamen Stream oder einer Bereinigung, die hub.stop() aufruft |
| Die Anzeigen erhalten unterschiedliche Teilmengen der Werte | Prüfen Sie, ob jeder Verbraucher ein separates Ergebnis von subscribe() erhält |
| Es erscheinen keine Werte | Prüfen Sie, ob der Timer gestartet und zur laufenden Main Run Loop der App hinzugefügt wurde |
| Werte werden gelegentlich übersprungen | Prüfen Sie, ob die Pufferungsrichtlinie für den neuesten Wert zur Anforderung der Anzeige passt |
| Nach dem Verschwinden der View wird weitergearbeitet | Prüfen Sie, ob das Herunterfahren beide Task-Handles und den Hub erreicht |
Was ändert sich bei einem einmaligen Wartenden oder einem Neustart?
Bei einem einmaligen Wartenden bleibt das Abonnement privat. Verarbeiten Sie den ersten geeigneten Wert und beenden Sie die Schleife. Behalten Sie den Abmeldevorgang defer bei: Er macht die beabsichtigte Lebensdauer des Abonnements ausdrücklich, selbst wenn die Schleife durch break endet. Ein reguläres einmaliges Ende sollte dieses Abonnement entfernen, während der gemeinsame Producer für B verfügbar bleibt.
Um A neu zu starten, erstellen Sie nach Abschluss der alten Task ein neues Abonnement und eine neue Task. Versuchen Sie nicht, die abgebrochene Task zurückzusetzen oder ihren beendeten Stream wiederzuverwenden. Warten Sie bei einer neu startbaren Oberfläche auf den Abschluss der alten Task, bevor Sie einen Ersatz einsetzen, oder unterscheiden Sie die aufeinanderfolgenden Abonnement-Lebenszyklen anderweitig, damit die alte Bereinigung nicht den neuen Zustand entfernt.
Das Beispiel bietet bewusst nur einen Durchlauf pro View-Instanz. Das globale Herunterfahren macht den wiederholten Timer auf dem Main Actor ungültig, auf dem er installiert wurde. Apple dokumentiert, dass ungültig gemachte Timer nicht wiederverwendet werden können; für einen Neustart muss ein anderer Timer erstellt werden. Timer-Lebenszyklus.
Unabhängiger Abbruch ergibt sich unmittelbar aus dem unabhängigen Besitz der Abonnements. Belassen Sie den Timer im Hub, geben Sie jedem Verbraucher einen separaten Stream und ein separates Task-Handle und entfernen Sie beim Beenden nur das Abonnement dieses Verbrauchers. So kann ein Desktop-Steuerelement eine wartende Anzeige stoppen, während die andere Anzeige weiterhin dieselbe synthetische Berechnung empfängt.
Quellen
- AsyncStream.swift — Swift Project.
- AsyncStream — Apple.
- onTermination — Apple.
- cancel() — Apple.
- Concurrency — Swift Project.
- Timer — Apple.
- init(timeInterval:repeats:block:) — Apple.
- add(_:forMode:) — Apple.
- MainActor — Apple.
- StateObject — Apple.
- onDisappear(perform:) — Apple.