Лучшие приложения для Android, использующие MVVM с чистой архитектурой
Опубликовано: 2022-03-11Если вы не выберете правильную архитектуру для своего Android-проекта, вам будет сложно поддерживать ее по мере роста вашей кодовой базы и расширения вашей команды.
Это не просто учебник по Android MVVM. В этой статье мы собираемся объединить MVVM (Model-View-ViewModel или иногда стилизованный «шаблон ViewModel») с чистой архитектурой. Мы увидим, как эту архитектуру можно использовать для написания несвязанного, тестируемого и поддерживаемого кода.
Почему MVVM с чистой архитектурой?
MVVM отделяет ваше представление (т.е. Activity и Fragment ) от вашей бизнес-логики. MVVM достаточно для небольших проектов, но когда ваша кодовая база становится огромной, ваши ViewModel начинают раздуваться. Разделение обязанностей становится трудным.
В таких случаях MVVM с чистой архитектурой довольно хорош. Это делает еще один шаг в разделении обязанностей вашей кодовой базы. Он четко абстрагирует логику действий, которые могут быть выполнены в вашем приложении.
Примечание. Вы также можете комбинировать чистую архитектуру с архитектурой модель-представление-презентер (MVP). Но поскольку компоненты архитектуры Android уже предоставляют встроенный класс ViewModel , мы выбираем MVVM вместо MVP — инфраструктура MVVM не требуется!
Преимущества использования чистой архитектуры
- Ваш код еще легче тестировать, чем с обычным MVVM.
- Ваш код дополнительно развязан (самое большое преимущество).
- В структуре пакета еще проще ориентироваться.
- Проект еще проще в обслуживании.
- Ваша команда может добавлять новые функции еще быстрее.
Недостатки чистой архитектуры
- У него немного крутая кривая обучения. Чтобы понять, как все уровни работают вместе, может потребоваться некоторое время, особенно если вы исходите из шаблонов, таких как простой MVVM или MVP.
- Он добавляет много дополнительных классов, поэтому он не идеален для проектов низкой сложности.
Наш поток данных будет выглядеть так:
Наша бизнес-логика полностью отделена от нашего пользовательского интерфейса. Это делает наш код очень простым в обслуживании и тестировании.
Пример, который мы собираемся увидеть, довольно прост. Он позволяет пользователям создавать новые сообщения и просматривать список созданных ими сообщений. Я не использую сторонние библиотеки (например, Dagger, RxJava и т. д.) в этом примере для простоты.
Уровни MVVM с чистой архитектурой
Код разделен на три отдельных слоя:
- Уровень представления
- Слой домена
- Уровень данных
Подробнее о каждом слое мы поговорим ниже. На данный момент наша результирующая структура пакета выглядит так:
Даже в используемой нами архитектуре приложений для Android существует множество способов структурировать иерархию файлов/папок. Мне нравится группировать файлы проекта на основе функций. Я нахожу это аккуратным и лаконичным. Вы вольны выбрать любую структуру проекта, которая вам подходит.
Уровень представления
Сюда входят наши Activity s, Fragment s и ViewModel s. 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!! } } } Как следует из названия, вариант UseCase GetPosts отвечает за получение всех сообщений пользователя.
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 является быть посредником между вашими ViewModel и Repository .

Допустим, в будущем вы решите добавить функцию «редактировать запись». Все, что вам нужно сделать, это добавить новый вариант UseCase EditPost и весь его код будет полностью отделен и отделен от других UseCase . Мы все видели это много раз: вводятся новые функции, которые непреднамеренно ломают что-то в ранее существовавшем коде. Создание отдельного UseCase использования очень помогает избежать этого.
Конечно, вы не можете исключить эту возможность на 100 процентов, но вы можете свести ее к минимуму. Вот что отличает чистую архитектуру от других шаблонов: код настолько развязан, что вы можете рассматривать каждый слой как черный ящик.
Уровень данных
Здесь есть все репозитории, которые может использовать уровень домена. Этот уровень предоставляет API источника данных внешним классам:
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 потому что теперь мы можем просто передавать в конструктор PostDataRepository PostDataRepository версии LocalDataSource и RemoteDataSource вместо фактических значений.
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 для внедрения зависимостей в сложных проектах. Но с его чрезвычайно крутой кривой обучения это выходит за рамки этой статьи. Так что, если вы хотите углубиться, я настоятельно рекомендую введение Хари Виньеша Джаяпалана в Dagger 2.
MVVM с чистой архитектурой: надежная комбинация
Нашей целью в этом проекте было понять MVVM с чистой архитектурой, поэтому мы пропустили несколько вещей, которые вы можете попытаться улучшить:
- Используйте LiveData или RxJava, чтобы удалить обратные вызовы и сделать его немного аккуратнее.
- Используйте состояния для представления вашего пользовательского интерфейса. (Для этого ознакомьтесь с удивительным выступлением Джейка Уортона.)
- Используйте Dagger 2 для внедрения зависимостей.
Это одна из лучших и наиболее масштабируемых архитектур для приложений Android. Надеюсь, вам понравилась эта статья, и я с нетерпением жду, когда вы услышите, как вы использовали этот подход в своих собственных приложениях!
