Дискуссии об архитектуре Android часто перерастают в жаркие споры, вызывая и восторг, и резкую критику. Писать на такие темы непросто, и именно поэтому это стоит делать.

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

Рассматривайте статью как снапшот моих навыков и знаний в 2025 году.

Возможно, я присоединяюсь к теме позже других, но лучше поздно, чем никогда.

Многие Android разработчики используют ViewModel для управления состоянием UI, которое затем используется View (Fragment, Activity или Composable) для отображения осмысленного контента. Вам нужно загрузить данные, преобразовать их во View State и предоставить публичное поле для чтения.

Раньше использовали LiveData, сейчас же Flow выступает связующим звеном между View и ViewModel (чаще всего). Есть решения, использующие molecule от Cash App, но в этот раз мы не будем их обсуждать.

Как показывает обсуждение в Твиттере, большинство разработчиков загружают данные в блоке init {} во ViewModel. Хотя подход кажется логичным, у него есть архитектурные недостатки, которые Ян Лейк (Ian Lake) и другие назвали антипаттернами — включая использование LaunchedEffect для загрузки данных.

Очевидна ирония: даже официальные примеры порой противоречат этим передовым практикам:

прим. переводчика: имеется в виду пример из Advanced Kotlin guides → StateFlow and SharedFlow.

Хотя официальный гайд от Google всё же содержит рекомендации не использовать init, эта рекомендация идёт последним пунктом последней статьи раздела UI layer libraries.

Считаю это заметным пробелом в структуре документации, поэтому хочу оставить скриншот предупреждения прямо тут

Не используйте блок init для инициализации загрузки данных
Не используйте блок init для инициализации загрузки данных

Почему разработчики выбирают блок init (и почему это проблема)

Использование блока init {} во ViewModel можно понять — это гарантирует, что загрузка данных переживёт смену конфигурации, одновременно предотвращая повторные обращения к API или чтение из базы данных. Однако, этот подход порождает 4 критичных проблемы:

Проблема #1: Усложнение backstack'а навигации

После использовании блока init {} для загрузки данных возврат на экран с уже существующей ViewModel не обновится автоматически. Это заставляет разработчиков придумывать дополнительную логику, используя onStart или onResume, чтобы обновить данные. Это создаёт спагетти-код, который тяжело поддерживать.

Проблема #2: Гонка Dispatcher'ов

При загрузке данных в блоке init {} обычно используется viewModelScope, который запускается на Dispatchers.Main.immediate. Использование immediate диспатчера может привести к гонке, когда загрузка данных заканчивается до того, как UI успеет отрисоваться, особенно в Compose приложении.

прим. переводчика: проблемы из-за гонки маловероятны, но всё же возможны. Скорее всего для этого UI должен быть довольно специфическим — например, содержать Effect, который реагирует на смену состояния. В таком случае пропуск промежуточных состояний может быть критичным для UX.

Проблема #3: Устаревание данных

Современным CRUD приложениям нужны свежие данные. Пользователи могут вернуться с других экранов, или вернуться в приложение спустя значительное время. Подход с init {} не предполагает автоматическую проверку актуальности данных.

Проблема #4: Сложности с тестированием

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

Flow-based решение: превращаем холодные потоки в горячие

Решение использует Kotlin Flow, а именно преобразует холодные потоки в горячие, используя StateFlow с подходящей стратегией. Напоминает песню Кэтти Перри, только с предсказуемым поведением.

прим. переводчика: игра слов от автора. В песне Кэтти Перри "Hot n Cold" поётся о мужчине, который не может ни с чем определиться (в отличие от предсказуемого поведения Flow):

'Cause you're hot, then you're cold You're yes, then you're no

Строим фундамент: структура Use Case и View Model

ОБРАТИТЕ ВНИМАНИЕ, ЧТО ЭТОТ КОД — ТОЛЬКО ПРИМЕР. КАК ИСПОЛЬЗОВАТЬ ЕГО — РЕШАТЬ ВАМ, ЭТО НЕ BEST PRACTICE, КРОМЕ ЧАСТИ ПРО ЗАГРУЗКУ

inline fun  provideFactory(
    crossinline creator: () -> T
) = viewModelFactory {
    initializer {
        creator()
    }
}

Factory используется только для демонстрации

