طبقة البيانات

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

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

بنية طبقة البيانات

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

في بنية نموذجية، توفّر مستودعات طبقة البيانات البيانات لبقية التطبيق وتعتمد على مصادر البيانات.
الشكل 1. دور طبقة البيانات في بنية التطبيق

تكون فئات المستودع مسؤولة عن المهام التالية:

  • عرض البيانات لبقية التطبيق
  • تجميع التغييرات التي تطرأ على البيانات
  • حلّ التعارضات بين مصادر البيانات المتعددة
  • تجريد مصادر البيانات من بقية التطبيق
  • يحتوي على منطق النشاط التجاري.

يجب أن يكون كل فئة من فئات مصادر البيانات مسؤولة عن التعامل مع مصدر واحد فقط للبيانات، ويمكن أن يكون هذا المصدر ملفًا أو مصدر شبكة أو قاعدة بيانات محلية. فئات مصادر البيانات هي الرابط بين التطبيق والنظام لتنفيذ عمليات البيانات.

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

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

باتّباع أفضل الممارسات المتعلّقة بالحقن التبعي، يتلقّى المستودع مصادر البيانات كعناصر تابعة في الدالة الإنشائية:

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }

عرض واجهات برمجة التطبيقات

تعرض الفئات في طبقة البيانات بشكل عام وظائف لإجراء عمليات إنشاء وقراءة وتعديل وحذف لمرة واحدة، أو لتلقّي إشعارات بشأن تغييرات البيانات بمرور الوقت. يجب أن تعرض طبقة البيانات ما يلي لكل حالة من هذه الحالات:

  • بالنسبة إلى العمليات التي تتم لمرة واحدة، يجب عرض دوال التعليق.
  • لتلقّي إشعارات بشأن التغييرات في البيانات بمرور الوقت، يمكنك عرض عمليات نقل البيانات.
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

اصطلاحات التسمية في هذا الدليل

في هذا الدليل، يتم تسمية فئات المستودع وفقًا للبيانات التي تكون مسؤولة عنها. تكون الاصطلاحات على النحو التالي:

نوع البيانات + المستودع

على سبيل المثال: NewsRepository أو MoviesRepository أو PaymentsRepository.

يتم تسمية فئات مصادر البيانات وفقًا للبيانات التي تكون مسؤولة عنها والمصدر الذي تستخدمه. تكون الاصطلاحات على النحو التالي:

نوع البيانات + نوع المصدر + DataSource

بالنسبة إلى نوع البيانات، استخدِم عن بُعد أو محلية لتكون أكثر عمومية لأنّ عمليات التنفيذ يمكن أن تتغيّر. على سبيل المثال: NewsRemoteDataSource أو NewsLocalDataSource. لتكون أكثر تحديدًا في حال كان المصدر مهمًا، استخدِم نوع المصدر. على سبيل المثال: NewsNetworkDataSource أو NewsDiskDataSource.

لا تسمِّ مصدر البيانات استنادًا إلى تفاصيل التنفيذ، مثل UserSharedPreferencesDataSource، لأنّ المستودعات التي تستخدم مصدر البيانات هذا يجب ألا تعرف كيفية حفظ البيانات. إذا اتّبعت هذه القاعدة، يمكنك تغيير طريقة تنفيذ مصدر البيانات (على سبيل المثال، نقل البيانات من SharedPreferences إلى DataStore) بدون التأثير في الطبقة التي تستدعي هذا المصدر.

مستويات متعددة من المستودعات

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

على سبيل المثال، يمكن أن يعتمد مستودع يتعامل مع بيانات مصادقة المستخدم، UserRepository، على مستودعات أخرى مثل LoginRepository وRegistrationRepository لتلبية متطلباته.

في المثال، يعتمد UserRepository على فئتَي مستودعَين أخريَين:
    LoginRepository، الذي يعتمد على مصادر بيانات تسجيل الدخول الأخرى،
    وRegistrationRepository، الذي يعتمد على مصادر بيانات التسجيل الأخرى.
الشكل 2. الرسم البياني للاعتمادية لمستودع يعتمد على مستودعات أخرى

المصدر الموثوق

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

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

