في أي تطبيق يتكون من أكثر من شاشة واحدة ، هناك حاجة لتطبيق التنقل بين مكوناته. يبدو أن هذا لا ينبغي أن يكون مشكلة ، لأنه في UIKit توجد مكونات حاوية ملائمة تمامًا مثل UINavigationController و UITabBarController ، فضلاً عن طرق عرض الشاشة المشروطة المرنة: استخدم فقط التنقل المناسب في الوقت المناسب.

ومع ذلك ، بمجرد أن ينتقل التطبيق إلى شاشة باستخدام إشعار دفع أو رابط ، يصبح كل شيء أكثر تعقيدًا بعض الشيء. هناك على الفور الكثير من الأسئلة:
- ما يجب القيام به مع وحدة تحكم العرض ، والذي هو الآن على الشاشة؟
- كيفية تبديل السياق (مثل علامة التبويب النشطة في UITabBarController)؟
- هل يوجد مكدس التنقل الحالي على الشاشة الصحيحة؟
- متى يجب تجاهل الملاحة؟

في تطوير iOS ، واجهنا في Badoo كل هذه المشكلات. نتيجةً لذلك ، قمنا بإضفاء الطابع الرسمي على أساليب الحلول الخاصة بنا في مكتبة تضم مكونات للتنقل ، والتي نستخدمها في جميع المنتجات الجديدة. في هذه المقالة سأتحدث عن نهجنا بمزيد من التفصيل. مثال على تطبيق الممارسات الموصوفة يمكن رؤيته في
مشروع تجريبي صغير.
مشكلتنا
غالبًا ما يتم حل مشكلات التنقل عن طريق إضافة مكون عمومي يعرف بنية الشاشات في التطبيق ويقرر ما يجب القيام به في حالة معينة. بنية الشاشات تعني معلومات حول وجود الحاوية في التسلسل الهرمي الحالي لوحدات التحكم وأقسام التطبيق.
تمت زيارتها Badoo عنصر مماثل. لقد عملت بطريقة مماثلة مع المكتبة القديمة من Facebook ، والتي لم تعد موجودة الآن في مستودعها العام. كان التنقل يعتمد على عناوين URL المرتبطة بشاشات التطبيق. بشكل أساسي ، تم تضمين كل المنطق في فئة واحدة ، والتي كانت مرتبطة بوجود شريط علامات تبويب وبعض الوظائف الأخرى الخاصة بـ Badoo. كان التعقيد والاتصال في هذا المكون مرتفعًا جدًا لدرجة أن حل المهام التي تتطلب تغييرًا في منطق التنقل قد يستغرق عدة مرات وقتًا أطول من المخطط له. أثار اختبار هذه الفئة أيضًا أسئلة كبيرة.
تم إنشاء هذا المكون عندما كان لدينا تطبيق واحد فقط. لم نكن نتخيل أننا في المستقبل سنطور العديد من المنتجات التي تختلف تمامًا عن بعضها البعض (
Bumble ،
Lumen وغيرها). لهذا السبب ، كان من المستحيل استخدام المستكشف من تطبيقنا الأكثر نضجًا - Badoo - في المنتجات الأخرى وكان على كل فريق التوصل إلى شيء جديد.
لسوء الحظ ، تم أيضًا تطوير طرق جديدة لتطبيقات محددة. مع تزايد عدد المشاريع ، أصبحت المشكلة واضحة وبدأت الفكرة لإنشاء مكتبة توفر مجموعة معينة من المكونات ، بما في ذلك منطق التنقل العالمي. هذا من شأنه أن يساعد في تقليل وقت تنفيذ وظائف مماثلة في المنتجات الجديدة.
نحن ننفذ جهاز توجيه عالمي
المهام الرئيسية التي حلها المستكشف العالمي ليست كثيرة:
- ابحث عن الشاشة النشطة الحالية.
- قارن بطريقة أو بأخرى نوع الشاشة النشطة ومحتوياتها بما يلزم عرضه.
- تنفيذ الانتقال حسب الضرورة (تسلسل التحولات).
ربما تبدو صياغة المهام مجردة بعض الشيء ، ولكن هذا التجريد هو الذي يجعل من الممكن تعميم المنطق.
1. نشط البحث الشاشة
تبدو المهمة الأولى بسيطة للغاية: عليك فقط الاطلاع على التسلسل الهرمي الكامل للشاشات والعثور على
UIViewController الأعلى.

