
منذ بضع سنوات ، بدأنا في Badoo باستخدام نهج MVI لتطوير Android. كان الهدف من ذلك هو تبسيط قاعدة تعليمات برمجية معقدة وتجنب مشكلة الحالات غير الصحيحة: في السيناريوهات البسيطة يكون الأمر سهلاً ، ولكن كلما زاد تعقيد النظام ، زاد صعوبة الاحتفاظ به في الشكل الصحيح ، وكان من الأسهل تفويت أي خطأ.
في Badoo ، جميع التطبيقات غير متزامنة - ليس فقط بسبب الوظائف الواسعة المتاحة للمستخدم من خلال واجهة المستخدم ، ولكن أيضًا بسبب إمكانية إرسال البيانات في اتجاه واحد بواسطة الخادم. باستخدام النهج القديم في وحدة الدردشة الخاصة بنا ، صادفنا العديد من الأخطاء الغريبة التي يصعب إعادة إنتاجها ، والتي كان علينا قضاء الكثير من الوقت للقضاء عليها.
أخبر زميلنا Zsolt Kocsi (
متوسط ،
Twitter ) من مكتب لندن كيف نستخدم MVI في بناء مكونات مستقلة يسهل إعادة استخدامها ، وما المزايا التي نحصل عليها وما هي العيوب التي واجهناها عند استخدام هذا النهج.
هذا هو المقال الثالث في سلسلة من المقالات حول بنية Badoo Android. روابط إلى الأولين:
- الهندسة المعمارية الحديثة MVI على أساس Kotlin .
- بناء نظام مكون تفاعلي مع Kotlin .
لا أسهب في الحديث عن المكونات السيئة التوصيل.
ويعتبر ضعف الاتصال أفضل من قوي. إذا كنت تعتمد فقط على واجهات وليس على تطبيقات محددة ، فسيكون من الأسهل بالنسبة لك استبدال المكونات ، يكون من الأسهل التبديل إلى تطبيقات أخرى دون إعادة كتابة معظم الكود ، مما يسهل تضمين اختبار الوحدة.
عادة ما ننتهي هنا ونقول إننا قمنا بكل شيء ممكن من حيث الاتصال.
ومع ذلك ، فإن هذا النهج ليس الأمثل. افترض أن لديك فئة A تحتاج إلى استخدام إمكانات ثلاث فئات أخرى: B و C و D. حتى إذا أشرت إليها من خلال واجهات ، فإن الفئة A تصبح أكثر صعوبة مع كل من هذه الفئات:
- يعرف كل الطرق في جميع الواجهات وأسمائهم وأنواع الإرجاع ، حتى لو لم يستخدمها ؛
- عند اختبار A ، تحتاج إلى تكوين المزيد من mocks ( كائن وهمية )؛
- من الأصعب استخدام الحرف A بشكل متكرر في السياقات الأخرى التي لا نمتلك فيها B أو C أو D.
بالطبع ، يجب أن تحدد الفئة (أ) بالتحديد الفئة الدنيا من السطوح البينية اللازمة لها (مبدأ فصل الواجهة عن
SOLID ). ومع ذلك ، في الممارسة العملية ، كان علينا جميعًا التعامل مع المواقف التي ، من أجل الراحة ، تم اتباع نهج مختلف: أخذنا فئة حالية تنفذ بعض الوظائف ، واستخلصنا جميع أساليبها العامة في الواجهة ، ثم استخدمنا هذه الواجهة حيث كانت هناك حاجة للفئة المذكورة. بمعنى أنه تم استخدام الواجهة ليس على أساس ما يتطلبه هذا المكون ، ولكن على أساس ما يمكن أن يقدمه مكون آخر.
مع هذا النهج ، يزداد الوضع سوءًا بمرور الوقت. في كل مرة نضيف وظائف جديدة ، ترتبط فصولنا في شبكة من الواجهات الجديدة التي يحتاجون إلى معرفتها. يزداد حجم الفصول الدراسية وأصبح الاختبار أكثر صعوبة.
نتيجة لذلك ، عندما تحتاج إلى استخدامها في سياق مختلف ، سيكون من المستحيل تقريبًا نقلها دون كل هذا التشابك الذي تتصل به ، حتى من خلال الواجهات. يمكنك رسم تشبيه: أنت تريد استخدام موزة ، وهو في أيدي قرد معلق على شجرة ، ونتيجة لذلك ، في الحمل على الموز ، ستحصل على جزء كامل من الغابة. باختصار ، تستغرق عملية النقل الكثير من الوقت ، وسرعان ما تبدأ في سؤال نفسك عن سبب صعوبة إعادة استخدام الشفرة في الممارسة العملية.
مكونات الصندوق الاسود
إذا أردنا أن يكون المكون سهل الاستخدام وقابل لإعادة الاستخدام ، فلن نحتاج في هذا إلى معرفة شيئين:
- حول مكان آخر يتم استخدامه ؛
- حول المكونات الأخرى التي لا تتعلق بتنفيذه الداخلي.
السبب واضح: إذا كنت لا تعرف العالم الخارجي ، فلن تكون متصلاً به.
ما نريده حقًا من المكون:
- تحديد مدخلاتها (المدخلات) وبيانات المخرجات (المخرجات) ؛
- لا تفكر في مصدر هذه البيانات أو من أين تذهب ؛
- يجب أن يكون مكتفيًا ذاتيًا حتى لا نحتاج إلى معرفة الهيكل الداخلي للمكون لاستخدامه.
يمكنك اعتبار المكون مربعًا أسودًا أو دائرة متكاملة. لديها اتصالات المدخلات والمخرجات. أنت تلحمهم - وتصبح الدائرة الصغيرة جزءًا من نظام لا يعرف شيئًا عنه.

