Geef elke consumer een eigen AsyncStream en taakhandle om één timerwachter onafhankelijk te annuleren. Deel de Foundation-timer en verspreid de waarden vervolgens over afzonderlijke streams. Zo kan het annuleren van consumer A het wachten van A beëindigen terwijl consumer B doorgaat.

Deze tutorial bouwt een kleine Mac-desktopapp met twee synthetische getalweergaven en een knop Cancel A. Beide weergaven ontvangen waarden van één timer. Het concrete doel is om A’s asynchrone consumptie te stoppen zonder B te stoppen of de gedeelde timer ongeldig te maken.

Waarom kan het annuleren van één wach ter andere consumers beïnvloeden?

AsyncSequence beschrijft asynchrone iteratie; het annuleringsgedrag komt van de concrete sequentie-implementatie. Voor AsyncStream documenteert de huidige broncode van de standaardbibliotheek dat het annuleren van een taak die in next() is gesuspendeerd, de stream beëindigt. De aanroep kan onmiddellijk nil teruggeven of bij volgende aanroepen. Een nieuwe iterator over dezelfde stream maakt geen onafhankelijke inschrijving aan. Broncode van Swift-standaardbibliotheek: AsyncStream.swift.

Daarom is eigenaarschap belangrijk. Als twee taken dezelfde stream consumeren, is annulering behandelen als een privébewerking op één iterator niet het juiste contract voor deze taak. Maak in plaats daarvan twee streams met afzonderlijke continuations en stuur elke gegenereerde waarde expliciet naar beide. Apple documenteert AsyncStream als een brug van callbacks naar asynchrone iteratie, waarbij yield(_:) wordt gebruikt om elementen aan te leveren en finish() om een stream te beëindigen. AsyncStream.

Taakannulering werkt ook coöperatief: het aanroepen van cancel() markeert de taak en voert de annuleringshandlers uit, maar onderbreekt geen willekeurige code. De consumer moet annulering controleren voordat een ontvangen waarde wordt toegepast. Annulering wordt ook doorgegeven aan onderliggende taken, dus het annuleren van een gemeenschappelijke bovenliggende taak kan een breder effect hebben dan het annuleren van de bedoelde consumerhandle. Task.cancel().

Gebruik voor deze app de volgende eigenaarschapsregels:

Bewerking Beoogd effect
Taak van A annuleren De consumptie van A beëindigen en de inschrijving van A verwijderen
Continuation van A beëindigen De stream van A normaal beëindigen
Taak van B annuleren De consumptie van B onafhankelijk beëindigen
Timerhub stoppen De timer ongeldig maken en alle resterende streams beëindigen

Welke waarden moeten de timerconsumers ontvangen?

Het voorbeeld gebruikt bewust een klein waardeschema. Elke verwerkte timercallback verhoogt een geheel getal van 1 tot en met 1.000, waarna het weer op 1 begint. Elke weergave toont dat getal en het kwadraat ervan. Een illustratieve waarde van 7 levert bijvoorbeeld n=7, square=49 op. De bovengrens houdt de berekening klein tijdens herhaalde cycli.

Elke inschrijving buffert alleen de nieuwste waarde die nog verwerkt moet worden. Dit is geschikt voor een weergave die de nieuwste synthetische status moet tonen: een trage weergave kan tussenliggende waarden overslaan. Het is ongeschikt wanneer elke gegenereerde gebeurtenis verwerkt moet worden. Apple documenteert zowel begrensde buffering als de standaardbuffer zonder limiet voor AsyncStream. AsyncStream-buffering.

De teller meet verwerkte callbacks, geen verstreken seconden. Foundation-timers zijn afhankelijk van hun run loop, kunnen te laat afgaan en spelen niet elke gemiste afvuurtijd alsnog af. Het interval van twee seconden hieronder maakt de annuleringsinteractie goed waarneembaar; het vormt geen nauwkeurige klok. Timer.

Hoe bouw je onafhankelijke inschrijvingen?

Gebruik in Xcode een macOS SwiftUI-appdoel met een Swift 6-toolchain en een implementatiedoel van macOS 14 of hoger. Voeg de volgende drie bestanden toe en vervang het app-entrypoint en de weergave uit de template. Samen vormen ze één compleet voorbeeld; er zijn geen externe packages, netwerktoegang of opgeslagen gegevens nodig. De code is nagekeken, maar is niet gecompileerd in deze onderzoeksomgeving.

Maak de gedeelde timerhub

De hub beheert de timer en een dictionary met continuations voor inschrijvingen. Elke aanroep van subscribe() maakt een nieuwe stream. De beëindigingscallback verwijdert alleen de bijbehorende identifier. Apple documenteert dat het annuleren van actieve streamiteratie onTermination aanroept en de iteratie beëindigt met nil. AsyncStream.Continuation.onTermination.

Plaats deze code in 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()
        }
    }
}

De timerinitializer maakt een timer die nog niet is ingepland, dus voegt de hub hem expliciet toe aan de main run loop. De callback gaat een main-actortaak in voordat hij de status van de hub aanraakt. De controle op timer != nil voorkomt dat een al ingeplande callback na het afsluiten waarden verstuurt. Timer.init(timeInterval:repeats:block:) en RunLoop.add(_:forMode:).

Alle status van de hub is geïsoleerd tot MainActor, waarvan de executor gelijkwaardig is aan de main dispatch queue. De beëindigingscallback plant de opschoning daar in, in plaats van de dictionary rechtstreeks vanuit de uitvoeringscontext van de callback te benaderen. MainActor.