Для примера возьмём такой Use case:

class GetUserDetailsUseCase private constructor(
    private val authRepository: AuthRepository = AuthRepository(),
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
    private val billingCache: BillingCache = BillingCache.create(),
    private val dateFormatter : DateFormatter = DataFormatter()
) {
    suspend fun execute(): Result<UserDetails> =
        withContext(dispatcher) {
            val userDetails: Result<UserDetailsResponseModel> = authRepository.getUserDetails()

            userDetails.map { details ->
                UserDetails(
                    creationDate = dateFormatter.format(details.creationDate, DateFormatter.Format.UTC_SHORT).getOrNull(),
                    avatarUrl = details.avatar,
                    isPremium = billingCache.isPremium(),
                    email = details.email
                )
            }
        }

    companion object {
        fun create() = GetUserDetailsUseCase()
    }
}

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

internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel() {

    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )
    }

    val userDetails: Flow<ViewState> = flow {
        emit(
            getUserDetailsUseCase.execute()
                .fold(
                    onSuccess = {
                        ViewState(
                            isLoading = false,
                            isError = false,
                            userInfo = ViewState.UserInfo(
                                displayEmail = it.email,
                                avatarUrl = it.avatarUrl,
                                showPremiumBadge = it.isPremium,
                                memberSince = it.creationDate?.toString()
                            )
                        )
                    },
                    onFailure = {
                        ViewState(isLoading = false, isError = true)
                    }
                )
        )
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        ViewState(isLoading = true, isError = false)
    )

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

Такой подход имеет несколько преимуществ:

  • Актуальность данных: таймаут в 5 секунд соответствует порогу ANR в Android, гарантируя обновление данных после повторной подписки по истечение таймаута

  • Обработка смены конфигурации: данные переживают смену конфигурации в пределах таймаута

  • Эффективность использования ресурсов: нет лишних сетевых запросов при быстрой навигации

Лайфхак: Установите таймаут 0 для приложений, которым всегда нужны актуальные данные

прим. переводчика: хотя автор написал про соответствие ANR таймауту, никакой технической или семантической связи started стратегии Flow с ANR скорее всего нет. Вероятно, таймаут для Flow сделан равным таймауту ANR только по желанию автора. Тем не менее, размер таймаута в 5 секунд мне кажется довольно логичным.

Добавляем взаимодействие с пользователем: реализуем обновление

В реальных приложениях нужна возможность обновления по запросу пользователя. Продакты любят swipe-to-refresh, но наш Flow пока не поддерживает это. Давайте исправим это.

Добавим MutableSharedFlow, который собирается внутри нашего основного flow:

internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel(), IntentAware<UserAccountDetailsViewModel.ViewState.Intents> {

    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )

        sealed class Intents {
            data object Refresh : Intents()
        }

    }

    private val refreshListener = MutableSharedFlow<Unit>()
    val userDetails: Flow<ViewState> = flow {
        emit(getUserDetailsState())

        refreshListener.collect {
            emit(ViewState(isLoading = true, isError = false))
            emit(getUserDetailsState())
        }
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        ViewState(isLoading = true, isError = false)
    )

    private suspend fun getUserDetailsState(): ViewState = getUserDetailsUseCase.execute()
        .fold(
            onSuccess = {
                ViewState(
                    isLoading = false,
                    isError = false,
                    userInfo = ViewState.UserInfo(
                        displayEmail = it.email,
                        avatarUrl = it.avatarUrl,
                        showPremiumBadge = it.isPremium,
                        memberSince = it.creationDate?.toString()
                    )
                )
            },
            onFailure = {
                ViewState(isLoading = false, isError = true)
            }
        )

    override fun onIntent(intent: ViewState.Intents) {
        when (intent) {
            ViewState.Intents.Refresh -> {
                viewModelScope.launch {
                    refreshListener.emit(Unit)
                }
            }
        }
    }

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

Отлично! Мы отрефакторили логику и добавили возможность обновления. Но это еще не всё.

Избавляемся от ненужных emit'ов состояния

Чтобы избежать лишних обновлений UI, добавим distinctUntilChanged(), чтобы избежать повторных emit'ов одинаковых состояний.

Обновление состояния