حتى الآن ، كان من المفترض أننا نتحدث عن تدفقات البيانات ثنائية الاتجاه: إذا احتاجت الفئة A إلى شيء ما ، فإنها تستخرج طريقة من خلال الواجهة B وتتلقى النتيجة في شكل القيمة التي تُرجعها الدالة.

ولكن بعد ذلك A يعرف B ، ونحن نريد تجنب هذا.
بالطبع ، مثل هذا المخطط منطقي بالنسبة لميزات التنفيذ منخفضة المستوى. ولكن إذا كنا بحاجة إلى مكون قابل لإعادة الاستخدام يعمل مثل الصندوق الأسود المدمج بذاته ، فإننا نحتاج إلى التأكد من أنه لا يعرف شيئًا عن الواجهات الخارجية أو أسماء الطرق أو قيم الإرجاع.
نحن ننتقل إلى أحادية الاتجاه
ولكن بدون أسماء الطرق والأساليب ، لا يمكننا استدعاء أي شيء! كل ما تبقى هو استخدام دفق بيانات أحادي الاتجاه ، حيث يمكننا ببساطة الحصول على المدخلات وتوليد المخرجات:

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

هذا هو ما نحتاجه! يمكن استخدام الميزات في أي مكان يمكنك من خلاله مدخلات ، ومع الإخراج يمكنك القيام بكل ما تريد. ونظرًا لأن الميزات لا تتواصل مباشرة مع المكونات الأخرى ، فهي وحدات قائمة بذاتها وغير مرتبطة.
الآن ، يمكنك مشاهدة العرض وتصميمه بحيث يكون أيضًا وحدة قائمة بذاتها.
أولاً ، يجب أن يكون العرض بسيطًا بقدر الإمكان حتى يتمكن من التعامل مع مهامه الداخلية فقط.
أي نوع من المهام؟ هناك اثنان منهم:
- تقديم ViewModel (الإدخال) ؛
- تشغيل ViewEvents حسب إجراءات المستخدم (الإخراج).
لماذا استخدام ViewModel؟ لماذا لا ترسم مباشرة حالة الميزة؟
- (عدم) عرض ميزة على الشاشة ليس جزءًا من التنفيذ. يجب أن تكون طريقة العرض قادرة على تقديم نفسها إذا كانت البيانات تأتي من عدة مصادر.
- ليست هناك حاجة لتعكس تعقيد الدولة في العرض. يجب أن يحتوي ViewModel فقط على المعلومات الجاهزة للعرض اللازمة لتبسيط الأمر.
أيضًا ، يجب ألا يكون العرض مهتمًا بما يلي:
- من أين تأتي كل نماذج العرض هذه؟
- ماذا يحدث عندما يتم تشغيل ViewEvent ؛
- أي منطق عمل ؛
- تتبع تحليلي
- تسجيل.
- مهام أخرى.
كل هذه مهام خارجية ، ويجب ألا تكون طريقة العرض مرتبطة بها. دعونا نتوقف ونلخص بساطة طريقة العرض:
interface FooView : Consumer<ViewModel>, ObservableSource<Event> { data class ViewModel( val title: String, val bgColor: Int ) sealed class Event { object ButtonClicked : Event() data class TextFocusChanged(val hasFocus: Boolean) : Event() } }
يجب على تطبيق Android:
- ابحث عن طرق عرض Android بمعرفهم.
- قم بتطبيق طريقة قبول واجهة المستهلك من خلال تحديد القيمة من ViewModel.
- اضبط المستمعين (ClickListeners) للتفاعل مع واجهة المستخدم لإنشاء أحداث محددة.
مثال:
class FooViewImpl @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, private val events: PublishRelay<Event> = PublishRelay.create<Event>() ) : LinearLayout(context, attrs, defStyle), FooView,
إذا لم يقتصر على الميزة والعرض ، فإليك ما سيبدو عليه أي مكون آخر مع هذا النهج:
interface GenericBlackBoxComponent : Consumer<Input>, ObservableSource<Output> { sealed class Input sealed class Output }
الآن كل شيء واضح مع النمط!

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