قد تحتوي مستودعات مختلفة في تطبيقك على مصادر مختلفة للبيانات الأساسية. على سبيل المثال، قد تستخدم الفئة LoginRepository ذاكرة التخزين المؤقت كمصدر موثوق، وقد تستخدم الفئة PaymentsRepository مصدر بيانات الشبكة.

لتوفير إمكانية استخدام التطبيق بلا إنترنت، ننصح باستخدام مصدر بيانات محلي، مثل قاعدة بيانات، كمصدر موثوق.

إنشاء سلاسل محادثات

يجب أن تكون مصادر البيانات والمستودعات التي يتم استدعاؤها آمنة في سلسلة التعليمات الرئيسية، أي يمكن استدعاؤها من سلسلة التعليمات الرئيسية. تكون هذه الفئات مسؤولة عن نقل تنفيذ منطقها إلى سلسلة التعليمات البرمجية المناسبة عند تنفيذ عمليات حظر طويلة الأمد. على سبيل المثال، يجب أن يكون مصدر البيانات آمنًا على سلسلة التعليمات الرئيسية لقراءة البيانات من ملف، أو أن يكون المستودع قادرًا على إجراء فلترة مكلفة على قائمة كبيرة.

يُرجى العِلم أنّ معظم مصادر البيانات توفّر حاليًا واجهات برمجة تطبيقات آمنة على مستوى سلسلة التعليمات الرئيسية، مثل طلبات استدعاء طريقة suspend التي توفّرها Room أو Retrofit أو Ktor. يمكن أن يستفيد المستودع من واجهات برمجة التطبيقات هذه عندما تكون متاحة.

لمزيد من المعلومات حول سلاسل المحادثات، يُرجى الاطّلاع على دليل المعالجة في الخلفية. بالنسبة إلى مستخدمي Kotlin، يُنصح باستخدام الروتينات الفرعية.

مراحل النشاط

تظل مثيلات الفئات في طبقة البيانات مخزَّنة في الذاكرة طالما يمكن الوصول إليها من جذر جمع البيانات غير المرغوب فيها، ويكون ذلك عادةً من خلال الإشارة إليها من عناصر أخرى في تطبيقك.

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

إذا كانت مسؤولية الفئة ضرورية للتطبيق بأكمله، يمكنك تحديد نطاق مثيل لهذه الفئة إلى الفئة Application. ويؤدي ذلك إلى أن يتّبع المثيل دورة حياة التطبيق. بدلاً من ذلك، إذا كنت بحاجة إلى إعادة استخدام المثيل نفسه في مسار معيّن في تطبيقك فقط، مثل مسار التسجيل أو تسجيل الدخول، عليك تحديد نطاق المثيل للفئة التي تملك دورة حياة هذا المسار. على سبيل المثال، يمكنك تحديد نطاق RegistrationRepository يحتوي على بيانات في الذاكرة إلى RegistrationActivity أو إلى سجلّ الخلف باستخدام NavEntryDecorator.

تُعدّ دورة حياة كل مثيل عاملاً مهمًا في تحديد كيفية توفير التبعيات داخل تطبيقك. وننصحك باتّباع أفضل الممارسات في تضمين التبعيات حيث تتم إدارة التبعيات ويمكن تحديد نطاقها في حاويات التبعيات. لمزيد من المعلومات حول تحديد النطاق في Android، يمكنك الاطّلاع على مشاركة المدونة تحديد النطاق في Android وHilt.

تمثيل نماذج الأنشطة التجارية

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

على سبيل المثال، لنفترض أنّ هناك خادمًا لواجهة News API يعرض معلومات المقالة، بالإضافة إلى سجلّ التعديل وتعليقات المستخدمين وبعض البيانات الوصفية:

data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)

لا يحتاج التطبيق إلى الكثير من المعلومات حول المقالة لأنّه يعرض محتوى المقالة على الشاشة فقط، بالإضافة إلى معلومات أساسية حول مؤلفها. من الممارسات الجيدة فصل فئات النماذج وجعل المستودعات تعرض فقط البيانات التي تتطلّبها الطبقات الأخرى من التسلسل الهرمي. على سبيل المثال، إليك كيفية تقليل حجم ArticleApiModel من الشبكة من أجل عرض فئة نموذج Article على طبقتَي النطاق وواجهة المستخدم:

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

