Imagine a normal feed built with LazyColumn: news, cards, and an ad banner between them. The banner loads, the user scrolls it off the screen, then scrolls back. The ad flickers and loads again.
Scroll down again. Scroll up again. One more flicker and one more load.
At first, the reason looks clear: the item leaves the screen, rememberBannerAdState loses its state, and Compose creates everything again. So, it should be enough to store BannerAdState outside the item. Then the problem should disappear.
I thought the same.
But it did not disappear.
The flickering was still there.
At this point, a simple Yandex Ads integration bug (?) became a small investigation for me. I had to understand what really happens to an item inside LazyColumn, how SubcomposeLayout reuses a composition, why this does not mean that AndroidView is also reused, and what lifecycle the Yandex Ads Compose API builds on top of all this.
In the end, the question “Why does the banner flicker?” became more interesting than the workaround itself.
Part 1: Integrating Yandex Ads into the Feed
It started with the updated Compose API of Yandex Mobile Ads. At the time of this article, I used version 8.3.0. There is documentation, and there are also Compose samples. The integration looks easy. However, I did not find a separate example with several banners inside LazyColumn or an explanation of their lifecycle.
I added the following component to my 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())
}
}
}
}
}
Then I checked how it looked:
@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)
}
Why does this happen?
Part 2: Hypothesis: BannerAdState Is Lost
Let us check the logs:
> [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 {...}
It looks like the problem may be related to how LazyList works:
The item leaves the screen → the composition changes →
rememberBannerAdStatemay be lost → the ad may load again because of this.
For the first experiment, I moved BannerAdState outside the lifecycle of LazyListScope.item.
This is only an example, not a recommendation for ad state management. Its purpose is to check if the problem disappears when we keep 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()
}
}
}
Then I updated InlineBanner to get BannerAdState from the cache:
@Composable
override fun InlineBanner(feedAd: FeedItem.FeedAdItem, modifier: Modifier) {
val bannerState = remember {
bannerStates[feedAd.position]
}
bannerState?.value?.let {
Banner(it, modifier)
}
}
And then?
Nothing changed. The flickering was still there. The logs were the same.
So the problem is not only in the banner state. We need to understand what happens to an item inside LazyList.
Part 3: What Happens to an Item Inside LazyColumn
Maybe the problem is in LazyList, I thought.
I needed to answer two questions:
- Is the item composition fully destroyed, or can
LazyListreuse it? - Can recreation of the composition cause the banner to flicker?
Let us look at how it works internally.
At first, I thought that LazyList was based on the basic Layout, with its own logic for recycling, reuse, measuring, and other features. In fact, it is based on SubcomposeLayout.
Only the compatibility policy is changed through contentType. LazyList keeps no more than seven inactive slots for each 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
This is an internal detail of a specific Compose version. It is not a public API guarantee.
SubcomposeLayout itself does not describe this number, but SubcomposeSlotReusePolicy describes the general mechanism. It can be used to create custom layouts with their own reuse logic:
/**
* 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)
You can find a more detailed explanation in the article Inside SubcomposeLayout: Jetpack Compose’s Most Misunderstood API by Shreyas Patil.
In short, we have two interfaces: Composition and ReusableComposition.
- A normal
Compositionsupports full cleanup throughdispose(). -
ReusableCompositionalso providesdeactivate(). All remembered content is removed, but reusable nodes remain and can be used again.
So, this is what happens internally:
- When a slot is no longer needed,
LazyListcan deactivate it and temporarily keep it in the reuse pool. If the slot is not kept, or if it is removed later, its resources are fully released. - During scrolling,
LazyListcan select one of the compatible slots kept for thecontentTypepassed toitem(key, contentType, content). It runs the content again with a new state, but uses the existing nodes. This is more efficient than creating the nodes again.
LazyLayout → SubcomposeLayout → ReusableComposition → deactivate → reuse pool
Good. This means that LazyList does not always remove the complete item and build it again. But why does the banner still disappear?
LazyList can keep a reusable node and, at the same time, forget values created with remember. So, we need to check two things separately: whether AndroidView is a reusable node and what happens to BannerAdView itself.
Part 4: Compose Reuse and View Reuse Are Not the Same
Below is a simplified version of the Banner implementation. I removed details that are not important for this investigation.
@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
)
}
}
The first important detail is that BannerAdView is cached with remember. However, when LazyList deactivates a reusable composition, the remembered slots are removed. Because of this, the mechanism does not help here. A normal remember does not guarantee that BannerAdView will be kept after the item leaves the active area.
To create a View pool, AndroidView has a mechanism that is easy to miss. This is what the documentation says:
*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.*
In simple words, the documentation recommends using onReset, especially when a composable is used inside a scrolling container. Creating a View again is expensive, so it is better to avoid it when possible.
if (onReset != null) {
ReusableComposeNode<LayoutNode, UiApplier>( /* internal code */ )
} else {
ComposeNode<LayoutNode, UiApplier>( /* internal code */ )
}
onReset lets us test the next hypothesis: is the flickering related to destruction of BannerAdView? It can reduce the number of View recreations and improve list performance. However, it does not guarantee that the ad request will not be sent again.
Part 5: The Yandex Ads Lifecycle Loop
Let us look at the lifecycle of the ad component and see what attach/detach do inside Banner.
For this, we need BannerAdState. Its implementation looks like this:
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
}
}
| Event | Behavior |
|---|---|
loadAd(), no View |
Saves lastRequest
|
attachView() with the same View |
Nothing happens |
attachView() with another View |
The old View is destroyed, and lastRequest is loaded |
detachView() |
The listener is removed, and the View is destroyed |
This explains why keeping BannerAdState did not help. The state survives the item, but the loaded ad content is stored inside the destroyed BannerAdView, not inside the state.
Part 6: Why Does It Flicker?
The ad item becomes inactive
↓
Reusable composition is deactivated
↓
remembered BannerAdView is forgotten
↓
DisposableEffect.onDispose()
↓
BannerAdState.detachView()
↓
BannerAdView.destroy()
↓
Hoisted BannerAdState keeps lastRequest
↓
The item becomes active again
↓
A new BannerAdView is created
↓
attachView() loads lastRequest
↓
While the request is running, the user sees an empty state or flickering
The state and its lastRequest are kept. The ad content is not kept because it was inside the destroyed View.
Part 7: Limitation of the Yandex Compose SDK
For a normal Compose screen, this API may be enough. But when the banner becomes an item in a reusable feed, there are not many options to control its lifecycle.
What we know from the implementation:
- The View is destroyed in
detachView(). - The wrapper does not enable View reuse.
- The saved request is loaded when a new View is attached.
What we cannot know from the public API:
- Can a loaded View be kept outside the screen?
- How does it affect impression and click tracking?
- Can the same loaded banner be shown after another attach?
- Is the current lifecycle a technical limitation or a selected API model?
Because of this, I have several questions for the SDK team:
- Are several inline banners inside
LazyColumnsupported? - Is it required to destroy
BannerAdViewwhen an item is temporarily deactivated? - Is it allowed to reuse a loaded View after
onReset? - Can the SDK provide a composable with
onReset/onReleasesupport? - What guarantees are needed for correct impression and click tracking?
Part 8: Experiment with View Reuse
If my theory is correct, the flickering is related to View destruction. In this case, enabling View reuse should reduce the number of factory, destroy(), and repeated loadAd() calls in a controlled scenario.
Let us test it.
I created my own experimental Banner API with extra optimizations.
Here is what we know:
-
AndroidViewcan reuse a createdViewwhen anonResetblock is provided. -
remembercannot keep the component when it is reused insideLazyList. So, we can move the View setup logic toupdate. - We can implement
attach/detach/destroyat theAndroidViewlevel. - I will add a local request ID and state to every request. This will help me understand when the ad must be loaded again.
When an impression callback arrives, the raw data contains a request key. Unfortunately, this key is not available to us. It is generated by the SDK or backend, so we cannot connect our local state to it directly.
@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 instances are reused between different list items. Because of this, we need to store the request state directly in the View. Tags are a good solution for this (setTag/getTag).
Knowing these limitations, we copy BannerAdState to FeedBannerAdState. Then we change loadAd and our YandexBanner component:
@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
)
}
In onReset, the state stops treating the View as its own, but the View is not destroyed. When it is finally removed, onRelease clears the listener, local tags, and resources.
Our YandexAdRequest model has a local ID. It helps us decide if the ad must be loaded again when a View is reused inside LazyList:
data class YandexAdRequest(
val requestId: Uuid,
val raw: AdRequest,
val label: String? = null, // for debugging purpose
val state: AdLoadingState = AdLoadingState.Idle,
)
For this reason, I use an independent local UUID. It is not the same as the Yandex requestId. It is used only to connect the item, View, and loading state.
The most important part is the updated loadAd for FeedBannerAdState. It checks that the same request is not loaded again when the View already contains the related ad. As we know, loadAd is called every time in 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 does not mean Item ↔ View affinity. A slot with an ad loaded for one item may be used for another ad item. In this case, the local requestId changes and loading starts again.
Part 9: The Result
> 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 removed the visible flickering in the test scenario. However, this does not guarantee that there will be no reload during random scrolling, with many banners, or after eviction from the pool.
Now we know the following:
- Flickering is not a required behavior of
LazyColumn. - A different
BannerAdViewlifecycle really changes the behavior. - So, other implementations of the Compose API are technically possible. However, they must be confirmed by the SDK team.
But this is only an experiment. It is not a production-ready solution.
This also does not mean that Yandex can use this implementation without side effects. It may break the expected behavior of refresh, impression, or click tracking.
To finish, I would especially like to hear from the Yandex Ads team: is the current lifecycle a selected limitation of the advertising model, or was the LazyColumn case simply not a target for the Compose API yet?
Repository with demo code
If you have seen similar behavior with advertising Views inside LazyColumn, or if you know what reuse limitations the Yandex Ads model has, please share your experience in the comments. I would especially like to hear from the SDK team.
If you are interested not only in Android, but also in the person behind the code, visit my Telegram channel. I write about personal thoughts, AI, interesting things from the internet, and sometimes cooking.


Top comments (0)