ПРоект-исследование Jetpack библиотек и возможности перехода от Surf Android Standard на них с минимальными доработками.
Пока что проект одномодульный, с разделением фич по пакетам в модуле app.
Используется привычный для Surf подход с Clean Architecture.
используется Dagger2, а точнее мультибайндинг. В различных источниках привозятся примеры как сделать инжект непосредственно вьюмодели во фрагмент, с использованием Subcomponent, что в нашем случае не подходит, так как сильно ограничивает возможности переиспользования экранов - они могут переиспользоваться не только в рамках одной активити или другого фрагмент-контейнера. В случае использования Subcomponent'ов их родительские компоненты будут знать о дочерних компонентах и предоставлять билдеры для создания дочерних компонентов.
Как это работает в нашем случае?
- У класса вьюмодели проставляется @Inject constructor и dagger генерирует для него Provider
- Этот провайдер кладется в генерируемый даггером Map<Class, Provider>, который инжектится в фабрику, создающую вьюмодели (DaggerViewModelFactory.kt)
На примере экрана каталога это происходит в конфигураторе CategoriesScreenConfigurator в абстрактном модуле:
@Module
internal abstract class CategoriesViewModelModule {
@Binds
@IntoMap
@ViewModelKey(CategoriesViewModel::class)
abstract fun bindsFavoritesViewModel(viewModel: CategoriesViewModel): ViewModel
}
- Дальше мы можем инжектить эту фабрику во фрагмент или активити и доставать вьюмодель из провайдера уже во вью, а можем запровайдить через даггер ViewModelStore и извлекать вьюмодель из него уже в методах, которые провайдят вьюмодель. Но тут мы сталкиваемся с тем, что даггер нам уже сгенерировал Provider и мы не можем провайдить CategoriesViewModel из модуля экрана, так как получится циклическая зависимость (даггер генерирует провайдер, который кладется в фабрику, а фабрика используется для того, чтобы получить вьюмодель с этом провайдере). Поэтому в нашем случае инжектится не сама вьюмодель, а ее интерфейс.
@Provides
internal fun provideViewModel(
viewModelStore: ViewModelStore,
factory: DaggerViewModelFactory,
route: CategoriesRoute
): ICategoriesViewModel { // Вот тут нельзя запровайдить CategoriesViewModel из-за возникновения цикла
return ViewModelProvider(viewModelStore, factory).get(
route.getId(),
CategoriesViewModel::class.java
)
}
- Так как у нас есть возможность переопределять метод Route.getId() то мы можем получать разные вьюмодели для разных экранов
Минусы:
- нужно создавать и описывать интерфейс вьюмодели и из вью будет видно только то, что описано в этом интерфейсе.
- сложная схема с использованием даггера и мультибайндингов
Плюсы:
- у нас всего одна фабрика, которая может быть использована во всем приложении, у нее нет скоупа, для каждого экрана она создается отдельная.
- вьюмодель не надо доставать из хранилища во вью, она достается в модуле экрана
- так как мы используем интерфейс - нам не нужно создавать дублирующие проперти для LiveData полей, чтобы подписываться на них из вью (например val users: LiveData<List> и private val _users: MutableLiveData<List>), мы просто в интерфейсе их описываем как LiveData, а в реализации используем MutableLiveData. Так что если сравнивать с гайдами, то получается даже круче
Конфигураторы можно генерировать как обычно мы генерируем сущности для экранов, так что их код писать не придется.
Так как мы инжектим интерфейсы - нам не нужно байндить вьюмодели в Map даггеру и инжектить этот Map в супер фабрику, достаточно создать фабрику, которая принимает в себя провайдер вьюмодели, сгенерированный даггером и передать эту фабрику в методе, который провайдит вьюмодель в компоненте экрана.
- то есть у нас в проекте появляется универсальная фабрика
class ProviderViewModelFactory<T: ViewModel>(
private val provider: Provider<T>
): ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return provider.get() as T
}
}
-
проставляем у нашей вьюмодели @Inject constructor, чтобы даггер сгенерировал для нее Provider
-
и мы эту фабрику передаем в провайдер вьюмоделей.
@Provides
fun provideViewModel(
viewModelStore: ViewModelStore,
provider: Provider<CategoriesViewModel>,
route: CategoriesRoute
): ICategoriesViewModel {
return ViewModelProvider(viewModelStore, ProviderViewModelFactory(provider)).get(
route.getId(),
CategoriesViewModel::class.java
)
}
и не нужно мультибайндингов
получаем все те же плюсы, что у DI с мультибайндингами, только без переусложненности (одним очень большим минусом меньше)
При открытии экрана если в роут не были переданы подкатегории стартует запрос категорий (для наглядности используется задержка). Вью умеет отрисовывать три состояния этого запроса - загрузка, ошибка и успешное состояние.
По клику на элемент списка открывается новый экран каталога, которому в роут передаются подкатегории, так что новые экраны уже не загружают данные.
При повороте экрана вьюмодели не пересоздаются - можно посмотреть логи.
Видео работы экрана
https://drive.google.com/file/d/11WPFWqCXuO3ryLasOA9jcBHKZhJnf-Gq/view?usp=sharing
При открытии экрана запрашивается первая порция данных на время которой показывается полноэкранный лоадер, если возникла ошибка и никаких данных еще не было загружено, то показывается ошибка и кнопка повторить. если данные получены они отображаются в ресайклере. При скролле запрашиваются новые порции пагинации. Если порция не была загружена - показывается заглушка в конце списка.
также реализовано простое добавление и удаление из избранного. После клика по иконке избранного она меняет своей состояние сразу. в случае успеха показывается снэк успеха, если запрос завершается с ошибкой то иконка возвращает предыдущее состояние и показывается снэк ошибки.
Видео работы экрана
-
пагинация и обработка ошибок https://drive.google.com/file/d/1vhn3qVU53-Jvm26_wkhnRzr656s1EuV2/view?usp=sharing
-
добавление в избранное и снэки https://drive.google.com/file/d/1uRfpjgwkPOB7hLhrzWeeTj86yjkLvpJG/view?usp=sharing
Используется студийный мокер https://r1.mocker.surfstudio.ru/files/mvvm_android_research/
для работы с флоу добавлен экстеншен, который сразу при начале запроса эмитит Request.Loading, а как только запрос завершится - эмитит успех или ошибку в зависимости от того как завершился запрос.
fun <T> requestFlow(requestFunc: suspend () -> T): Flow<Request<T>> {
return flow<Request<T>> {
emit(Request.Loading())
try {
emit(Request.Success(requestFunc()))
} catch (e: Exception) {
emit(Request.Error(e))
}
}
}