Зачем эта статья?
Я работал над 15+ приложениями и прочитал тысячи материалов по Kotlin, и видел так много неправильных способов загружать данные для отображения в UI, что меня беспокоит, сколько людей неосознанно их используют.
Хуже всего то, что они продолжают появляться! Каждый месяц я вижу очередную статью / видео / пример кода с неправильной реализацией либо событий, либо стратегий загрузки данных, которые вызывают скрытые баги, которые скорее всего обнаружатся только в продакшене.
Хотите узнать, используете ли вы один из них? Исправить проблемы? Тогда эта статья для вас, потому что это исчерпывающее руководство, которое вам нужно для правильной загрузки данных в Kotlin-приложениях.
Наш пример
Для иллюстрации предположим, что у нас есть приложение, которое загружает домашнюю страницу с:
- Информацией о профиле пользователя, которая кешируется в локальном файле + загружается из сети.
- Несколькими новостными статьями для пользователя, из сети, без файлового кеширования.
Требования:
- Мы должны показывать только актуальные статьи, пока пользователь просматривает страницу.
- Пользователь может открывать дополнительные страницы поверх нашей домашней страницы и редактировать информацию своего профиля или настройки статей. Когда он вернется, мы должны обновить данные.
Теперь предположим, что мы пытаемся выполнить требования и уже реализовали наш репозиторий.
interface HomeRepository {
suspend fun getArticles(): List<Article>
val user: Flow<User>
suspend fun refreshUser(): User
}
sealed interface HomeState {
data object Loading: HomeState
data class DisplayingFeed(val user: User, val feed: List<Article>): HomeState
}
Если вы не поняли, почему мы структурировали наш state таким образом, это описано в моей статье о state management (ссылка в конце статьи). Несколько замечаний:
- Мы специально опустили маппинг UI-модели и пагинацию для простоты
- Имейте в виду, что я также опустил обработку ошибок и обработку авторизации пользователя, чтобы упростить код
И теперь пришло время загрузить эти данные, например, в нашем Compose-приложении на Android.
Часть 1: Что НЕ надо делать
Лучший способ понять, что делать в нашем случае - начать с того, чтобы исключить, что не надо делать, и посмотреть, что останется.
1. Не загружайте данные сразу!
Предположим, что наивно мы написали следующий код:
class HomeViewModel(
private val repo: HomeRepository,
): ViewModel() {
private val _state = MutableStateFlow<HomeState>(HomeState.Loading)
val state = _state.asStateFlow()
init {
viewModelScope.launch {
val user = repo.refreshUser()
val articles = repo.getArticles()
_state.value = DisplayingFeed(user, articles)
}
}
}
В этом коде есть несколько утечек ресурсов, гонок и UX-проблем:
1. Устаревшие данные:
Данные загружаются только один раз, когда создается ViewModel.
- Если приложение уходит в фон и возвращается часами (или даже днями/неделями!) позже, пользователь видит устаревшую информацию. Современные телефоны могут держать приложения в suspended-состоянии долгое время.
- Если пользователь уходит со страницы (например, на экран редактирования профиля),
HomeViewModelчасто остается живой в backstack. Когда юзер вернется после внесения изменений, он все еще будет видеть старые данные профиля, думая, что обновление не сработало. Это быстрый способ добиться удаления вашего приложения. - Мы не подписываемся на
repo.userflow, поэтому любые фоновые обновления (нашим кодом) кешированных данных пользователя игнорируются.
2. Неэффективная загрузка:
Данные (user и articles) загружаются последовательно (refreshUser завершается до того, как начнется getArticles). Это делает начальное время загрузки дольше, чем нужно. Хотя вы можете использовать async {} для распараллеливания, это часто добавляет сложность, гонки при маппинге данных и больше бойлерплейта.
3. Проблемы с ручным управлением state:
Использование сырого MutableStateFlow и ручное обновление .value чревато ошибками.
- Легко внести баги, связанные с атомарностью и потокобезопасностью, особенно когда логика разрастается.
- Это обходит надежные механизмы управления state, которые дают корутины. Как обсуждалось в моих других статьях, когда вы делаете свое собственное управление state, это часто приводит к проблемам. Мы должны стремиться к единому, надежному источнику истины для UI state.
4. Загружается сразу:
Загрузка данных начинается немедленно при инициализации ViewModel, независимо от того, нужны ли UI эти данные на самом деле или кто-то подписывается на state flow. Это может случиться с условной UI-логикой (например, ожидание логина) или сложными структурами компонентов. Это пустая трата ресурсов (CPU, сети, батареи).
2. Не делайте свой собственный колбек onDataRefresh()!
Предположим, мы попытались решить проблему #1 (устаревшие данные) и проигнорировали 2 и 3 (потому что они будут найдены только QA в продакшене позже).
Распространенный (но багованный) подход - это подключиться к системному жизненному циклу и вызвать функцию “refresh”:
class HomeViewModel() : ViewModel() {
init {
refreshData()
}
fun onResume() = refreshData()
fun onPullToRefresh() = refreshData()
private fun refreshData() = viewModelScope.launch {
val user = repo.refreshUser()
val articles = repo.getArticles()
_state.value = DisplayingFeed(user, articles)
}
}
Этот “фикс” - просто хак, который добавляет больше проблем:
1. Количество корутин теперь растет бесконтрольно.
Каждый раз, когда экран возвращается в фокус (onResume), запускается новый refreshData. Если сеть медленная, а пользователь быстро переключается туда-сюда (или взаимодействует с диалогами), вы можете запустить много параллельных refresh-задач. Они потом могут:
- Перезаписывать результаты друг друга непредсказуемым образом.
- Перегружать систему и сетевые ресурсы.
- Падать из-за конкуренции за ресурсы.
Ручное управление этими задачами (например, отмена предыдущих) требует сложного, чреватого ошибками бойлерплейта с Job-экземплярами.
2. Мы все еще не решили исходную проблему.
Этот ручной refresh срабатывает только на события жизненного цикла или конкретные действия пользователя. Что если лежащие в основе данные (как кешированный repo.user) изменятся из-за какого-то другого фонового процесса, не связанного с жизненным циклом этого экрана? Например, задача на старте приложения может обновить профиль пользователя после того, как refreshData уже отработал. Экран будет все еще показывать устаревшие данные до следующего ручного обновления.
3. Проблемы с потоками усугубляются.
Когда мы добавляем больше операций (например, фильтрацию ленты на основе пользовательского ввода), управление параллелизмом и предотвращение конфликтов с продолжающимися refreshData-вызовами становится еще сложнее без правильной стратегии управления state.
4. Мы сделали неполную реализацию жизненного цикла подписки.
Простое использование onResume часто слишком частое для одних данных и недостаточно частое для других. onStart/onStop иногда может быть лучше, но выбор правильного события жизненного цикла и правильная реализация логики срабатывания каждый раз добавляет значительный бойлерплейт, особенно если вы (правильно) избегаете общих BaseViewModel-классов.
5. Утечка корутины загрузки.
Когда пользователь уходит с экрана, refreshData, запущенная через viewModelScope, продолжает работать в фоне, пока не завершится. Если загрузка данных ресурсоемкая, а пользователь быстро переходит куда-то еще, вы тратите ресурсы на загрузку данных, которые больше не нужны. В идеале эта работа должна быть отменена. Это может показаться мелочью для домашнего экрана, но если этот паттерн применять для всей кодовой базы, проблема всплывет более значительными способами позже.
3. Не подписывайтесь на источники данных в фоне!
Предположим, QA сообщил нам о #1 и #2 из предыдущей главы. Мы решили не добавлять хаков и вместо этого правильно подписаться на user: Flow в нашей ViewModel.
Это хорошо, потому что теперь мы можем реактивно отслеживать данные в нашей вьюмодели. Когда что угодно изменит данные, мы узнаем об обновлении. Но мы сделали критическую ошибку - мы собираем flow в блоке init.
class HomeViewModel(
private val repo: HomeRepository,
): ViewModel() {
init {
repo.user.onEach { user ->
_state.value = DisplayingFeed(user, repo.getArticles())
}.launchIn(viewModelScope)
}
}
Я использовал launchIn здесь, чтобы сделать код обманчиво простым (и похожим на то, что я видел, как пишут мои коллеги). Основная проблема не в самом launchIn, а в сборе flow на протяжении всего времени жизни viewModelScope без учета жизненного цикла UI. Любой механизм, который делает холодный поток горячим (как collect, launchIn, stateIn с SharingStarted.Eagerly или Lazily), может привести к этому.
Причина, по которой ошибка “критическая”, в том, что когда мы начинаем делать это для всех наших экранов, у нас будут утечки, которые тратят ресурсы пропорционально размеру бекстека, не говоря уже о трате ресурсов, пока приложение в фоне.
Предположим, пользователь может открыть страницу ленты несколько раз. Бекстек будет расти без ограничений, и каждая новая вьюмодель будет продолжать загружать и обновлять данные. Если у вас 100 страниц в бекстеке, в момент, когда одно свойство объекта user изменится, 100 страниц перезагрузят свою информацию. Это всплывет в плохих отзывах пользователей (плохая производительность, устройство греется, плохое время работы батареи) и непонятных, неотслеживаемых ANR и OOM крашах в продакшене. Это почти невозможно обнаружить ручным QA, интеграционным тестированием, unit-тестированием или во время разработки, если вы не ищете эту конкретную проблему.
Если вы начнете исправлять это в каждом отдельном случае, вам нужно будет добавить кеширование с возможностью ретрая, ручные хуки жизненного цикла для каждой ViewModel, обработку ошибок и throttling-код, и это быстро выйдет из-под контроля. Поэтому,
НЕ собирайте flow используя viewModelScope, если вам явно не нужно, чтобы поток данных оставался активным, даже когда UI не видим.
Исключение выше редкое (может быть <5% случаев) и часто указывает на низлежащую архитектурную проблему или необходимость в фоновых задачах/воркерах.
4. Не запускайте загрузку из UI
Предположим, вы просто разозлились, что не работает, и решили запускать загрузку всей информации из UI (в нашем примере - из композиции):
class HomeViewModel() : ViewModel() {
suspend fun observeData() = coroutineScope {
val feed = repo.getArticles()
repo.user.collect { user ->
_state.update {
it.copy(user = user, feed = feed)
}
}
}
}
@Composable
fun HomeScreen(vm: HomeViewModel) {
LaunchedEffect(Unit) {
vm.observeData()
}
}
Так что в нашем примере мы обновляем данные до тех пор, пока observeData() не будет отменена, то есть пока страница видна.
Этот подход, включая вариации вроде отправки “ScreenVisible” событий/интентов из UI в ViewModel для запуска viewModelScope.launch { ... }, - это просто другая форма ручного управления жизненным циклом и задачами.
Проблемы с этим:
- Мы снова вручную пытаемся синхронизировать загрузку данных с жизненным циклом UI, что
LaunchedEffectилиonResumeрешают только частично. - Мы слили ответственность ViewModel (загрузка данных) на UI-слой .
- Теперь нам всегда нужно отслеживать, запускает ли только один подписчик
observeData(). - Есть дополнительная нагрузка на UI - решать, когда нужен refresh. Что если UI не нужен state до тех пор, пока, например, пользователь не закрыл диалог обновления? Или не авторизовался? Вся эта логика теперь на UI.
- Как вы теперь сделаете ретраи, если
repo.getArticles()кинется? Добавить логику retry внутрьobserveDataвозможно, но запускать ее или управлять ее состоянием из UI становится неудобно.
Часть 2: Правильный способ загружать и отслеживать данные
Итак, мы определили, что мы не должны делать:
- Не подписывайтесь на потоки данных бесконечно в backstack/
viewModelScopeбез учета жизненного цикла. - Не полагайтесь на ручные UI-триггеры или lifecycle callbacks (
onResume,LaunchedEffect) для запуска основной логики загрузки данных в ViewModel. - Не используйте mutable state (
MutableStateFlow) как основной держатель state без надежного, атомарного механизма обновления. - Не делайте своё собственное сложное управление задачами для отмены и перезапуска.
- Не загружайте все источники данных снова, если изменился только один (если это не нужно).
- Не используйте логику загрузки, которую нельзя легко отменить или повторить.
И теперь вот что мы на самом деле должны делать:
Комбинируйте несколько источников данных реактивно, используя операторы flow (т.к. combine), и отдавайте результат как StateFlow, используя stateIn, настроив его для учета наличия UI-подписчиков через SharingStarted.WhileSubscribed.
Пример:
class HomeViewModel(
private val repo: HomeRepository,
): ViewModel() {
private val articles = flow { emit(repo.getArticles()) }
val state = combine(
repo.user.distinctUntilChanged(),
articles,
) { user, feed ->
DisplayingFeed(user, feed)
}.stateIn(
scope = viewModelScope,
initialValue = HomeState.Loading
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 1.seconds,
replayExpirationMillis = 9.seconds,
),
)
}
1. Мы оборачиваем вызов suspend-функции repo.getArticles() в flow { } builder. Это создает cold flow - он ничего не делает, пока нет подписчиков. Мы также можем применять любые операторы маппинга к отдельным flow вместо того, чтобы делать это во время сборки state, значительно сокращая напрасную работу.
2. Оператор combine:
- Собирает все flow (
repo.user,articles) параллельно. - Как только все flow выдали хотя бы одно значение, вызывает маппер (
{ user, articles -> ... }) с последним значением из каждого flow. - Когда любой из входных flow выдает новое значение позже,
combineперезапускает лямбду с новым значением и самыми свежими (кешированными) значениями из других flow, производя обновленныйHomeState.
3. Мы вызываем оператор stateIn, чтобы конвертировать наш cold flow в hot flow, главным образом потому, что мы хотим иметь value, которое можно использовать в UI для рендера, и чтобы закешировать и переиспользовать результат оператора. Мы будем производить родительский flow в scope вьюмодели, но…
4. Мы будем собирать родительский flow только WhileSubscribed. Когда появится первый подписчик, stateIn запустит сборку результирующего flow из combine, который в свою очередь запустит все наши источники данных. Более того, мы настраиваем WhileSubscribed так, что…
5. Мы останавливаем и отменяем сборку flow через 1 секунду (или другую разумную небольшую задержку). 1 секунда - это просто произвольное значение, которое примерно эквивалентно тому, сколько может занять configuration change на Android в худшем случае. Нам нужно это делать, потому что на Android конкретно, UI ненадолго отпишется, пока иногда меняется конфигурация, и мы не хотим тратить ресурсы из-за этого.
6. Дополнительно мы настраиваем replayExpirationMillis, то есть как долго значение, вычисленное в последний раз, будет валидным для нашего UI, если ему нужно переподписаться. Если это время истечет, state вернется к initialValue снова. Это не то же самое, что stopTimeout, так как истекший stopTimeout заставит combine перезапустить все flow независимо от replay, но валидный replay также даст последнее выданное значение подписчикам, пока происходит перезапуск (вместо initialValue). Это число не включает stopTimeout, поэтому я вычел 1 секунду из желаемых десяти. Иногда вы можете захотеть, чтобы это было долго или бесконечно, в зависимости от желаемого UX.
Вот почему этот код не имеет ни одной из проблем, обсужденных выше:
- Ленивая загрузка по запросу: Загрузка данных (
articles) начинается только когда UI подписывается наstate.repo.userотслеживается только пока UI подписан. Никакой работы не делается, если UI не заинтересован. - Всегда актуальные данные:
combineгарантирует, что изменения в любом лежащем в основе источнике данных (repo.user) автоматически вызывают обновление state. - Параллельная загрузка:
combineсобирает свои входные flow параллельно. Наблюдение заrepo.userначинается, и выполнениеarticlesFlowначинается параллельно. ПервыйDisplayingFeedstate выдается, как только оба произвели значение. - Атомарные и безопасные обновления: внутрянка корутин обрабатывает параллелизм, атомарность и thread safety внутри
combineиstateIn. Нам не нужна ручная синхронизация в маппере. - Один upstream-подписчик:
stateInгарантирует, что upstreamcombineflow собирается только один раз, независимо от того, сколько UI-подписчиков наблюдают финальныйstate. Нет риска бесконтрольного роста кол-ва корутин. - С учетом жизненного цикла и возможностью отмены:
SharingStarted.WhileSubscribedавтоматически отменяет upstream-подписки (включая сетевой вызовarticles), когда UI больше не наблюдает (после таймаута), предотвращая утечку работы и экономя ресурсы. - Разделенная ответственность: ViewModel фокусируется чисто на определении state на основе источников данных. UI просто собирает
state, используя подписчиков с учетом жизненного цикла (какcollectAsStateWithLifecycle()в Compose), не нуждаясь знать, как производится state, или что-либо запускать. - Возможность ретрая: Вы можете легко добавить логику ретрая к отдельным flow до того, как они будут использованы в операторе
combine(например, используя операторretryнаarticles) без усложнения общей структуры.
Но что если у меня есть временные данные, которые я хочу обновлять вручную?
Тогда НЕ делайте это:
class HomeViewModel @Inject constructor() : ViewModel() {
private val screenState = MutableStateFlow(value = ScreenState())
val uiState = screenState
.onStart { fetchArticleList() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L),
initialValue = screenState.value,
)
suspend fun fetchArticleList() {
delay(timeMillis = 2000L)
screenState.update { state ->
state.copy(
text = "Fetch Data ${state.counter}",
counter = state.counter + 1
)
}
}
}
Этот неправильный код скопирован напрямую из другой статьи о загрузке начальных данных.
Проблемы с кодом:
- Двойной диспатчинг. Мы конвертируем hot flow в cold flow используя
onStart, а затем обратно в hot flow. Как минимум это напрасные вычисления, но этот код также работает неправильно, так как опцияWhileSubscribedне имеет эффекта на flow, который уже под капотом является hot flow. ОператорonStartвызывается жадно, потому что flow уже горячий. - onStart - это неправильный оператор. Я уже объяснил, как кастомные колбеки делают наш код хрупким и небезопасным в плане потоков.
Код выше делает свой собственный мутабельный стейт и использует двойную конвертацию с триггером для загрузки начальных данных, но самое важное, он использует паттерн “дополнения” временного стейта исходными данными. Мы должны делать наоборот, дополняя стейт источника данных временным стейтом, вот так:
data class UserInput(
val searchQuery: String? = null,
)
sealed interface HomeState {
data class DisplayingArticles(
val input: UserInput,
)
}
class HomeViewModel() : ViewModel() {
private val input = MutableStateFlow(UserInput())
val state = combine(
repo.user,
articles,
input,
) { user, feed, input ->
DisplayingFeed(input, user, feed)
}.stateIn( /* ... */ )
fun onSearchQueryChanged(value: String) = input.update {
it.copy(searchQuery = value)
}
}
Таким образом:
- Временный state (
input) управляется отдельно и обновляется атомарно, используяupdate. - Основной
stateвсе еще выводится реактивно черезcombineи получает все преимуществаstateIn.
А что если я использую MVI?
Тут уже начинаются трудности. С MVI мы должны использовать единый, mutable state как источник истины. Это один из “недостатков” MVI, на которые часто ссылаются сторонники MVVM. Mutable state в MVI имеет свои преимущества, но здесь нам приходится платить цену. Но не волнуйтесь, это решаемо.
Нам просто нужно реализовать логику, похожую на то, как WhileSubscribed работает под капотом: он отслеживает количество подписчиков, и когда их количество падает до 0, он отправляет специальную команду flow для отмены сборки родительских флоу.
Я подумал и разработал следующие функции-расширения, которые должны вести себя похожим образом:
suspend inline fun MutableSharedFlow<*>.whileSubscribed(
stopDelay: Duration = 1.seconds,
minSubscribers: Int = 1,
crossinline action: suspend () -> Unit
) = subscriptionCount
.map { it >= minSubscribers }
.dropWhile { !it }
.debounce { if (it) Duration.ZERO else stopDelay }
.distinctUntilChanged()
.collectLatest { if (it) action() }
inline fun MutableSharedFlow<*>.whileSubscribed(
scope: CoroutineScope,
stopDelay: Duration = 1.seconds,
minSubscribers: Int = 1,
crossinline action: suspend () -> Unit
) = scope.launch(start = CoroutineStart.UNDISPATCHED) {
whileSubscribed(stopDelay, minSubscribers, action)
}
Объясним построчно:
- Каждый
MutableSharedFlow(которым StateFlow является) имеет отдельный flowsubscriptionCount, мы используем его… - Чтобы замапить, есть ли у нас подписчики, удовлетворяющие критериям…
- Затем ждем, пока это условие не станет истинным в первый раз…
- Затем если условие стало истинным, мы немедленно продолжаем, иначе ждем
stopDelay, чтобы увидеть, появятся ли подписчики снова в скором времени… - Затем фильтруем дубликаты (которые иначе будут повторно отменять наш flow) событий подписки…
- И затем на каждом изменении условия количества подписок мы запускаем
action, если оно сталоtrue.
Затем все, что вам нужно сделать, чтобы использовать эту функцию:
class HomeViewModel() : ViewModel() {
init {
_state.whileSubscribed(viewModelScope) {
combine(
repo.user,
articles,
) { user, feed ->
updateState { produceState(user, feed) }
}.collect()
}
}
}
Имейте в виду:
- Она обрабатывает только подписки на state. Если у вас есть отдельный канал для сайд-эффектов, они не будут учтены, и вам нужно доработать эту реализацию
- Она не имеет поведения сброса state из
WhileSubscribed(обычно не нужно с MVI). Если вам это нужно, простое дополнение кода вcollectможет это дать. - Вы все равно должны правильно подписываться на state с учетом жизненного цикла, используя что-то вроде
collectAsStateWithLifecycleв Compose. - Опасно использовать эту функцию без Serialized State Transactions, когда задействованы параллельные обновления (если только вы не делаете транзакции атомарными вручную тщательно).
- Ожидается, что вы будете саспендить в блоке
action(вместо использованияviewModelScope), чтобы работать с нашей политикой отмены.
Заключение: Не изобретайте велосипед
Если вы используете простой MVVM(+), вам повезло! Просто не изобретайте велосипед - используйте проверенный и стандартный способ загрузки данных и создания state, и все будет в порядке!
И если в какой-то момент вы решили, что хотите более безопасное управление state или некоторые фичи MVI, и вся эта муть звучит сложно - вы правы. Слишком много всего нужно держать в голове.
Когда люди чувствуют, что им нужно что-то более надежное, чем MVVM, они часто делают свою собственную “внутреннюю реализацию MVI”, но когда дело доходит до правильной загрузки и отслеживания данных, обработки сайд-эффектов или управления state, 95% этих “реализаций” багованные так или иначе.
Это только звучит просто на поверхности:
“Мне просто нужен flow для сайд-эффектов и state flow для состояний, а затем посылать интенты, правда?”
Не совсем, как видите. По моему мнению, поэтому и существуют архитектурные фреймворки. Преимущество их в том, что все сложные вещи вроде этого решены, задокументированы, тщательно протестированы, забенчмаркены и проверены в продакшене до того, как вы начнете использовать новый код.
Вы можете изобрести велосипед и не зависеть от фреймворка - конечно, отлично, “одной библиотекой меньше, которую автор может забросить в любой момент”. Но чем ваш велосипед (который ваша команда теперь должна поддерживать и чинить) лучше, чем поддерживаемое сообществом, протестированное, оптимизированное, задокументированное, отполированное решение, которое решало проблемы, о существовании которых вы даже не знали, годами?
Я сделал FlowMVI именно поэтому - я устал видеть, как я и моя команда делаем одни и те же ошибки снова, и снова, и снова. Я потратил 2 года на полировку фреймворка, чтобы вся эта статья свелась к этому коду:
val store = store(HomeState.Loading) {
val articles by retry { repo.getArticles() }
whileSubscribed {
combine(repo.user, articles) { user, feed ->
updateState { DisplayingFeed(user, feed, typed<DisplayingFeed>()) }
}.collect()
}
}
@Composable
fun HomeScreen() {
val state by store.subscribe()
}
Код выше следует системному, композиционному, навигационному и подписочному жизненному циклу, использует SST для обновления state параллельно, использует временный state для сохранения данных, позволяет делать ретрай вычислений, правильно сбрасывает state при завершении, и даже сохраняет state на диск при необходимости.
Так что давайте решим проблему “загрузки данных” вместе - если наткнетесь на еще одну статью, пример кода, реализацию или видео о том, как вы “должны загружать данные в ViewModels”, которая имеет проблемы, которые я упомянул, я буду рад, если вы пришлете автору ссылку на эту статью.
Я хочу решить эту проблему навсегда и сохранить людям бесчисленные часы отладки, тестирования и исправления проблем, потому что мне так больно, когда эти проблемы находят слишком поздно.