Материал подготовлен для 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 лежит три варианта, и вписаны они во все три сразу:

  1. META-INF/proguard/coroutines.pro,

  2. META-INF/com.android.tools/proguard/coroutines.pro

  3. META-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. «Написание тестов для Андроид в эпоху ИИ». Записаться

Весь список бесплатных вебинаров октября собрали в дайджесте.

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