لإلغاء انتظار مؤقّت واحد بشكل مستقل، امنح كل مستهلك AsyncStream خاصًا به ومقبض مهمة خاصًا به. شارك مؤقّت Foundation، ثم وزّع قيمه على تدفقات منفصلة. عندئذٍ، يمكن لإلغاء المستهلك A إنهاء انتظاره بينما يواصل المستهلك B العمل.

يبني هذا البرنامج التعليمي تطبيقًا صغيرًا لسطح مكتب Mac يعرض رقمين اصطناعيين وزرًا باسم Cancel A. يتلقى العرضان القيم من مؤقّت واحد. والمهمة المحددة هي إيقاف الاستهلاك غير المتزامن لدى A دون إيقاف B أو إبطال المؤقّت المشترك.

لماذا قد يؤثر إلغاء منتظر واحد في مستهلكين آخرين؟

تصف AsyncSequence التكرار غير المتزامن؛ أما سلوك الإلغاء فيأتي من تنفيذ التسلسل المحدد. يوثّق المصدر الحالي للمكتبة القياسية أن إلغاء مهمة معلّقة في next() ينهي التدفق. وقد يعيد الاستدعاء nil فورًا أو عند الاستدعاءات اللاحقة. لا ينشئ مُكرّر جديد فوق التدفق نفسه اشتراكًا مستقلًا. مصدر المكتبة القياسية لـ Swift: AsyncStream.swift.

لذلك، تكتسب الملكية أهمية. إذا استهلكت مهمتان التدفق نفسه، فاعتبار الإلغاء عملية خاصة بمُكرّر واحد لا يفي بالعقد المطلوب لهذه المهمة. بدلًا من ذلك، أنشئ تدفقين بمتابعتين منفصلتين وأرسل كل قيمة مُولّدة إليهما صراحةً. توثّق Apple AsyncStream بوصفها جسرًا من الاستدعاءات إلى التكرار غير المتزامن، حيث يُستخدم yield(_:) لتوفير العناصر وfinish() لإنهاء التدفق. AsyncStream.

إلغاء المهام تعاوني أيضًا: يؤدي استدعاء cancel() إلى وضع علامة على المهمة وتشغيل معالجات الإلغاء، لكنه لا يوقف الشيفرة الاعتباطية. ينبغي للمستهلك التحقق من الإلغاء قبل تطبيق قيمة مستلمة. كما ينتقل الإلغاء إلى المهام الفرعية، لذا قد يكون لإلغاء مهمة أب مشتركة أثر أوسع من إلغاء مقبض المستهلك المقصود. Task.cancel().

استخدم قواعد الملكية التالية لهذا التطبيق:

العملية الأثر المقصود
إلغاء مهمة A إنهاء استهلاك A وإزالة اشتراكه
إنهاء متابعة A إنهاء تدفق A بصورة طبيعية
إلغاء مهمة B إنهاء استهلاك B بشكل مستقل
إيقاف مركز المؤقّت إبطال المؤقّت وإنهاء كل التدفقات المتبقية

ما القيم التي ينبغي أن يتلقاها مستهلكو المؤقّت؟

يستخدم المثال عقد قيم بسيطًا عمدًا. تزيد كل معالجة لاستدعاء المؤقّت عددًا صحيحًا من 1 إلى 1,000، ثم تعيده إلى 1. يعرض كل عرض ذلك العدد ومربعه. على سبيل المثال، تنتج القيمة التوضيحية 7 القيمة n=7, square=49. يحافظ هذا الحد على صغر العملية الحسابية عبر الدورات المتكررة.

يخزّن كل اشتراك أحدث قيمة معلّقة فقط. وهذا مناسب لعرض مهمته إظهار أحدث حالة اصطناعية؛ فقد يتجاوز العرض البطيء القيم الوسيطة. ولا يناسب الحالات التي يجب فيها معالجة كل حدث مُولّد. توثّق Apple كلًا من التخزين المؤقت محدود السعة والتخزين المؤقت غير المحدود الافتراضي لـ AsyncStream. التخزين المؤقت في AsyncStream.