Для сценариев, когда Intent обновляет состояние без перезагрузки данных, нам нужно текущее состояние. Представьте переключение чекбокса видимости Email (ToggleEmailVisibility) — нужно обновить состояние, но не перезагрузить данные.

data class ViewState(
	val isLoading: Boolean = false,
	val isError: Boolean = false,
	val isEmailVisible : Boolean = false,
	val userInfo: UserInfo? = null
) {
	data class UserInfo(
		val displayEmail: String,
		val avatarUrl: String?,
		val showPremiumBadge: Boolean,
		val memberSince: String?
	)

	sealed class Intents {
		data object Refresh : Intents()
		data class ToggleEmailVisibility(val isEmailVisible: Boolean) : Intents()
	}

	sealed class StateParameters {
		data class EmailVisibilityChanged(val isEmailVisible: Boolean) : StateParameters()
		data object Refresh : StateParameters()
	}
}

// ...

private val refreshListener = MutableSharedFlow<ViewState.StateParameters>()
val userDetails: Flow<ViewState> = flow {
	emit(getUserDetailsState())

	refreshListener.collect { refreshParams ->
		when(refreshParams){
			is ViewState.StateParameters.EmailVisibilityChanged -> {
				//do some changes here
			}
			ViewState.StateParameters.Refresh -> {
				emit(ViewState(isLoading = true, isError = false))
				emit(getUserDetailsState())
			}
		}
	}
}
	.distinctUntilChanged()
	.stateIn(
		viewModelScope,
		SharingStarted.WhileSubscribed(5_000),
		ViewState(isLoading = true, isError = false)
	)

Для решения задачи нужен доступ к состоянию внутри Flow. Будем сохранять его внутри класса:

internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel(), IntentAware<UserAccountDetailsViewModel.ViewState.Intents>  {
  
    // ...
  
    private var currentState = ViewState(isLoading = true, isError = false)
    
    val userDetails: Flow<ViewState> = flow {
        emit(getUserDetailsState())
        refreshListener.collect { refreshParams ->
            when(refreshParams){
                is ViewState.StateTriggers.EmailVisibilityChanged -> {
                    emit(currentState.copy(isEmailVisible = refreshParams.isEmailVisible))
                }
                ViewState.StateTriggers.Refresh -> {
                    emit(ViewState(isLoading = true, isError = false))
                    emit(getUserDetailsState())
                }
            }
        }
    }
        .distinctUntilChanged()
        .onEach {
            currentState = it
        }
        .stateIn(
	        viewModelScope,
	        SharingStarted.WhileSubscribed(5_000),
	        currentState
	    )
        
    // ...
    
}

Кэширование данных: условная загрузка

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

internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel(), IntentAware<UserAccountDetailsViewModel.ViewState.Intents> {

    // ...
    private var currentState = ViewState(isLoading = true, isError = false)
  
    val userDetails: Flow<ViewState> = flow {
        emit(currentState)

        // added error check just because this is for demonstration of this edge case
        if (currentState.isDataLoaded.not() || currentState.isError) { 
            emit(getUserDetailsState())
        }
        
        refreshListener.collect { refreshParams ->
            when (refreshParams) {
                is ViewState.StateTriggers.EmailVisibilityChanged -> {
                    emit(currentState.copy(isEmailVisible = refreshParams.isEmailVisible))
                }

                ViewState.StateTriggers.Refresh -> {
                    emit(ViewState(isLoading = true, isError = false))
                    emit(getUserDetailsState())
                }
            }
        }
    }
        .distinctUntilChanged()
        .onEach {
            currentState = it
        }
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5_000),
            currentState
        )

    // ..
}

Такое решение даёт нам "умное" кэширование — даже после таймаута в 5 секунд вы можете решить, перезагружать ли данные, учитывая разные факторы:

  • Тяжелые вызовы API: кэшируйте данные во View Model, чтобы уменьшить нагрузку на сеть

  • Статичные данные: не перезагружайте информацию, которая меняется редко

  • Real-time: перезагружайте данные всегда, если приложению всего требуются свежие данные

Создаём абстракцию

Постоянно писать одинаковый код скучно. Давайте создадим абстракцию, которую можно переиспользовать. Начнём с extension для View Model:

