Untuk membatalkan satu penunggu pemasa secara berasingan, berikan setiap pengguna AsyncStream dan pemegang tugasnya sendiri. Kongsi pemasa Foundation, kemudian agihkan nilainya kepada strim yang berasingan. Dengan itu, pembatalan pengguna A boleh menamatkan penantian A sementara pengguna B meneruskan.

Tutorial ini membina aplikasi desktop Mac kecil dengan dua paparan nombor sintetik dan butang Cancel A. Kedua-dua paparan menerima nilai daripada satu pemasa. Tugas khususnya ialah menghentikan penggunaan tak segerak A tanpa menghentikan B atau membatalkan pemasa yang dikongsi.

Mengapakah pembatalan satu penunggu boleh menjejaskan pengguna lain?

AsyncSequence menerangkan lelaran tak segerak; tingkah laku pembatalan bergantung pada pelaksanaan jujukan yang konkrit. Bagi AsyncStream, kod sumber pustaka standard semasa mendokumenkan bahawa pembatalan tugas yang digantung dalam next() menamatkan strim. Panggilan itu boleh memulangkan nil serta-merta atau pada panggilan seterusnya. Iterator baharu bagi strim yang sama tidak mewujudkan langganan yang berasingan. Kod sumber pustaka standard Swift: AsyncStream.swift.

Oleh itu, pemilikan penting. Jika dua tugas menggunakan strim yang sama, menganggap pembatalan sebagai operasi peribadi pada satu iterator bukanlah kontrak yang sesuai untuk tugas ini. Sebaliknya, cipta dua strim dengan continuation berasingan dan hantar setiap nilai yang dijana kepada kedua-duanya secara jelas. Apple mendokumenkan AsyncStream sebagai jambatan daripada panggil balik kepada lelaran tak segerak, menggunakan yield(_:) untuk membekalkan elemen dan finish() untuk menamatkan strim. AsyncStream.

Pembatalan tugas juga bersifat kerjasama: memanggil cancel() menandakan tugas itu sebagai dibatalkan dan menjalankan pengendali pembatalannya, tetapi tidak menyampuk kod sewenang-wenangnya. Pengguna patut menyemak pembatalan sebelum menggunakan nilai yang diterima. Pembatalan turut merebak kepada tugas anak, jadi membatalkan induk yang dikongsi boleh memberi kesan yang lebih luas daripada membatalkan pemegang pengguna yang dimaksudkan. Task.cancel().

Untuk aplikasi ini, gunakan peraturan pemilikan berikut:

Operasi Kesan yang dimaksudkan
Batalkan tugas A Tamatkan penggunaan A dan buang langganan A
Tamatkan continuation A Tamatkan strim A secara normal
Batalkan tugas B Tamatkan penggunaan B secara berasingan
Hentikan hab pemasa Batalkan pemasa dan tamatkan semua strim yang masih ada

Apakah nilai yang patut diterima oleh pengguna pemasa?

Contoh ini menggunakan kontrak nilai yang sengaja ringkas. Setiap panggil balik pemasa yang diproses menaikkan integer daripada 1 hingga 1,000, kemudian kembali kepada 1. Setiap paparan menunjukkan integer itu dan kuasa duanya. Contohnya, nilai ilustrasi 7 menghasilkan n=7, square=49. Had ini memastikan pengiraan kekal kecil sepanjang kitaran berulang.

Setiap langganan hanya menyimpan nilai tertunda yang paling baharu. Ini sesuai untuk paparan yang tugasnya menunjukkan keadaan sintetik terkini: paparan yang perlahan mungkin melangkau nilai perantaraan. Ia tidak sesuai apabila setiap peristiwa yang dijana mesti diproses. Apple mendokumenkan penimbalan terhad dan penimbal tidak terhad lalai untuk AsyncStream. Penimbalan AsyncStream.