قد تبدو واجهة كائننا كما يلي:
protocol TopViewControllerProvider { var topViewController: UIViewController? { get } }
ومع ذلك ، ليس من الواضح كيفية تحديد عنصر الجذر للتسلسل الهرمي وما يجب القيام به مع شاشات الحاويات مثل UIPageViewController والحاويات الخاصة بالتطبيق.
أسهل خيار لتحديد عنصر الجذر هو أخذ وحدة تحكم الجذر من الشاشة النشطة:
UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController
قد لا يعمل هذا النهج دائمًا مع التطبيقات التي توجد بها نوافذ متعددة. ولكن هذه حالة نادرة إلى حد ما ، ويمكن حل المشكلة عن طريق تمرير الإطار المطلوب بوضوح كمعلمة.
يمكن حل مشكلة شاشات الحاوية عن طريق إنشاء بروتوكول خاص بها ، والذي سيحتوي على طريقة للحصول على شاشة نشطة ، أو يمكنك استخدام البروتوكول المعلن أعلاه. يجب على جميع وحدات تحكم الحاوية المستخدمة في التطبيق تطبيق هذا البروتوكول. على سبيل المثال ، بالنسبة
لتطبيق UITabBarController ، قد يبدو تنفيذ مثل هذا:
extension UITabBarController: TopViewControllerProvider { var topViewController: UIViewController? { return self.selectedViewController } }
يبقى فقط للذهاب من خلال التسلسل الهرمي بأكمله والحصول على الشاشة العليا. إذا نفذت وحدة التحكم التالية TopViewControllerProvider ، فسنحصل على الشاشة المعروضة عليها من خلال الطريقة المعلنة. خلاف ذلك ، سيتم التحقق من وحدة تحكم المعروضة عليه مشروط (إن وجد).
2. السياق الحالي
تبدو مهمة تحديد السياق الحالي أكثر تعقيدًا. نريد تحديد نوع الشاشة ، وربما ، المعلومات التي تظهر عليها. يبدو من المنطقي إنشاء هيكل يحتوي على هذه المعلومات.
ولكن ما هي الأنواع التي يجب أن تحتوي على خصائص الكائن؟ هدفنا النهائي هو مقارنة السياق بما يجب عرضه ، لذلك يجب عليهم تنفيذ بروتوكول
Equatable . يمكن تنفيذ ذلك من خلال الأنواع العامة:
struct ViewControllerContext<ScreenType: Equatable, InfoType: Equatable>: Equatable { let screenType: ScreenType let info: InfoType? }
ومع ذلك ، نظرًا لخصائص Swift ، يفرض هذا قيودًا معينة على استخدام هذا النوع. لتجنب المشكلات ، تتميز هذه البنية في طلباتنا بمظهر مختلف قليلاً:
protocol ViewControllerContextInfo { func isEqual(to info: ViewControllerContextInfo?) -> Bool } struct ViewControllerContext: Equatable { public let screenType: String public let info: ViewControllerContextInfo? }
خيار آخر هو الاستفادة من ميزة Swift الجديدة ،
أنواع Opaque ، ولكنها متوفرة فقط بدءًا من iOS 13 ، والتي لا تزال غير مقبولة بالنسبة للعديد من المنتجات.
تنفيذ مقارنة السياق واضح جدا. من أجل عدم كتابة الدالة isEqual للأنواع التي تطبق بالفعل Equatable ، يمكنك القيام بخدعة بسيطة ، هذه المرة باستخدام مزايا Swift:
extension ViewControllerContextInfo where Self: Equatable { func isEqual(to info: ViewControllerContextInfo?) -> Bool { guard let info = info as? Self else { return false } return self == info } }
عظيم ، لدينا كائن للمقارنة. ولكن كيف يمكنك ربطه مع
UIViewController ؟ تتمثل إحدى الطرق في استخدام
الكائنات المرتبطة ، وهي وظيفة مفيدة للغة الهدف جيم في بعض الحالات ، لكن أولاً ، ليست واضحة للغاية ، وثانياً ، نريد عادة مقارنة سياق بعض شاشات التطبيقات فقط. لذلك ، فإن إنشاء بروتوكول يبدو أفكارًا جيدة:
protocol ViewControllerContextHolder { var currentContext: ViewControllerContext? { get } }
وتنفيذه فقط في الشاشات اللازمة. إذا لم تقم الشاشة النشطة بتطبيق هذا البروتوكول ، فيمكن اعتبار محتوياته غير مهمة ولا تؤخذ في الاعتبار عند عرض برنامج جديد.
3. تنفيذ الانتقال
دعونا نرى ما لدينا بالفعل. القدرة في أي وقت للحصول على معلومات حول الشاشة النشطة في شكل بنية بيانات محددة. المعلومات التي يتم تلقيها خارجيًا من خلال عنوان URL مفتوح أو إشعار بالدفع أو طريقة أخرى لبدء التنقل ، والتي يمكن تحويلها إلى بنية من نفس النوع وتكون بمثابة نية تنقل. إذا كانت الشاشة العليا تعرض المعلومات الضرورية بالفعل ، فيمكنك ببساطة تجاهل التنقل أو تحديث محتويات الشاشة.

