كوتلن: بناء تطبيقات الاندرويد بنمط الـ MVVM والـ Coroutines
تطويع الروتينات المساعدة Coroutines مع لغة الكوتلن Kotlin للتعامل مع قاعدة البيانات Room وبناء تطبيقات اندرويد بهندسة MVVM
بسم الله الرحمن الرحيم
السلام عليكم ورحمة الله وبراكته
في هذه المقالة سنرى كيفية تطويع الروتينات المساعدة Coroutines التي تأتي مع لغة الكوتلن Kotlin في التعامل مع قاعدة البيانات الـ Room في بناء تطبيقات الاندرويد بنمط الهندسة الـ MVVM.
قبل الشروع في القراءة أعلم أن هذه المقالة تنقاش فقط جزئية الكوروتينات Coroutines في كلاً من الفئات Dao و Repository و ViewModel في بيئة الاندرويد. وقد تبدو فيها بعض الصعوبه لذلك من الافضل الالمام بهذا المجال قبل كل شئ. اذا اردت هذه دروس تفصيليه خطوة بخطوة تفضل بزيارة احد هذه الدورات العربية:
- Android Architecture Components: دورة نصية تختص بشرح بنمط الهندسة MVVM وقاعدة البيانات Room (جافا).
- احترف الـ Room في تطوير تطبيقات الأندرويد - JAVA: دورة مصورة تختص بشرح بنمط الهندسة MVVM وقاعدة البيانات Room (جافا).
- الروتينات التعاونية Coroutines في لغة الكوتلن: دورة نصية تختص بشرح الروتينات التعاونية Coroutines في لغة الكوتلن (كوتلن).
وللمزيد اطلع في المصادر.
في البداية من المستحسن التعامل مع أي شئ I/O (المخارج والمداخل لتطبيقك مثل قواعد البيانات, وحدات التخزين, اتصالات السيرفرات) في خيط حاسوبي مختلف عن الخيط الرئيسي. وسبب ذلك لأن هذه الامور بطيئة واغلب الاحيان تسبب بطئ للتطبيق والتهنيق وهذا الأمر (البطئ في وحدات التخزين أو في الانترنت) خارج عن سيطرة المبرمج.
الحل القديم
في الماضي كان استخدام الـ AsyncTask في برمجة تطبيقات الاندرويد هو الحل الأول لمثل هذه الأمور. وبالرغم من كونه سبب كثير من المشاكل مثل تسرب الذاكرة والبطئ إلا أنه كان مستحسن لدى المبرمجين لسهولته. مما أطر العاملين عليه الـ SDK Android بتطويره وبناءه مرة تلوى الاخرى بإستخدام Threads الى Executors وتقنيات اخرى إلا أنه بالرغم من تلك التطويرات مازال يسبب الكثير من المشاكل مما دفعهم في النهاية للتخلي عنه وتوصيفه كـ Deprecated.
الحل الحاضر
وكان الحل الرائج وقتها هو استخدام الـ Executors الخاص بالجافا (ماهو نمط الـ Thread Pools (الـ Executors)). ولكن مطوري الكوتلن ارادو شئ آخر خاص بهم فقاموا بإنشاء مكتبة الروتينات المساعدة Coroutines.
الحل المستقبلي
الى الآن يستطيع مطوري تطبيقات الأندرويد بلغة الكوتلن أستخدام جميع الخيارات سواء كان Thread أو Executors أو RxJava (الـ AsyncTask لا). ولكن كخيار حديث ومناسب للمستقبل وخاص بلغة الكوتلن فالروتينات التعاونية Coroutines هي الحل المستقبلي لمن يريد تطوير التطبيقات بالكوتلن.
هندسة التطبيقات بنمط الـ MVVM
كتعريف سريع له فهو يتكون من عدة طبقات منها طبقة الـ DAO وهي المسؤولة عن أوامر التخاطب مع الـ SQL لقاعدة البيانات من قراءة وكتابة. والطبقة الأخرى هي الـ Repository وتمثل طبقة اختيارية إلا في تطبيقات تحتوي على أكثر من مصدر للبيانات (كأكثر من قاعدة بيانات و خوادم و API) وتمثل المستودع الذي يغدي التطبيق بالبيانات. أما الطبقة الاخيرة وهي الـ ViewModel فقد صصمت للإلتصاق بالـ UI كالـ Activity والـ Fragment مع إدراك لدورتهم الحياتية LifeCycle وتزويدها بالبيانات من المستودع (أو من الـ DAO أذا قمنا باهمال طبقة المستودع).
الروتينات التعاونية Coroutines
أما الروتين التعاوني Coroutine نستطيع فهمه في لغة الكوتلن على أنه كائن قابل للتأجيل (أي الايقاف والتشغيل بشكل متكرر بدلاً من الحجب كما تفعل الـ Thread) ومتعاون مع بعضه البعض وفي طياته يحمل شفرة برمجية ليرسلها الى المعالج ليتم تنفيذها. يستطيع الانتقال والتبديل إلى خيوط اخرى. فمثلاً إذا أنشأت Coroutine في الخيط الحاسوبي الرئيسي Main Thread تستطيع توقيفه مؤقتاً إي تأجيله Suspended ومن ثم نقله الى خيط حاسوبي آخر واستئنافه Resume ليتم عمله, وما الى ذلك.
إستخدام الروتينات التعاونية Coroutines مع هندسة الـ MVVM
والان لو اردنا دمجهم مع بعض فانها تتداخل (الروتينات) تقريباً في كل الطبقات للـ MVVM. فنحن نقوم بتشغيل شفرة برمجية تستخرج لنا بيانات من قاعدة البيانات في كروتين موجود على الخيط الخلفي ثم نقوم بنقله الى الخيط الرئيسي عندما تكون النتيجة متاحة وتعرضه لواجهة المستخدم. إي اننا نستطيع وضعهم في الـ Dao وذلك بترميز دوالها بكلمة suspend لجعلها دالة روتينه مؤجله أو جعل دوالها تقوم بإرجاع تدفق Flow (حيث تعتبر كلمة suspend و Flow من حزمة الكروتينات) ثم نقوم بنقلهم الى الخيط الرئيسي بإستخدام كروتين آخر ليعرضهم في الواجهة.
التسلسل للشفرة البرمجية لنمط الـ MVVM
أبرز نقاط التسلسل لنمط الـ MVVM هي كالتالي:
- وضع الإعتماديات للـ Room في ملف الـ Build.Gradle.
- وضع الإعتماديات للـ Coroutines في ملف الـ Build.Gradle.
- إنشاء الـ Model أو بالاحرى الـ Entity لتمثيل البيانات.
- إنشاء الـ Dao لتمثيل أوامر الـ SQL.
- لاننسى وسم تلك الدوال بالـ suspend وكذلك نوع الارجاع لها إما يكون مغلف بـ LiveData أو Flow. وكذلك لاننسى تهيئته لإستخدام الـ Paging في حالة احتياجنا ذلك.
- إنشاء قاعدة البيانات الـ Room.
- لاننسى تعبئتها بالبيانات مسبقاً اذا اردنا ذلك.
- كتابة وإجراء الاختبارات التي تتعلق بقاعدة البيانات.
- إنشاء الـ Repository لتمثيل مستودع البيانات.
- في العادة سيكون اختياري, وضروري اذا كان لدينا أكثر من قاعدة بيانات او مصدر بيانات آخر (مع أن الشئ الأمثل هو جعل جميع مصادر البيانات تصب في قاعدة الـ Room ونقرئ فقط منها).
- إنشاء الـ ViewModel لتمثيل الربط بين قاعدة البيانات وواجهة المستخدم.
- لاننسى وضع شفرات المنطق Logic الخفيفة التي تتعلق بالواجهه او شفرات التعديل على البيانات.
- ولاننسى وضع شفرات التبديل من خيط الى آخر سواء كانت Threads أو Executors أو Coroutines.
- وكذلك إستخدام النطاق الخاص بها viewModelScope أو إنشاء نطاق مخصص.
- تخزين وعرض البيانات في واجهة المستخدم.
- سواء كان بإنشاء Adapter لربطة بالـ RecyclerView.
- أو إنشاء views تتمثل كأزرار للحفظ والتعديل والحذف والاستعلام في قاعدة البيانات.
وسأتطرق الى النقاط التي تتعلق بالـ Dao و الـ ViewModel والـ Activity/Fragment.
طبقة الـ Dao بالـ Coroutines
في هذه الطبقة سنرى عدة امثلة, منها مثالين بإستخدام LiveData كمراجعة لها, ومن ثم مثالين لإستخدام التدفق Flow من الكورتينات, واخيراً ثلاثة أمثلة لإستخدام كلمة suspend لجعل الدوال تأجيلية.
// LiveData
@Query("SELECT * FROM note_table WHERE id = :noteId")
fun getNote(noteId: String): LiveData<NoteEntity?>
// LiveData
@Query("SELECT * FROM note_table")
fun getAllNotes(): LiveData<List<NoteEntity>>
// Flow
@Query("SELECT * FROM note_table WHERE id = :noteId")
fun getNote(noteId: String): Flow<NoteEntity?>
// Flow
@Query("SELECT * FROM note_table")
fun getAllNotes(): Flow<List<NoteEntity>>
// suspend
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(note: NoteEntity)
// suspend
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun update(note: NoteEntity)
// suspend
@Delete
suspend fun delete(note: NoteEntity)
- المثال الاول: قمنا بإستخدام الـ LiveData لإرجاع عنصر واحد فقط. انتبه الى أن هذا العنصر سيكون نداء آمن Safe Call Operator وذلك بإستخدام رمز الاستفهام والذي ربما يقوم بإرجاع عنصر نصي null لايعطل البرنامج (للمزيد اطلع على: التخلص من Null في كوتلن).
- المثال الثاني: مشابه للمثال الأول ولكن هنا قمنا بإرجاع سلسلة كاملة من العناصر, وإذا لم توجد ستقوم بإرجاع سلسلة فارغة.
- انتبه الى أن الـ LiveData تمكن الدالة من العمل في خيط حاسوبي خلفي. ولإستخراج منها البيانات يجب علينا مراقبتها Observer في احدى الكائنات التي تتميز بدورة حياة Lifecycle مثل الـ Activity و الـ Fragment والإ سيتوجب علينا إستخدام observeForever() مع مراعاة إنهائه والا سيحدث تسريب في الذاكرة Memory Leak ولا ننسى أننا نستطيع إنشاء دورة حياة LifeCycle لإي فئة class نريد كحل آخر.
- انتبه الى أن الـ LiveData هي للاستعلامات الخفيفة Query أما الاستعلامات المعقدة والمتداخلة نستطيع إستخدام MediatorLiveData.
- المثال الثالث: قمنا بإستخدام الكورنتينات Coroutine وبالأخص نوع التدفق منها Flow (للمزيد عنه يمكنك الاطلاع على درس ماهي القنوات Coroutine Channels حيث انه بني عليها وكذلك درس ماهو التدفق Coroutine Flow). ولإرجاع عنصر واحد كما بالمثال الأول فأننا سنقوم بإستخدام هذا التدفق لاستخلاص نتيجة واحدة منه وذلك من خلال الدالة first() لاحقاً, التي تتيح لنا الحصول على أول نتيجة للتدفق ومن ثم إغلاقه.
- المثال الرابع: مشابهة للمثال الثالث ولكن هنا نريد الحصول على سلسلة كاملة من العناصر. ولذلك نقوم بإستخدام الدالة collect() لاحقاً, او تحويله الى LiveData ومراقبته.
- الأمثلة الاخرى: الى هنا انتهينا من أوامر الـ SQL للإستعلام التي تأتي بنوتيشن الـ @Query. يبقى لنا الأوامر الاخرى في الأمثله المتبقية والتي هي تمثل التخزين والتحديث والحذف. هنا نستطيع إستخدام معها كلمة suspend لجعلها دالة تأجيله تعمل في الخلف حتى لا يحدث بطئ لنا (ما هي الدوال التأجيلية).
- لاحظ الى انك تستطيع جعل هذه الدوال تقوم بإرجاع نتيجة ما. فمثلاً دوال الحذف والتحديث تستطيع إرجاع عنصر Int لعدد الصفوف المتأثرة بها. والدالة Insert تستطيع إرجاع عنصر من نوع Long كرقم الـ ID للحقل التي تم حفظه.
- في النهاية تستطيع الاستغناء عن الـ LiveData واستخدام الـ Flow.
- وسنرى طريقة إستخدامهم وإستخلاص البيانات منهم في الطبقات القادمة.
طبقة الـ ViewModel بالـ Coroutines
ملاحظة: قمت بتخطي طبقة الـ Repository لإنها طبقة أخيارية في حالات كثيرة, إلا اذا كان لدينا أكثر من مصدر للبيانات فهنا تكون ضروريه. وبعض الاحيان ستحتاج الى إنشاء دورة حياة لها LifeCycle ونطاق Scope للكروتينات وما الى ذلك. بينما الـ ViewModel تأتي مزودة بدورة حياة LifeCycle تلتصق بالواجهه سواء Activity أو Fragment, وكذلك تأتي بنطاق خاص بها Scope يسمى ViewModelScope (ماهو النطاق Coroutine Scope؟) وجاهز للإستخدام. أما اذا لاتريد كل هذه الاشياء فستكون طبقة تكرارية للـ ViewModel ولا فائدة لها. وفي هذه الامثلة سنقوم بإستقبال الدوال التي قمنا بتعريفها في ملف الـ Dao في القسم السابق.
وبالنسبة لتمرير المدخلات الى الـ ViewModel فبشكل افتراضي ستحتاج الى كلاس اخرى من نمط الـ Factory لعمل حقن Injection لهذه المدخلات. مثلاً المدخل من نوع Application الذي يمثل النطاق Context أو كمدخل قاعدة البيانات والذي يتمثل كـ Dao أو حتى كمدخلات خاصة مثل IDs للعناصر وما الى ذلك. مثال للـ Factory لتمرير فقط مدخلين Application و Dao كالتالي:
class MyViewModelFactory(
private val dataSource: NoteDao,
private val application: Application,
private val noteId: Long
) : ViewModelProvider.Factory {
@Suppress("unchecked_cast")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MyViewModel::class.java)) {
return MyViewModel(dataSource, application, noteId) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
يكمن عمل الـ Factory كطريقة حقن بدائية للـ ViewModel. نستطيع أيضاً استخدام مكتبات حقن للإعتماديات Dependency injection مثل مكتبة الخنجر Dagger سابقاً, وحالياً المنصح أستخدامها بشكل رسمي هي مكتبة مقبض السيف Hilt التي ستعتبر من ضمن مكاتب الـ Jetpack لاحقاً في الاصدارات النهاية. سأقوم بتحديث هذه المقالة عندما تكون متاحة للإستخدام النهائي أن امكن.
والأن نأتي الى أمثلة الـ ViewModel:
class MyViewModel(
val database: NoteDao,
application: Application,
noteId: Long
) : AndroidViewModel(application) {
private var notes = database.getAllNotes()
private var note = database.getNote(noteId)
...
- لإستقبال العناصر من الدوال التي تقوم بإرجاع LiveData فكل ماعلينا فعله هو ندائها.
- لاتقلق فإنها تقوم بتشغيل نفسها في الخلفية ومطلعة على آخر التحديثات للبيانات بفضل مميزات الـ LiveData.
ولإستقبال العناصر من الدوال التي تقوم بإرجاع Flow فالامر أسهل. فكل ماعلينا هو عمل دالة تأجيلية وذلك بإستخدام كلمة suspend كالتالي:
...
suspend fun getAllNotes(): Flow<List<NoteEntity>> {
return database.getAllNotes()
}
suspend fun getNote(noteId: Long): NoteEntity {
return database.getNote(noteId).first()
}
...
- بما أن نوع الأرجاع في الـ Dao هو Flow وبحيث أن الـ Flow يعتبر كروتين/دالة تأجيلية فيجب علينا ندائه من داخل كروتين آخر أو من داخل دالة تأجيلة. لذلك قمنا بجعل هذه الدوال تأجيلية suspend.
- ففي المثال الأول قمنا بندائه من داخل دالة تأجيلية. وسنقوم بإستخلاص البيانات منه في الواجهه لاحقاً.
- أما في المثال الثاني فقد قمنا بعمل كما جاء في المثال الأول ولكننا زدنا إستخدام دالة تأجيلية آخرى وهي first() حيث تمكننا من الاستعلام عن اول نتيجة ومن ثم أغلاق هذا التدفق Flow. لاحقاً نستطيع أستخلاص هذا البيان في الواجهه من خلال كروتين. لاحظ أن الناتج المرجع هو عنصر Note وليس تدفق.
والآن بالنسبة الى الدوال الاخرى المسؤولة عن الحفظ والتحديث والحذف. نقوم بإستقبالهم من خلال دالة تأجيلية تعمل في خيط حاسوبي خلفي. ومن ثم سنقوم بإستخدام هذه الدالة في كروتين يعمل في الخيط الحاسوبي الرئيسي. حتى يكون الوضع نظيف عندما نأتي للتعامل معهم في طبقة واجهة المستخدم. وتعتبر طريقة للخروج من جحر الأرنب (الكروتينات). المثال التالي سنطبقه على دالة الحفظ وقس على ذلك بقية الدوال:
...
private suspend fun insert(note: NoteEntity) {
viewModelScope.launch(Dispatchers.IO) {
database.insert(note)
}
}
fun save(note: NoteEntity) {
viewModelScope.launch(Dispatchers.Main) {
insert(note)
}
}
...
- الدالة الاولى insert() تعتبر دالة تأجيلية من خلال كلمة suspend (إي كروتين) وتعمل في الخلفية وذلك من خلال الموزع Dispatchers.IO (الموزعين الـ Dispatchers).
- كذلك لاحظ استخدام النطاق Scope الذي يأتي مع الـ ViewModel في حزمتها بدلاً من إنشاء نطاقنا الخاص (النطاقات الـ Scopes).
- أما الدالة الثانية فتعتبر دالة عادية إي ليست كروتين, ولكنها تقوم بإنشاء كروتين يعمل في الخيط الرئيسي, وتقوم وظيفته بنداء الدالة التأجيلية السابقة (بحكم أننا لانستطيع نداء دالة تأجيلية إلا من خلال كروتين أو دالة اخرى). وهكذا نخرج من جحر الأرنب ونستطيع نداء دالتنا save في إي مكان نريد لحفظ عناصر الـ Note.
طبقة الواجهه Activity أو Fragment لإستخلاص البيانات وعرضهم
في النهاية عندما نصل الى طبقات واجهة التطبيق كالـ Activity أو الـ Fragment نستطيع إنشاء الكروتينات كمثال إستخدام البناء launch (البنائين الـ Builders) ليقوم بدورة بتشغيل الدوال المؤجلة التي قمنا بكتابتها في الطبقات السابقة واستخلاص النتيجة لنا في الخيط الحاسوبي الرئيسي UI لنتمكن من عرضها للمستخدم.
ولكن في البداية سنحتاج الى إنشاء نطاق خاص بهذه الواجهه سواء كانت Activity أو Fragment وذلك بإنشاء عنصر Job لها ومن ثم Scope والذي سيتكون من الـ Job والموزع Dispatcher المتواجد في الخيط الحاسوبي الرئيسي Despatcher.Main ليقوم بتشغيل الكروتينات كالتالي:
class Activity/Fragment
...
private var viewModelJob = Job()
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)
private lateinit var myViewModel: MyViewModel
...
- في البداية ننشئ عنصر من نوع Job للتحكم في هذا النطاق لإغلاق أو نداء الكروتينات.
- ثم ننشئ النطاق uiScope الخاص بنا, والذي نريده ان ينقل ويقوم بتشغيل الكروتينات في الخيط الرئيسي.
- ولاننسى إنهاء الـ Job في الدوال التي تنتهي عند إنهاء دورة الحياة كـ onDestroy او onDestroyView في حالة الـ Fragment.
ولعمل تهيئه Init للـ ViewModel من خلال الـ Factory وإعطائه المدخلات كالتالي:
...
val application = requireNotNull(this.activity).application
val dataSource = AppDatabase.getInstance(application).noteDao
val viewModelFactory = MyViewModelFactory(dataSource, application, 1)
myViewModel = ViewModelProvider(this, viewModelFactory).get(MyViewModel::class.java)
...
ولإستخلاص البيانات بالنسبة للـ LiveData فسنحتاج الى مراقبتهم وذلك بإنشاء مراقب كالتالي:
...
myViewModel.getAllNotes().observe(viewLifecycleOwner, Observer {
it?.let {
// it: List<NoteEntity>
}
})
...
أما بالنسبة الى الـ Flow فسنحتاج الى مراقبته من خلال الدالة collect() وذلك بداخل كروتين, لإننا لانستطيع نداء كروتين إلا من داخل كروتين آخر (إنشاء الكروتين Coroutine) كالتالي:
...
uiScope.launch {
startFragmentViewModel.getAllNotes().collect {
// it: List<NoteEntity>
}
}
...
- قمنا بإنشاء هذا الكروتين في النطاق uiScope الذي قمنا بإنشاءه مسبقاً, والذي يسمح لنا بإستقبال العناصر في الخيط الرئيسي.
وفي حالة إستخدام الدالة first() فأننا سنحصل على العنصر الفردي بشكل مباشر هكذا:
...
uiScope.launch {
val note: NoteEntitiy = myViewModel.getNote(1)
}
...
هذا الجدول يوضح الدوال في الطبقات
طبقة الـ Dao | طبقة الـ ViewModel | طبقة الواجهه Fragment أو Activity |
---|---|---|
|
|
|
في النهاية اذا وجدت ملاحظة أو اخطاء أو تحديثات يرجى تركهم كتعليق.
المصادر
- المرجع الرسمي للـ Documentaion الخاصة بتطوير الاندرويد.
- كتاب Elements of Android Room.
- كتاب Elements of Kotlin Coroutines.
- كتاب Learning Concurrency in Kotlin: Build highly efficient and robust applications.
- كتاب KOTLIN FOR ANDROID DEVELOPERS: LEARN KOTLIN THE EASY WAY WHILE DEVELOPING AN ANDROID APP.
التعليقات (0)
لايوجد لديك حساب في عالم البرمجة؟
تحب تنضم لعالم البرمجة؟ وتنشئ عالمك الخاص، تنشر المقالات، الدورات، تشارك المبرمجين وتساعد الآخرين، اشترك الآن بخطوات يسيرة !