يفيد فصل فئات النماذج بالطرق التالية:

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

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

أنواع عمليات البيانات

يمكن لطبقة البيانات التعامل مع أنواع العمليات التي تختلف حسب مدى أهميتها، وهي العمليات التي تركّز على واجهة المستخدم، والعمليات التي تركّز على التطبيق، والعمليات التي تركّز على النشاط التجاري.

العمليات الموجّهة نحو واجهة المستخدم

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

يتم عادةً تشغيل العمليات الموجّهة إلى واجهة المستخدم من خلال طبقة واجهة المستخدم، وتتبع مراحل نشاط المتصل، مثل مراحل نشاط ViewModel. راجِع القسم إجراء طلب شبكة للاطّلاع على مثال لعملية موجّهة نحو واجهة المستخدم.

العمليات التي تركّز على التطبيقات

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

تتبع هذه العمليات عادةً دورة حياة الفئة Application أو طبقة البيانات. للاطّلاع على مثال، راجِع القسم إبقاء عملية نشطة لفترة أطول من مدة عرض الشاشة.

العمليات الموجّهة نحو الأنشطة التجارية

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

ننصح باستخدام WorkManager للعمليات الموجّهة نحو الأنشطة التجارية. يمكنك الاطّلاع على قسم جدولة المهام باستخدام WorkManager لمعرفة المزيد.

عرض الأخطاء

يمكن أن تنجح التفاعلات مع المستودعات ومصادر البيانات أو أن تؤدي إلى حدوث استثناء عند حدوث خطأ. بالنسبة إلى الروتينات المشتركة وعمليات نقل البيانات، عليك استخدام آلية معالجة الأخطاء المضمّنة في Kotlin. بالنسبة إلى الأخطاء التي يمكن أن تحدث بسبب وظائف التعليق، استخدِموا مربّعات try/catch عند الحاجة، وفي التدفقات، استخدِموا عامل التشغيل catch. باستخدام هذا الأسلوب، من المتوقّع أن تتعامل طبقة واجهة المستخدِم مع الاستثناءات عند استدعاء طبقة البيانات.

يمكن لطبقة البيانات فهم أنواع مختلفة من الأخطاء والتعامل معها وعرضها باستخدام استثناءات مخصّصة، مثل UserNotAuthenticatedException.

لمزيد من المعلومات حول الأخطاء في الكوروتينات، يمكنك الاطّلاع على مشاركة المدوّنة الاستثناءات في الكوروتينات.

مهام شائعة

تقدّم الأقسام التالية أمثلة حول كيفية استخدام طبقة البيانات وتصميمها لتنفيذ مهام معيّنة شائعة في تطبيقات Android. تستند الأمثلة إلى تطبيق &quot;الأخبار&quot; النموذجي المذكور سابقًا في الدليل.

إجراء طلب شبكة

يُعدّ تقديم طلب عبر الشبكة من أكثر المهام شيوعًا التي قد ينفّذها تطبيق Android. يجب أن يعرض تطبيق &quot;الأخبار&quot; للمستخدم أحدث الأخبار التي يتم جلبها من الشبكة. لذلك، يحتاج التطبيق إلى فئة مصدر بيانات لإدارة عمليات الشبكة: NewsRemoteDataSource. لعرض المعلومات لبقية التطبيق، يتم إنشاء مستودع جديد يتعامل مع العمليات على بيانات الأخبار: NewsRepository.

والشرط هو أنّه يجب دائمًا تعديل آخر الأخبار عندما يفتح المستخدم الشاشة. وبالتالي، هذه عملية موجّهة إلى واجهة المستخدم.

إنشاء مصدر البيانات

يجب أن يعرض مصدر البيانات دالة تعرض آخر الأخبار: قائمة بعناصر ArticleHeadline. يجب أن يوفّر مصدر البيانات طريقة آمنة رئيسية للحصول على آخر الأخبار من الشبكة. لذلك، يجب أن تعتمد على CoroutineDispatcher أو Executor لتنفيذ المهمة.

