الوحدة 4 · إطلاق تطبيق حقيقي
async/await والـ concurrency في Swift 6
20 دقراءة 4 داكتشف الخطأ
الدرس في لمحة
اكتب دوال async، وابدأ العمل من SwiftUI عبر .task، وشغّل الاستدعاءات بالتوازي باستخدام async let ومجموعات المهام، واقرأ أخطاء سباق البيانات في Swift 6 المتعلّقة بالـ actors والـ Sendable.
- 1async وawait
- 2بدء العمل غير المتزامن من SwiftUI
- 3الـ concurrency المهيكلة: أبناء متوازون
- 4سباقات البيانات والـ compiler في Swift 6
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
يوشك Pocket Budget أن يستدعي خادمًا للحصول على أسعار الصرف. قد يستغرق الطلب 200 ميلي ثانية، أو ثماني ثوانٍ في القطار. لو جلس الـ main thread، الذي يرسم الواجهة ويتعامل مع اللمسات، ينتظره، لتجمّد التطبيق وربما أنهاه iOS في النهاية. نموذج الـ concurrency (التزامن) في Swift يتيح لك كتابة كود ينتظر دون أن يحجب، بأسلوب يُقرأ من الأعلى إلى الأسفل، وتضيف Swift 6 فحوصات في الـ compiler تثبت أن الكود الذي يعمل بالتوازي خالٍ من سباقات البيانات.
async وawait
import Foundation
struct RateService {
func fetchRate(from base: String, to target: String) async throws -> Decimal {
try await Task.sleep(for: .milliseconds(300)) // stands in for a network call
return target == "EUR" ? 0.92 : 0.79
}
}async في التوقيع تعني «هذه الدالة قد تتوقّف مؤقتًا». وكل استدعاء لها يُعلَّم بـ await، التي تعلّم نقطة تعليق (suspension point): قد تتوقّف الدالة هناك، ويذهب الـ thread لأداء عمل آخر (مثل تمرير قائمة)، ثم تستأنف الدالة حين تكون النتيجة جاهزة. وتجتمع async مع throws، فيكون الاستدعاء try await. في الدرس التالي يصبح Task.sleep طلبًا حقيقيًا.
بدء العمل غير المتزامن من SwiftUI
الدوال غير المتزامنة لا تُستدعى إلا من سياق غير متزامن. ويعطيك SwiftUI واحدًا عبر الـ modifier المسمّى .task:
@MainActor
@Observable
final class BudgetStore {
var expenses: [Expense] = []
var euroRate: Decimal?
var isLoadingRate = false
private let rates = RateService()
func loadEuroRate() async {
isLoadingRate = true
defer { isLoadingRate = false }
do {
euroRate = try await rates.fetchRate(from: "USD", to: "EUR")
} catch {
euroRate = nil
}
}
}
struct RateBanner: View {
let store: BudgetStore
var body: some View {
Group {
if store.isLoadingRate {
ProgressView()
} else if let rate = store.euroRate {
Text("1 USD = \(rate.formatted()) EUR")
} else {
Text("Rate unavailable")
}
}
.task {
await store.loadEuroRate()
}
}
}تبدأ .task حين يظهر الـ view وتُلغى تلقائيًا حين يختفي، فالمستخدم الذي يغادر الشاشة لا يترك طلبًا يعمل في الخلفية. أما للنقر على زر، فغلّف الاستدعاء في Task { await store.loadEuroRate() }، الذي يبدأ عملًا جديدًا على المستوى الأعلى. وتعيد defer ضبط علامة التحميل أيًّا كانت طريقة خروج الدالة.
الـ concurrency المهيكلة: أبناء متوازون
طلبان مستقلّان لا يجب أن ينتظر أحدهما الآخر. async let تبدأ العمل فورًا وتتيح لك جمعه لاحقًا:
func loadBothRates(using service: RateService) async throws -> (eur: Decimal, gbp: Decimal) {
async let eur = service.fetchRate(from: "USD", to: "EUR")
async let gbp = service.fetchRate(from: "USD", to: "GBP")
return try await (eur, gbp)
}يعمل الطلبان في الوقت نفسه، فيستغرق هذا نحو 300 ms بدل 600. ولعدد من المهام لا تعرفه إلا وقت التشغيل، استخدم مجموعة مهام (task group):
func loadRates(for codes: [String], using service: RateService) async throws -> [String: Decimal] {
try await withThrowingTaskGroup(of: (String, Decimal).self) { group in
for code in codes {
group.addTask {
(code, try await service.fetchRate(from: "USD", to: code))
}
}
var rates: [String: Decimal] = [:]
for try await (code, rate) in group {
rates[code] = rate
}
return rates
}
}هذه هي الـ concurrency المهيكلة (structured concurrency): المهام الأبناء تعيش داخل نطاق أبيها. لا تستطيع الدالة أن تعود ما دام ابن ما زال يعمل، وإن رمى أحد الأبناء خطأً أُلغي الباقون، وإلغاء الأب يلغي كل الأبناء. لاحظ أن كل ابن يعيد نتيجته والأب يجمّع القاموس. الأبناء لا يكتبون أبدًا في state مشتركة.
سباقات البيانات والـ compiler في Swift 6
يحدث سباق البيانات (data race) حين يصل threadان إلى الذاكرة نفسها في الوقت نفسه ويكتب أحدهما على الأقل. والنتيجة بيانات تالفة أو عطل يظهر مرة في كل ألف تشغيل. يحوّل وضع اللغة Swift 6 فحوصات ذلك إلى أخطاء ترجمة، منظّمة حول مفهوم العزل (isolation): كل قطعة من الـ state القابلة للتغيير تنتمي إلى نطاق واحد بالضبط، ولا يلمسها مباشرةً إلا الكود الموجود في ذلك النطاق.
ثلاث أدوات تغطّي معظم الحالات في أي تطبيق:
الـ main actor. يعزل @MainActor النوع على الـ main thread. حالة الواجهة تنتمي إلى هناك، ولهذا عُلّم BudgetStore بـ @MainActor في الكود أعلاه: الـ views التي تقرؤه تعمل على الـ main actor، والآن يضمن الـ compiler ألّا يكتب فيه أي شيء آخر. مشاريع التطبيقات الجديدة في Xcode 26 وما بعده تضبط إعداد البناء Default Actor Isolation على MainActor، فيكون كود تطبيقك معزولًا على الـ main actor ما لم تقل غير ذلك. إنه افتراض معقول: معظم كود التطبيقات هو كود واجهة.
الـ actors. الـ actor نوع مرجعي يحمي الـ state الخاصة به بتنفيذ استدعاء واحد في كل مرة. والكود خارج الـ actor يجب أن يستخدم await للوصول إليه:
actor RateCache {
private var rates: [String: Decimal] = [:]
func rate(for code: String) -> Decimal? {
rates[code]
}
func save(_ rate: Decimal, for code: String) {
rates[code] = rate
}
}
// From anywhere: await cache.save(0.92, for: "EUR")القيم من نوع Sendable. يكون النوع Sendable (قابلًا للإرسال) حين يكون تمريره بين النطاقات آمنًا. أنواع القيم المبنية من أجزاء Sendable (String وDecimal والـ struct المسمّى Expense) تستوفي ذلك تلقائيًا، وكذلك الـ actors والـ classes المعزولة على الـ main actor. أما الـ class العادي الذي فيه خصائص var فلا يستوفيه، ويمنعك الـ compiler من مشاركته بين المهام.
هذه هي المفردات: async وawait والمهام والـ actors وSendable. لا تحتاج إلى إتقان كل قاعدة دفعة واحدة؛ اقرأ كل خطأ من الـ compiler كسؤال عن النطاق الذي يملك البيانات. بعد ذلك تستبدل Task.sleep التمثيلية بطلب حقيقي وتفكّ ترميز الـ JSON الذي يعيده.
أهم النقاط
awaitتعلّم نقطة قد تتوقّف فيها الدالة مؤقتًا؛ ويكون الـ thread حرًّا لأداء عمل آخر، مثل إبقاء الواجهة مستجيبة، أثناء الانتظار.- ابدأ العمل غير المتزامن من view عبر
.task، الذي يلغيه SwiftUI تلقائيًا حين يختفي الـ view. async letومجموعات المهام تشغّل العمل بالتوازي كمهام أبناء يجب أن تنتهي، أو تُلغى، قبل أن يعود الأب.- ترفض Swift 6 ترجمة الكود الذي قد تلمس فيه مهمّتان الـ state القابلة للتغيير نفسها في الوقت ذاته؛ والـ actors والأنواع من نوع
Sendableهي طريقتك لإرضائها. - أبقِ حالة الواجهة على الـ main actor عبر
@MainActor؛ ومشاريع Xcode الجديدة تجعل العزل على الـ main actor هو الافتراضي لكود التطبيق.