سلسلة التزامن السريع 04 | حدود استخدام المهام
المهمة ليست المدخل العالمي للتعليمات البرمجية غير المتزامنة. ما يهم حقًا هو من أنشأها، ومن أبطلها، ومن المسؤول عن النتائج.
العادة السيئة الأكثر شيوعًا التي تطورها العديد من الفرق عندما تواجه Swift Concurrency لأول مرة على نطاق واسع هي إساءة استخدام Task.
لأنها مريحة للغاية.
لا يمكنك مباشرة await في زر رد الاتصال، ثم كتابة Task. لا يمكنك await مباشرة في طريقة الوكيل UIKit، ثم كتابة Task. إذا كنت تريد التسلل إلى العالم غير المتزامن بطريقة مزامنة معينة، فإن أسهل طريقة هي كتابة Task.
بمرور الوقت، سيتغير Task من “بوابة تربط بين التزامن وعدم التزامن” إلى “طبقة من شريط التزامن تغطي أي مشكلة.”
ما يريد هذا المقال الإجابة عليه حقًا هو ثلاثة أسئلة أقرب إلى الهندسة:
- ما نوع المشكلة التي يحلها؟
- تحت أي ظروف يكون الانتقاء طبيعيًا، وتحت أي ظروف يكون مجرد إخفاء المشاكل الهيكلية.
- ما هي الأسئلة التي يجب أن تطرحها على نفسك أولاً عندما تقرر فتح
Task؟
1. أولاً، لنوضح الموضع: Task هي نقطة إنشاء المهام غير المتزامنة، وليست أداة لتصميم العمليات.
عندما رأيت Task {} لأول مرة، ما فكرت فيه هو “التنفيذ غير المتزامن لجزء من التعليمات البرمجية”.
وهذا الفهم ليس خاطئا، لكنه ليس كافيا.
بتعبير أدق، ما يفعله Task هو:
- إنشاء مهمة متزامنة جديدة
- ضع جزءًا من التعليمات البرمجية في سياق غير متزامن
- ربط المسؤوليات مثل التنفيذ والإلغاء والأولوية والنتائج وغيرها بهذه المهمة
لذا فإن Task لا يقتصر أبدًا على “رمي الكود في الخلفية وتشغيله”.
بمجرد أن كتبت ذلك، اتخذت بالفعل عدة قرارات في نفس الوقت:
- بدأت هذه القطعة من العمل الآن في الوقوف من تلقاء نفسها
- قد ينتهي بعد نقطة الاتصال الحالية
- يجوز أو لا يجوز إلغاؤه
- يتم استهلاك نتيجته أو التخلص منه
- ينشئ علاقة معينة مع الكائن الحالي والصفحة الحالية وإجراء المستخدم الحالي
يوضح هذا أيضًا أن Task لا يمكن فهمه نحويًا فقط.
من الناحية النحوية، فهي مجرد كتلة تعليمات برمجية، ولكن من الناحية الهندسية تعني “تم إنشاء دورة حياة المهمة”.
2. السيناريو الأنسب لـ Task: من الضروري حقًا الانتقال من العالم المتزامن إلى العالم غير المتزامن
سيناريو الاستخدام الأكثر طبيعية لـ Task هو في الواقع بسيط جدًا:
السياق الحالي ليس
async، ولكن يجب تشغيل عملية غير متزامنة.
مثل هذه الأنواع من المواقف.
1. رد اتصال تفاعل المستخدم
Button("保存") {
Task {
await viewModel.save()
}
}
من المعقول استخدام Task هنا، لأن إجراء Button نفسه ليس async، ولكن من الواضح أن إجراء الحفظ هو عملية غير متزامنة.
2. طريقة وكيل مزامنة UIKit/AppKit
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
Task {
await viewModel.search(keyword: searchText)
}
}
يتم تحديد توقيع رد اتصال الوكيل من خلال إطار العمل، وليس ما إذا كان يمكن تغييره إلى async. للدخول في عملية غير متزامنة، يلزم وجود نقطة جسر.
3. دورة حياة التطبيق أو رد الاتصال بالإشعارات
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
Task {
await refreshIfNeeded()
}
}
لا تزال قيمة Task هنا كما هي: قم بتحويل حدث متزامن إلى مهمة غير متزامنة.
إذا نظرت إلى هذه الأمثلة معًا، ستجد شيئًا واحدًا مشتركًا:
- الأحداث تأتي من API المزامنة
- من المتوقع أن تكون معالجة الأعمال غير متزامنة
Taskهو المدخل فقط، وليس الجسم الرئيسي
في هذا الوقت، تعد Task أداة جيدة.
3. الخطر الحقيقي: تعامل مع Task كطريقة إصلاح “لإصلاح أي خطأ يتم الإبلاغ عنه”
المشكلة الأكثر شيوعًا في الفريق هي “استخدامه بشكل طبيعي جدًا”.
المواقف الخاطئة الأكثر شيوعًا هي كما يلي.
1. إذا كان المترجم لا يسمح بـ await، فسوف يتضمن طبقة من Task
func refresh() {
Task {
await loadData()
}
}
قد لا يكون هذا الرمز خاطئًا عند عرضه بشكل منفصل. تكمن المشكلة في أنه في كثير من الحالات، يمكن تصميم refresh() نفسه على أنه async، ثم تقرر الطبقة العليا متى يتم استدعاؤه.
بمجرد أن يصبح التفكير الافتراضي “لا يمكن await، ثم افتح Task”، ستفقد السيطرة على حدود المهمة.
2. بالفعل في الدالة async، نحتاج إلى إضافة Task أخرى
func loadPage() async {
Task {
await loadUser()
}
Task {
await loadFeed()
}
}
المشكلة في هذا النوع من التعليمات البرمجية هي أنه يكسر تدفق التحكم الذي ينتمي في الأصل إلى الوظيفة.
سوف تواجه العديد من المشاكل على الفور:
- من يضمن الترتيب النهائي لهاتين المهمتين.
- كيفية التعامل مع الفشل بشكل موحد.
- كيف يعرف المتصل أن
loadPage()قد اكتمل بالفعل. - في حالة إلغاء المهمة الخارجية هل ستتوقف المهمتان الفرعيتان معًا؟
إذا كانت النية هي التنفيذ بالتوازي، فمن الواضح عادةً كتابتها كـ async let أو مجموعة مهام، بدلاً من إنشاء اثنين إضافيين غير شفافين Task.
3. عندما أواجه منافسة على الحالة، أريد “تحويل الذروة” عن طريق فتح المزيد من Task
سيتم كتابة بعض التعليمات البرمجية مثل هذا:
Task {
self.loading = true
}
Task {
let data = await service.fetch()
self.items = data
}
Task {
self.loading = false
}
ظاهريًا، يبدو الأمر وكأنه تفكيك للأشياء، لكنه في الواقع يترك اتساق الحالة للحظ مباشرةً.
لا أعرف إذا كانت المهمة الثالثة ستكتمل قبل المهمة الثانية، ولا أعرف في أي حالة ستتوقف الواجهة عند حدوث الإلغاء.
عادةً ما يكون السبب الجذري لهذا النوع من المشكلات هو انقطاع تدفق الحالة الذي يجب توصيله.
4. للحكم على ما إذا كان يجب عليك فتح Task، اطرح هذه الأسئلة الأربعة أولاً
هذه هي المجموعة الأكثر فائدة من قوائم المراجعة الهندسية. أكثر فائدة بكثير من حفظ القواعد.
1. من أنشأ هذه المهمة؟
هل يتم إنشاؤها بنقرة زر؟ تم إنشاؤها عندما تظهر الصفحة؟ تم إنشاؤها أثناء تهيئة ViewModel؟ أم أنه تم إنشاؤه سرًا بواسطة طبقة الخدمة؟
إذا كانت الإجابة على سؤال “من أنشأه” غير واضحة، فسيكون من المستحيل تقريبًا معرفة “من يجب أن يلغيه” لاحقًا.
2. من يملك هذه المهمة؟
إذا كانت المهمة هي مجرد “أطلق وانسى”، فهذا يعني عادةً أنه لا أحد يديرها فعليًا.
لكن العديد من الشركات ليست مناسبة لسياسة “أطلق وانسى”.
على سبيل المثال، البحث والترحيل والحفظ والتحميل والاستقصاء، يجب أن يتم تنفيذ هذه المهام بشكل صريح بواسطة كائن:
final class SearchViewModel: ObservableObject {
private var searchTask: Task<Void, Never>?
func updateKeyword(_ keyword: String) {
searchTask?.cancel()
searchTask = Task {
await search(keyword)
}
}
}
ما هو ذو قيمة حقًا هنا هو:
- يوجد مدخل واحد فقط للمهام المشابهة
- عند ظهور مهام جديدة، سيتم إلغاء المهام القديمة
- المهمة تخص
SearchViewModel
Task التي لا “تمتلكها” عادةً ما تصبح مهمة شبحية لاحقًا.
3. إذا غادر المستخدم الصفحة، فهل يجب أن يستمر تشغيلها؟
تعتبر هذه المشكلة ذات أهمية خاصة لأنها تحدد بشكل مباشر المكان الذي يجب أن تكون فيه دورة حياة المهمة مرتبطة.
على سبيل المثال:
- الصفحة الموجودة أعلى طلب الطي: عادةً لا يضطر المستخدمون إلى المتابعة بعد مغادرة الصفحة
- تقديم الطلب: قد يلزم الاستمرار في إكماله حتى لو تم إغلاق الصفحة
- الجلب المسبق للصورة: يمكن أن تكون الأولوية منخفضة جدًا، ويجب إلغاؤها عند مغادرة الصفحة
أنواع مختلفة من المهام لها تصميمات مختلفة تمامًا.
بدون الإجابة على هذا السؤال أولاً، من السهل كتابة جميع المهام على النحو التالي:
Task {
await doSomething()
}
ظاهريًا، هم موحدون، لكن في الواقع الدلالات متشابكة تمامًا.
4. من سيستفيد من نتائج هذه المهمة؟
ستتم إعادة كتابة نتائج بعض المهام إلى واجهة المستخدم، وستحتاج نتائج بعض المهام إلى تحديث ذاكرة التخزين المؤقت، وسيتم الإبلاغ عن بعض المهام فقط.
عندما لا يكون للنتائج وجهة واضحة، عادة ما يظهر نوعان من الروائح الكريهة:
- تم فتح المهمة ولكن لم يجيب أحد على الخطأ
- اكتملت المهمة لكن لم يستخدمها أحد
لذلك أنا لست من أشد المعجبين بفكرة “أطلق وانسى” الجامحة. معظم مهام العمل لا تقتصر على “مجرد إرسالها”.
5. عندما تكون في عالم async، أعط الأولوية للتزامن المنظم بدلاً من إنشاء Task إضافي
هذه نقطة فشلت العديد من المقالات في معالجتها بصدق.
بالفعل في وظيفة async ، فهذا يعني أن لديك بالفعل تدفق تحكم غير متزامن. غالبًا ما تكون كتابة Task إضافية في هذا الوقت بمثابة تجاوز للقيود التي يفرضها التزامن المنظم.
انظر إلى المقارنتين.
ميل الخطأ: استخدم عدة عمليات هدم صلبة Task بالتوازي
func loadDashboard() async {
let userTask = Task { await api.loadUser() }
let statsTask = Task { await api.loadStats() }
let noticesTask = Task { await api.loadNotices() }
let user = await userTask.value
let stats = await statsTask.value
let notices = await noticesTask.value
self.state = .loaded(user, stats, notices)
}
هذا الرمز ليس خاطئًا تمامًا، لكنه ليس واضحًا بدرجة كافية. لأن ما يراه المتصل هو “لقد قمت بإنشاء ثلاث مهام بشكل نشط” بدلاً من “هناك ثلاث تبعيات متوازية هنا”.
تعبير أفضل: async let
func loadDashboard() async throws {
async let user = api.loadUser()
async let stats = api.loadStats()
async let notices = api.loadNotices()
self.state = try .loaded(user: user, stats: stats, notices: notices)
}
وميزة هذا النوع من الكتابة هي أن الدلالات أكثر وضوحا:
- هذه الوظائف تنتمي إلى الوظيفة الحالية
- إنهم مقيدون بنفس بنية المكالمة الحالية
- الوظيفة الحالية سوف تنتظر النتيجة قبل أن تنتهي
- عند إلغاء الطبقة الخارجية، يتم إلغاء التزامن الداخلي أيضًا.
بمعنى آخر، الفرق بين Task والتزامن المنظم يكمن في من المسؤول عن دورة الحياة.
6. الكارثة الأكثر شيوعًا على مستوى الصفحة: فتح Task واحد لكل مدخل
إذا أخذنا صفحة قائمة حقيقية كمثال، فعادةً ما تكون هناك نقاط التشغيل هذه:
- تحميل صفحة الدخول الأولى
- اسحب للأسفل للتحديث
- البحث عن تغييرات الكلمات الرئيسية
- تبديل المرشحات
- انقر فوق “إعادة المحاولة”
- انتقل إلى الصفحة التالية لتحميل المزيد تلقائيًا
إذا تم كتابة كل إدخال بمفرده:
Task {
await load()
}
بعد تكرار واحد أو اثنين، من المرجح أن تحتوي الصفحة على هذه الظواهر:
- طلبات متعددة تطير في نفس الوقت
- النتائج القديمة تحل محل النتائج الجديدة
- من الواضح أن أحدث كلمة رئيسية هي
swift، لكن الواجهة تعرض نتائجswi - بعد خروج المستخدم من الصفحة، يستمر رد الاتصال في الكتابة
loading،isRefreshing،errorيتقاتلون مع بعضهم البعض
من المواقف الشائعة في هذه المرحلة أن تعتقد خطأً أن ما تواجهه هو “التزامن المعقد”.
في الواقع، المشكلة أكثر تحديدًا: **مدخل المهمة مبعثر جدًا، ولا يوجد إغلاق موحد لتغييرات الحالة. **
عادة ما يكون النهج الأكثر استقرارًا هو تركيز “المهام التي سيتم فتحها” في كائن الحالة، بدلاً من السماح لطبقة العرض بإنشاء مهام جديدة في كل مكان.
على سبيل المثال:
@MainActor
final class ArticlesViewModel: ObservableObject {
@Published private(set) var state: ViewState = .idle
private var reloadTask: Task<Void, Never>?
func reload() {
reloadTask?.cancel()
reloadTask = Task {
state = .loading
do {
let articles = try await repository.fetchArticles()
guard !Task.isCancelled else { return }
state = .loaded(articles)
} catch is CancellationError {
// 忽略取消
} catch {
state = .failed(error)
}
}
}
}
هناك ثلاث مشاكل يحلها هذا الرمز حقًا:
- يوجد مدخل خاص للمهام المشابهة
- علاقة الاستبدال بين المهام المتشابهة واضحة -إعادة كتابة الحالة في مكان واحد
لا يزال Task مستخدمًا هنا، ولكنه بالفعل “مدخل متحكم فيه لإدارة المهام”.
7. متى يكون من المناسب الاحتفاظ بمرجع Task ومتى لا يكون ذلك ضروريًا؟
هذه أيضًا إشارة للحكم على مدى نضج الكود.
مناسب للسيناريوهات التي يتم فيها الاحتفاظ بالمراجع
- بحث المدخلات المضادة للاهتزاز
- مهمة تحديث الصفحة
- الطلبات التي يمكن تشغيلها بشكل متكرر ويجب أن تحل المهام الجديدة محل المهام القديمة
- عمليات الاقتراع والاستماع والمزامنة طويلة الأمد
لأن هذه السيناريوهات تتضمن بطبيعة الحال الإلغاء أو الاستبدال.
ليس من الضروري الاحتفاظ بالمشهد المشار إليه
- المهام القصيرة التي يقوم بها المستخدمون مرة واحدة فقط بعد النقر مرة واحدة
- من الواضح أنها نقطة الدفن، وتنظيف السجل وذاكرة التخزين المؤقت لميزة “الحرق والنسيان”.
- المهام التي تم إدارة دورة حياتها من خلال الإطار الخارجي للفريق
لا ينصب التركيز على “ما إذا كان يجب أن تكون أكثر تقدمًا أم لا”، ولكن على ما إذا كانت المهمة تتم إدارتها بشكل صحيح.
إذا تم إلغاء مهمة ما، أو استبدالها، أو قد تؤثر على رؤية المستخدم، فمن المرجح ألا يتم تشغيلها كألعاب نارية مجهولة المصدر.
8. Task.detached هو بيان عزل أقوى
على الرغم من أن هذه المقالة تتحدث بشكل أساسي عن Task، إلا أن العديد من الفرق ستستخدم Task.detached قريبًا بعد فتح Task بشكل عشوائي.
إليك تذكير سريع:
- سوف يرث
Task {}جزءًا من السياق الحالي Task.detached {}يشبه إلى حد كبير "منفصل عن السياق الحالي ويعمل بشكل مستقل"لذلك، إذا لم يتم تسوية إسناد وإلغاءTaskالعادي، فلا ينبغي استخدامdetachedلتوسيع درجة الحرية.
ينتهي الأمر بالعديد من Task.detached إلى الهروب من المسؤولية.
9. معيار الحكم العملي: هل تقوم بإنشاء مهام أم تهرب من النمذجة؟
هذا هو السؤال الأكثر شيوعًا في مراجعاتي.
عندما تكون جاهزًا للكتابة:
Task {
...
}
توقف لثانيتين واسأل نفسك:
- هل أقوم بإنشاء مهمة ذات دورة حياة واضحة؟
- أم أن تغييره إلى
asyncمزعج للغاية، لذا يتم تغطيته بطبقة مؤقتًا؟ - هل أعرف متى تنتهي ومن يلغيها ولمن تنتهي؟
إذا لم تتمكن من الإجابة على هذه الأسئلة، ففي معظم الحالات لم يتم تقويم المستوى الحالي للتجريد.
10. الخلاصة: Task يستحق الاستخدام المتكرر، لكنه لا يستحق الاستخدام العرضي.
من المؤكد أن Task مهم في Swift Concurrency وغالبًا ما يتم استخدامه بشكل متكرر.
لكن قيمته الصحيحة هي:
- أدخل بأمان العملية غير المتزامنة عند المدخل المتزامن
- إنشاء المهام بشكل صريح عندما تكون هناك حاجة إلى دورة حياة مستقلة
- توفير حدود واضحة للتزامن عند الحاجة إلى الإلغاء والاستبدال والعزل
لذلك أفضل أن أفهم الأمر بهذه الطريقة:
Taskهو إعلان صريح لدورة حياة المهمة.
عند استخدامه كبيان، يصبح الكود أكثر وضوحًا. عند استخدامه كتصحيح، عاجلاً أم آجلاً سيصبح الكود “يمكن تشغيل كل طبقة، لكن لا أحد يستطيع معرفة سبب تشغيله بهذه الطريقة”.
What to read next
Want more posts about Swift Concurrency?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to keep following #Swift Concurrency?
Tags are useful for related tools, specific problems, and similar troubleshooting notes.
View same tagWant to explore another direction?
If you are not sure what to read next, return to the homepage and start from categories, topics, or latest updates.
Back home