ولكن ماذا عن التحول نفسه؟
من المنطقي تكوين مكون (دعنا نسميه
جهاز توجيه ) يأخذ ما تحتاج إلى إظهاره عند الإدخال ، ومقارنته بما تم عرضه بالفعل ، وتنفيذ عملية انتقال أو تسلسل انتقالات. أيضًا ، قد يحتوي جهاز التوجيه على منطق عام لمعالجة المعلومات وحالة التطبيق والتحقق من صحتها. الشيء الرئيسي هو أنه يجب عليك عدم تضمين المنطق المحدد لمجال أو وظيفة التطبيق في هذا المكون. إذا كنت تلتزم بهذه القاعدة ، فستظل قابلة لإعادة الاستخدام للتطبيقات المختلفة وسهل الصيانة.
يبدو إعلان الواجهة الأساسي لهذا البروتوكول كما يلي:
protocol ViewControllerContextRouterProtocol { func navigateToContext(_ context: ViewControllerContext, animated: Bool) }
يمكنك تعميم الوظيفة أعلاه بتمرير سلسلة من السياقات. لن يكون لهذا تأثير كبير على التنفيذ.
من الواضح تمامًا أن جهاز التوجيه سيحتاج إلى مصنع تحكم ، لأنه يتم استلام بيانات التنقل فقط عند إدخالها. من الضروري إنشاء شاشات منفصلة داخل المصنع ، وربما حتى الوحدات بأكملها بناءً على السياق المنقول. من حقل
screenType ،
يمكنك تحديد الشاشة التي ترغب في إنشائها ، من حقل
المعلومات - مع البيانات التي تحتاج إلى تعبئتها مسبقًا:
protocol ViewControllersByContextFactory { func viewController(for context: ViewControllerContext) -> UIViewController? }
إذا لم يكن التطبيق عبارة عن نسخة Snapchat ، فمن المرجح أن يكون عدد الطرق المستخدمة لعرض وحدة التحكم الجديدة ضئيلًا. لذلك ، بالنسبة لمعظم التطبيقات ،
يكفي تحديث مكدس
UINavigationController وعرض شاشة مشروط. في هذه الحالة ، يمكنك تحديد التعداد مع الأنواع الممكنة ، على سبيل المثال:
enum NavigationType { case modal case navigationStack case rootScreen }
يعتمد نوع الشاشة على كيفية عرضها. إذا كان هذا إخطارًا بالحظر ، فيجب إظهاره بشكل مشروط. قد تحتاج إلى إضافة شاشة أخرى إلى مكدس تنقل موجود عبر
UINavigationController .
من الأفضل عدم تحديد كيفية عرض شاشة معينة في جهاز التوجيه نفسه. إذا أضفنا اعتماد جهاز التوجيه ضمن بروتوكول
ViewControllerNavigationTypeProvider وقمنا بتطبيق مجموعة الطرق المطلوبة لكل تطبيق ، فسوف نحقق هذا الهدف:
protocol ViewControllerNavigationTypeProvider { func navigationType(for context: ViewControllerContext) -> NavigationType }
ولكن ماذا لو أردنا تقديم نوع جديد من التنقل في أحد التطبيقات؟ تحتاج إلى إضافة خيار جديد للتعداد ، وجميع التطبيقات الأخرى سوف تعرف عن ذلك؟ ربما ، في بعض الحالات ، هذا هو ما نهدف إليه تمامًا ، ولكن إذا التزمنا
بالمبدأ المفتوح ، فيمكننا عندئذٍ تقديم قدر أكبر من المرونة لبروتوكول كائن يمكنه إجراء انتقالات:
protocol ViewControllerContextTransition { func navigate(from source: UIViewController?, to destination: UIViewController, animated: Bool) }
ثم
سيتحول ViewControllerNavigationTypeProvider إلى هذا:
protocol ViewControllerContextTransitionProvider { func transition(for context: ViewControllerContext) -> ViewControllerContextTransition }
الآن نحن لا نقتصر على مجموعة ثابتة من أنواع شاشات العرض ويمكننا توسيع إمكانات التنقل دون تغييرات في جهاز التوجيه نفسه.
في بعض الأحيان لا تحتاج إلى إنشاء
UIViewController جديد للتبديل إلى بعض الشاشة - فقط قم بالتبديل إلى شاشة موجودة. المثال الأكثر وضوحًا هو تبديل علامات التبويب في
UITabBarController . مثال آخر هو الانتقال إلى عنصر موجود في مجموعة وحدات التحكم المعروضة بدلاً من إنشاء شاشة جديدة بنفس المحتوى. للقيام بذلك ، في جهاز التوجيه ، قبل إنشاء
UIViewController جديد
، يمكنك أولاً التحقق مما إذا كان يمكن تبديل السياق ببساطة.
كيفية حل هذه المشكلة؟ المزيد من التجريدات!
protocol ViewControllerContextSwitcher { func canSwitch(to context: ViewControllerContext) -> Bool func switchContext(to context: ViewControllerContext, animated: Bool) }
في حالة علامات التبويب ، يمكن تنفيذ هذا البروتوكول بواسطة مكون يعرف ما هو موجود في
UITabBarViewController ، وهو قادر على تعيين
ViewControllerContext إلى علامة تبويب معينة وتبديل علامات التبويب.