هنا ، ترتبط الميزات (F) و View (V) بكل بساطة مع بعضها البعض.
الارتباطات المقابلة ستكون:
bind(feature to view using stateToViewModelTransformer) bind(view to feature using uiEventToWishTransformer)
لنفترض أننا نريد إضافة تتبع لبعض أحداث واجهة المستخدم لهذا النظام.
internal object AnalyticsTracker : Consumer<AnalyticsTracker.Event> { sealed class Event { object ProfileImageClicked: Event() object EditButtonClicked : Event() } override fun accept(event: AnalyticsTracker.Event) {
والخبر السار هو أنه يمكننا القيام بذلك ببساطة عن طريق إعادة استخدام قناة عرض الإخراج الحالية:

في الكود ، يبدو كما يلي:
bind(feature to view using stateToViewModelTransformer) bind(view to feature using uiEventToWishTransformer)
يمكن إضافة وظائف جديدة مع سطر واحد فقط من الربط الإضافي. الآن ، لا يمكننا فقط تغيير سطر واحد من عرض الكود ، لكن لا يعرف حتى أن المخرجات تستخدم لحل مشكلة جديدة.
من الواضح ، الآن أصبح من الأسهل بالنسبة لنا تجنب المخاوف الإضافية والمكونات المعقدة غير الضرورية. تظل بسيطة. يمكنك إضافة وظائف إلى النظام من خلال ربط المكونات بالمكونات الموجودة.
الميزة الثانية: سهلة الاستخدام مرارا وتكرارا
باستخدام مثال الميزة والعرض ، يمكن أن نرى بوضوح أنه يمكننا إضافة مصدر إدخال جديد أو مستهلك لبيانات الإخراج مع سطر واحد فقط مع الربط. هذا يسهل إلى حد كبير إعادة استخدام المكونات في أجزاء مختلفة من التطبيق.
ومع ذلك ، فإن هذا النهج لا يقتصر على الطبقات. تتيح لنا هذه الطريقة في استخدام الواجهات وصف المكونات التفاعلية من أي حجم.
بتقييد أنفسنا على بعض بيانات المدخلات والمخرجات ، نتخلص من الحاجة إلى معرفة كيفية عمل كل شيء تحت الغطاء ، وبالتالي نتجنب بسهولة ربط المكونات الداخلية للمكونات بأجزاء أخرى من النظام عن طريق الخطأ. وبدون الربط ، يمكنك بسهولة وببساطة استخدام المكونات بشكل متكرر.
سنعود إلى ذلك في واحدة من المقالات التالية وننظر في أمثلة لاستخدام هذه التقنية لتوصيل المكونات ذات المستوى الأعلى.
السؤال الأول: أين تضع الروابط؟
- اختيار مستوى التجريد. اعتمادًا على البنية ، قد يكون هذا نشاطًا أو جزءًا أو بعضًا من ViewController. أتمنى أن لا يزال لديك مستوى من التجريد في تلك الأجزاء التي لا يوجد فيها واجهة مستخدم. على سبيل المثال ، في بعض نطاقات شجرة سياق DI.
- إنشاء فئة منفصلة للربط في نفس المستوى مثل هذا الجزء من واجهة المستخدم. إذا كان FooActivity أو FooFragment أو FooViewController ، فيمكنك وضع FooBindings بجواره.
- تأكد من تضمين FooBindings في مثيلات المكون نفسها التي تستخدمها في النشاط ، الجزء ، إلخ.
- لتشكيل نطاق الارتباطات ، استخدم دورة حياة النشاط أو التجزؤ. إذا كانت هذه الحلقة غير مرتبطة بنظام Android ، فيمكنك إنشاء مشغلات يدويًا ، على سبيل المثال ، عند إنشاء نطاق DI أو إتلافه. تم توضيح أمثلة أخرى للنطاق في المقالة الثانية .
السؤال الثاني: الاختبار
نظرًا لأن المكون لدينا لا يعرف شيئًا عن الآخرين ، فنحن عادة لا نحتاج إلى روتين بدلاً من ذلك. يتم تبسيط الاختبارات للتحقق من الاستجابة الصحيحة للمكون لبيانات الإدخال وإعطاء النتائج المتوقعة.
في حالة الميزة ، وهذا يعني:
- القدرة على اختبار ما إذا كانت بعض بيانات الإدخال تنشئ الحالة المتوقعة (الإخراج).
وفي حالة العرض:
- يمكننا اختبار ما إذا كان ViewModel (إدخال) معين يؤدي إلى الحالة المتوقعة لواجهة المستخدم ؛
- يمكننا اختبار ما إذا كانت محاكاة التفاعل مع واجهة المستخدم تؤدي إلى التهيئة في ViewEvent المتوقع (الإخراج).
بالطبع ، لا تختفي التفاعلات بين المكونات بطريقة سحرية. نحن فقط استخراج هذه المهام من المكونات نفسها. ما زالوا بحاجة إلى اختبار. لكن اين؟
في حالتنا ، تكون Binders مسؤولة عن توصيل المكونات:
يجب أن تؤكد اختباراتنا ما يلي:
1. المحولات (المصممون).
تحتوي بعض الاتصالات على معينين ، وتحتاج إلى التأكد من قيامهم بتحويل العناصر بشكل صحيح. في معظم الحالات ، يكون اختبار الوحدة البسيط للغاية كافيًا لهذا ، لأن المصممين عادة ما يكونون في غاية البساطة:
@Test fun testCase1() { val transformer = Transformer() val testInput = TODO() val actualOutput = transformer.invoke(testInput) val expectedOutput = TODO() assertEquals(expectedOutput, actualOutput) }
2. الاتصالات.
تحتاج إلى التأكد من تكوين الاتصالات بشكل صحيح. ما هي النقطة في عمل المكونات الفردية ومخططات ، إذا لسبب ما لم يتم تأسيس اتصال بينهما؟ كل هذا يمكن اختباره من خلال إعداد بيئة الربط مع بذرة ، ومصادر التهيئة والتحقق من تلقي النتائج المتوقعة من جانب العميل:
class BindingEnvironmentTest { lateinit var component1: ObservableSource<Component1.Output> lateinit var component2: Consumer<Component2.Input> lateinit var bindings: BindingEnvironment @Before fun setUp() { val component1 = PublishRelay.create() val component2 = mock() val bindings = BindingEnvironment(component1, component2) } @Test fun testBindings() { val simulatedOutputOnLeftSide = TODO() val expectedInputOnRightSide = TODO() component1.accept(simulatedOutputOnLeftSide) verify(component2).accept(expectedInputOnRightSide) } }
وعلى الرغم من أنك ستختبر أنه يتعين عليك الكتابة عن نفس مقدار الشفرة كما هو الحال مع الطرق الأخرى ، إلا أن المكونات المكتفية ذاتياً تجعل من السهل اختبار الأجزاء الفردية ، حيث إن المهام منفصلة بوضوح.
الغذاء للتفكير
على الرغم من أن وصف نظامنا في شكل رسم بياني للصناديق السوداء مفيد للفهم العام ، إلا أن هذا يعمل فقط طالما كان حجم النظام صغيرًا نسبيًا.
خمسة إلى ثمانية خطوط الربط مقبولة. ولكن بعد الاتصال أكثر ، سيكون من الصعب إلى حد ما فهم ما يحدث:


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