Представьте обычный 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.

Мне здесь нужно было выяснить две вещи:

  1. Уничтожается ли Composition item полностью или LazyList умеет его переиспользовать?

  2. Возможно пересоздание 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
    }
}

Событие

Поведение

loadAd(), View отсутствует

Сохраняется lastRequest

attachView() с той же View

Ничего не происходит

attachView() с другой View

Старая уничтожается, lastRequest загружается

detachView()

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:

  1. Поддерживается ли несколько inline-баннеров в LazyColumn?

  2. Обязательно ли уничтожать BannerAdView, когда item временно деактивирован?

  3. Допустимо ли переиспользовать загруженную View после onReset?

  4. Может ли SDK предоставить composable с поддержкой onReset/onRelease?

  5. Какие гарантии нужны для корректного учёта impression и click?

Часть 8: Эксперимент c View reuse

Если моя теория причины верна, мерцание связано с уничтожением View. Тогда включение View reuse должно уменьшить количество вызовов factory, destroy() и повторных loadAd() в контролируемом сценарии.

Проверим.

Создаем собственный экспериментальный Banner API с блэкджеком и оптимизациями

Что мы имеем:

  1. AndroidView умеет переиспользовать созданный View если передать onReset блок

  2. remember никак не поможет пережить reuse компонента внутри LazyList, следовательно мы можем перенести блок настройки View в update

  3. attach/detach/destroy мы можем реализовать на уровне AndroidView

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

При этом когда приходят 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, интересные находки из интернета и иногда кулинария

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