يتم إجراء طلب شبكة من خلال استدعاء لمرة واحدة يتم التعامل معه بواسطة طريقة fetchLatestNews() جديدة:

class NewsRemoteDataSource(
  private val newsApi: NewsApi,
  private val ioDispatcher: CoroutineDispatcher
) {
    /**
     * Fetches the latest news from the network and returns the result.
     * This executes on an IO-optimized thread pool, the function is main-safe.
     */
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        // Move the execution to an IO-optimized thread since the ApiService
        // doesn't support coroutines and makes synchronous requests.
        withContext(ioDispatcher) {
            newsApi.fetchLatestNews()
        }
    }

// Makes news-related network synchronous requests.
interface NewsApi {
    fun fetchLatestNews(): List<ArticleHeadline>
}

تخفي واجهة NewsApi عملية تنفيذ برنامج واجهة برمجة التطبيقات للشبكة، ولا يهمّ ما إذا كانت الواجهة تستند إلى Retrofit أو HttpURLConnection. يؤدي الاعتماد على الواجهات إلى إمكانية تبديل عمليات تنفيذ واجهة برمجة التطبيقات في تطبيقك.

إنشاء المستودع

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

// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource
) {
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        newsRemoteDataSource.fetchLatestNews()
}

لمعرفة كيفية استخدام فئة المستودع مباشرةً من طبقة واجهة المستخدم، يمكنك الاطّلاع على دليل طبقة واجهة المستخدم.

تنفيذ التخزين المؤقت للبيانات في الذاكرة

لنفترض أنّه تم تقديم شرط جديد لتطبيق "الأخبار": عندما يفتح المستخدم الشاشة، يجب عرض الأخبار المخزّنة مؤقتًا للمستخدم إذا تم إرسال طلب مسبقًا. وفي ما عدا ذلك، يجب أن يرسل التطبيق طلب شبكة لجلب آخر الأخبار.

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

وحدات ذاكرة التخزين المؤقت

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

تخزين نتيجة طلب الشبكة مؤقتًا

لتبسيط العملية، تستخدم NewsRepository متغيرًا قابلاً للتغيير لتخزين آخر الأخبار مؤقتًا. لحماية عمليات القراءة والكتابة من سلاسل محادثات مختلفة، يتم استخدام Mutex. لمزيد من المعلومات عن الحالة المتغيرة المشترَكة والتزامن، يُرجى الاطّلاع على مستندات Kotlin.

يخزّن التنفيذ التالي معلومات آخر الأخبار مؤقتًا في متغيّر في المستودع محمي من الكتابة باستخدام Mutex. في حال نجاح طلب الشبكة، يتم تعيين البيانات إلى المتغيّر latestNews.

class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}

إبقاء عملية ما نشطة لفترة أطول من مدة عرض الشاشة

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

لاتباع أفضل ممارسات إدخال التبعيات، يجب أن تتلقّى NewsRepository نطاقًا كمَعلمة في الدالة الإنشائية بدلاً من إنشاء CoroutineScope خاص بها. بما أنّ المستودعات يجب أن تنفّذ معظم عملها في سلاسل التعليمات البرمجية في الخلفية، عليك ضبط CoroutineScope باستخدام Dispatchers.Default أو باستخدام مجموعة سلاسل التعليمات البرمجية الخاصة بك.

class NewsRepository(
    ...,
    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
    private val externalScope: CoroutineScope
) { ... }

بما أنّ NewsRepository جاهز لتنفيذ عمليات موجّهة للتطبيق باستخدام CoroutineScope الخارجي، يجب أن ينفّذ عملية طلب مصدر البيانات ويحفظ النتيجة باستخدام كوروتين جديد يبدأه هذا النطاق:

class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource,
    private val externalScope: CoroutineScope
) {
    /* ... */

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        return if (refresh) {
            externalScope.async {
                newsRemoteDataSource.fetchLatestNews().also { networkResult ->
                    // Thread-safe write to latestNews.
                    latestNewsMutex.withLock {
                        latestNews = networkResult
                    }
                }
            }.await()
        } else {
            return latestNewsMutex.withLock { this.latestNews }
        }
    }
}