يمكن تمرير مجموعة من هذه الكائنات إلى جهاز التوجيه كتبعية.
لتلخيص ، ستبدو خوارزمية معالجة السياق كما يلي:
func navigateToContext(_ context: ViewControllerContext, animated: Bool) { let topViewController = self.topViewControllerProvider.topViewController if let contextHolder = topViewController as? ViewControllerContextHolder, contextHolder.currentContext == context { return } if let switcher = self.contextSwitchers.first(where: { $0.canSwitch(to: context) }) { switcher.switchContext(to: context, animated: animated) return } guard let viewController = self.viewControllersFactory.viewController(for: context) else { return } let navigation = self.transitionProvider.navigation(for: context) navigation.navigate(from: self.topViewControllerProvider.topViewController, to: viewController, animated: true) }
من المناسب تقديم مخطط تبعية جهاز التوجيه في شكل مخطط UML:

يمكن استخدام جهاز التوجيه الناتج للتحولات التي تبدأ تلقائيًا أو من خلال إجراءات المستخدم. في منتجاتنا ، إذا لم يحدث التنقل تلقائيًا ، فسيتم استخدام وظائف النظام القياسية ، ولا تدرك معظم الوحدات وجود جهاز توجيه عالمي. من المهم فقط أن تتذكر عن تنفيذ بروتوكول
ViewControllerContextHolder حيث يكون ضروريًا حتى يتمكن الموجه دائمًا من معرفة المعلومات التي يراها المستخدم في الوقت الحالي.
مزايا وعيوب
في الآونة الأخيرة ، بدأنا في تقديم طريقة إدارة التنقل الموصوفة في منتجات Badoo. على الرغم من حقيقة أن التنفيذ تبين أنه أكثر تعقيدًا إلى حد ما من الخيار المعروض في
المشروع التجريبي ، إلا أننا سعداء بالنتائج. دعونا تقييم مزايا وعيوب النهج الموصوف.
من الفوائد ما يلي:
- براعة،
- سهولة التنفيذ النسبية ، عند مقارنتها بالخيارات المعروضة في قسم البدائل ،
- عدم وجود قيود على بنية التطبيق وتنفيذ الملاحة التقليدية بين الشاشات.
العيوب هي جزئيا نتيجة للمزايا.
- يحتاج المتحكمون إلى معرفة المعلومات التي يعرضونها. إذا أخذنا في الاعتبار بنية التطبيق ، فيجب تعيين UIViewController لطبقة العرض ، ويجب ألا يتم تخزين منطق العمل في هذه الطبقة. يجب تنفيذ بنية البيانات التي تحتوي على سياق التنقل هناك من طبقة منطق الأعمال ، ولكن مع ذلك ، ستخزن وحدات التحكم هذه المعلومات ، وهذا غير صحيح.
- مصدر الحقيقة حول حالة التطبيق هو التسلسل الهرمي للشاشات المعروضة ، والتي قد تكون في بعض الحالات قيدًا.
البدائل
يمكن أن يكون البديل لهذا النهج هو بناء تسلسل هرمي للوحدات النشطة يدويًا. مثال على هذا الحل هو
تنفيذ نمط المنسق ، حيث يشكل المنسقون بنية شجرة تعمل كمصدر للحقيقة لتحديد الشاشة النشطة ، ومنطق قرار إظهار هذه الشاشة أو تلك أو عدم وجودها في المنسقين أنفسهم.
يمكن العثور على أفكار مماثلة في بنية
RIBs ، والتي
يستخدمها فريق Android الخاص بنا.
توفر هذه البدائل تجريدًا أكثر مرونة ، ولكنها تتطلب التوحيد في الهندسة المعمارية ويمكن أن تكون مرهقة للغاية بالنسبة للعديد من التطبيقات.
إذا اتبعت طريقة مختلفة لحل مثل هذه المشكلات ، فلا تتردد في التحدث عنها في التعليقات.