Pembilang mengukur panggil balik yang diproses, bukan saat yang berlalu. Pemasa Foundation bergantung pada run loop, boleh terlewat, dan tidak memainkan semula setiap pencetusan yang terlepas. Selang dua saat di bawah memudahkan pemerhatian interaksi pembatalan; ia tidak memberikan jam yang tepat. Timer.

Bagaimanakah anda membina langganan yang berasingan?

Gunakan sasaran aplikasi SwiftUI macOS dalam Xcode dengan toolchain Swift 6 dan sasaran penggunaan macOS 14 atau lebih baharu. Tambahkan tiga fail berikut, dengan menggantikan titik masuk aplikasi templat dan paparannya. Bersama-sama, fail ini membentuk satu contoh lengkap; ia tidak memerlukan pakej luaran, akses rangkaian atau data tersimpan. Kod ini telah diperiksa tetapi belum dikompil dalam persekitaran penyelidikan ini.

Cipta hab pemasa yang dikongsi

Hab ini memiliki pemasa dan kamus continuation langganan. Setiap panggilan kepada subscribe() membina strim baharu. Panggil balik penamatannya hanya membuang pengecam yang sepadan. Apple mendokumenkan bahawa pembatalan lelaran strim yang aktif memanggil onTermination dan menamatkan lelaran dengan nil. AsyncStream.Continuation.onTermination.

Letakkan kod ini dalam 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()
        }
    }
}

Pemula pemasa mencipta pemasa yang belum dijadualkan, jadi hab menambahkannya secara jelas pada run loop utama. Panggil balik memasuki tugas main actor sebelum menyentuh keadaan hab. Semakan timer != nil menghalang panggil balik yang sudah beratur daripada mengeluarkan nilai selepas penutupan. Timer.init(timeInterval:repeats:block:) dan RunLoop.add(_:forMode:).

Semua keadaan hab diasingkan kepada MainActor, yang pelaksananya setara dengan baris gilir penghantaran utama. Panggil balik penamatan menjadualkan pembersihannya di situ dan bukannya mengakses kamus secara langsung daripada konteks pelaksanaan panggil balik. MainActor.

Berikan setiap pengguna pemegang tugas yang berasingan

Letakkan kod berikut dalam TimerDemoModel.swift. Dua panggilan kepada subscribe() adalah penting: kedua-duanya mencipta jangka hayat strim yang berasingan. Blok defer juga berhenti melanggan apabila pengguna keluar, sama ada melalui keluar gelung secara jelas atau pembatalan.

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

Perhatikan bahawa cancelA() melakukan tepat satu operasi pembatalan. Ia tidak memanggil hub.stop() atau menyentuh tugas B. Gelung menyemak Task.isCancelled selepas menerima nombor dan sebelum mengubah paparan, selaras dengan model pembatalan kerjasama yang didokumenkan oleh Swift. The Swift Programming Language — Concurrency.

Kedua-dua laluan pembersihan boleh cuba membuang A: panggil balik penamatan dan defer tugas. Hal ini disengajakan. unsubscribe(_:) membuang entri terlebih dahulu, kemudian menamatkan continuation yang dipulangkan. Pembuangan seterusnya tidak menemui apa-apa, maka langganan lain tidak terjejas.

Tambahkan kawalan desktop

Letakkan kod ini dalam TimerWaiterDemoApp.swift. Pastikan hanya ada satu pengisytiharan aplikasi @main dalam sasaran.

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 memberikan model storan yang stabil sepanjang hayat bekas paparan, dan perubahan yang diterbitkan mengemas kini paparan teks yang bergantung padanya. Tindakan apabila paparan hilang menyediakan pembersihan pada peringkat paparan; masa tepatnya bergantung pada paparan. Butang Stop all yang jelas juga menyediakan operasi penutupan terus. StateObject dan onDisappear(perform:).

Bagaimanakah anda mengesahkan bahawa pembatalan kekal setempat?

