
The path from a working banner to a production-grade Kotlin Multiplatform ad layer, and why I stopped duplicating state, callbacks, and lifecycle code.
When I started looking for a clean way to add AdMob to a Compose Multiplatform app, I found plenty of examples that could display a banner. What I could not find was a solution I would trust with the full life of a production app: Android and iOS, consent, full-screen ads, native feeds, retries, caching, and testing.
The usual answer was reasonable: create an expect declaration in commonMain, write an actual implementation for Android, write another for iOS, and bridge the native views into Compose.
That approach works. For one banner, it may even be the right approach.
But a working demo and a maintainable advertising layer are two different things.
The first banner was not the real problem
A basic Kotlin Multiplatform AdMob integration often begins like this:
// commonMain
@Composable
expect fun AdMobBanner(modifier: Modifier = Modifier)
Then Android renders an AdView, iOS renders a GADBannerView, and both are exposed through one shared composable.
The result feels like success because an ad appears on both screens. I had crossed the platform boundary and kept my shared UI clean.
Then the requirements grew.
I needed interstitial and rewarded ads. I needed to know when an ad had loaded, failed, opened, closed, earned revenue, or granted a reward. I needed cached ads to expire instead of being shown hours later. I needed app-open ads to stay away from onboarding and other full-screen flows.
The small bridge started becoming an ad framework hidden inside the application.
Every new behavior had two implementations, two callback models, two threading rules, and two opportunities to disagree.
That was the point where I stopped asking, “Can expect/actual make this compile?" and started asking, "Who owns this ad, and what happens to it when the UI disappears?"
Ads are mostly an ownership problem
The difficult part of mobile ads is rarely the load() call. It is everything around that call.
What happens if a coroutine is cancelled while a full-screen ad is still visible? Can another ad be presented at the same time? If a banner refresh fails, should the current banner disappear? If a feed row leaves a LazyColumn, is its native ad still reusable? What happens when a user leaves a tab and comes back?
Compose makes UI lifetime explicit. Native ad SDKs bring their own object lifetime. Those lifetimes do not automatically agree.
Native ads made this especially clear. A stable item key can identify a row, but it does not preserve the platform ad object behind that row. A composable may leave composition while the logical feed still exists. If the app owns the native object directly, it can reload too often, leak it, reuse it incorrectly, or lose it during ordinary navigation.
I learned a simple rule from that work:
A stable key preserves identity. It does not create ownership.
That distinction eventually led me away from a loose pool of native ads and toward bounded, session-based ownership. The feed reports stable logical slots. The SDK owns loaded platform objects. The composable receives a short-lived render lease only while it is on screen.
That is more machinery than a tutorial banner needs. It is also the kind of machinery a real feed quietly demands.
Privacy could not remain an afterthought
The next problem was initialization.
It is easy to call Google Mobile Ads as soon as the app launches. A production flow needs more care. UMP consent must be resolved before ad requests are allowed. On iOS, App Tracking Transparency has its own system prompt and timing. The application also needs a privacy-options entry point when UMP requires one.
The order matters:
- Gather or refresh UMP consent.
- Request ATT authorization on iOS when appropriate.
- Initialize Google Mobile Ads.
- Request ads only when the manager is ready and consent permits them.
When this logic lives independently in two platform entry points, small differences are easy to introduce and hard to notice. I wanted consent state to be part of the ad API, not a paragraph developers were expected to remember after copying the initialization snippet.
iOS worked until the tests had to link
There was another sharp edge that only appeared after the application itself was running.
Xcode can resolve Google Mobile Ads through Swift Package Manager when it builds the iOS app. A Kotlin/Native test executable is linked by Gradle, outside that Xcode build. The bindings compile, but the test linker cannot find symbols such as _OBJC_CLASS_$_GADBannerView unless the native frameworks are supplied separately.
The manual workaround is possible: download matching XCFrameworks, verify them, cache them, and apply the correct linker flags to test binaries without changing the production framework.
It is also exactly the kind of build logic every application should not have to maintain.
That became a Gradle plugin in AdMob CMP. The consumer applies one plugin when it runs Kotlin/Native tests, while the shipped iOS application continues to link Google’s SDKs through Swift Package Manager.
The lesson was broader than that linker error:
Cross-platform code is not finished when both apps launch. It is finished when both platforms can also be tested, diagnosed, and upgraded without folklore.
Building the shared layer
I eventually built AdMob CMP, an open-source Compose Multiplatform AdMob SDK for Android and iOS.
The project now has three deliberate layers:
- A Compose-free core containing consent, placements, state machines, caching, retries, full-screen presentation ownership, and native-ad sessions.
- A Compose layer containing rememberAdManager(), BannerAdView, NativeAdView, and the native-ad layout DSL.
- A single facade artifact (dev.avinya.ads:admob-cmp) so most applications only need one dependency.
The API stays in commonMain, but it does not pretend the platforms are identical. Android uses Google Mobile Ads Next-Gen. iOS uses Google Mobile Ads through Kotlin/Native cinterop and native framework linking. Platform differences stay behind the boundary while shared concepts remain shared.
Here is the shape I wanted at the beginning:
val adManager = rememberAdManager()
val placement = remember {
AdPlacement(
id = "main_interstitial",
format = AdFormat.Interstitial,
androidAdUnitId = TestAdIds.ANDROID_INTERSTITIAL,
iosAdUnitId = TestAdIds.IOS_INTERSTITIAL,
strictTestMode = true,
)
}
val interstitial = remember(adManager) {
adManager.interstitial(placement)
}
scope.launch {
interstitial.load()
when (val result = interstitial.show()) {
is AdShowResult.Shown -> Unit
is AdShowResult.NotReady -> Unit
is AdShowResult.Failed -> log(result.error)
}
}
The same controller model covers interstitial, rewarded, rewarded-interstitial, and app-open ads. Banners and native ads get Compose-native rendering APIs. State is exposed through StateFlow; events arrive through a shared event model; show() suspends until the ad is dismissed.
Today the SDK supports six formats on Android and iOS: banner, interstitial, rewarded, rewarded interstitial, app-open, and native.
What the SDK deliberately does not hide
A clean abstraction should remove repeated machinery, not erase important platform responsibilities.
Applications still provide their own AdMob app IDs and ad-unit IDs. Android still needs its manifest metadata. iOS still links Google Mobile Ads and UMP through Swift Package Manager and supplies the required Info.plist values. Store disclosures, consent policy, mediation adapters, and decisions about where ads belong remain the application's responsibility.
AdMob CMP also does not make ads appear on desktop or web. The ad-capable surface targets Android and iOS, while a broader Compose Multiplatform application can keep its unsupported targets ad-free.
That boundary matters to me. “Cross-platform” should not mean pretending every platform has the same SDK. It should mean giving the shared application one dependable contract around the differences that actually exist.
Could I have stayed with expect/actual?
Yes.
If an app needs one banner, rarely changes it, and does not need a shared lifecycle model, a small local bridge may be the simplest answer. I would not add a large abstraction merely to avoid two short files.
The calculation changes when the application needs several formats, privacy flow, shared telemetry, native feeds, cache policy, reliable full-screen ownership, or Kotlin/Native tests. At that point, the hand-written bridge is no longer just integration code. It is an SDK maintained inside one app.
Try it in a Compose Multiplatform app
I built AdMob CMP for that point.
If you want to render a test ad first, start with the five-minute quickstart. It covers the dependency, Android and iOS app IDs, consent-aware initialization, and a shared banner placement.
The complete source is on GitHub. If the project solves a problem you have also run into, a star helps other Kotlin Multiplatform developers discover it. Issues and discussions are welcome too, especially the awkward platform cases that simple examples usually leave out.
In the next article, I will walk through the implementation step by step: project setup, UMP and ATT ordering, a shared banner, an interstitial, test safety, and the final production checklist.
AdMob CMP is an independent open-source project. It is not affiliated with or endorsed by Google. AdMob and Google Mobile Ads are trademarks of Google LLC.