Site icon AppTractor

Осваиваем ViewModel в Android: «можно» и «нельзя» — Часть 1

В этом цикле статей мы рассмотрим лучшие практики использования ViewModel в Android, подчеркнем основные «можно» и «нельзя» для повышения качества кода. Мы рассмотрим роль ViewModel в управлении состоянием пользовательского интерфейса и бизнес-логикой, стратегии ленивого внедрения зависимостей и важность реактивного программирования. Кроме того, мы обсудим общие подводные камни, которых следует избегать, такие как неправильная инициализация состояния и раскрытие mutable состояний, предоставив разработчикам исчерпывающее руководство.

Понимание ViewModel

Согласно документации Android, класс ViewModel выступает в роли хранителя состояния для бизнес-логики или экрана. Он отображает состояние для пользовательского интерфейса и инкапсулирует связанную с ним бизнес-логику. Его главное преимущество — кэширование состояния и его сохранение при изменении конфигурации. Это означает, что пользовательскому интерфейсу не нужно снова получать данные при переходе от одного действия к другому или при изменении конфигурации, например при повороте экрана.

Ключевые моменты для обсуждения в этой серии

  1. Избегайте инициализации состояния в блоке init{}.
  2. Избегайте раскрытия мутабельных состояний.
  3. Используйте update{} при использовании MutableStateFlows.
  4. Лениво внедряйте зависимости в конструктор.
  5. Примите более реактивное и менее императивное программирование.
  6. Избегайте инициализации ViewModel из внешнего мира.
  7. Избегайте передачи параметров из внешнего мира.
  8. Избегайте жесткого прописывания диспетчеров корутинов.
  9. Проводите модульное тестирование своих ViewModel.
  10. Избегайте раскрытия suspended функций.
  11. Используйте обратный вызов onCleared() во ViewModel.
  12. Обрабатывайте смерть процесса и изменения конфигурации.
  13. Вставляйте UseCases, которые вызывают Репозитории, которые, в свою очередь, вызывают DataSource.
  14. Включайте в ViewModel только доменные объекты.
  15. Используйте операторы shareIn() и stateIn(), чтобы избежать многократных обращений к восходящему потоку.

Давайте начнем с первого пункта списка!

№1: Избегайте инициализации состояния в блоке init {}

Инициирование загрузки данных в блоке init{} вью-модели Android может показаться удобным для инициализации данных сразу после создания вью-модели. Однако такой подход имеет ряд недостатков, таких как тесная связь с созданием ViewModel, проблемы с тестированием, ограниченная гибкость, обработка изменений конфигурации, управление ресурсами и отзывчивость пользовательского интерфейса. Чтобы уменьшить эти проблемы, рекомендуется использовать более продуманный подход к загрузке данных, используя LiveData или другие компоненты с поддержкой жизненного цикла для управления данными с учетом жизненного цикла Android.

Тесная связь с созданием ViewModel

Загрузка данных в блоке init{} тесно связывает получение данных с жизненным циклом ViewModel. Это может привести к трудностям в управлении временем загрузки данных, особенно в сложных пользовательских интерфейсах, где вам может понадобиться более детальный контроль над тем, когда данные будут получены на основе взаимодействия с пользователем или других событий.

Проблемы с тестированием

Тестирование становится сложнее, поскольку загрузка данных начинается сразу после инстанцирования ViewModel. Это может затруднить тестирование ViewModel в изоляции, не вызывая при этом сетевых запросов или запросов к базе данных, что усложняет настройку тестов и потенциально может привести к их некорректной работе.

Ограниченная гибкость

Автоматическое начало загрузки данных при инстанцировании ViewModel ограничивает вашу гибкость при работе с различными пользовательскими потоками или состояниями пользовательского интерфейса. Например, вы можете захотеть отложить получение данных до получения определенных разрешений или до перехода пользователя в определенную часть приложения.

Обработка изменений конфигурации

Модели представлений Android разработаны таким образом, чтобы выдерживать изменения конфигурации, например поворот экрана. Если загрузка данных инициируется в блоке init{}, изменение конфигурации может привести к неожиданному поведению или ненужной повторной выборке данных, если не позаботиться об этом.

Управление ресурсами

Мгновенная загрузка данных может привести к неэффективному использованию ресурсов, особенно если пользователю не нужны данные сразу после входа в приложение или на экран. Это может быть особенно проблематично для приложений, которые потребляют значительный объем данных или используют дорогостоящие операции для получения или обработки этих данных.

Отзывчивость пользовательского интерфейса

Инициирование загрузки данных в блоке init{} может повлиять на отзывчивость пользовательского интерфейса, особенно если операция загрузки данных длится долго или блокирует основной поток. Как правило, рекомендуется сохранять блок init{} легким и простым, а тяжелые или асинхронные операции перекладывать в фоновый поток или использовать LiveData/Flow для наблюдения за изменениями данных.

Чтобы смягчить эти проблемы, часто рекомендуется использовать более продуманный подход к загрузке данных, например запускать ее в ответ на определенные действия пользователя или события пользовательского интерфейса и использовать LiveData или другие компоненты с поддержкой жизненного цикла для управления данными с учетом жизненного цикла Android. Это поможет обеспечить отзывчивость вашего приложения, облегчит тестирование и позволит эффективнее использовать ресурсы.

Давайте рассмотрим несколько примеров этого антипаттерна.

Пример №1

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 и снижает гибкость. Выставление mutable состояния _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())

}

Рефакторинг удаляет выборку данных из блока инициализации 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.

И мы можем рефакторить его следующим образом.

Улучшение №2

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{} может помешать нашему прогрессу, и изучили более разумные, оптимизированные методы организации пользовательского интерфейса и логики нашего приложения с помощью ViewModel. Мы обсудили простые решения и основные тактики, позволяющие избежать часто встречающихся подводных камней.

Источник

Exit mobile version