Jalankan aplikasi dan ikuti urutan penerimaan ini. Nombor di bawah ialah pemerhatian ilustrasi yang perlu dicari, bukannya hasil ujian yang direkodkan.

  1. Tunggu sehingga kedua-dua paparan berubah. Biasanya, kedua-duanya patut menunjukkan nombor terkini dan kuasa duanya yang sama.
  2. Klik Cancel A antara pencetusan pemasa. Status A berubah kepada A cancelled, dan nilai yang dipaparkan kekal.
  3. Tunggu sekurang-kurangnya dua pencetusan lagi. B patut terus berubah sementara A kekal.
  4. Klik Stop all. B juga patut berhenti berubah.
  5. Lancarkan semula aplikasi dan batalkan A serta-merta, sebelum nilai pertamanya. A patut kekal pada Waiting, sementara B mula menerima nombor.

Urutan ini menyemak pembatalan selepas penggunaan bermula dan sebelum nilai pertama yang berguna diterima. Dalam reka bentuk ini, tindakan pembatalan dan kemas kini paparan pada main actor disirikan. Kemas kini yang selesai sebelum tindakan butang kekal kelihatan; semakan pembatalan menghalang lelaran A yang disambung semula kemudian daripada menggunakan nilai lain.

Jika tingkah lakunya berbeza, periksa sempadan pemilikan sebelum mengubah selang pemasa:

Gejala Perkara pertama untuk diperiksa
B berhenti apabila A dibatalkan Cari strim yang dikongsi atau pembersihan yang memanggil hub.stop()
Paparan menerima subset nilai yang berbeza Pastikan setiap pengguna mendapat hasil subscribe() yang berasingan
Tiada nilai muncul Pastikan pemasa telah dimulakan dan ditambahkan pada run loop utama aplikasi yang sedang berjalan
Nilai kadangkala melangkau Periksa sama ada dasar penimbalan nilai terkini memenuhi keperluan paparan
Kerja berterusan selepas paparan hilang Pastikan penutupan mencapai kedua-dua pemegang tugas dan hab

Apakah yang berubah untuk penunggu sekali guna atau mula semula?

Bagi penunggu sekali guna, jadikan langganannya peribadi, proses nilai pertama yang boleh diterima, kemudian keluar daripada gelung. Kekalkan operasi berhenti melanggan defer: operasi itu menjelaskan jangka hayat langganan yang dimaksudkan walaupun gelung tamat melalui break. Penyiapan sekali guna yang normal patut membuang langganan itu sambil membiarkan pengeluar yang dikongsi tersedia untuk B.

Untuk memulakan semula A, cipta langganan baharu dan tugas baharu selepas tugas lama selesai. Jangan cuba menetapkan semula tugas yang dibatalkan atau menggunakan semula strimnya yang telah ditamatkan. Dalam antara muka yang boleh dimulakan semula, tunggu sehingga tugas lama selesai sebelum memasang pengganti, atau bezakan jangka hayat langganan yang berturutan dengan cara lain supaya pembersihan lama tidak membuang keadaan baharu.

Contoh ini sengaja menawarkan satu sesi bagi setiap tika paparan. Penutupan global membatalkan pemasa berulangnya pada main actor, tempat pemasa itu dipasang. Apple mendokumenkan bahawa pemasa yang dibatalkan tidak boleh digunakan semula; permulaan semula memerlukan pembinaan pemasa lain. Kitar hayat Timer.

Pembatalan berasingan terhasil secara langsung daripada pemilikan langganan yang berasingan. Kekalkan pemasa dalam hab, berikan setiap pengguna strim dan pemegang tugas yang berasingan, dan buang hanya langganan pengguna itu apabila ia selesai. Dengan itu, kawalan desktop boleh menghentikan satu paparan yang sedang menunggu sementara paparan yang satu lagi terus menerima pengiraan sintetik yang sama.

Sumber