fun <T, R> ViewModel.loadData(
    initialState: T,
    loadData: suspend FlowCollector<T>.(currentState: T) -> Unit,
    refreshMechanism: SharedFlow<R>? = null,
    timeout: Long = 5_000,
    refreshData: (suspend FlowCollector<T>.(currentState: T, refreshParams: R) -> Unit)? = null,
): StateFlow<T> {
    if (refreshMechanism != null) {
        requireNotNull(refreshData) {
            "You've provided a refresh mechanism but no way to refresh the data"
        }
    }
    if (refreshData != null) {
        requireNotNull(refreshMechanism) {
            "You've provided a refresh data but no mechanism to refresh the data"
        }
    }

    var latestValue = initialState
    return flow {
        emit(latestValue)
        loadData(latestValue)
        refreshMechanism?.collect { refreshParams ->
            if (refreshData != null) {
                refreshData(latestValue, refreshParams)
            }
        }
    }
        .distinctUntilChanged()
        .onEach {
            latestValue = it
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(timeout),
            initialValue = initialState
        )
}

fun <T> ViewModel.loadData(
    initialState: T,
    loadData: suspend FlowCollector<T>.(currentState: T) -> Unit,
    timeout: Long = 5_000,
): StateFlow<T> {
    var latestValue = initialState
    return flow {
        emit(latestValue)
        loadData(latestValue)
    }
        .onEach {
            latestValue = it
        }
        .distinctUntilChanged()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(timeout),
            initialValue = initialState
        )
}

С таким расширением ViewModel становится намного чище.

Примечание: Эта абстракция покрывает 90% однотипных сценариев, но не поддерживает сложные цепочки flow.

internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel(), IntentAware<UserAccountDetailsViewModel.ViewState.Intents> {

    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val isEmailVisible: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        val isDataLoaded get() = userInfo != null

        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )

        sealed class Intents {
            data object Refresh : Intents()
            data class ToggleEmailVisibility(val isEmailVisible: Boolean) : Intents()
        }

        sealed class StateTriggers {
            data class EmailVisibilityChanged(val isEmailVisible: Boolean) : StateTriggers()
            data object Refresh : StateTriggers()
        }
    }

    private val refreshListener = MutableSharedFlow<ViewState.StateTriggers>()
    val userDetails = loadData(
        initialState = ViewState(isLoading = true, isError = false),
        loadData = { currentState ->
            if (currentState.isDataLoaded.not() || currentState.isError.not()) {
                emit(getUserDetailsState())
            }
        },
        refreshMechanism = refreshListener,
        refreshData = { currentState, refreshParams ->
            when (refreshParams) {
                is ViewState.StateTriggers.EmailVisibilityChanged -> {
                    emit(currentState.copy(isEmailVisible = refreshParams.isEmailVisible))
                }

                ViewState.StateTriggers.Refresh -> {
                    emit(ViewState(isLoading = true, isError = false))
                    emit(getUserDetailsState())
                }
            }
        }
    )

    private suspend fun getUserDetailsState(): ViewState = getUserDetailsUseCase.execute()
        .fold(
            onSuccess = {
                ViewState(
                    isLoading = false,
                    isError = false,
                    userInfo = ViewState.UserInfo(
                        displayEmail = it.email,
                        avatarUrl = it.avatarUrl,
                        showPremiumBadge = it.isPremium,
                        memberSince = it.creationDate?.toString()
                    )
                )
            },
            onFailure = {
                ViewState(isLoading = false, isError = true)
            }
        )

    override fun onIntent(intent: ViewState.Intents) {
        when (intent) {
            ViewState.Intents.Refresh -> {
                viewModelScope.launch {
                    refreshListener.emit(ViewState.StateTriggers.Refresh)
                }
            }

            is ViewState.Intents.ToggleEmailVisibility -> {
                viewModelScope.launch {
                    refreshListener.emit(ViewState.StateTriggers.EmailVisibilityChanged(intent.isEmailVisible))
                }
            }
        }
    }

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

Можем также избавиться от повторяющихся полей, вроде refreshListener, создав базовый класс:

abstract class ViewModelLoader<State : Any, Intent : Any, Trigger : Any> : ViewModel() {

    private val _trigger by lazy { MutableSharedFlow<Trigger>() }

