التنقل في تطبيق Android باستخدام المنسقين

على مدى السنوات القليلة الماضية ، قمنا بتطوير مناهج مشتركة لإنشاء تطبيقات Android. العمارة النقية ، الأنماط المعمارية (MVC ، MVP ، MVVM ، MVI) ، ونمط المستودع ، وغيرها. ومع ذلك ، لا تزال هناك طرق مقبولة عمومًا لتنظيم التنقل داخل التطبيق. اليوم أود أن أتحدث معك عن قالب "المنسق" وإمكانيات تطبيقه في تطوير تطبيقات Android.
غالبًا ما يتم استخدام نمط المنسق في تطبيقات iOS وقد تم تقديمه بواسطة Soroush Khanlou من أجل تبسيط التنقل في التطبيق. يُعتقد أن عمل Sorush يعتمد على نهج وحدة التحكم في التطبيق الموصوف في أنماط هندسة تطبيقات المؤسسات من قبل مارتن فاولر.
تم تصميم قالب "المنسق" لحل المهام التالية:

  • تكافح مع مشكلة Massive View Controller (تم كتابة المشكلة بالفعل على المحور - ملاحظة المترجم) ، والتي غالبًا ما تتجلى مع ظهور نشاط الله (نشاط مع الكثير من المسؤوليات).
  • فصل منطق الملاحة في كيان منفصل
  • إعادة استخدام شاشات التطبيق (النشاط / الأجزاء) بسبب ضعف الاتصال بمنطق التنقل

ولكن ، قبل أن تبدأ في التعرف على القالب ومحاولة تنفيذه ، دعنا نلقي نظرة على تطبيقات التنقل المستخدمة في تطبيقات Android.

يتم وصف منطق التنقل في النشاط / الجزء


نظرًا لأن Android SDK يتطلب سياقًا لفتح نشاط جديد (أو FragmentManager من أجل إضافة جزء إلى النشاط) ، غالبًا ما يتم وصف منطق التنقل مباشرة في النشاط / الجزء. حتى الأمثلة الواردة في وثائق Android SDK تستخدم هذا الأسلوب.

class ShoppingCartActivity : Activity() { override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { val intent = Intent(this, CheckoutActivity::class.java) startActivity(intent) } } } 

في المثال أعلاه ، يرتبط التنقل ارتباطًا وثيقًا بالنشاط. هل من المناسب اختبار مثل هذا الرمز؟ يمكن للمرء أن يجادل بأنه يمكننا فصل التنقل إلى كيان منفصل وتسميته ، على سبيل المثال ، Navigator ، والذي يمكن تنفيذه. دعونا نرى:

 class ShoppingCartActivity : Activity() { @Inject lateinit var navigator : Navigator override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { navigator.showCheckout(this) } } } class Navigator { fun showCheckout(activity : Activity){ val intent = Intent(activity, CheckoutActivity::class.java) activity.startActivity(intent) } } 

اتضح أنه ليس سيئًا ، لكني أريد المزيد.

الملاحة مع MVVM / MVP


سأبدأ بالسؤال: أين تضع منطق التنقل عند استخدام MVVM / MVP؟

في الطبقة تحت مقدم (دعنا نسميها منطق الأعمال)؟ ليست فكرة جيدة ، لأنك على الأرجح ستعيد استخدام منطق عملك في نماذج أو عروض تقديمية أخرى.

في طبقة العرض؟ هل تريد بالتأكيد رمي الأحداث بين العرض التقديمي ونموذج العرض / العرض التقديمي؟ دعونا نلقي نظرة على مثال:

 class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var presenter : ShoppingCartPresenter override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { presenter.checkoutClicked() } } override fun navigateToCheckout(){ navigator.showCheckout(this) } } class ShoppingCartPresenter : Presenter<ShoppingCartView> { ... override fun checkoutClicked(){ view?.navigateToCheckout(this) } } 