يتم استخدام async لبدء الكوروتين في النطاق الخارجي. يتم استدعاء await في الروتين الفرعي الجديد ليتم تعليقه إلى أن يعود طلب الشبكة ويتم حفظ النتيجة في ذاكرة التخزين المؤقت. إذا كان المستخدم لا يزال على الشاشة بحلول ذلك الوقت، سيظهر له آخر الأخبار. أما إذا انتقل المستخدم إلى شاشة أخرى، فسيتم إلغاء await، ولكن سيستمر تنفيذ المنطق داخل async.

مزيد من المعلومات عن الأنماط الخاصة بـ "CoroutineScope"

حفظ البيانات واستردادها من القرص

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

إذا كانت البيانات التي تعمل عليها بحاجة إلى البقاء بعد إيقاف العملية نهائيًا، عليك تخزينها على القرص بإحدى الطرق التالية:

  • بالنسبة إلى مجموعات البيانات الكبيرة التي يجب الاستعلام عنها أو التي تحتاج إلى تكامل مرجعي أو إلى تعديلات جزئية، احفظ البيانات في قاعدة بيانات Room. في مثال تطبيق الأخبار، يمكن حفظ المقالات الإخبارية أو المؤلفين في قاعدة البيانات.
  • بالنسبة إلى مجموعات البيانات الصغيرة التي يجب استردادها وتعيينها فقط (وليس الاستعلام عنها أو تعديلها جزئيًا)، استخدِم DataStore. في مثال تطبيق &quot;الأخبار&quot;، يمكن حفظ تنسيق التاريخ المفضّل لدى المستخدم أو إعدادات العرض الأخرى في مخزن البيانات.
  • بالنسبة إلى أجزاء البيانات، مثل عنصر JSON، استخدِم ملفًا.

كما هو موضّح في قسم مصدر المعلومات الموثوق، يعمل كل مصدر بيانات مع مصدر واحد فقط ويتوافق مع نوع بيانات معيّن (مثل News أو Authors أو NewsAndAuthors أو UserPreferences). ويجب ألا تعرف الفئات التي تستخدم مصدر البيانات طريقة حفظ البيانات، مثلاً في قاعدة بيانات أو في ملف.

‫Room كمصدر بيانات

بما أنّ كل مصدر بيانات يجب أن يكون مسؤولاً عن العمل مع مصدر واحد فقط لنوع معيّن من البيانات، سيتلقّى مصدر بيانات Room إما كائن الوصول إلى البيانات (DAO) أو قاعدة البيانات نفسها كمعلَمة. على سبيل المثال، قد تأخذ NewsLocalDataSource نسخة من NewsDao كمعلَمة، وقد تأخذ AuthorsLocalDataSource نسخة من AuthorsDao.

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

لمزيد من المعلومات حول استخدام واجهات برمجة التطبيقات Room، يمكنك الاطّلاع على أدلة Room.

‫DataStore كمصدر بيانات

تُعدّ DataStore مثالية لتخزين أزواج المفتاح/القيمة، مثل إعدادات المستخدم. قد تشمل الأمثلة تنسيق الوقت وإعدادات الإشعارات وما إذا كان سيتم عرض أو إخفاء الأخبار بعد أن يقرأها المستخدم. يمكن أن يخزّن DataStore أيضًا عناصر مكتوبة باستخدام مخازن البروتوكول.

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

على سبيل المثال، يمكنك إنشاء NotificationsDataStore لا يتعامل إلا مع الإعدادات المفضّلة المتعلقة بالإشعارات، وNewsPreferencesDataStore لا يتعامل إلا مع الإعدادات المفضّلة المتعلقة بشاشة الأخبار. بهذه الطريقة، يمكنك تحديد نطاق التحديثات بشكل أفضل، لأنّ مسار newsScreenPreferencesDataStore.data لا ينبعث منه إلا عند تغيير أحد الإعدادات المفضّلة المرتبطة بهذه الشاشة. ويعني ذلك أيضًا أنّ مراحل النشاط للعنصر يمكن أن تكون أقصر لأنّه يمكن أن يبقى نشطًا فقط طالما أنّ شاشة الأخبار معروضة.