    fun <T> loadData(
        initialState: T,
        loadData: suspend FlowCollector<T>.(currentState: T) -> Unit,
        triggerData: (suspend FlowCollector<T>.(currentState: T, triggerParams: Trigger) -> Unit)? = null,
        timeout: Long = 5000L, //matching ANR timeout in Android
    ): StateFlow<T> {
        var latestValue = initialState
        return flow {
            emit(latestValue)
            loadData(latestValue)
            if (triggerData != null) {
                _trigger.collect { triggerParams ->
                    triggerData(this, latestValue, triggerParams)
                }
            }
        }
            .distinctUntilChanged()
            .onEach {
                latestValue = it
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(timeout),
                initialValue = initialState
            )
    }

    abstract val state: StateFlow<State>

    val currentState get() = state.value

    open fun onIntent(intent: Intent) {}

    protected fun sendTrigger(trigger: Trigger) {
        viewModelScope.launch {
            _trigger.emit(trigger)
        }
    }
}

Финальная реализация становится на удивление чистой и простой в поддержке:

internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModelLoader<UserAccountDetailsViewModel.ViewState, UserAccountDetailsViewModel.ViewState.Intents, UserAccountDetailsViewModel.ViewState.StateTriggers>() {

    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val isEmailVisible: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        val isDataLoaded get() = userInfo != null

        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )

        sealed class Intents {
            data object Refresh : Intents()
            data class ToggleEmailVisibility(val isEmailVisible: Boolean) : Intents()
        }

        sealed class StateTriggers {
            data class EmailVisibilityChanged(val isEmailVisible: Boolean) : StateTriggers()
            data object Refresh : StateTriggers()
        }
    }

    override val state = loadData(
        initialState = ViewState(isLoading = true, isError = false),
        loadData = { currentState ->
            if (currentState.isDataLoaded.not() || currentState.isError.not()) {
                emit(getUserDetailsState())
            }
        },
        triggerData = { currentState, refreshParams ->
            when (refreshParams) {
                is ViewState.StateTriggers.EmailVisibilityChanged -> {
                    emit(currentState.copy(isEmailVisible = refreshParams.isEmailVisible))
                }

                ViewState.StateTriggers.Refresh -> {
                    emit(ViewState(isLoading = true, isError = false))
                    emit(getUserDetailsState())
                }
            }
        }
    )

    private suspend fun getUserDetailsState(): ViewState = getUserDetailsUseCase.execute()
        .fold(
            onSuccess = {
                ViewState(
                    isLoading = false,
                    isError = false,
                    userInfo = ViewState.UserInfo(
                        displayEmail = it.email,
                        avatarUrl = it.avatarUrl,
                        showPremiumBadge = it.isPremium,
                        memberSince = it.creationDate?.toString()
                    )
                )
            },
            onFailure = {
                ViewState(isLoading = false, isError = true)
            }
        )

    override fun onIntent(intent: ViewState.Intents) {
        when (intent) {
            ViewState.Intents.Refresh -> {
                sendTrigger(ViewState.StateTriggers.Refresh)
            }

            is ViewState.Intents.ToggleEmailVisibility -> {
                sendTrigger(ViewState.StateTriggers.EmailVisibilityChanged(intent.isEmailVisible))
            }
        }
    }

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

Сложность состояния

Абстракция выше использует флаги (isLoadingisError), которые создают неоднозначные состояния. С использованием sealed классов решение становится чище:

@Immutable
sealed interface UIState {

    @Immutable
    data object Success : UIState

    @Immutable
    data object Error : UIState

    @Immutable
    data object Idle : UIState

    @Immutable
    data object Loading : UIState
}

Для сценариев, когда нужно одновременно отображать и ошибку (например, Snackbar), и данные, можно создать более сложный тип состояния:

@Immutable
data class UIStateHolder<out T>(
    val uiState: UIState = UIState.Idle,
    val payload: T? = null
)

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

Применение вне View Model

Это решение не привязано к ViewModel. Если передать CoroutineScope, код можно использовать в других компонентах: Composable, репозиториях или в доменном слое.

Как комбинировать Flow

Прелесть этого подхода можно расширить, чтобы использовать несколько источников данных. Вот пример обработки одного или двух Flow:

