Ако не изберете правилната архитектура за вашия Android проект, трудно ще го поддържате, тъй като вашата кодова база се разраства и екипът ви се разширява.
Това не е просто урок за Android MVVM. В тази статия ще комбинираме MVVM (Model-View-ViewModel или понякога стилизиран „модел ViewModel“) с Чиста архитектура . Ще видим как тази архитектура може да се използва за писане на отделен, тестваем и поддържаем код.
MVVM разделя вашия изглед (т.е. Activity
s и Fragment
s) от вашата бизнес логика. MVVM е достатъчен за малки проекти, но когато вашата кодова база стане огромна, вашите ViewModel
започват да се подуват. Разделянето на отговорностите става трудно.
MVVM с чиста архитектура е доста добър в такива случаи. Това отива една стъпка по-напред в разделянето на отговорностите на вашата кодова база. Той ясно абстрахира логиката на действията, които могат да бъдат извършени във вашето приложение.
Забележка: Можете да комбинирате Clean Architecture и с архитектурата на модел-изглед-презентатор (MVP). Но тъй като Архитектурни компоненти на Android вече предоставя вграден ViewModel
клас, отиваме с MVVM през MVP - не се изисква MVVM рамка!
Нашият поток от данни ще изглежда така:
Нашата бизнес логика е напълно отделена от нашия потребителски интерфейс. Това прави нашия код много лесен за поддръжка и тестване.
Примерът, който ще видим, е съвсем прост. Позволява на потребителите да създават нови публикации и да виждат списък с публикации, създадени от тях. В този пример не използвам никаква библиотека на трети страни (като Dagger, RxJava и др.) За по-голяма простота.
Кодът е разделен на три отделни слоя:
Ще влезем в повече подробности за всеки слой по-долу. Засега получената от нас структура на пакета изглежда така:
Дори в рамките на архитектурата на приложението за Android, която използваме, има много начини за структуриране на йерархията на вашите файлове / папки. Харесва ми да групирам файлове на проекти въз основа на функции. Намирам го за спретнато и кратко. Можете свободно да изберете каквато структура на проекта ви подхожда.
Това включва нашите Activity
s, Fragment
s и ViewModel
s. An Activity
трябва да бъде възможно най-тъп. Никога не поставяйте бизнес логиката си в Activity
s.
An Activity
ще говори с ViewModel
и a ViewModel
ще разговаря с домейн слоя за извършване на действия. A ViewModel
никога не говори директно със слоя данни.
Тук предаваме UseCaseHandler
и две UseCase
s към нашите ViewModel
. Скоро ще влезем в това по-подробно, но в тази архитектура a UseCase
е действие, което определя как a ViewModel
взаимодейства със слоя данни.
Ето как нашите Котлински код изглежда:
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 { 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 { override fun onSuccess(response: SavePost.ResponseValue) { callback.onSaveSuccess() } override fun onError(t: Throwable) { callback.onError(t) } }) } }
Домейновият слой съдържа всички случаи на употреба на вашето приложение. В този пример имаме UseCase
, абстрактен клас. Всички наши UseCase
ще разширят този клас.
abstract class UseCase { var requestValues: Q? = null var useCaseCallback: UseCaseCallback? = 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 { fun onSuccess(response: R) fun onError(t: Throwable) } }
И UseCaseHandler
обработва изпълнение на UseCase
. Никога не трябва да блокираме потребителския интерфейс, когато извличаме данни от базата данни или от отдалечения ни сървър. Това е мястото, където решаваме да изпълним нашите UseCase
на фонова нишка и получавате отговора на основната нишка.
class UseCaseHandler(private val mUseCaseScheduler: UseCaseScheduler) { fun execute( useCase: UseCase, values: T, callback: UseCase.UseCaseCallback) { useCase.requestValues = values useCase.useCaseCallback = UiCallbackWrapper(callback, this) mUseCaseScheduler.execute(Runnable { useCase.run() }) } private fun notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback) { mUseCaseScheduler.notifyResponse(response, useCaseCallback) } private fun notifyError( useCaseCallback: UseCase.UseCaseCallback, t: Throwable) { mUseCaseScheduler.onError(useCaseCallback, t) } private class UiCallbackWrapper( private val mCallback: UseCase.UseCaseCallback, private val mUseCaseHandler: UseCaseHandler) : UseCase.UseCaseCallback { 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() { protected override fun executeUseCase(requestValues: GetPosts.RequestValues?) { mDataSource.getPosts(requestValues?.userId ?: -1, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List) { 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) : UseCase.ResponseValue }
Целта на UseCase
s е да бъде посредник между вашите ViewModel
s и Repository
s.
Да кажем, че в бъдеще сте решили да добавите функция за „редактиране на публикация“. Всичко, което трябва да направите, е да добавите нов EditPost
UseCase
и целият му код ще бъде напълно отделен и отделен от другите UseCase
s. Всички сме го виждали много пъти: Въвеждат се нови функции и те неволно нарушават нещо в съществуващия код. Създаване на отделен UseCase
помага изключително за избягването на това.
Разбира се, не можете да премахнете тази възможност на 100 процента, но със сигурност можете да я минимизирате. Това е, което разделя Clean Architecture от другите модели: Кодът е толкова отделен, че можете да третирате всеки слой като черна кутия.
Това има всички хранилища, които домейн слоят може да използва. Този слой излага API на източник на данни на външни класове:
interface PostDataSource { interface LoadPostsCallback { fun onPostsLoaded(posts: List) 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) { 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) { refreshCache() refreshLocalDataSource(posts) callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } private fun refreshLocalDataSource(posts: List) { posts.forEach { localDataSource.savePost(it) } } private fun refreshCache() { isCacheDirty = false } }
Кодът е предимно обяснителен. Този клас има две променливи, localDataSource
и remoteDataSource
. Техният тип е PostDataSource
, така че не ни интересува как всъщност са внедрени под капака.
От моя личен опит тази архитектура се оказа безценна. В едно от моите приложения започнах с Firebase на задната страна, което е чудесно за бързо изграждане на вашето приложение. Знаех, че в крайна сметка ще трябва да премина към собствения си сървър.
Когато го направих, трябваше само да променя изпълнението в RemoteDataSource
. Не трябваше да докосвам друг клас дори след такава огромна промяна. Това е предимството на отделения код. Промяната на даден клас не трябва да засяга други части на кода ви.
Някои от допълнителните класове, които имаме, са:
interface UseCaseScheduler { fun execute(runnable: Runnable) fun notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback) fun onError( useCaseCallback: UseCase.UseCaseCallback, 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 notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback) { mHandler.post { useCaseCallback.onSuccess(response) } } override fun onError( useCaseCallback: UseCase.UseCaseCallback, t: Throwable) { mHandler.post { useCaseCallback.onError(t) } } }
UseCaseThreadPoolScheduler
отговаря за асинхронното изпълнение на задачи, използвайки ThreadPoolExecuter
.
class ViewModelFactory : ViewModelProvider.Factory { override fun create(modelClass: Class): 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 за инжектиране на зависимости в сложни проекти. Но с изключително стръмната си крива на обучение, това е извън обхвата на тази статия. Така че, ако се интересувате от задълбочаване, горещо препоръчвам Въведение на Хари Вигнеш Джаяпалан в Кинжал 2 .
Нашата цел с този проект беше да разберем MVVM с чиста архитектура, затова пропуснахме няколко неща, които можете да опитате да подобрите допълнително:
Това е една от най-добрите и мащабируеми архитектури за приложения за Android. Надявам се тази статия да ви е харесала и с нетърпение очаквам да чуя как сте използвали този подход в собствените си приложения!
Свързани: Xamarin Forms, MVVMCross и SkiaSharp: Светата Троица на разработката на приложения с различни платформиАрхитектурата на Android е начинът, по който структурирате вашия Android проект код, така че кодът ви да е мащабируем и лесен за поддръжка. Разработчиците отделят повече време за поддържане на проект, отколкото първоначално да го изградят, така че има смисъл да се следва правилен архитектурен модел.
В Android MVC се отнася до шаблона по подразбиране, където Activity действа като контролер, а XML файловете са изгледи. MVVM третира както класовете на активност, така и XML файловете като изгледи, а класовете ViewModel са мястото, където пишете вашата бизнес логика. Той напълно отделя потребителския интерфейс на приложението от неговата логика.
В MVP водещият знае за изгледа, а изгледът за водещия. Те си взаимодействат помежду си чрез интерфейс. В MVVM само изгледът знае за модела на изгледа. Изгледният модел няма представа за изгледа.
Едната е разделянето на проблемите, т.е. вашата бизнес логика, потребителски интерфейс и модели на данни трябва да живеят на различни места. Друго е отделянето на кода: Всяка част от кода трябва да действа като черна кутия, така че промяната на каквото и да е в клас не би трябвало да има ефект върху друга част от вашата кодова база.
„Чистата архитектура“ на Робърт С. Мартин е модел, който ви позволява да разчлените взаимодействието си с данни на по-прости обекти, наречени „случаи на използване“. Той е чудесен за писане на развързан код.
Повечето приложения запазват и извличат данни или от локално хранилище, или от отдалечен сървър. Репозиториите за Android са класове, които решават дали данните да идват от сървър или локално хранилище, отделяйки логиката ви за съхранение от външни класове.