لمزيد من المعلومات حول استخدام واجهات برمجة التطبيقات DataStore، يمكنك الاطّلاع على أدلة DataStore.

ملف كمصدر بيانات

عند التعامل مع عناصر كبيرة، مثل عنصر JSON أو صورة نقطية، عليك استخدام عنصر File والتعامل مع تبديل سلاسل المحادثات.

لمزيد من المعلومات حول استخدام مساحة تخزين الملفات، يُرجى الاطّلاع على صفحة نظرة عامة على مساحة التخزين.

جدولة المهام باستخدام WorkManager

لنفترض أنّه تم طرح متطلب جديد آخر لتطبيق "الأخبار": يجب أن يوفّر التطبيق للمستخدم خيار جلب آخر الأخبار بانتظام وتلقائيًا طالما أنّ الجهاز قيد الشحن ومتصل بشبكة لا تفرض تكلفة استخدام. وهذا ما يجعلها عملية موجّهة نحو النشاط التجاري. يضمن هذا الشرط أنّه حتى إذا لم يكن الجهاز متصلاً بالإنترنت عند فتح المستخدم للتطبيق، سيظل بإمكانه الاطّلاع على آخر الأخبار.

تسهّل مكتبة WorkManager جدولة العمل غير المتزامن والموثوق، ويمكنها الاهتمام بإدارة القيود. وهي المكتبة المقترَحة لتنفيذ المهام المستمرة. لتنفيذ المهمة المحدّدة أعلاه، يتم إنشاء فئة Worker: RefreshLatestNewsWorker. يستخدم هذا الصف NewsRepository كعنصر تابع من أجل استرداد آخر الأخبار وتخزينها مؤقتًا على القرص.

class RefreshLatestNewsWorker(
    private val newsRepository: NewsRepository,
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = try {
        newsRepository.refreshLatestNews()
        Result.success()
    } catch (error: Throwable) {
        Result.failure()
    }
}

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

في هذا المثال، يجب استدعاء المهمة المرتبطة بالأخبار من NewsRepository، التي ستتطلّب مصدر بيانات جديدًا كعنصر تابع، NewsTasksDataSource، على النحو التالي:

private const val REFRESH_RATE_HOURS = 4L
private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"
private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"

class NewsTasksDataSource(
    private val workManager: WorkManager
) {
    fun fetchNewsPeriodically() {
        val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(
            REFRESH_RATE_HOURS, TimeUnit.HOURS
        ).setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)
                .setRequiresCharging(true)
                .build()
        )
            .addTag(TAG_FETCH_LATEST_NEWS)

        workManager.enqueueUniquePeriodicWork(
            FETCH_LATEST_NEWS_TASK,
            ExistingPeriodicWorkPolicy.KEEP,
            fetchNewsRequest.build()
        )
    }

    fun cancelFetchingNewsPeriodically() {
        workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)
    }
}

تُسمّى هذه الأنواع من الفئات حسب البيانات التي تكون مسؤولة عنها، مثل NewsTasksDataSource أو PaymentsTasksDataSource. يجب تضمين جميع المهام ذات الصلة بنوع معيّن من البيانات في الفئة نفسها.

إذا كان يجب تشغيل المهمة عند بدء تشغيل التطبيق، يُنصح بتشغيل طلب WorkManager باستخدام مكتبة بدء تشغيل التطبيق التي تستدعي المستودع من Initializer.

لمزيد من المعلومات حول استخدام واجهات برمجة تطبيقات WorkManager، يُرجى الاطّلاع على أدلة WorkManager.

الاختبار

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

اختبارات الوحدات

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

اختبارات الدمج

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

بالنسبة إلى قواعد البيانات، يتيح Room إنشاء قاعدة بيانات في الذاكرة يمكنك التحكّم فيها بشكل كامل في اختباراتك. لمزيد من المعلومات، اطّلِع على صفحة اختبار قاعدة البيانات وتصحيح أخطائها.

بالنسبة إلى الشبكات، تتوفّر مكتبات شائعة، مثل WireMock أو MockWebServer، تتيح لك محاكاة طلبات HTTP وHTTPS والتحقّق من أنّ الطلبات تم إجراؤها على النحو المتوقّع.

مراجع إضافية

نماذج