inline fun <reified T, R> ViewModel.loadFlow(
    initialState: R,
    flow: Flow<T>,
    crossinline transform: suspend CoroutineScope.(newValue: T, currentState: R) -> R,
    timeout: Long = 0,
): StateFlow<R> {
    var latestValue = initialState
    return flow
        .map { newValue ->
            coroutineScope {
                transform(newValue, latestValue)
            }
        }
        .onEach {
            latestValue = it
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(timeout),
            initialValue = latestValue
        )
}

Два Flow:

inline fun <reified T1, reified T2, R> ViewModel.loadFlow(
    initialState: R,
    flow1: Flow<T1>,
    flow2: Flow<T2>,
    crossinline transform: suspend CoroutineScope.(newValue1: T1, newValue2: T2, currentState: R) -> R,
    timeout: Long = 0,
): StateFlow<R> {
    var latestValue = initialState
    return combine(flow1, flow2) { value1, value2 ->
        coroutineScope {
            transform(value1, value2, latestValue)
        }
    }
        .onEach {
            latestValue = it
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(timeout),
            initialValue = latestValue
        )
}

Эти расширения позволяют легко комбинировать множество источников данных, одновременно сохраняя возможности кэширования и обработки состояния.

Тестирование

Перед заключением, давайте посмотрим как протестировать нашу UserAccountDetailsViewModel с использование фэйков и Turbine для тестирования Flow.

Настраиваем зависимости

@OptIn(ExperimentalCoroutinesApi::class)
class UserAccountDetailsViewModelTest {

    private val testDispatcher = StandardTestDispatcher()
    private val fakeGetUserDetailsUseCase = FakeGetUserDetailsUseCase()
    private lateinit var viewModel: UserAccountDetailsViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)
        viewModel = UserAccountDetailsViewModel(fakeGetUserDetailsUseCase)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }
}

// Fake implementation for realistic testing, this sounds funny to write haha
class FakeGetUserDetailsUseCase {
    private var shouldReturnError = false
    private var userDetailsToReturn: UserDetails? = null
    private var executionCount = 0
    
    fun setSuccessResponse(userDetails: UserDetails) {
        this.userDetailsToReturn = userDetails
        this.shouldReturnError = false
    }
    
    fun setErrorResponse() {
        this.shouldReturnError = true
        this.userDetailsToReturn = null
    }
    
    // Expose execution count when testing caching/performance behavior
    fun getExecutionCount() = executionCount
    fun reset() { executionCount = 0 }
    
    suspend fun execute(): Result<UserDetails> {
        executionCount++
        delay(50) // Simulate network delay
        
        return if (shouldReturnError) {
            Result.failure(Exception("Network error"))
        } else {
            Result.success(userDetailsToReturn ?: createDefaultUserDetails())
        }
    }
    
    private fun createDefaultUserDetails() = UserDetails(
        email = "default@example.com",
        avatarUrl = null,
        isPremium = false,
        creationDate = "2023-01-01"
    )
}

Параметризованные тесты для успешного и неуспешного сценариев

@ParameterizedTest
@ValueSource(booleans = [true, false])
fun `should handle both success and error scenarios`(shouldSucceed: Boolean) = runTest {
    // Given
    if (shouldSucceed) {
        fakeGetUserDetailsUseCase.setSuccessResponse(
            UserDetails(
                email = "success@example.com",
                avatarUrl = "https://avatar.url",
                isPremium = true,
                creationDate = "2023-01-01"
            )
        )
    } else {
        fakeGetUserDetailsUseCase.setErrorResponse()
    }

    // When
    viewModel.state.test {
        advanceUntilIdle()

        // Then Focus on behavior, not implementation details
        if (shouldSucceed) {
            awaitItem() // Loading state
            val successState = awaitItem()
            assertThat(successState.isLoading).isFalse()
            assertThat(successState.isError).isFalse()
            assertThat(successState.userInfo?.displayEmail).isEqualTo("success@example.com")
            assertThat(successState.userInfo?.showPremiumBadge).isTrue()
        } else {
            awaitItem() // Loading state
            val errorState = awaitItem()
            assertThat(errorState.isLoading).isFalse()
            assertThat(errorState.isError).isTrue()
            assertThat(errorState.userInfo).isNull()
        }
    }
}

Тестирование обновления данных

