DEV Community

Cover image for The Hidden Cause of Yandex Ads Flickering
Ozodrukh
Ozodrukh

Posted on

The Hidden Cause of Yandex Ads Flickering

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())
                }  
            }  
        }  
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 {...}
Enter fullscreen mode Exit fullscreen mode

It looks like the problem may be related to how LazyList works:

The item leaves the screen → the composition changes → rememberBannerAdState may 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()  
        }  
    }
}
Enter fullscreen mode Exit fullscreen mode

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)  
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Is the item composition fully destroyed, or can LazyList reuse it?
  2. 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 Composition supports full cleanup through dispose().
  • ReusableComposition also provides deactivate(). 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, LazyList can 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, LazyList can select one of the compatible slots kept for the contentType passed to item(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
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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 */  )   
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Are several inline banners inside LazyColumn supported?
  2. Is it required to destroy BannerAdView when an item is temporarily deactivated?
  3. Is it allowed to reuse a loaded View after onReset?
  4. Can the SDK provide a composable with onReset/onRelease support?
  5. 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:

  1. AndroidView can reuse a created View when an onReset block is provided.
  2. remember cannot keep the component when it is reused inside LazyList. So, we can move the View setup logic to update.
  3. We can implement attach/detach/destroy at the AndroidView level.
  4. 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  
    )
}
Enter fullscreen mode Exit fullscreen mode

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  
    )
}
Enter fullscreen mode Exit fullscreen mode

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,  
)
Enter fullscreen mode Exit fullscreen mode

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)  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 BannerAdView lifecycle 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)