أفضل تطبيقات Android باستخدام MVVM مع بنية نظيفة
نشرت: 2022-03-11إذا لم تختر البنية المناسبة لمشروع Android الخاص بك ، فستجد صعوبة في الحفاظ عليها مع نمو قاعدة التعليمات البرمجية وتوسع فريقك.
هذا ليس مجرد برنامج تعليمي لنظام Android MVVM. في هذه المقالة ، سنقوم بدمج MVVM (Model-View-ViewModel أو أحيانًا "نمط ViewModel") مع الهندسة المعمارية النظيفة. سنرى كيف يمكن استخدام هذه البنية لكتابة كود منفصل وقابل للاختبار وقابل للصيانة.
لماذا MVVM مع العمارة النظيفة؟
يفصل Fragment
وجهة نظرك (أي Activity
والأجزاء) عن منطق عملك. يعد MVVM كافيًا للمشاريع الصغيرة ، ولكن عندما تصبح قاعدة التعليمات البرمجية الخاصة بك ضخمة ، يبدأ طراز ViewModel
الخاص بك في الانتفاخ. يصبح فصل المسؤوليات صعبًا.
MVVM مع Clean Architecture جيد جدًا في مثل هذه الحالات. يذهب خطوة أخرى إلى الأمام في فصل مسؤوليات قاعدة التعليمات البرمجية الخاصة بك. من الواضح أنه يلخص منطق الإجراءات التي يمكن تنفيذها في تطبيقك.
ملاحظة: يمكنك أيضًا الجمع بين الهندسة المعمارية النظيفة وبنية مقدم عرض النموذج (MVP). ولكن نظرًا لأن Android Architecture Components توفر بالفعل فئة ViewModel
مضمنة ، فإننا نشترك مع MVVM عبر MVP - لا يتطلب إطار عمل MVVM!
مزايا استخدام العمارة النظيفة
- الكود الخاص بك هو أكثر قابلية للاختبار بسهولة من MVVM العادي.
- تم فصل الكود الخاص بك بشكل أكبر (أكبر ميزة.)
- هيكل الحزمة أسهل في التنقل.
- المشروع أسهل في الصيانة.
- يمكن لفريقك إضافة ميزات جديدة بسرعة أكبر.
مساوئ العمارة النظيفة
- لديها منحنى تعليمي حاد قليلاً. قد تستغرق كيفية عمل كل الطبقات معًا بعض الوقت لفهمها ، خاصةً إذا كنت قادمًا من أنماط مثل MVVM أو MVP.
- إنها تضيف الكثير من الفئات الإضافية ، لذا فهي ليست مثالية للمشروعات منخفضة التعقيد.
سيبدو تدفق البيانات لدينا كما يلي:
منطق عملنا منفصل تمامًا عن واجهة المستخدم الخاصة بنا. إنه يجعل الكود الخاص بنا سهل الصيانة والاختبار.
المثال الذي سنراه بسيط للغاية. يسمح للمستخدمين بإنشاء مشاركات جديدة ومشاهدة قائمة الوظائف التي أنشأوها. أنا لا أستخدم أي مكتبة تابعة لجهات خارجية (مثل Dagger و RxJava وما إلى ذلك) في هذا المثال من أجل البساطة.
طبقات MVVM مع العمارة النظيفة
الكود مقسم إلى ثلاث طبقات منفصلة:
- طبقة العرض
- طبقة المجال
- طبقة البيانات
سوف ندخل في مزيد من التفاصيل حول كل طبقة أدناه. في الوقت الحالي ، تبدو بنية الحزمة الناتجة كما يلي:
حتى داخل بنية تطبيقات Android التي نستخدمها ، هناك العديد من الطرق لهيكلة التسلسل الهرمي للملفات / المجلدات. أحب تجميع ملفات المشروع بناءً على الميزات. أجده أنيقًا وموجزًا. لك الحرية في اختيار أي هيكل مشروع يناسبك.
طبقة العرض
يتضمن هذا Activity
، Fragment
، ViewModel
. يجب أن يكون Activity
غبيًا قدر الإمكان. لا تضع أبدًا منطق عملك في Activity
s.
سيتحدث Activity
إلى ViewModel
ViewModel
إلى طبقة المجال لتنفيذ الإجراءات. لا يتحدث ViewModel
أبدًا مع طبقة البيانات مباشرة.
نحن هنا نقوم بتمرير UseCaseHandler
واثنين من UseCase
إلى ViewModel
الخاص بنا. سوف ندخل في ذلك بمزيد من التفاصيل قريبًا ، ولكن في هذه البنية ، يعد UseCase
إجراءً يحدد كيفية تفاعل ViewModel
مع طبقة البيانات.
إليك كيف يبدو كود Kotlin الخاص بنا:
class PostListViewModel( val useCaseHandler: UseCaseHandler, val getPosts: GetPosts, val savePost: SavePost): ViewModel() { fun getAllPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { val requestValue = GetPosts.RequestValues(userId) useCaseHandler.execute(getPosts, requestValue, object : UseCase.UseCaseCallback<GetPosts.ResponseValue> { override fun onSuccess(response: GetPosts.ResponseValue) { callback.onPostsLoaded(response.posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } fun savePost(post: Post, callback: PostDataSource.SaveTaskCallback) { val requestValues = SavePost.RequestValues(post) useCaseHandler.execute(savePost, requestValues, object : UseCase.UseCaseCallback<SavePost.ResponseValue> { override fun onSuccess(response: SavePost.ResponseValue) { callback.onSaveSuccess() } override fun onError(t: Throwable) { callback.onError(t) } }) } }
طبقة المجال
تحتوي طبقة المجال على جميع حالات استخدام التطبيق الخاص بك. في هذا المثال ، لدينا UseCase
، فئة مجردة. ستعمل جميع UseCase
بنا على تمديد هذه الفئة.
abstract class UseCase<Q : UseCase.RequestValues, P : UseCase.ResponseValue> { var requestValues: Q? = null var useCaseCallback: UseCaseCallback<P>? = null internal fun run() { executeUseCase(requestValues) } protected abstract fun executeUseCase(requestValues: Q?) /** * Data passed to a request. */ interface RequestValues /** * Data received from a request. */ interface ResponseValue interface UseCaseCallback<R> { fun onSuccess(response: R) fun onError(t: Throwable) } }
ويعالج UseCaseHandler
تنفيذ UseCase
. يجب ألا نحظر واجهة المستخدم مطلقًا عند جلب البيانات من قاعدة البيانات أو من خادمنا البعيد. هذا هو المكان الذي نقرر فيه تنفيذ UseCase
الخاص بنا على سلسلة رسائل في الخلفية وتلقي الرد على الموضوع الرئيسي.
class UseCaseHandler(private val mUseCaseScheduler: UseCaseScheduler) { fun <T : UseCase.RequestValues, R : UseCase.ResponseValue> execute( useCase: UseCase<T, R>, values: T, callback: UseCase.UseCaseCallback<R>) { useCase.requestValues = values useCase.useCaseCallback = UiCallbackWrapper(callback, this) mUseCaseScheduler.execute(Runnable { useCase.run() }) } private fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mUseCaseScheduler.notifyResponse(response, useCaseCallback) } private fun <V : UseCase.ResponseValue> notifyError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mUseCaseScheduler.onError(useCaseCallback, t) } private class UiCallbackWrapper<V : UseCase.ResponseValue>( private val mCallback: UseCase.UseCaseCallback<V>, private val mUseCaseHandler: UseCaseHandler) : UseCase.UseCaseCallback<V> { override fun onSuccess(response: V) { mUseCaseHandler.notifyResponse(response, mCallback) } override fun onError(t: Throwable) { mUseCaseHandler.notifyError(mCallback, t) } } companion object { private var INSTANCE: UseCaseHandler? = null fun getInstance(): UseCaseHandler { if (INSTANCE == null) { INSTANCE = UseCaseHandler(UseCaseThreadPoolScheduler()) } return INSTANCE!! } } }
كما يوحي اسمه ، فإن GetPosts
UseCase
مسؤول عن الحصول على جميع مشاركات المستخدم.
class GetPosts(private val mDataSource: PostDataSource) : UseCase<GetPosts.RequestValues, GetPosts.ResponseValue>() { protected override fun executeUseCase(requestValues: GetPosts.RequestValues?) { mDataSource.getPosts(requestValues?.userId ?: -1, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { val responseValue = ResponseValue(posts) useCaseCallback?.onSuccess(responseValue) } override fun onError(t: Throwable) { // Never use generic exceptions. Create proper exceptions. Since // our use case is different we will go with generic throwable useCaseCallback?.onError(Throwable("Data not found")) } }) } class RequestValues(val userId: Int) : UseCase.RequestValues class ResponseValue(val posts: List<Post>) : UseCase.ResponseValue }
الغرض من UseCase
s هو أن تكون وسيطًا بين ViewModel
s و Repository
s.

لنفترض أنك قررت في المستقبل إضافة ميزة "تعديل المنشور". كل ما عليك القيام به هو إضافة EditPost
UseCase
وستكون كل التعليمات البرمجية الخاصة به منفصلة تمامًا وفصلها عن UseCase
s الأخرى. لقد رأيناها جميعًا عدة مرات: يتم تقديم ميزات جديدة وتقوم عن غير قصد بكسر شيء ما في التعليمات البرمجية الموجودة مسبقًا. يساعد إنشاء حالة استخدام UseCase
بشكل كبير في تجنب ذلك.
بالطبع ، لا يمكنك التخلص من هذا الاحتمال بنسبة 100 في المائة ، لكن يمكنك بالتأكيد تقليله. هذا هو ما يفصل العمارة النظيفة عن الأنماط الأخرى: الشفرة مفصولة بحيث يمكنك التعامل مع كل طبقة على أنها صندوق أسود.
طبقة البيانات
يحتوي هذا على جميع المستودعات التي يمكن لطبقة المجال استخدامها. تعرض هذه الطبقة واجهة برمجة تطبيقات مصدر البيانات للفئات الخارجية:
interface PostDataSource { interface LoadPostsCallback { fun onPostsLoaded(posts: List<Post>) fun onError(t: Throwable) } interface SaveTaskCallback { fun onSaveSuccess() fun onError(t: Throwable) } fun getPosts(userId: Int, callback: LoadPostsCallback) fun savePost(post: Post) }
تنفذ PostDataRepository
PostDataSource
. يقرر ما إذا كنا نحضر البيانات من قاعدة بيانات محلية أو خادم بعيد.
class PostDataRepository private constructor( private val localDataSource: PostDataSource, private val remoteDataSource: PostDataSource): PostDataSource { companion object { private var INSTANCE: PostDataRepository? = null fun getInstance(localDataSource: PostDataSource, remoteDataSource: PostDataSource): PostDataRepository { if (INSTANCE == null) { INSTANCE = PostDataRepository(localDataSource, remoteDataSource) } return INSTANCE!! } } var isCacheDirty = false override fun getPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { if (isCacheDirty) { getPostsFromServer(userId, callback) } else { localDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { getPostsFromServer(userId, callback) } }) } } override fun savePost(post: Post) { localDataSource.savePost(post) remoteDataSource.savePost(post) } private fun getPostsFromServer(userId: Int, callback: PostDataSource.LoadPostsCallback) { remoteDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() refreshLocalDataSource(posts) callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } private fun refreshLocalDataSource(posts: List<Post>) { posts.forEach { localDataSource.savePost(it) } } private fun refreshCache() { isCacheDirty = false } }
الرمز في الغالب لا يحتاج إلى شرح. تحتوي هذه الفئة على متغيرين ، localDataSource
و remoteDataSource
. نوعهم هو PostDataSource
، لذلك نحن لا نهتم بكيفية تنفيذها فعليًا تحت الغطاء.
في تجربتي الشخصية ، أثبتت هذه الهندسة أنها لا تقدر بثمن. في أحد تطبيقاتي ، بدأت باستخدام Firebase في النهاية الخلفية وهو أمر رائع لبناء تطبيقك بسرعة. علمت في النهاية أنه سيتعين علي الانتقال إلى الخادم الخاص بي.
عندما فعلت ذلك ، كان كل ما علي فعله هو تغيير التطبيق في RemoteDataSource
. لم يكن عليّ أن أتطرق إلى أي فصل دراسي آخر حتى بعد هذا التغيير الهائل. هذه هي ميزة الكود المنفصل. يجب ألا يؤثر تغيير أي فصل دراسي على أجزاء أخرى من التعليمات البرمجية الخاصة بك.
بعض الفئات الإضافية لدينا هي:
interface UseCaseScheduler { fun execute(runnable: Runnable) fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) } class UseCaseThreadPoolScheduler : UseCaseScheduler { val POOL_SIZE = 2 val MAX_POOL_SIZE = 4 val TIMEOUT = 30 private val mHandler = Handler() internal var mThreadPoolExecutor: ThreadPoolExecutor init { mThreadPoolExecutor = ThreadPoolExecutor(POOL_SIZE, MAX_POOL_SIZE, TIMEOUT.toLong(), TimeUnit.SECONDS, ArrayBlockingQueue(POOL_SIZE)) } override fun execute(runnable: Runnable) { mThreadPoolExecutor.execute(runnable) } override fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mHandler.post { useCaseCallback.onSuccess(response) } } override fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mHandler.post { useCaseCallback.onError(t) } } }
UseCaseThreadPoolScheduler
مسؤول عن تنفيذ المهام بشكل غير متزامن باستخدام ThreadPoolExecuter
.
class ViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { if (modelClass == PostListViewModel::class.java) { return PostListViewModel( Injection.provideUseCaseHandler() , Injection.provideGetPosts(), Injection.provideSavePost()) as T } throw IllegalArgumentException("unknown model class $modelClass") } companion object { private var INSTANCE: ViewModelFactory? = null fun getInstance(): ViewModelFactory { if (INSTANCE == null) { INSTANCE = ViewModelFactory() } return INSTANCE!! } } }
هذا هو لدينا ViewModelFactory
. يجب عليك إنشاء هذا لتمرير الوسائط في مُنشئ ViewModel
الخاص بك.
حقن التبعية
سأشرح حقن التبعية بمثال. إذا نظرت إلى فئة PostDataRepository
الخاصة بنا ، فإنها تحتوي على تبعيتين ، LocalDataSource
و RemoteDataSource
. نستخدم فئة Injection
لتوفير هذه التبعيات لفئة PostDataRepository
.
التبعية للحقن ميزتان رئيسيتان. الأول هو أنه يمكنك التحكم في إنشاء مثيل للكائنات من مكان مركزي بدلاً من نشرها عبر قاعدة الكود بأكملها. آخر هو أن هذا سيساعدنا في كتابة اختبارات الوحدة لـ PostDataRepository
لأنه يمكننا الآن فقط تمرير إصدارات مزيفة من LocalDataSource
و RemoteDataSource
إلى مُنشئ PostDataRepository
بدلاً من القيم الفعلية.
object Injection { fun providePostDataRepository(): PostDataRepository { return PostDataRepository.getInstance(provideLocalDataSource(), provideRemoteDataSource()) } fun provideViewModelFactory() = ViewModelFactory.getInstance() fun provideLocalDataSource(): PostDataSource = LocalDataSource.getInstance() fun provideRemoteDataSource(): PostDataSource = RemoteDataSource.getInstance() fun provideGetPosts() = GetPosts(providePostDataRepository()) fun provideSavePost() = SavePost(providePostDataRepository()) fun provideUseCaseHandler() = UseCaseHandler.getInstance() }
ملاحظة: أفضل استخدام Dagger 2 لحقن التبعية في المشاريع المعقدة. ولكن مع منحنى التعلم شديد الانحدار ، فهو خارج نطاق هذه المقالة. لذلك إذا كنت مهتمًا بالتعمق أكثر ، فإنني أوصي بشدة بتقديم Hari Vignesh Jayapalan إلى Dagger 2.
MVVM مع بنية نظيفة: مزيج صلب
كان هدفنا من هذا المشروع هو فهم MVVM باستخدام Clean Architecture ، لذلك تخطينا بعض الأشياء التي يمكنك محاولة تحسينها بشكل أكبر:
- استخدم LiveData أو RxJava لإزالة عمليات الاسترجاعات وجعلها أكثر إتقانًا.
- استخدم الدول لتمثيل واجهة المستخدم الخاصة بك. (لذلك ، تحقق من هذا الحديث الرائع لجيك وارتون.)
- استخدم Dagger 2 لحقن التبعيات.
هذه واحدة من أفضل الهياكل وأكثرها قابلية للتطوير لتطبيقات Android. أتمنى أن تكون قد استمتعت بهذه المقالة ، وأتطلع إلى سماع كيف استخدمت هذا الأسلوب في تطبيقاتك الخاصة!