@Test
fun `should refresh data when refresh intent is triggered`() = runTest {
    // Given Initial successful load
    fakeGetUserDetailsUseCase.setSuccessResponse(
        UserDetails(
            email = "initial@example.com",
            avatarUrl = null,
            isPremium = false,
            creationDate = "2022-01-01"
        )
    )

    viewModel.state.test {
        advanceUntilIdle()
        
        awaitItem() // Loading
        val initialState = awaitItem() // Success
        assertThat(initialState.userInfo?.displayEmail).isEqualTo("initial@example.com")
        
        // Change response and trigger refresh
        fakeGetUserDetailsUseCase.setSuccessResponse(
            UserDetails(
                email = "refreshed@example.com",
                avatarUrl = "https://new-avatar.url", 
                isPremium = true,
                creationDate = "2023-01-01"
            )
        )
        
        viewModel.onIntent(ViewState.Intents.Refresh)
        advanceUntilIdle()

        // Then
        awaitItem() // Loading during refresh
        val refreshedState = awaitItem() // New Success
        assertThat(refreshedState.isLoading).isFalse()
        assertThat(refreshedState.userInfo?.displayEmail).isEqualTo("refreshed@example.com")
        assertThat(refreshedState.userInfo?.showPremiumBadge).isTrue()
        
        // Verify both initial load and refresh were called
        assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(2)
    }
}

Тестирование изменений состояния UI

@Test
fun `should toggle email visibility without triggering data reload`() = runTest {
    // Given Successful initial load
    fakeGetUserDetailsUseCase.setSuccessResponse(
        UserDetails(
            email = "test@example.com",
            avatarUrl = null,
            isPremium = false,
            creationDate = "2023-01-01"
        )
    )

    viewModel.state.test {
        advanceUntilIdle()
        
        awaitItem() // Loading
        val loadedState = awaitItem() // Success
        assertThat(loadedState.userInfo?.displayEmail).isEqualTo("test@example.com")
        assertThat(loadedState.isEmailVisible).isFalse()

        // When Toggle email visibility
        viewModel.onIntent(ViewState.Intents.ToggleEmailVisibility(isEmailVisible = true))
        advanceUntilIdle()

        // Then
        val toggledState = awaitItem()
        assertThat(toggledState.isEmailVisible).isTrue()
        assertThat(toggledState.userInfo?.displayEmail).isEqualTo("test@example.com")
        
        assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(1)
    }
}

Тестирование кэширования данных

@Test
fun `should use cached data when returning to screen quickly`() = runTest {
    // Given
    fakeGetUserDetailsUseCase.setSuccessResponse(
        UserDetails(
            email = "cached@example.com",
            avatarUrl = null,
            isPremium = true,
            creationDate = "2023-01-01"
        )
    )

    // When First collection
    viewModel.state.test {
        advanceUntilIdle()
        
        awaitItem() // Loading
        val firstState = awaitItem() // Success
        assertThat(firstState.userInfo?.displayEmail).isEqualTo("cached@example.com")
        
        cancel() // Simulate leaving screen
    }
    
    // When Quick return (simulating navigation back within timeout)
    viewModel.state.test {
        advanceUntilIdle()
        
        // Then Should have cached data immediately (no Loadin)
        val cachedState = awaitItem()
        assertThat(cachedState.isLoading).isFalse()
        assertThat(cachedState.userInfo?.displayEmail).isEqualTo("cached@example.com")
        
        expectNoEvents()
    }
    
    assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(1)
}

Тестирование восстановления после ошибки

@Test  
fun `should recover from Error on successful refresh`() = runTest {
    // Given Initial error
    fakeGetUserDetailsUseCase.setErrorResponse()
    
    viewModel.state.test {
        advanceUntilIdle()
        
        awaitItem() // Loading
        val errorState = awaitItem() // Error
        assertThat(errorState.isError).isTrue()
        
        // When Fix the response and refresh
        fakeGetUserDetailsUseCase.setSuccessResponse(
            UserDetails(
                email = "recovered@example.com",
                avatarUrl = null,
                isPremium = false,
                creationDate = "2023-01-01"
            )
        )
        
        viewModel.onIntent(ViewState.Intents.Refresh)
        advanceUntilIdle()
        
        // Then Should recover successfully  
        awaitItem() // Loading during refresh
        val recoveredState = awaitItem() // Success
        assertThat(recoveredState.isError).isFalse()
        assertThat(recoveredState.userInfo?.displayEmail).isEqualTo("recovered@example.com")
        
        assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(2)
    }
}

