Представьте обычный feed на LazyColumn: новости, карточки, между ними рекламный баннер. Баннер загрузился, пользователь пролистал его вниз, затем вернулся обратно, и мерцание реклама грузится заново.
Ещё раз вниз. Ещё раз вверх. Ещё одно мерцание и загрузка.
На первый взгляд причина кажется очевидной: item покидает экран, rememberBannerAdState теряет состояние, Compose создаёт всё заново. Значит, достаточно сохранить BannerAdState за пределами item и проблема исчезнет.
Я тоже так подумал.
Не исчезла.
Мерцания остались.
С этого момента простой баг (?) интеграции Yandex Ads превратился для меня в небольшое расследование: пришлось разобраться, что на самом деле происходит с item внутри LazyColumn, как SubcomposeLayout переиспользует composition, почему это ещё не означает переиспользование AndroidView и какой lifecycle поверх всего этого построил Compose API Yandex Ads.
В итоге вопрос "почему баннер моргает?" оказался гораздо интереснее самого workaround.
Часть 1: Интегрируем Yandex Ads в feed
Началось всё с обновлённого Compose API Yandex Mobile Ads. На момент моей публикации использовалась версия 8.3.0: документация есть, Compose samples тоже есть, и интеграция выглядит легкой. Правда, отдельного примера с несколькими баннерами внутри LazyColumn и объяснения их lifecycle я не нашёл.
Добавил к себе в LazyList
@Composable fun AppDemoScreen(modifier: Modifier) { val feedItems = remember { DemoStatic.generateRandomFeed(size = 50, repeatAdsEach = 3) } LazyColumn { itemsIndexed( items = feedItems, key = { id, item -> when (item) { is FeedItem.HeroNewsItem -> "hero_${item.article.id}" is FeedItem.CompactNewsItem -> "compact_${item.article.id}" is FeedItem.FeedAdItem -> "ad_$id" } }, contentType = { _, item -> when (item) { is FeedItem.HeroNewsItem -> HeroCard is FeedItem.CompactNewsItem -> CompactCard is FeedItem.FeedAdItem -> AdCard } } ) { _, item -> when (item) { is FeedItem.HeroNewsItem -> ... is FeedItem.CompactNewsItem -> ... is FeedItem.FeedAdItem -> { InlineBanner(item, Modifier.fillMaxWidth()) } } } } }
компонент и пошел смотреть как это выглядит:
@Composable override fun InlineBanner(feedAd: FeedItem.FeedAdItem, modifier: Modifier) { val bannerState = rememberBannerAdState(adSize) LaunchedEffect(bannerState) { bannerState.loadAd(AdRequest.Builder("demo-banner-yandex").build()) } Banner(bannerState, modifier) }