يقيس العداد استدعاءات المعالجة، لا الثواني المنقضية. تعتمد مؤقّتات Foundation على حلقة التشغيل الخاصة بها، وقد تعمل متأخرة، ولا تعيد تشغيل كل مرات الإطلاق الفائتة. تجعل الفترة ذات الثانيتين أدناه ملاحظة تفاعل الإلغاء سهلة؛ لكنها لا تنشئ ساعة دقيقة. Timer.

كيف تنشئ اشتراكات مستقلة؟

استخدم هدف تطبيق macOS SwiftUI في Xcode مع سلسلة أدوات Swift 6 وهدف نشر macOS 14 أو أحدث. أضف الملفات الثلاثة التالية، واستبدل نقطة دخول التطبيق والعرض النموذجيين. تشكّل هذه الملفات معًا مثالًا كاملًا واحدًا؛ ولا تتطلب حزمة خارجية أو اتصالًا بالشبكة أو بيانات مخزّنة. جرى فحص الشيفرة، لكن لم تُترجم في بيئة البحث هذه.

إنشاء مركز المؤقّت المشترك

يمتلك المركز المؤقّت وقاموسًا لمتابعات الاشتراكات. ينشئ كل استدعاء لـ subscribe() تدفقًا جديدًا. ويزيل ردّ الاتصال عند انتهاء التدفق المعرّف المطابق فقط. توثّق Apple أن إلغاء التكرار النشط للتدفق يستدعي onTermination وينهي التكرار باستخدام nil. AsyncStream.Continuation.onTermination.

ضع هذه الشيفرة في 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()
        }
    }
}

ينشئ مُهيّئ المؤقّت مؤقّتًا غير مجدول، لذا يضيفه المركز صراحةً إلى حلقة التشغيل الرئيسية. يدخل ردّ الاتصال مهمة على الممثل الرئيسي قبل التعامل مع حالة المركز. ويمنع التحقق timer != nil صدور قيمة من ردّ اتصال سبق وضعه في قائمة الانتظار بعد إيقاف التشغيل. Timer.init(timeInterval:repeats:block:) وRunLoop.add(_:forMode:).

تُعزل كل حالة المركز إلى MainActor، الذي يعادل منفّذه قائمة انتظار الإرسال الرئيسية. ويجدول ردّ الاتصال عند انتهاء التدفق عملية التنظيف على ذلك الممثل، بدلًا من الوصول مباشرةً إلى القاموس من سياق تنفيذ ردّ الاتصال. MainActor.

منح كل مستهلك مقبض مهمة منفصلًا

ضع الشيفرة التالية في TimerDemoModel.swift. إن استدعاءَي subscribe() ضروريان: فهما ينشئان عمري تدفق مستقلين. كما تلغي الكتل defer الاشتراك عند خروج المستهلك، سواء انتهت الحلقة صراحةً أو بسبب الإلغاء.

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

لاحظ أن cancelA() ينفذ عملية إلغاء واحدة فقط. فهو لا يستدعي hub.stop() ولا يلمس مهمة B. تتحقق الحلقة من Task.isCancelled بعد استلام رقم وقبل تغيير العرض، وفقًا لنموذج الإلغاء التعاوني الموثّق في Swift. لغة برمجة Swift — التزامن.

قد يحاول مسارا التنظيف إزالة A: ردّ الاتصال عند انتهاء التدفق وdefer الخاص بالمهمة. وهذا مقصود هنا. إذ يزيل unsubscribe(_:) الإدخال أولًا، ثم ينهي المتابعة التي أعادها. ولا يعثر أي حذف لاحق على شيء، فتظل الاشتراكات الأخرى كما هي.

إضافة عناصر التحكم لسطح المكتب

ضع هذه الشيفرة في TimerWaiterDemoApp.swift. أبقِ إعلان تطبيق واحدًا فقط باستخدام @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 تخزينًا ثابتًا للنموذج طوال عمر حاوية العرض، وتحدّث التغييرات المنشورة العروض النصية التابعة. يوفّر إجراء الاختفاء تنظيفًا على مستوى العرض؛ ويعتمد توقيته الدقيق على العرض. كما يوفّر زر Stop all عملية إيقاف مباشرة. StateObject وonDisappear(perform:).

كيف تتحقق من أن الإلغاء يظل محليًا؟