Принципы тестирования Flow-based ViewModel

Ох... сейчас начнётся холивар, но есть отличная статья, которая хорошо описывает то, как я сам пишу тесты. Чтобы вы сильно не отвлекались на её чтение, вот её резюме:

  1. Используйте Fakes вместо Mocks: Фэйки дают реалистичное поведение и их проще поддерживать, особенно с сегодняшними LLM.

  2. Параметризованные тесты: Тестирование разного поведения без лишних тестов

  3. Turbine для тестирования Flow: Чистые, понятные тесты Flow для проверки состояния

  4. Тестирование изменений состояния: Проверяйте все переходы между состояниями, а не только конечное состояние

  5. Тестирование количества вызовов: используйте только для тестирования кэширования, производительности или поведения ретраев

  6. Тестирование поведения: тестируйте то, что видит пользователь, а не детали реализации (вроде это очевидно)

Заключение

В этом исследовании о Flow-based загрузке данных в Android ViewModel решены фундаментальные проблемы использования традиционного подхода с блоком init {}. Хотя покрытие всех возможных вариантов архитектуры было бы сложным, текущий подход успешно решает пример 90% распространённых сценариев.

Подход с абстрактным базовым классом (ViewModelLoader) даёт понятное и поддерживаемые решение:

  • Понятная работа с состоянием: Понятная обработка пользовательских действий и обновление состояния

  • Тестируемость: Тестируемая архитектура на корутинах

  • Гибкость: Легко расширять для разных шаблонов вроде MVI

  • Кэширование и обновление: В статье обсудили механизмы кэширования и обновления, которые при необходимости могут быть легко доработаны

Ключевые выводы

  1. Flow-based загрузка данных по сравнению с использованием блока init {} предотвращает состояние гонки, улучшает тестируемость и решает проблемы с навигацией

  2. StateFlow с правильной стратегией шаринга позволяет удобно управлять данными

  3. Абстракция уменьшает бойлерплейт, сохраняя гибкость

  4. Комплексное тестирование гарантирует стабильное поведение приложения при любых вариантах использования.

Помните, что рассмотрен всего лишь один из возможных архитектурных подходов. Это решило мои проблемы, но не обязательно станет идеальным для вашего проекта. Важно понимать, какие проблемы решаются и формировать решения исходя из ваших конкретных требований. С течением времени это решение может сильно поменяться или устареть. В последнее время я уделяю больше внимания бэкэнд разработке с Ktor, что невероятно интересно.

Предложенная архитектура успешно применена в приложении WallHub на Android и iOS. Это показывает, что она вполне может быть применена в реальном приложении. Кроме того, она адаптирована кросс-платформенно.

Не забывайте пить воду, до следующей статьи!


От переводчика:

Статья цепляет своим разнообразием идей. Код из неё вряд ли можно просто скопировать в проект без адаптации, но сами подходы и принципы, описанные автором, могут дать хорошие инсайты и вдохновить на улучшение архитектуры вашего приложения.

Также хочу поделиться, что веду блог про Android разработку и про работу в стартапе. Если интересно обменяться опытом и поискать инсайтов — заходите, добро пожаловать: https://t.me/androidcoffee

Комментарии (3)


  1. alyxe
    26.09.2025 07:58

    Реактивная архитектура, безусловно, заслуживает внимания. Более того, даже архитектура экрана, изначально использующая лишь один запрос, со временем будет расшириться.

    Есть предложение по развитию этой идеи. Можно использовать Combine для всех useCase'ов + intentsFlow, в который будет эмиттиться из других функций viewModel. Из useCase будет торчать flow. Метод reload(): Unit можно добавить в каждый useCase. Если нужно форсировать перезагрузку каждый раз, можно в useCase добавить reloadFlow(): Flow<T>.


    1. keymusicman Автор
      26.09.2025 07:58

      Плюсую. Да и полноценное кэширование я бы делал не во вью модели, а в других слоях


  1. keymusicman Автор
    26.09.2025 07:58

    Плюсую. Да и полноценное кэширование я бы делал не во вью модели, а в других слоях