أو إذا كنت تفضل MVVM ، يمكنك استخدام SingleLiveEvents أو EventObserver

 class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var viewModel : ViewModel override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { viewModel.checkoutClicked() } viewModel.navigateToCheckout.observe(this, Observer { navigator.showCheckout(this) }) } } class ShoppingCartViewModel : ViewModel() { val navigateToCheckout = MutableLiveData<Event<Unit>> fun checkoutClicked(){ navigateToCheckout.value = Event(Unit) // Trigger the event by setting a new Event as a new value } } 

أو دعنا نضع المتصفح في نموذج عرض بدلاً من استخدام EventObserver كما هو موضح في المثال السابق

 class ShoppingCartViewModel @Inject constructor(val navigator : Navigator) : ViewModel() { fun checkoutClicked(){ navigator.showCheckout() } } 

يرجى ملاحظة أنه يمكن تطبيق هذا النهج على مقدم العرض. نتجاهل أيضًا تسربًا محتملاً للذاكرة في المتصفح إذا كان يحتفظ برابط إلى المنشط.

منسق


إذن ، أين نضع منطق الملاحة؟ منطق الأعمال؟ في وقت سابق ، درسنا بالفعل هذا الخيار وتوصلنا إلى استنتاج مفاده أن هذا ليس الحل الأفضل. قد يعمل رمي الأحداث بين العرض ونموذج العرض ، لكنه لا يبدو كحل أنيق. علاوة على ذلك ، لا يزال العرض مسؤولًا عن منطق التنقل ، على الرغم من إحضاره إلى المستكشف. باتباع طريقة الاستبعاد ، لا يزال لدينا خيار وضع منطق التنقل في نموذج العرض التقديمي ، ويبدو هذا الخيار واعدًا. ولكن هل يجب أن يهتم نموذج العرض بالملاحة؟ أليست هذه مجرد طبقة بين العرض والنموذج؟ لهذا السبب توصلنا إلى فكرة المنسق.

"لماذا نحتاج إلى مستوى آخر من التجريد؟" - تسأل. هل يستحق تعقيد النظام؟ في المشاريع الصغيرة ، يمكن أن يتحول التجريد حقًا من أجل التجريد ، ومع ذلك ، في التطبيقات المعقدة أو في حالة استخدام اختبارات A / B ، قد يكون المنسق مفيدًا. افترض أنه يمكن للمستخدم إنشاء حساب وتسجيل الدخول. لدينا بالفعل بعض المنطق ، حيث يجب علينا التحقق مما إذا كان المستخدم قد قام بتسجيل الدخول وإظهار إما شاشة تسجيل الدخول أو الشاشة الرئيسية للتطبيق. يمكن للمنسق المساعدة في المثال المعطى. لاحظ أن المنسق لا يساعد في كتابة كود أقل ؛ إنه يساعد على الحصول على رمز منطق التنقل من طريقة العرض أو نموذج العرض.

فكرة المنسق بسيطة للغاية. إنه يعرف فقط شاشة التطبيق التي سيتم فتحها بعد ذلك. على سبيل المثال ، عندما ينقر مستخدم على زر الدفع لطلب ، يتلقى المنسق الحدث المقابل ويعلم أن الخطوة التالية هي فتح شاشة الدفع. في iOS ، يتم استخدام المنسق كمحدد خدمة لإنشاء ViewControllers والتحكم في المكدس الخلفي. هذا يكفي للمنسق (تذكر مبدأ المسؤولية الوحيدة). في تطبيقات Android ، يقوم النظام بإنشاء أنشطة ، ولدينا العديد من الأدوات لتنفيذ التبعيات وهناك مجموعة خلفية للأنشطة والأجزاء. الآن دعنا نعود إلى الفكرة الأصلية للمنسق: المنسق يعرف فقط الشاشة التالية.

مثال: تطبيق أخبار باستخدام منسق


دعنا نتحدث مباشرة عن القالب. تخيل أننا بحاجة إلى إنشاء تطبيق إخباري بسيط. يحتوي التطبيق على شاشتين: "قائمة المقالات" و "نص المقالة" ، والتي يتم فتحها بالنقر فوق عنصر قائمة.



 class NewsFlowCoordinator (val navigator : Navigator) { fun start(){ navigator.showNewsList() } fun readNewsArticle(id : Int){ navigator.showNewsArticle(id) } } 

يحتوي البرنامج النصي (التدفق) على شاشة واحدة أو أكثر. في مثالنا ، يتكون السيناريو الإخباري من شاشتين: "قائمة المقالات" و "نص المقالة". كان المنسق بسيط للغاية. عندما يبدأ التطبيق ، نسمي NewsFlowCoordinator # start () لعرض قائمة بالمقالات. عندما ينقر مستخدم على عنصر قائمة ، يتم استدعاء طريقة NewsFlowCoordinator # readNewsArticle (id) ويتم عرض شاشة تحتوي على النص الكامل للمقالة. ما زلنا نعمل مع المستكشف (سنتحدث عن هذا الأمر بعد ذلك بقليل) ، والذي نفوض فيه فتح الشاشة. ليس لدى المنسق حالة ، ولا يعتمد على تنفيذ الواجهة الخلفية وينفذ وظيفة واحدة فقط: فهو يحدد إلى أين يذهب بعد ذلك.

ولكن كيف يمكن ربط المنسق بنموذج العرض التقديمي الخاص بنا؟ سنتبع مبدأ انعكاس التبعية: سنمرر لامدا إلى نموذج العرض ، والذي سيتم استدعاؤه عندما ينقر المستخدم على المقالة.

 class NewsListViewModel( newsRepository : NewsRepository, var onNewsItemClicked: ( (Int) -> Unit )? ) : ViewModel() { val newsArticles = MutableLiveData<List<News>> private val disposable = newsRepository.getNewsArticles().subscribe { newsArticles.value = it } fun newsArticleClicked(id : Int){ onNewsItemClicked!!(id) // call the lambda } override fun onCleared() { disposable.dispose() onNewsItemClicked = null // to avoid memory leaks } } 

onNewsItemClicked: (Int) -> الوحدة هي لامدا لها وسيطة عدد صحيح وإرجاع وحدة. يرجى ملاحظة أن لامدا قد تكون فارغة ، وهذا سيسمح لنا بمسح الرابط لتجنب تسرب الذاكرة. يجب على منشئ نموذج العرض (على سبيل المثال ، خنجر) تمرير رابط إلى طريقة المنسق:

 return NewsListViewModel( newsRepository = newsRepository, onNewsItemClicked = newsFlowCoordinator::readNewsArticle ) 

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

 class Navigator{ var activity : FragmentActivity? = null fun showNewsList(){ activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsListFragment()) .commit() } fun showNewsDetails(newsId: Int) { activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsDetailFragment.newInstance(newsId)) .addToBackStack("NewsDetail") .commit() } } class MainActivity : AppCompatActivity() { @Inject lateinit var navigator : Navigator override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) navigator.activty = this } override fun onDestroy() { super.onDestroy() navigator.activty = null // Avoid memory leaks } } 

التنفيذ السابق للملاح ليس مثاليًا ، ولكن الفكرة الرئيسية لهذا المنشور هي إدخال منسق في النمط. من الجدير بالذكر أنه نظرًا لأن الملاح والمنسق ليس لهما حالة ، يمكن الإعلان عنهما داخل التطبيق (على سبيل المثال ، نطاقات Singleton في خنجر) ويمكن استنساخها في التطبيق # onCreate ().

دعنا نضيف تفويضًا لتطبيقنا. سنحدد شاشة تسجيل دخول جديدة (LoginFragment + LoginViewModel ، من أجل البساطة ، سنحذف استعادة كلمة المرور والتسجيل) و LoginFlowCoordinator. لماذا لا تضيف وظائف جديدة إلى NewsFlowCoordinator؟ لا نريد أن يكون منسق الله مسؤولاً عن كل التنقل في التطبيق؟ أيضا ، لا ينطبق نص التفويض على سيناريو قارئ الأخبار ، أليس كذلك؟

 class LoginFlowCoordinator( val navigator: Navigator ) { fun start(){ navigator.showLogin() } fun registerNewUser(){ navigator.showRegistration() } fun forgotPassword(){ navigator.showRecoverPassword() } } class LoginViewModel( val usermanager: Usermanager, var onSignUpClicked: ( () -> Unit )?, var onForgotPasswordClicked: ( () -> Unit )? ) { fun login(username : String, password : String){ usermanager.login(username, password) ... } ... } 

هنا نرى أنه لكل حدث واجهة مستخدم هناك لامدا المقابلة ، ولكن لا يوجد لامدا لاستعادة تسجيل الدخول الناجح. هذه أيضًا تفاصيل تنفيذية ويمكنك إضافة لامدا المقابلة ، ولكن لدي فكرة أفضل. دعونا نضيف RootFlowCoordinator والاشتراك في تغييرات النموذج.

 class RootFlowCoordinator( val usermanager: Usermanager, val loginFlowCoordinator: LoginFlowCoordinator, val newsFlowCoordinator: NewsFlowCoordinator, val onboardingFlowCoordinator: OnboardingFlowCoordinator ) { init { usermanager.currentUser.subscribe { user -> when (user){ is NotAuthenticatedUser -> loginFlowCoordinator.start() is AuthenticatedUser -> if (user.onBoardingCompleted) newsFlowCoordinator.start() else onboardingFlowCoordinator.start() } } } fun onboardingCompleted(){ newsFlowCoordinator.start() } } 

وبالتالي ، سيكون RootFlowCoordinator نقطة الدخول في التنقل بدلاً من NewsFlowCoordinator. دعونا نركز على RootFlowCoordinator. إذا قام المستخدم بتسجيل الدخول ، فإننا نتحقق مما إذا كان قد أكمل الإعداد (المزيد عن هذا لاحقًا) ونبدأ البرنامج النصي للأخبار أو الإعداد. يرجى ملاحظة أن LoginViewModel لا يشارك في هذا المنطق. نحن نصف سيناريو التأهيل.



 class OnboardingFlowCoordinator( val navigator: Navigator, val onboardingFinished: () -> Unit // this is RootFlowCoordinator.onboardingCompleted() ) { fun start(){ navigator.showOnboardingWelcome() } fun welcomeShown(){ navigator.showOnboardingPersonalInterestChooser() } fun onboardingCompleted(){ onboardingFinished() } } 

يبدأ Onboarding عن طريق استدعاء OnboardingFlowCoordinator # start () ، والذي يعرض WelcomeFragment (WelcomeViewModel). بعد النقر على زر "التالي" ، يتم استدعاء طريقة OnboardingFlowCoordinator # welcomeShown (). الذي يظهر على الشاشة التالية PersonalInterestFragment + PersonalInterestViewModel ، حيث يختار المستخدم فئات الأخبار المثيرة للاهتمام. بعد تحديد الفئات ، ينقر المستخدم على الزر "التالي" وتسمى طريقة OnboardingFlowCoordinator # onboardingCompleted () ، التي تعمل على استدعاء المكالمة إلى RootFlowCoordinator # onboardingCompleted () ، والتي تطلق NewsFlowCoordinator.
دعونا نرى كيف يمكن للمنسق تبسيط العمل باستخدام اختبارات A / B. سأضيف شاشة مع عرض لإجراء عملية شراء في التطبيق وسأعرضها لبعض المستخدمين.



 class NewsFlowCoordinator ( val navigator : Navigator, val abTest : AbTest ) { fun start(){ navigator.showNewsList() } fun readNewsArticle(id : Int){ navigator.showNewsArticle(id) } fun closeNews(){ if (abTest.isB){ navigator.showInAppPurchases() } else { navigator.closeNews() } } } 

مرة أخرى ، لم نقم بإضافة أي منطق إلى طريقة العرض أو نموذجها. هل قررت إضافة InAppPurchaseFragment إلى الإعداد؟ للقيام بذلك ، ما عليك سوى تغيير منسق الإعداد ، لأن جزء التسوق ونموذج العرض الخاص به مستقلان تمامًا عن الأجزاء الأخرى ويمكننا إعادة استخدامه بحرية في سيناريوهات أخرى. سيساعد المنسق أيضًا في تنفيذ اختبار A / B ، الذي يقارن بين سيناريوهين للإعداد.

يمكن العثور على مصادر كاملة على github ، وقد أعدت عرضًا توضيحيًا للكسول


نصيحة مفيدة: باستخدام kotlin ، يمكنك إنشاء dsl مناسب لوصف المنسقين في شكل رسم بياني للملاحة.

 newsFlowCoordinator(navigator, abTest) { start { navigator.showNewsList() } readNewsArticle { id -> navigator.showNewsArticle(id) } closeNews { if (abTest.isB){ navigator.showInAppPurchases() } else { navigator.closeNews() } } } 

ملخص:


سيساعد المنسق في جلب منطق التنقل إلى المكون الذي تم اختباره بشكل فضفاض. في الوقت الحالي لا توجد مكتبة جاهزة للإنتاج ، وصفت فقط مفهوم حل المشكلة. هل المنسق ينطبق على طلبك؟ لا أعرف ، يعتمد ذلك على احتياجاتك ومدى سهولة دمجها في البنية الحالية. قد يكون من المفيد كتابة طلب صغير باستخدام منسق.

التعليمات:

لا تذكر المقالة استخدام منسق بنمط MVI. هل من الممكن استخدام منسق مع هذه البنية؟ نعم ، لدي مقال منفصل .

قدمت Google مؤخرًا وحدة التحكم في التنقل كجزء من Android Jetpack. كيف يرتبط المنسق بملاحة جوجل؟ يمكنك استخدام وحدة التحكم الجديدة في التنقل بدلاً من المستكشف في المنسقين أو مباشرة في المستكشف بدلاً من إنشاء المعاملات الجزئية يدويًا.

وإذا كنت لا أرغب في استخدام الأجزاء / النشاط وأردت كتابة النهاية الخلفية الخاصة بي لإدارة طرق العرض - فهل ستعمل في حالتي لاستخدام منسق؟ كما فكرت في هذا وأنا أعمل على نموذج أولي. سأكتب عن هذا في مدونتي. يبدو لي أن آلة الدولة ستبسط المهمة إلى حد كبير.

هل المنسق مرتبط بمنهج تطبيق النشاط الواحد؟ لا ، يمكنك استخدامه في سيناريوهات مختلفة. يتم إخفاء تنفيذ الانتقال بين الشاشات في المستكشف.

مع النهج الموصوف ، تحصل على ملاح ضخم. حاولنا نوعا ما الابتعاد عن الله - Object'a؟ نحن لسنا مطالبين بوصف الملاح في فئة واحدة. قم بإنشاء عدة ملاحات صغيرة مدعومة ، على سبيل المثال ، متصفح منفصل لكل سيناريو مستخدم.

كيفية العمل مع الرسوم المتحركة الانتقالية المستمرة؟ وصف الرسوم المتحركة الانتقالية في المستكشف ، ثم لن يعرف النشاط / الجزء أي شيء عن الشاشة السابقة / التالية. كيف يعرف الملاح متى يبدأ الرسم المتحرك؟ لنفترض أننا نريد عرض رسم متحرك للانتقال بين الأجزاء A و B. يمكننا الاشتراك في حدث onFragmentViewCreated (v: View) باستخدام FragmentLifecycleCallback ، وعندما يحدث هذا الحدث ، يمكننا العمل مع الرسوم المتحركة بنفس الطريقة التي قمنا بها مباشرة في الجزء: أضف OnPreDrawListener الانتظار حتى تصبح جاهزًا واستدعاء startPostponedEnterTransition (). بنفس الطريقة تقريبًا ، يمكنك تنفيذ انتقال متحرك بين النشاط باستخدام ActivityLifecycleCallbacks أو بين ViewGroup باستخدام OnHierarchyChangeListener. لا تنس إلغاء الاشتراك في الأحداث لاحقًا لتجنب تسرب الذاكرة.

Source: https://habr.com/ru/post/ar416301/


All Articles