شغّل التطبيق واتبع تسلسل التحقق هذا. الأرقام أدناه ملاحظات توضيحية متوقعة، وليست نتائج اختبار مسجّلة.

  1. انتظر حتى يتقدم العرضان. من المفترض عادةً أن يعرضا أحدث رقم ومربعه نفسيهما.
  2. انقر Cancel A بين إطلاقات المؤقّت. تتغير حالة A إلى A cancelled، وتبقى القيمة المعروضة ثابتة.
  3. انتظر إطلاقين إضافيين على الأقل. ينبغي أن يواصل B التقدم بينما يظل A ثابتًا.
  4. انقر Stop all. ينبغي أن يتوقف B عن التقدم أيضًا.
  5. أعد تشغيل التطبيق وألغِ A فورًا، قبل وصول قيمته الأولى. ينبغي أن يظل A عند Waiting، بينما يبدأ B بتلقي الأرقام.

يتحقق هذا التسلسل من الإلغاء بعد بدء الاستهلاك وقبل وصول أول قيمة مفيدة. تُسلسَل عملية الإلغاء على الممثل الرئيسي وتحديثات العرض في هذا التصميم. يظل أي تحديث اكتمل قبل إجراء الزر ظاهرًا؛ ويمنع التحقق من الإلغاء تكرار A المستأنف لاحقًا من تطبيق قيمة أخرى.

إذا اختلف السلوك، فتحقق من حدود الملكية قبل تغيير فترة المؤقّت:

العَرَض أول ما ينبغي فحصه
يتوقف B عند إلغاء A ابحث عن تدفق مشترك أو تنظيف يستدعي hub.stop()
يتلقى العرضان مجموعتين مختلفتين من القيم تأكد من أن كل مستهلك يحصل على نتيجة subscribe() منفصلة
لا تظهر أي قيم تأكد من بدء المؤقّت وإضافته إلى حلقة التشغيل الرئيسية العاملة للتطبيق
تتخطى القيم أحيانًا تحقق من ملاءمة سياسة التخزين المؤقت لأحدث قيمة لمتطلبات العرض
يستمر العمل بعد اختفاء العرض تأكد من وصول الإيقاف إلى مقبضي المهمتين وإلى المركز

ما الذي يتغير عند استخدام منتظر لمرة واحدة أو إعادة التشغيل؟

عند استخدام منتظر لمرة واحدة، أبقِ اشتراكه خاصًا به، وعالج أول قيمة مقبولة، ثم اخرج من الحلقة. احتفظ بعملية إلغاء الاشتراك defer؛ فهي توضّح عمر الاشتراك المقصود حتى عند انتهاء الحلقة عبر break. ينبغي للإكمال الطبيعي لمرة واحدة أن يزيل ذلك الاشتراك مع إبقاء المنتج المشترك متاحًا لـ B.

لإعادة تشغيل A، أنشئ اشتراكًا جديدًا ومهمة جديدة بعد اكتمال المهمة القديمة. لا تحاول إعادة تعيين المهمة الملغاة أو إعادة استخدام تدفق منتهٍ. في واجهة قابلة لإعادة التشغيل، انتظر اكتمال المهمة القديمة قبل تثبيت بديل، أو ميّز بوسيلة أخرى بين أعمار الاشتراكات المتعاقبة كي لا يزيل التنظيف القديم الحالة الجديدة.

يقدّم المثال عمدًا تشغيلًا واحدًا لكل نسخة من العرض. يبطل الإيقاف العام المؤقّت المتكرر على الممثل الرئيسي، حيث ثُبّت. وتوثّق Apple أنه لا يمكن إعادة استخدام المؤقّت بعد إبطاله؛ إذ تتطلب إعادة التشغيل إنشاء مؤقّت آخر. دورة حياة Timer.

ينبع الإلغاء المستقل مباشرةً من ملكية الاشتراكات المستقلة. أبقِ المؤقّت في المركز، وامنح كل مستهلك تدفقًا ومقبض مهمة منفصلين، وأزل اشتراك ذلك المستهلك وحده عند انتهائه. يتيح ذلك لعنصر تحكم على سطح المكتب إيقاف عرض واحد منتظر بينما يواصل العرض الآخر تلقي الحساب الاصطناعي نفسه.

المصادر