Почему?
Часть 2: Гипотеза о потере BannerAdState
Смотрим в логи:
> [Integration] Ad type banner was integrated successfully > New ad loaded for 3 > Impression {...} > [Integration] Ad type banner was integrated successfully > New ad loaded for 7 > Impression {...} > [Integration] Ad type banner was integrated successfully > New ad loaded for 3 > Impression {...} > [Integration] Ad type banner was integrated successfully > New ad loaded for 7 > Impression {...}
Ага, скорее всего проблема заключается в специфике LazyList
Item выходит за экран → composition меняется →
rememberBannerAdStateможет потеряться → возможно поэтому реклама загружается заново.
Проводим первый эксперимент выносим BannerAdState, за lifecycle LazyListScope.item
Это пример, а не рекомендация по управлению рекламой. Задача которого проверить, исчезнет ли проблема при сохранении BannerAdState.
private val bannerStates = hashMapOf<Int, MutableState<BannerAdState?>>() @Composable override fun PrepareBanners(ads: List<FeedItem.FeedAdItem>) { ads.forEach { feedAd -> key(feedAd.position) { val bannerState = rememberBannerAdState(adSize) remember(bannerState) { bannerStates.getOrPut( key = feedAd.position, defaultValue = { mutableStateOf(null) } ).apply { value = bannerState } } LaunchedEffect(bannerState) { bannerState.loadAd( AdRequest.Builder("demo-banner-yandex").build() ) } } } DisposableEffect(ads) { onDispose { bannerStates.clear() } } }
и обновим наш InlineBanner подбирать BannerAdState из кэша
@Composable override fun InlineBanner(feedAd: FeedItem.FeedAdItem, modifier: Modifier) { val bannerState = remember { bannerStates[feedAd.position] } bannerState?.value?.let { Banner(it, modifier) } }
и-и-и?
Ничего не меняется. Мерцания остались. Логи идентичны.
Значит, дело не только в состоянии баннера. Необходимо понять, что происходит с item внутри LazyList.
Часть 3: Что происходит с item внутри LazyColumn
Окей, подумал я возможно проблема заключается в LazyList.
Мне здесь нужно было выяснить две вещи:
Уничтожается ли
Composition itemполностью илиLazyListумеет его переиспользовать?Возможно пересоздание
Compositionкак-то влияет на моргание баннера?
Рассмотрим как он под капотом работает:
Изначально я предполагал, что LazyList основывается на базовом Layout, где своя логика recycling (reusage) & measuring и остальных фич, но на деле он основывается на SubcomposeLayout.
Модифицируется лишь политика совместимости через contentType, где LazyList определяет не более семи неактивных slots на каждый contentType
/** * We currently use the same number of items to reuse (recycle) items as RecyclerView does: 5 * (RecycledViewPool.DEFAULT_MAX_SCRAP) + 2 (Recycler.DEFAULT_CACHE_SIZE) */ private const val MaxItemsToRetainForReuse = 7 // per contentType
Это внутренняя деталь конкретной версии Compose, а не публичная гарантия API.
В самом SubcomposeLayout не говорится об этом, но говорится в SubcomposeSlotReusePolicy (можно создавать кастомные Layout со своей логикой переиспользования):
/** * Creates [SubcomposeSlotReusePolicy] which retains the fixed amount of slots. * * @param maxSlotsToRetainForReuse the [SubcomposeLayout] will retain up to this amount of slots. */ fun SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse: Int)
более детально об этом рассказано в блоге Shreyas Patil - Inside SubcomposeLayout: Jetpack Compose’s Most Misunderstood API
Вкратце: у нас есть два интерфейса Composition и ReusableComposition.
Обычная
Compositionподдерживает полное освобождение черезdispose()ReusableCompositionдополнительно предоставляетdeactivate(): все remembered содержимое удаляется, но reusable nodes остаются для переиспользования
То есть под капотом:
Когда slot больше не нужен,
LazyListможет деактивировать и временно сохранить его в reuse pool. Если slot не был сохранён или позднее удален, его ресурсы окончательно освобождаютсяКогда мы скролим он может выбрать один из удерживаемых совместимых slots по
contentTypeкоторый мы указываем вitem(key, contentType, content)и перезапускает контент с новым состоянием с существующими нодами, это эффективнее чем их пересоздавать.
LazyLayout → SubcomposeLayout → ReusableComposition → deactivate → reuse pool
Хорошо. Значит, LazyList не обязательно каждый раз выбрасывает весь item и строит его заново. Но тогда почему баннер всё равно исчезает?
LazyList может сохранить reusable node, одновременно забыв значения, созданные через remember. Следовательно, нужно отдельно проверить, является ли AndroidView reusable node и что происходит с самим BannerAdView
Часть 4: Compose reuse и View reuse — не одно и то же
Ниже упрощённая реконструкция реализации Banner, несущественные детали были опущены
@Composable fun Banner( state: BannerAdState, modifier: Modifier = Modifier ) { // кусок который нам необходим val context = LocalContext.current key(state.adSize) { // View хранится в пределах жизни текущего remembered slot val bannerView = remember { BannerAdView(context).apply { setAdSize(resolve(state.adSize, context)) setBannerAdEventListener(/* listener binding with observer */) } } // подключение view к composable lifecycle DisposableEffect(state, bannerView) { state.attachView(bannerView) onDispose { state.detachView() } } AndroidView( factory = { bannerView }, modifier = modifier ) } }
Первое на что можно обратить внимание, что BannerAdView кэшируют используя remember, вот только поведение LazyList при деактивации reusable composition remembered slots удаляются, поэтому такой механизм не сработает, обычный remember не гарантирует сохранение BannerAdView после ухода item за пределы активной области.
И для того чтобы у нас был View pool у AndroidView есть неочевидный механизм, и вот что говорит сама документация об этом:
By default, AndroidView does not automatically pool or reuse Views. If placed inside of a reusable container (including inside a LazyRow or LazyColumn), the View instances will always be discarded and recreated if the composition hierarchy containing the AndroidView changes, even if its group structure did not change and the View could have conceivably been reused.
Views are eligible for reuse if AndroidView is given a non‑null onReset callback. Since it is expensive to discard and recreate View instances, reusing Views can lead to noticeable performance improvements — especially when building a scrolling list of AndroidViews. It is highly recommended to specify an onReset implementation and opt‑in to View reuse when possible.
Нам буквально говорят, старайтесь использовать onReset, особенно если вы собираетесь использовать ваш @Composable в скролл контейнерах, потому что пересоздание View процесс сложный и нам не нужно его абьюзить
if (onReset != null) { ReusableComposeNode<LayoutNode, UiApplier>( /* internal code */ ) } else { ComposeNode<LayoutNode, UiApplier>( /* internal code */ ) }
onReset даёт возможность проверить следующую гипотезу: связано ли мерцание с уничтожением BannerAdView. Он может уменьшить количество пересозданий View (повысить перформанс в списке), но сам по себе не гарантирует отсутствие повторных рекламных запросов
Часть 5: Сансара Yandex Ads
Разберемся как устроен жизненный цикл рекламного компонента, или что делают attach/detach в компоненте Banner.
Для этого нам понадобится BannerAdState и вот как его реализация:
class BannerAdState internal constructor( internal val adSize: BannerSize, internal var events: BannerEvents ) { internal var bannerView: BannerAdView? = null private var lastRequest: AdRequest? = null fun loadAd(adRequest: AdRequest) { // makes copy of request with compose mark in request params val markedRequest = adRequest.withDeclarativeUiMark() lastRequest = markedRequest bannerView?.loadAd(markedRequest) } internal fun attachView(view: BannerAdView) { if (bannerView != view) { bannerView?.destroy() bannerView = view lastRequest?.let { request -> view.loadAd(request) } } } internal fun detachView() { bannerView?.setBannerAdEventListener(null) bannerView?.destroy() bannerView = null } }
Событие |
Поведение |
|---|---|
|
Сохраняется |
|
Ничего не происходит |
|
Старая уничтожается, |
|
Listener снимается, View уничтожается |
Это объясняет, почему сохранение BannerAdState не помогает. State действительно переживает item, но загруженный рекламный контент находится внутри уничтоженной BannerAdView, а не внутри state
Часть 6: Почему же он мерцает?
``` Рекламный item перестаёт быть активным ↓ Reusable composition деактивируется ↓ remembered BannerAdView забывается ↓ DisposableEffect.onDispose() ↓ BannerAdState.detachView() ↓ BannerAdView.destroy() ↓ Hoisted BannerAdState сохраняет lastRequest ↓ item снова становится активным ↓ Создаётся новая BannerAdView ↓ attachView() загружает lastRequest ↓ Пока запрос выполняется, пользователь видит пустое состояние / мерцание
State сохранился вместе с lastRequest, но рекламный контент не сохранился, потому что был внутри уничтоженного View.
Часть 7: Ограничение Yandex Compose SDK
Для обычного Compose-экрана такой API может быть вполне достаточен. Но как только баннер становится элементом reusable feed, пространства для маневров оказывается мало.
Что следует из реализации:
View уничтожается при
detachView();wrapper не включает View reuse;
сохранённый запрос загружается при присоединении новой View.
Чего из публичного API узнать нельзя:
можно ли сохранять загруженную View вне экрана;
как это влияет на impression/click;
можно ли показывать тот же загруженный баннер после повторного attach;
является ли текущий lifecycle техническим ограничением или выбранной моделью API.
Но именно поэтому здесь и возникает вопрос к команде SDK:
Поддерживается ли несколько inline-баннеров в
LazyColumn?Обязательно ли уничтожать
BannerAdView, когда item временно деактивирован?Допустимо ли переиспользовать загруженную View после
onReset?Может ли SDK предоставить composable с поддержкой
onReset/onRelease?Какие гарантии нужны для корректного учёта impression и click?
Часть 8: Эксперимент c View reuse
Если моя теория причины верна, мерцание связано с уничтожением View. Тогда включение View reuse должно уменьшить количество вызовов factory, destroy() и повторных loadAd() в контролируемом сценарии.
Проверим.
Создаем собственный экспериментальный Banner API с блэкджеком и оптимизациями
Что мы имеем:
AndroidViewумеет переиспользовать созданныйViewесли передатьonResetблокrememberникак не поможет пережить reuse компонента внутриLazyList, следовательно мы можем перенести блок настройкиViewв updateattach/detach/destroyмы можем реализовать на уровне AndroidViewК каждому запросу я добавлю локальный идентификатор запроса и состояние, что бы понимать в какой момент мне нужно перезагрузить рекламу
При этом когда приходят callback об impression в raw data можно увидеть ключ запроса который нам к сожалению недоступен, этот идентификатор генерирует SDK/backend, и напрямую связать с ним локальное состояние нельзя
@Composable fun YandexBanner( state: FeedBannerState, modifier: Modifier = Modifier ) { AndroidView( factory = { context -> val v = BannerAdView(context) // настраиваем event callbacks задаем теги v.setBannerAdEventListener(...) // далее у нас будет использоваться extension currentAdRequest v.setTag(R.id.yandex_requested_ad, null) }, onReset = { view -> state.detachView() }, update = { view -> view.setAdSize(...) state.attachView(view) }, onRelease = { it.setTag(R.id.yandex_requested_ad, null) it.setBannerAdEventListener(null) it.destroy() // только когда View уже нам не нужен }, modifier = modifier ) }
Так как экземпляры BannerAdView переиспользуются между разными элементами списка, нам нужно хранить состояние запроса прямо в View, для чего идеально подходят теги (setTag/getTag)
Зная ограничения, мы скопируем BannerAdState -> FeedBannerAdState и модифицируем loadAd и свой компонент YandexBanner
@Composable fun YandexBanner( state: FeedBannerState, modifier: Modifier = Modifier ) { AndroidView( factory = { context -> val v = BannerAdView(context) // настраиваем event callbacks задаем теги v.setBannerAdEventListener(...) // далее у нас будет использоваться extension currentAdRequest v.setTag(R.id.yandex_requested_ad, null) }, onReset = { view -> state.detachView() }, update = { view -> view.setAdSize(...) state.attachView(view) }, onRelease = { it.setTag(R.id.yandex_requested_ad, null) it.setBannerAdEventListener(null) it.destroy() // только когда View уже нам не нужен }, modifier = modifier ) }
В onReset state перестаёт считать View своей, но сама View не уничтожается. При окончательном удалении onRelease освобождает listener, локальные теги и ресурсы.
Наша моделька YandexAdRequest с локальным идентификатором, с помощью которого мы будем понимать нужно ли нам перезагружать рекламу при переиспользовании View внутри LazyList
data class YandexAdRequest( val requestId: Uuid, val raw: AdRequest, val label: String? = null, // for debugging purpose val state: AdLoadingState = AdLoadingState.Idle, )
Поэтому я ввожу независимый локальный UUID. Он не совпадает с Yandex requestId и используется только для корреляции item, View и состояния загрузки.
и самое главное обновленный loadAd для FeedBannerAdState, с проверкой не загружать один и тот же request повторно, если View уже содержит соответствующее объявление, а как мы помним loadAd вызывается каждый раз при attachView
fun loadAd(adRequest: YandexAdRequest, forced: Boolean = false) { bannerView?.let { view -> if (view.currentAdRequest?.requestId != adRequest.requestId || view.currentAdRequest?.state == AdLoadingState.Idle || forced ) { val loadingReq = adRequest.copy(state = AdLoadingState.Loading) view.currentAdRequest = loadingReq view.loadAd(adRequest.raw) _requestState.value = loadingReq } else { _requestState.value = view.currentAdRequest } } ?: let { _requestState.value = adRequest.copy(state = AdLoadingState.Idle) } }
View reuse не означает Item ↔ View affinity. Slot с загруженной рекламой одного item может быть использован для другого рекламного item. В этом случае локальный requestId изменится и загрузка запустится заново.
Часть 9: Развязка.

> 01a01a24-d64e-7548-af03-73b8b0e4ea09 - FeedAd-3 - Loading > 01a01a24-d64e-7548-af03-73b8b0e4ea09 - FeedAd-3 - Loaded > 01a01a24-d64e-7548-af03-73b8b0e4ea09 - FeedAd-3 - ImpressionEvent > scroll > 01a01a24-d659-7411-b8ae-f772c0af8906 - FeedAd-7 - Loading > 01a01a24-d659-7411-b8ae-f772c0af8906 - FeedAd-7 - Loaded > 01a01a24-d659-7411-b8ae-f772c0af8906 - FeedAd-7 - ImpressionEvent > scroll up, no new loading logs
Reuse устранил видимое мерцание в тестовом сценарии, но это не гарантирует отсутствие reload при произвольном scroll / большом количестве banner / eviction из pool.
Окей, мы убедились что:
Мерцание не является неизбежным свойством
LazyColumn.Другой lifecycle
BannerAdViewдействительно меняет поведение.Следовательно, технически существуют другие варианты построения Compose API, однако она должна быть подтверждена командой SDK.
Но! Это эксперимент, и он не является production-ready решением.
Это не означает что Yandex может без последствий использовать такую реализацию, поскольку такая реализация может нарушать семантику refresh, impression или click.
Поэтому в завершение мне было бы особенно интересно услышать команду Yandex Ads: текущий lifecycle это осознанное ограничение рекламной модели или просто сценарий с LazyColumn пока не был целью Compose API?
Репозиторий с демо кодом.
Если вы сталкивались с похожим поведением рекламных View в LazyColumn или знаете, какие ограничения на reuse накладывает рекламная модель Yandex Ads, поделитесь опытом в комментариях — особенно интересно услышать команду SDK. А если вам интересен не только Android, но и автор по ту сторону кода, заглядывайте в мой Telegram-канал: там личные мысли, AI, интересные находки из интернета и иногда кулинария