طبقات المستودع وقضايا اتساق الحالة
ما يصعب إدارته حقًا هو أن ذاكرة التخزين المؤقت المحلية وحالة الذاكرة وإرجاع الحزمة عن بعد والحالة المشتقة من واجهة المستخدم كلها تكتب "الحقيقة" سرًا.
عندما تكون العديد من مشاريع Android في حالة من الارتباك، فإن رد الفعل الأول هو الاستمرار في إضافة الطبقات.
ViewModel -> UseCase -> Repository -> LocalDataSource -> RemoteDataSource عندما يتم وضع هذه السلسلة، يبدو الكود أكثر تنظيمًا. المشكلة هي أن الدقة والاتساق ليسا نفس الشيء. تعمل العديد من الفرق على جعل المستودع يشبه “بوابة موحدة” أكثر فأكثر، ولكن في النهاية يجدون أن استنتاج حالة الصفحة أكثر صعوبة: القائمة والتفاصيل غير متسقة، وتنتقل حالة المجموعة ذهابًا وإيابًا، ولا تتغير واجهة المستخدم بعد نجاح الطلب، وتظهر مجموعة من البيانات القديمة بعد إعادة بناء العملية.
حكمي هو: ** تكمن قيمة طبقات المستودع في وضوح مصادر الحالة وحدود الكتابة. طالما أن ذاكرة التخزين المؤقت للذاكرة، وقاعدة البيانات المحلية، وإرجاع الحزمة عن بعد، والحالة المشتقة من واجهة المستخدم يمكن أن تغير قيمها، كلما كانت الطبقات أكثر دقة، كلما كان من الصعب الحفاظ على اتساق الحالة. **
المشكلة الحقيقية هي أن هناك أكثر من حقيقة
الموقف الشائع هو أن المستودع يمكنه “توحيد إدارة البيانات”، وهو نصف صحيح فقط.
بالطبع، يمكن للمستودع حزم الشبكة وذاكرة التخزين المؤقت المحلية واستمرارية القرص، ولكن إذا لم تستمر في السؤال “من هو مصدر الحقيقة”، فإن المستودع يقوم فقط بتغليف حالات متعددة في نفس اسم الفئة.
المسار الأكثر شيوعًا للخروج عن نطاق السيطرة هو:
- تقوم الصفحة بقراءة قاعدة البيانات المحلية أولاً وتعرض القيمة القديمة على الفور؛
- بدء طلب عن بعد في نفس الوقت، وتحديث ذاكرة التخزين المؤقت للذاكرة بعد إرجاع الحزمة؛
- من أجل متابعة التفاعل السلس، قم أولاً بتغيير حالة واجهة المستخدم مباشرةً وإجراء تحديث متفائل؛
- صفحة أخرى تقرأ قيمة أخرى من الحقل الفردي للمستودع؛
- أخيرًا، اكتمل التنزيل غير المتزامن لقاعدة البيانات، وتم إرجاع الصفحة القديمة إلى الخلف.
في هذا الوقت، ما تراه على السطح هو أن “البنية مكتملة هرميًا”. في الواقع، هناك بالفعل أربع مجموعات من الدول في النظام تتنافس على حق الترجمة الفورية.
يجيبون على أسئلة مختلفة على التوالي:
- تريد قاعدة البيانات الإجابة “هل يمكن استعادتها في المرة القادمة التي يتم فيها تشغيلها؟”;
- تريد ذاكرة التخزين المؤقت الإجابة “هل هذا الوصول سريع؟”؛
- يقوم الطرف البعيد بإرجاع الحزمة ويريد الإجابة “ماذا قال الخادم للتو؟”;
- تريد حالة واجهة المستخدم الإجابة عن “كيف يجب أن يتم عرض الواجهة في هذه اللحظة”.
هذه الأشياء كلها مهمة، لكن كونها مهمة لا يعني أنها يمكن أن تكون جميعها مصدرًا للحقيقة.
إذا لم يكن هناك تعريف واضح لـ “من المسؤول عن قيم الحقيقة الثابتة، ومن المسؤول فقط عن العرض المشتق، ومن يمكنه القراءة فقط وليس الكتابة”، فسوف يتدهور المستودع ببطء إلى محطة نقل حكومية. إنه يجسد كل التعقيد ولكنه لا يزيل أيًا منه.
يتم إساءة استخدام المستودع بسهولة كمنسق “يمكنه تغيير أي شيء”
لا تكمن المشكلة في العديد من الأكواد في أن المستودع ضعيف جدًا، بل في أنه قوي جدًا.
غالبًا ما يقوم المستودع النموذجي بهذه الأشياء في نفس الوقت:
- تقديم طلبات الشبكة.
- غرفة القراءة والكتابة.
- الحفاظ على خريطة الذاكرة. -الحقول اللازمة لتجميع واجهة المستخدم؛
- التراجع عن التحديث المتفائل بشأن الفشل؛
- إرسال الأحداث بسهولة لإخطار الوحدات الأخرى بالتحديث.
يبدو أنها مركزة للغاية، ولكنها في الواقع “طبقة الوصول إلى البيانات”، و"طبقة تنسيق الحالة"، و"طبقة استراتيجية التخزين المؤقت" و"طبقة قاعدة المجال" ملفوفة في كرة.
بمجرد أن يكون المستودع مسؤولاً عن كل من “تجميع القراءة” و"تنسيق الكتابة متعدد المصادر"، فإنه سيدخل بطبيعة الحال في حالة حرجة: يمكن لأي شخص تغيير البيانات من خلاله، ولكن لا يمكن لأحد أن يعرف بسرعة أي المراقبين سيؤثر التغيير في النهاية وأي مسار إعادة كتابة سيتم تشغيله.
على سبيل المثال، عملية التجميع، العديد من التطبيقات هي كما يلي:
suspend fun toggleFavorite(id: String) {
memory[id] = !(memory[id] ?: false)
dao.updateFavorite(id, memory[id]!!)
api.toggleFavorite(id)
}
هذا الرمز قصير بشكل ملائم، ولكنه يمزج بين ثلاثة مستويات من الدلالات:
- تريد واجهة المستخدم تقديم تعليقات فورية، لذا قم بتغيير الذاكرة أولاً؛
- أريد الحفاظ على الاتساق المحلي، لذلك أكتب المكتبة على الفور؛
- الخادم هو الحكم الحقيقي، ولكن يتم إرجاع النتائج في النهاية.
لا تكمن المشكلة في أن عبارة “التغيير المحلي أولاً” يجب أن تكون خاطئة، ولكن في عدم تحديد دلالات الفشل.
كيف يتم التقارب إذا انتهت مهلة الواجهة ولكن الخادم نجح بالفعل؟ إذا نجحت الكتابة المحلية ولكن فشلت الكتابة عن بعد، فمن سيتراجع؟ إذا تم النقر على صفحتين كمفضلتين في نفس الوقت، فأيهما سوف يكون له الغلبة في النهاية؟
بمجرد عدم تصميم هذه المشكلات بشكل صريح، يقوم المستودع بإخفاء حالة السباق بطريقة تبدو نظيفة.
يمكن للتدفق نشر الحالة، وهذا لا يعني أنه يضمن الاتساق تلقائيًا.
في السنوات الأخيرة، كان Android مغرمًا بربط Flow وStateFlow وSharedFlow بالمستودع، ثم كشف “مصدر بيانات سريع الاستجابة” للمنبع. وهذا بالتأكيد أفضل من معاودة الاتصال في كل مكان، لكنه غالبًا ما يخلق الوهم بأنه طالما قمت بتدفق البيانات، فإن مشكلة الاتساق ستختفي.
متعود.
يحل التدفق المستجيب كيفية نشر التغييرات، وليس من يحدد التغييرات.
النمط التالي شائع جدًا:
val userFlow = combine(
dao.observeUser(id),
memoryStateFlow,
remoteRefreshStateFlow
) { local, memory, remote ->
mergeUser(local, memory, remote)
}
الخطر الأكبر لهذا الكود ليس أنه قبيح في الكتابة، لكن mergeUser() غالبًا ما يقدم قرارات العمل بهدوء:
- الاسم مبني على النهاية البعيدة؛
- سواء كان متصلا أم لا يعتمد على الذاكرة؛
- سيتم تحديد ما إذا كان قد تمت قراءته محليًا؛
- يتم تعليق حالة التحميل أيضًا على واجهة المستخدم.
ما نحتاجه في النهاية هو “نتيجة خياطة بالكاد يمكنها عرض الصفحة في هذه اللحظة”.
يعد هذا النوع من الربط مناسبًا جدًا لمسار القراءة، لكنه قد يخرج بسهولة عن نطاق السيطرة في مسار الكتابة، لأنه من الصعب بالفعل الإجابة عليه:
- ما هو المستوى الذي يجب تغيير مجال معين فيه؟
- بعد تغيير الطبقة، هل يلزم مزامنة الطبقات الأخرى؟
- بعد إعادة بناء العملية، ما هي الحقول التي لا يزال من الممكن إعادة بنائها؟
- ما هي الحقول التي سيتم استبدالها بقيم جديدة أثناء عملية الاسترداد في وضع عدم الاتصال.
لذا فإن الظاهرة الغريبة في العديد من المشاريع هي: كلما كانت كتابة تدفق البيانات أكثر جمالًا، كلما أصبحت أخطاء الحالة أكثر ميتافيزيقية. السبب الجذري هو أنه لا يوجد مصدر واحد مسؤول للدولة في النظام.
ما يجب التحكم فيه حقًا هو حدود الكتابة
القيد الأكثر أهمية في تصميم المستودع هو “حيث يوجد إذن الكتابة”.
إذا كان من الممكن تعديل كائن الأعمال عن طريق التحديث الإيجابي لواجهة المستخدم، وتعديله بواسطة ذاكرة التخزين المؤقت لذاكرة المستودع، وإعادته بواسطة مراقب قاعدة البيانات، والكتابة فوقه بواسطة حزم إرجاع الواجهة، فسوف يواجه عاجلاً أم آجلاً مشكلات عدم تناسق الطلب.
بدلاً من الاستمرار في إضافة التجريدات، أوصي بتوضيح حدود الكتابة أولاً:
1. اختر مصدر الحقيقة أولاً
لا تتطلب كافة السيناريوهات أن “قاعدة البيانات المحلية هي المصدر الوحيد للحقيقة”، ولكن يجب تحديد مصدر أساسي.
- في السيناريوهات التي يكون فيها من الممكن استرداد الأولوية دون اتصال والقائمة، يجب عادةً استخدام قاعدة البيانات المحلية؛
- في السيناريوهات التي يكون فيها الوقت الحقيقي قويًا ولا يمكن قبول القيم القديمة، يمكن استخدام النتائج عن بعد؛
- يجب ترك حالات تفاعل الواجهة الخالصة، مثل التوسع والتحديد والإدخال، بشكل صريح في حالة واجهة المستخدم وعدم إعادتها إلى المستودع.
المفتاح هو عدم الاعتماد على قاعدة البيانات لتحديد نصف الحقول، والنصف الآخر من الحقول المراد تحديدها في الذاكرة، ثم الاعتماد على واجهة المستخدم لملء الفجوة عند حدوث خطأ.
2. فصل “الحالة المشتقة” و"الحالة الدائمة"
يأتي الكثير من الالتباس من كتابة حالة العرض المؤقتة مرة أخرى إلى طبقة الثبات.
على سبيل المثال:
isLoadingisRefreshingisExpandedpendingRetryCount
يمكن أن تحدد هذه الحالات كيفية رسم واجهة المستخدم، ولكن لا ينبغي خلطها مع قيم حقيقة الأعمال في نفس الكيان ونشرها.
بمجرد وضع الحالة المشتقة في النموذج العام للمستودع، سيتم إعادة استخدامها عن طريق الخطأ بين الصفحات المختلفة ودورات الحياة المختلفة. في النهاية، ليس من الواضح حتى أن “هذا الحقل لا يزال يحتفظ بقيمة الصفحة الأخيرة”.
3. جعل مسار الكتابة أقل من مسار القراءة
يمكن تجميع القراءات ويمكن إغلاق عمليات الكتابة.
يمكنك تجميع إشارات قاعدة البيانات والذاكرة والتحديث عن بعد معًا عند القراءة لإعطاء الصفحة نموذجًا كافيًا؛ لكن عند الكتابة، فمن الأفضل أن تسلك مسارًا واحدًا متحكمًا فيه وتتركه يقرر:
- سواء الكتابة محليا أولا؛
- ما إذا كان التعويض مطلوبا؛ -ما إذا كان مسموحًا بالكتابة فوق الإصدارات القديمة؛
- ما إذا كان سيتم تضمين رقم الإصدار أو الطابع الزمني؛
- ما هي الدلالات التي يجب أن تراها واجهة المستخدم بعد الفشل.
كلما زاد عدد الإدخالات التي يسمح بها النظام، زاد الاتساق الذي يعتمد على “عدم ارتكاب الأخطاء”. إنه ليس تصميمًا، إنه الحظ.
مثال مضاد شائع: من أجل “تجربة النعومة الحريرية”، قم بإجراء التغييرات أولاً قبل التحدث عنها
أسهل طريقة لكتابة اتساق الحالة السيئة هي القرار البسيط المتمثل في “هذا التفاعل بسيط جدًا، فلنغيره محليًا أولاً”.
على سبيل المثال، من السهل جدًا اعتبار الإعجابات والمجموعات والمتابعات والقراءات بمثابة “تغيير واجهة المستخدم أولاً، ثم الفشل”. تكمن المشكلة في أنه بمجرد عبور الصفحات والقوائم ومستويات التخزين المؤقت، فإنها لم تعد قرارات صغيرة.
عادة ما تبدو حالات الفشل كما يلي:
- انقر على “المفضلة” في صفحة التفاصيل، وسيضيء الزر على الفور؛
- تراقب صفحة القائمة أيضًا نفس حالة ذاكرة المستودع، بحيث تضيء بشكل متزامن؛
- تنتهي مهلة الواجهة ويقوم المستودع بتشغيل التراجع؛
- لكن صفحة القائمة حصلت بالفعل على القيمة القديمة بسبب مراقب قاعدة البيانات، ويختلف ترتيب التراجع عن صفحة التفاصيل؛
- يعود المستخدم إلى المستوى السابق ويرى أن حالة الصفحتين غير متناسقة؛
- بعد استئناف عملية القتل تعود إلى النتيجة الثالثة.
الشيء الأكثر إزعاجًا في هذا النوع من المشكلات هو أنه لا يتكرر دائمًا، لذلك يمكن للفريق أن ينسبها بسهولة إلى “مشكلات توقيت التدفق” أو “مشكلات إعادة التنظيم” أو “تقلبات الشبكة المتفرقة”.
في الواقع، السبب الجذري أبسط: ** يسمح لطبقات متعددة بالحصول على المؤهل لكتابة النتيجة النهائية في نفس الوقت. **
تم تصميم هذه الخدمة من أجل المساءلة، وليس الدقة الرسمية
أنا لست ضد طبقات المستودع. بدون المستودع، ستكون العديد من مشاريع Android أكثر فوضوية.
ولكن ما يجب أن يقدمه المستودع حقًا هو:
- هل يمكنك توضيح من أين يأتي مسار القراءة؟
- تدوين الأحكام التي مر بها المسار وهل يمكن المحاسبة عليها؟
- من سينتصر عند حدوث خطأ وما إذا كان من الممكن استرجاعه؛
- ما يتم مشاركته بين الصفحات هو القيمة الحقيقية للأعمال أو حالة العرض المؤقتة، وهل يمكن فصلها.
إذا لم يكن من الممكن الإجابة على هذه الأسئلة، مهما كانت الطبقات جميلة، فسيكون ذلك مجرد أمر بصري.
إنه يجعل الكود يبدو أشبه بمخطط معماري، لكنه لا يجعل الحالة تبدو أشبه بالنظام بالضرورة.
الحدود القابلة للتطبيق
تركز هذه المقالة بشكل رئيسي على:
- هل لديك ذاكرة التخزين المؤقت المحلية أو الغرفة؛
- حالة مشاركة الصفحات المتعددة؛
- متابعة سرعة الشاشة الأولى والاسترداد في وضع عدم الاتصال والتعليقات التفاعلية الفورية في نفس الوقت؛
- استخدم Repository + Flow/StateFlow لتنظيم قراءة البيانات وكتابتها.
إذا كان التطبيق خفيفًا جدًا، وكانت البيانات دائمًا تقريبًا طلبًا لمرة واحدة، وكانت الصفحة جاهزة للاستخدام، وليست هناك حاجة لمزامنة عبر الصفحات، فحتى لو تمت كتابة المستودع ببساطة وبشكل فظ، فلن تكون مشكلة تناسق الحالة بارزة بشكل خاص.
تكمن المشكلة الحقيقية في المشاريع “متوسطة إلى كبيرة ولكنها ليست كبيرة بما يكفي ليتم وضعها على منصة كاملة”: هناك المزيد والمزيد من الوظائف، وأصبحت مصادر البيانات أكثر تعقيدًا، لكن الفريق لا يزال يستخدم الطريقة المبكرة المتمثلة في “حزم طبقة من المستودع أولاً ثم التحدث عنها” لدعمها. في هذه المرحلة، من المرجح أن تظهر الأنظمة الأنيقة في البنية والفوضوية في السلوك.
ملخص
سوء الفهم الأكثر شيوعًا لطبقات المستودعات في Android هو الخلط بين “مدخل الوصول الموحد” و"الحالة الطبيعية الموحدة".
لا يمكن للمداخل الموحدة إلا أن تقلل من الارتباك على سطح المكالمة؛ فقط من خلال مسح مصدر الحقيقة، وإغلاق حدود الكتابة، وتحديد دلالات الفشل مقدمًا، يمكننا حقًا تقليل معارك الدولة.
وإلا فإن المطلوب هو مجموعة من الأشياء التي يصعب مساءلتها.
What to read next
Want more posts about Android?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to keep following #State Management?
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