Geef elke consumer een afzonderlijke taakhandle

Plaats de volgende code in TimerDemoModel.swift. De twee aanroepen van subscribe() zijn essentieel: ze maken onafhankelijke streamlevenscycli aan. De blokken defer schrijven ook uit wanneer een consumer eindigt, zowel bij het expliciet verlaten van de lus als bij annulering.

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

Merk op dat cancelA() precies één annuleringsbewerking uitvoert. De methode roept hub.stop() niet aan en raakt de taak van B niet aan. De lus controleert Task.isCancelled na ontvangst van een getal en voordat de weergave wordt aangepast, in overeenstemming met het gedocumenteerde coöperatieve annuleringsmodel van Swift. The Swift Programming Language — Concurrency.

Beide opruimpaden kunnen proberen A te verwijderen: de beëindigingscallback en defer van de taak. Dat is hier bewust zo. unsubscribe(_:) verwijdert eerst de entry en beëindigt daarna de teruggegeven continuation. Een latere verwijderpoging vindt niets en laat de andere inschrijvingen ongemoeid.

Voeg de desktopbedieningselementen toe

Plaats deze code in TimerWaiterDemoApp.swift. Behoud slechts één @main-appdeclaratie in het doel.

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 geeft het model stabiele opslag gedurende de levensduur van de viewcontainer, en gepubliceerde wijzigingen werken afhankelijke tekstweergaven bij. De actie bij het verdwijnen biedt opruiming op viewniveau; de precieze timing hangt af van de view. De expliciete knop Stop all biedt ook een directe afsluitbewerking. StateObject en onDisappear(perform:).

Hoe controleer je dat annulering lokaal blijft?

Start de app en volg deze acceptatiestappen. De getallen hieronder zijn illustratieve waarnemingen waar je op kunt letten, geen vastgelegde testresultaten.

  1. Wacht tot beide weergaven veranderen. Normaal tonen ze hetzelfde nieuwste getal en kwadraat.
  2. Klik tussen twee timerafvuurtijden op Cancel A. De status van A verandert in A cancelled en de weergegeven waarde blijft staan.
  3. Wacht minstens twee verdere timerafvuurtijden. B moet blijven veranderen terwijl A stilstaat.
  4. Klik op Stop all. B moet dan ook stoppen met veranderen.
  5. Start de app opnieuw en annuleer A direct, voordat de eerste waarde verschijnt. A moet op Waiting blijven staan, terwijl B getallen begint te ontvangen.

Deze stappen controleren annulering zowel nadat de consumptie is begonnen als voordat de eerste bruikbare waarde aankomt. De annuleringsactie en weergave-updates op de main actor worden in dit ontwerp geserialiseerd. Een update die vóór de knopactie is voltooid, blijft zichtbaar; de annuleringscontrole voorkomt dat een later hervatte iteratie van A nog een waarde toepast.

Als het gedrag anders is, controleer dan eerst de eigenaarschapsgrens voordat je het timerinterval wijzigt:

Symptoom Eerste punt om te controleren
B stopt wanneer A wordt geannuleerd Zoek naar een gedeelde stream of opruimcode die hub.stop() aanroept
De weergaven ontvangen verschillende deelverzamelingen van waarden Controleer of elke consumer een afzonderlijk resultaat van subscribe() krijgt
Er verschijnen geen waarden Controleer of de timer is gestart en aan de actieve main run loop van de app is toegevoegd
Waarden worden soms overgeslagen Controleer of de bufferstrategie voor nieuwste waarden past bij de vereisten voor de weergave
Werk gaat door nadat de view verdwijnt Controleer of de afsluiting beide taakhandles en de hub bereikt

Wat verandert er bij een eenmalige waiter of een herstart?

Houd bij een eenmalige waiter de inschrijving privé, verwerk de eerste geschikte waarde en verlaat de lus. Behoud de uitschrijfoperatie defer: zo wordt de bedoelde levensduur van de inschrijving expliciet, ook wanneer de lus eindigt via break. Een normale eenmalige voltooiing moet die inschrijving verwijderen terwijl de gedeelde producer beschikbaar blijft voor B.

Maak voor een herstart van A een nieuwe inschrijving en een nieuwe taak nadat de oude taak is voltooid. Probeer de geannuleerde taak niet opnieuw in te stellen of de beëindigde stream te hergebruiken. Wacht in een herstartbare interface op de voltooiing van de oude taak voordat je een vervanging installeert, of onderscheid de opeenvolgende inschrijvingslevenscycli anders, zodat oude opruimcode geen nieuwe status kan verwijderen.

Het voorbeeld biedt bewust één uitvoering per viewinstantie. De globale afsluiting maakt de herhalende timer ongeldig op de main actor waarop hij is geïnstalleerd. Apple documenteert dat ongeldige timers niet opnieuw kunnen worden gebruikt; voor een herstart moet een nieuwe timer worden gemaakt. Timer lifecycle.

Onafhankelijke annulering volgt rechtstreeks uit onafhankelijk eigenaarschap van inschrijvingen. Behoud de timer in de hub, geef elke consumer een afzonderlijke stream en taakhandle, en verwijder alleen de inschrijving van die consumer wanneer deze eindigt. Zo kan een desktopbedieningselement één wachtende weergave stoppen terwijl de andere dezelfde synthetische berekening blijft ontvangen.

Bronnen