В этой серии статей мы рассмотрим лучшие практики использования ViewModels в Android с акцентом на основных принципах повышения качества кода. Рассмотрим роль ViewModels в управлении состоянием пользовательского интерфейса и бизнес-логикой, стратегии для ленивого внедрения зависимостей и важность реактивного программирования. Кроме того, мы обсудим общие подводные камни, которых следует избегать, такие как неправильная инициализация состояния и обнародование изменяемых состояний.
ViewModels
Согласно документации Android, класс ViewModel выступает в роли компонента, который предназначен для хранения и управления данными, связанными с пользовательским интерфейсом и живущими в пределах жизненного цикла приложения. ViewModel помогает отделить бизнес-логику от пользовательского интерфейса, обеспечивая сохранение данных при повороте экрана или других изменениях конфигурации устройства.
Ключевые моменты для обсуждения
Избегайте инициализации состояния в блоке
init {}
.Избегайте раскрытия изменяемых состояний.
Используйте update{} при использовании MutableStateFlows
Делайте «ленивое» внедрение зависимостей в конструктор.
Используйте более реактивное и менее императивное кодирование.
Избегайте инициализации ViewModel извне.
Избегайте передачи параметров извне.
Избегайте жёсткого кодирования диспетчеров корутинов.
Проводите модульное тестирование ViewModel.
Избегайте использования приостановленных функций.
Используйте коллбэк
onCleared()
во ViewModels.Обрабатывайте завершение процесса и изменение конфигурации.
Внедряйте UseCases, которые вызывают Repositories, которые, в свою очередь, вызывают DataSources.
Включайте во ViewModels только объектов домена.
Используйте операторы
shareIn()
иstateIn()
во избежание многократных обращений к восходящему потоку.
Давайте начнём с первого пункта списка.
Избегайте инициализации состояния в блоке init {}
Инициирование загрузки данных в блоке init {}
ViewModel в Android может показаться удобным для инициализации данных сразу после создания ViewModel. Однако такой подход имеет ряд недостатков, таких как: тесная связь с созданием ViewModel, проблемы с тестированием, ограниченная гибкость, обработка изменений конфигурации, управление ресурсами и отзывчивость пользовательского интерфейса. Чтобы уменьшить эти проблемы, рекомендуется использовать более продуманный подход к загрузке данных, используя LiveData или другие компоненты с поддержкой жизненного цикла для управления данными с учётом жизненного цикла Android.
Тесная связь с созданием ViewModel
Загрузка данных в блоке init{}
тесно связывает получение данных с жизненным циклом ViewModel. Это может привести к трудностям в управлении временем загрузки данных — особенно в сложных пользовательских интерфейсах, где может понадобиться более детальный контроль над тем, когда данные будут получены на основе взаимодействия с пользователем или других событий.
Проблемы с тестированием
Тестирование становится сложнее, поскольку загрузка данных начинается сразу после инстанцирования ViewModel. Это может затруднить тестирование ViewModel в изоляции, без сетевых запросов или запросов к базе данных, что усложняет настройку тестов и потенциально может привести к их некорректной работе.
Ограниченная гибкость
Автоматическое начало загрузки данных при инстанцировании ViewModel ограничивает гибкость при работе с различными пользовательскими потоками или состояниями пользовательского интерфейса. Например, вы можете отложить получение данных до тех пор, пока не будут предоставлены определенные разрешения или пока пользователь не перейдет в определённую часть приложения.
Обработка изменений конфигурации
Android ViewModels разработаны таким образом, чтобы выдерживать изменения конфигурации — например, повороты экрана. Если загрузка данных инициируется в блоке init{}
, изменение конфигурации может привести к неожиданному поведению или ненужной повторной выборке данных.
Управление ресурсами
Мгновенная загрузка данных может привести к неэффективному использованию ресурсов — особенно если пользователю не нужны данные сразу после входа в приложение или на экран. Это может быть особенно проблематично для приложений, которые потребляют значительный объём данных или используют дорогостоящие операции для их получения или обработки.
Отзывчивость пользовательского интерфейса
Инициирование загрузки данных в блоке init{}
может повлиять на отзывчивость пользовательского интерфейса, особенно если операция загрузки данных длится долго или блокирует основной поток. Как правило, рекомендуется сохранять блок init{} лёгким, а тяжелые или асинхронные операции перекладывать на фоновый поток или использовать LiveData/Flow для наблюдения за изменениями данных.
Чтобы уменьшить эти проблемы, рекомендуется использовать более продуманный подход к загрузке данных, например — запускать её в ответ на определённые действия пользователя или события пользовательского интерфейса и использовать LiveData или другие компоненты с поддержкой жизненного цикла для управления данными с учётом жизненного цикла Android. Это поможет обеспечить отзывчивость приложения, облегчит проведение тестирования и позволит эффективнее использовать ресурсы.
Давайте рассмотрим несколько примеров этого антипаттерна:
Пример № 1. Плохая реализация ViewModel в Android
class SearchViewModel @Inject constructor(
private val searchUseCase: dagger.Lazy<SearchUseCase>,
private val wordsUseCase: GetWordsUseCase,
) : ViewModel() {
data class UiState(
val isLoading: Boolean,
val words: List<String> = emptyList()
)
init {
getWords()
}
val _state = MutableStateFlow(UiState(isLoading = true))
val state: StateFlow<UiState>
get() = _state.asStateFlow()
private fun getWords() {
viewModelScope.launch {
_state.update { UiState(isLoading = true) }
val words = wordsUseCase.invoke()
_state.update { UiState(isLoading = false, words = words) }
}
}
}
В SearchViewModel
загрузка данных запускается сразу в блоке init
, что жёстко связывает получение данных с инстанцированием ViewModel и снижает гибкость. Использование изменчивого состояния _state
внутри класса и отсутствие обработки потенциальных ошибок или изменяющихся состояний пользовательского интерфейса (загрузка, успех, ошибка) может привести к менее надёжной и трудно тестируемой реализации. Такой подход подрывает преимущества осознания жизненного цикла ViewModel и эффективность ленивой инициализации.
Как улучшить процесс?
Улучшение №1:
class SearchViewModel @Inject constructor(
private val searchUseCase: dagger.Lazy<SearchUseCase>,
private val wordsUseCase: GetWordsUseCase,
) : ViewModel() {
data class UiState(
val isLoading: Boolean = true,
val words: List<String> = emptyList()
)
val state: StateFlow<UiState> = flow {
emit(UiState(isLoading = true))
val words = wordsUseCase.invoke()
emit(UiState(isLoading = false, words = words))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState())
}
Рефакторинг удаляет выборку данных из блока init
ViewModel, вместо этого полагаясь на коллекцию для инициирования загрузки данных. Это изменение повышает гибкость в управлении получением данных и сокращает количество ненужных операций при инстанцировании ViewModel, напрямую решая проблемы преждевременной загрузки данных и повышая отзывчивость и эффективность ViewModel.
Пример № 2:
class SearchViewModel @Inject constructor(
private val searchUseCase: SearchUseCase,
@IoDispatcher val ioDispatcher: CoroutineDispatcher
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
private val _uiState = MutableLiveData<SearchUiState>()
val uiState = _uiState
init {
viewModelScope.launch {
searchQuery.debounce(DEBOUNCE_TIME_IN_MILLIS)
.collectLatest { query ->
Timber.d("collectLatest(), query:[%s]", query)
if (query.isEmpty()) {
_uiState.value = SearchUiState.Idle
return@collectLatest
}
try {
_uiState.value = SearchUiState.Loading
val photos = withContext(ioDispatcher){
searchUseCase.invoke(query)
}
if (photos.isEmpty()) {
_uiState.value = SearchUiState.EmptyResult
} else {
_uiState.value = SearchUiState.Success(photos)
}
} catch (e: Exception) {
_uiState.value = SearchUiState.Error(e)
}
}
}
}
fun onQueryChanged(query: String?) {
query ?: return
searchQuery.value = query
}
sealed class SearchUiState {
object Loading : SearchUiState()
object Idle : SearchUiState()
data class Success(val photos: List<FlickrPhoto>) : SearchUiState()
object EmptyResult : SearchUiState()
data class Error(val exception: Throwable) : SearchUiState()
}
companion object {
private const val DEBOUNCE_TIME_IN_MILLIS = 300L
}
}
Запуск корутины в блоке init
SearchViewModel
для немедленной обработки данных слишком тесно связывает получение данных с жизненным циклом ViewModel. Это потенциально может привести к неэффективности и проблемам управления жизненным циклом. Такой подход чреват ненужными сетевыми вызовами и усложняет обработку ошибок, особенно до того, как пользовательский интерфейс будет готов к обработке или отображению такой информации. Кроме того, он предполагает неявный возврат в основной поток для обновления пользовательского интерфейса — что не всегда безопасно и эффективно. Также это усложняет процесс тестирования, поскольку инициирует получение данных сразу после инстанцирования ViewModel.
И рефакторинг можно провести следующим образом:
class SearchViewModel @Inject constructor(
private val searchUseCase: dagger.Lazy<SearchUseCase>,
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
val uiState: LiveData<SearchUiState> = searchQuery
.debounce(DEBOUNCE_TIME_IN_MILLIS)
.asLiveData()
.switchMap(::createUiState)
private fun createUiState(query: @JvmSuppressWildcards String) = liveData {
Timber.d("collectLatest(), query:[%s]", query)
if (query.isEmpty()) {
emit(SearchUiState.Idle)
return@liveData
}
try {
emit(SearchUiState.Loading)
val photos = searchUseCase.get().invoke(query)
if (photos.isEmpty()) {
emit(SearchUiState.EmptyResult)
} else {
emit(SearchUiState.Success(photos))
}
} catch (e: Exception) {
emit(SearchUiState.Error(e))
}
}
fun onQueryChanged(query: String?) {
query ?: return
searchQuery.value = query
}
sealed class SearchUiState {
data object Loading : SearchUiState()
data object Idle : SearchUiState()
data class Success(val photos: List<FlickrPhoto>) : SearchUiState()
data object EmptyResult : SearchUiState()
data class Error(val exception: Throwable) : SearchUiState()
}
companion object {
private const val DEBOUNCE_TIME_IN_MILLIS = 300L
}
}
Новая реализация позволяет избежать запуска корутины непосредственно в блоке init
для наблюдения за изменениями searchQuery
. Вместо этого она предпочитает реактивную настройку, которая преобразует searchQuery
в LiveData
вне контекста корутины. Это устраняет потенциальные проблемы, связанные с управлением жизненным циклом и отменой корутин — гарантируя, что выборка данных по своей сути учитывает жизненный цикл и более эффективна с точки зрения ресурсов. Не полагаясь на блок init
, чтобы начать наблюдение и обработку пользовательского ввода, она также отделяет инициализацию ViewModel от логики получения данных, что приводит к более чистому разделению ответственности и более удобной структуре кода.
Заключение
Мы разобрались в причинах, по которым загрузка данных в блоке init{}
может помешать прогрессу, и изучили более разумные и рациональные методы организации пользовательского интерфейса и логики приложения с помощью ViewModels. Также мы обсудили простые решения и важные тактики, позволяющие избежать часто встречающихся подводных камней.
В завершение приглашаем всех желающих на открытый урок «Использование KSP2 для тестирования на Kotlin». Рассмотрим возможности новой версии Kotlin Symbol Processing и возможные сценарии их использования для генерации тестовых данных и сценариев. Записаться можно по ссылке.