Материал подготовлен для Android‑разработчиков, которые хотят выйти на новый уровень.
Привет, Хабр!
Список на экране показывает первое значение и застывает. В дебаге вроде всё работает, на устройстве разработчика тоже, воспроизводится только в релизе и не у всех. Логи чистые, исключений нет, запросы уходят и возвращаются.
Замолчать StateFlow умеет тремя законными способами, и все три разобраны в любом руководстве по архитектуре.
А с марта 2026 года к ним добавился четвёртый, и он не лечится правкой вашего кода вовсе, потому что живёт не в нём, а в оптимизаторе.
Хуже всего, что на экране все четыре выглядят одинаково.
Значение сравнивается, а не пересылается
MutableStateFlow при присваивании сравнивает новое значение со старым через equals и при совпадении не делает ничего.
Документация говорит это так: «Values in state flow are conflated using Any.equals comparison in a similar way to distinctUntilChanged operator», и дальше — что сравнение служит, чтобы «suppress emission of the values to collectors when new value is equal to the previously emitted one».
Не оптимизация, а определение: StateFlow держит состояние, а не пересылает события.
Отсюда правило, которое часто нарушают. Состояние обязано быть значением, сравнимым по содержимому. Положите внутрь изменяемую коллекцию, и поток замолчит на первом же обновлении:
data class UiState( val items: MutableList<String> = mutableListOf(), val error: String? = null, ) val state = MutableStateFlow(UiState()) state.value.items.add("первый") state.value = state.value // тот же экземпляр
Замер по эмиссиям, которые дошли до коллектора:
после add + присваивания того же объекта: эмиссий 1 после copy с новым списком: эмиссий 2
Единица в первой строке — это начальное значение, которое коллектор получает при подписке.
Добавление и присваивание не дали ничего. Ссылка та же, equals вернул true, отправлять нечего. Список при этом изменился, и в следующий раз, когда поток эмитит по другой причине, элемент внезапно появится вместе с ним.
Одно и то же состояние второй раз не придёт
Сравнение работает и на содержимом, а не только на ссылках, и вот это уже ломает вещи, которые выглядят совершенно правильными. Отправим четыре состояния, где последние два одинаковые:
state.value = UiState(error = "нет сети") state.value = UiState(error = null) state.value = UiState(error = "нет сети") state.value = UiState(error = "нет сети") // ровно то же, что и предыдущее
доставлено после начального: 3 из 4 что увидел коллектор: [null, нет сети, null, нет сети]
Четвёртое присваивание не доехало.
На экране это выглядит так: пользователь жмёт «Повторить», запрос снова падает с той же ошибкой, и снекбар не показывается, потому что состояние не изменилось. Первый раз показался, второй нет.
Обходят это обычно счётчиком или каким‑нибудь случайным идентификатором внутри состояния, и обход работает, только смысл у него обратный: вы делаете значение неравным самому себе, чтобы обмануть сравнение.
Правильнее признать, что показ снекбара — не состояние экрана, а событие, и хранить его негде.
Тысяча обновлений, коллектор увидел два
Третья причина — склейка. StateFlow не буферизует. Если коллектор занят, промежуточные значения теряются, а доезжает последнее.
Проверяем на коллекторе с задержкой 1 мс:
отправили 1000 за 1 мс, коллектор получил 2, последнее значение 1000
Две эмиссии из тысячи, от прогона к прогону выходило от двух до четырёх.
Для экрана так и надо: перерисовывать 998 промежуточных списков смысла нет, а последний доедет обязательно. Но если на каждое значение вы вешаете побочное действие — отправляете аналитику, пишете в базу, инкрементируете счётчик, то потеряете почти всё и не заметите, потому что итоговая картинка сойдётся.
Граница описана в той же документации: «a slow collector skips fast updates, but always collects the most recently emitted value». Последнее значение вы увидите, про путь к нему обещаний нет.
События через это не ходят
Стандартный совет для событий — SharedFlow с нулевым реплеем. Совет верный по замыслу, но у него три способа молча потерять событие, и я нарвался на все три.
val events = MutableSharedFlow<String>(replay = 0, extraBufferCapacity = 0) val ok = events.tryEmit("показать снекбар")
tryEmit без подписчика вернул true, подписчиков 0 после подписки tryEmit вернул false, коллектор получил [] с extraBufferCapacity=1 без подписчика tryEmit вернул true поздний подписчик получил []
Первая строка неприятна. Подписчиков нет, событие деть некуда, а
tryEmitотвечаетtrue, потому что формально он всё сделал: рассылать некому, буфера нет, значит и ошибки нет. Ваш код видит успех, пользователь не видит снекбара.Вторая строка переворачивает ситуацию: подписчик есть, а
tryEmitвозвращаетfalse, потому что без буфера доставка потребовала бы приостановки, аtryEmitприостанавливаться не умеет.Третья показывает, что буфер не заменяет реплей: значение в буфер положили, подписчик пришёл через 80 мс и не получил ничего.
Тот же сценарий через Channel ведёт себя так, как человек и ожидает от событий:
val ch = Channel<String>(capacity = Channel.BUFFERED) ch.trySend("показать снекбар") ch.trySend("и второй")
отправили 2 до подписки, поздний подписчик получил [показать снекбар, и второй]
Канал хранит элементы до того, как их прочитают, и отдаёт каждый ровно один раз. Для однократных событий это подходящая семантика, а SharedFlow берут там, где событие нужно раздать нескольким подписчикам сразу.
WhileSubscribed перезапускает источник, но не всегда
В Android к семантике добавляется жизненный цикл, и stateIn с SharingStarted.WhileSubscribed, тот самый мостик между ними, а таймаут внутри стратегии придуман из‑за поворота экрана: подписчик исчезает на миг, и перезапускать из‑за этого источник не нужно.
val state = upstream.stateIn( scope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000), initialValue = -1, )
Замер: подписались, ушли на 300 мс, вернулись, потом ушли на 1300 мс и вернулись снова.
[upstream] запуск №1 ушли с экрана, ждём 300 мс и возвращаемся запусков upstream за две подписки: 1 теперь уходим на 1300 мс, больше таймаута [upstream] запуск №2 запусков upstream всего: 2
Внутри таймаута источник продолжает работать, за таймаутом останавливается и при следующей подписке стартует заново.
Заодно проверил две соседние стратегии, потому что их путают. SharingStarted.Eagerly запускает источник сразу при создании потока, ещё до появления подписчиков, — в замере запуск случился при нулевом их числе.
Lazily и WhileSubscribed ждут первого подписчика и без него не стартуют вовсе.
Разница важная: Eagerly на экране, который пользователь может и не открыть, оплачивается запросом в сеть просто за факт создания ViewModel.
Отсюда следствие: таймаут держат близким к длительности поворота.
На таких деталях хорошо проверяется глубина понимания Android. Если интересно оценить свой уровень и найти слабые места, можно пройти короткий бесплатный вступительный тест.
Четвёртая причина, и она не в вашем коде
Все три предыдущие причины ведут себя одинаково в отладочной и в релизной сборке.
А теперь та, которая проявляется только в релизе.
stateIn и shareIn возвращают обёртку. Для состояния это ReadonlyStateFlow, для события ReadonlySharedFlow.
Внутри обёртки лежит поле, которое нигде не читается:
$ javap -p kotlinx.coroutines.flow.ReadonlyStateFlow final class kotlinx.coroutines.flow.ReadonlyStateFlow<T> implements ... { private final kotlinx.coroutines.flow.StateFlow<T> $$delegate_0; private final kotlinx.coroutines.Job job; }
Поле job — это якорь для сборщика мусора. Корутина, которая шарит поток, держится именно за него: пока живёт обёртка, живёт и ссылка на job, значит, корутину не соберут.
В исходниках это параметр конструктора, помеченный @Suppress("unused"), чтобы компилятор не ругался на неиспользуемое значение.
Дальше приходит R8 в full mode, стоящий в AGP по умолчанию с версии 8.0. Он видит поле, в которое пишут и ни разу не читают. И выбрасывает его. Аннотация ему не мешает: у @Suppress срок жизни исходный, в байткоде её нет, что видно по тому же классу:
private final kotlinx.coroutines.Job job; flags: (0x0012) ACC_PRIVATE, ACC_FINAL RuntimeInvisibleAnnotations: 0: #16() org.jetbrains.annotations.Nullable
Только @Nullable, и больше ничего. Якорь исчезает, корутина становится недостижимой, сборщик её забирает, и поток отдаёт первое значение, а дальше молчит. Та жалоба, с которой всё началось.
Заведено это 26 марта 2026 года как issue 4646 в kotlinx.coroutines. Затронута версия 1.10.2, автор воспроизвёл на R8 8.13.19 и Kotlin 2.3.20. Починили в 1.11.0, которая вышла 7 мая 2026, а строка в списке изменений выглядит так: «Fixed an R8 optimization leading to shareIn/stateIn coroutines getting garbage‑collected (#4646)».
Интересно, чем именно починили. Не кодом, а правилом внутри библиотеки: в jar лежат файлы правил для R8, и между двумя версиями разница вот такая.
+ # Keep GC anchor fields that prevent the sharing coroutine from being collected. + -keepclassmembers class kotlinx.coroutines.flow.ReadonlySharedFlow { + kotlinx.coroutines.Job job; + } + -keepclassmembers class kotlinx.coroutines.flow.ReadonlyStateFlow { + kotlinx.coroutines.Job job; + }
Правил в jar лежит три варианта, и вписаны они во все три сразу:
META-INF/proguard/coroutines.pro,META-INF/com.android.tools/proguard/coroutines.proMETA-INF/com.android.tools/r8/coroutines.pro.
Так библиотеки и доносят свои требования до оптимизатора, не заставляя каждое приложение прописывать их руками, и именно поэтому фикс приезжает вместе с версией, а не с правкой вашего кода.
Проверить, что у вас в сборке, можно, не дожидаясь жалобы. Возьмите jar корутин из кэша Gradle, распакуйте и поищите в правилах имя обёртки:
unzip -p kotlinx-coroutines-core-jvm-*.jar \ META-INF/com.android.tools/r8/coroutines.pro | grep ReadonlyStateFlow
Пусто — значит версия старая и якорь ничем не защищён. Я так и сравнивал 1.10.2 с 1.11.0. Разница — в этих шести строках.
Заодно в исходниках над классом появилась строчка, которой раньше не было: «Note: referenced by name in bundled ProGuard/R8 rules».
Класс приватный, но его имя теперь часть публичного контракта библиотеки: переименуешь — и правило перестанет находить поле, а баг вернётся молча. Такие связки между кодом и текстовыми правилами на сборке не проверяются вообще никак.
Само поле в 1.10.2 тоже есть, никуда оно не пропадало. Пропадало оно после оптимизатора, и теперь оптимизатору запрещено его трогать.
Отсюда два следствия.
Первое: обновление зависимости — единственный полный способ закрыть вопрос, потому что правило приезжает вместе с jar.
Второе: пока обновиться нельзя, те же шесть строк можно положить в свой
proguard-rules.pro: оптимизатору безразлично, из какого файла к нему пришло правило.
Почему это вылезло именно сейчас
Поле job живёт в библиотеке давно, R8 в full mode стоит по умолчанию с AGP 8.0, а жалобы пошли только в 2026 году. Причина в том, какой файл правил подключён у проекта.
Многие годами держат в build.gradle строчку с proguard-android.txt, и внутри этого файла есть -dontoptimize. Оптимизации выключены целиком, поле никто не выбрасывает, всё работает. Сборка всё равно уменьшается и обфусцируется. Повода что‑то менять не возникало.
С AGP 9 повод возник принудительно. Официальная формулировка из блога Android такая: «From AGP 9.0 onwards we are entirely dropping support for the file. This means you will have to migrate to proguard‑android‑optimize.txt».
Сборка на старом файле теперь падает с сообщением, которое объясняет причину прямым текстом: «getDefaultProguardFile('proguard-android.txt') is no longer supported since it includes -dontoptimize, which prevents R8 from performing many optimizations».
Складывается всё в одну последовательность. Проект переезжает на AGP 9, меняет имя файла на подсказанное, и оптимизатор впервые за годы включается по‑настоящему. Вместе с ним включается и всё, что от него зависело, включая вырезание якоря.
Правка была в одну строчку, поведение поменялось в релизной сборке, и связать одно с другим по симптому почти невозможно.

Когда привычные инструменты начинают вести себя не так, как ожидаешь, быстро становится понятно, каких знаний не хватает именно в реальной работе. Чтобы увереннее разбираться в таких ситуациях, находить причину быстрее и лучше понимать, что происходит под капотом Android‑приложения, приходите на открытые уроки:
8 октября в 20:00. «Нюансы работы с ИИ для разработчика». Записаться
21 октября в 20:00. «Написание тестов для Андроид в эпоху ИИ». Записаться
Весь список бесплатных вебинаров октября собрали в дайджесте.

