Native ads
What are native ads, and why does AdMob CMP support them?
Section titled “What are native ads, and why does AdMob CMP support them?”Native ads deliver ad assets — headline, body, icon, media, call to action, advertiser, star rating, price, store — without a fixed creative, so the app composes them into its own design. For a feed this is the difference between a banner-shaped ad that breaks the visual rhythm and an ad row that looks like every other row.
The mechanism behind it: adLayout { … } is a declarative description
that the SDK turns into platform-native views with every asset
registered for click and impression tracking, rather than Compose
surfaces layered over an invisible ad view. That distinction is the
reason clicks and impressions are attributed correctly. A native ad
that is rendered correctly but not registered correctly will
appear in the app without ever being billed.
Native ads are also the format with the most asymmetric support across the Kotlin Multiplatform AdMob wrapper ecosystem. Most wrappers stop at the three full-screen-plus-banner formats and treat native as a “future” feature. AdMob CMP supports native today because the publisher feed pattern is the most common reason people reach for an AdMob SDK in the first place.
How do I render a native ad in Compose Multiplatform?
Section titled “How do I render a native ad in Compose Multiplatform?”val placement = remember { AdPlacement( id = "feed_native", format = AdFormat.Native, androidAdUnitId = TestAdIds.ANDROID_NATIVE, iosAdUnitId = TestAdIds.IOS_NATIVE, maxCacheSize = 5, )}
val feedAdLayout = remember { adLayout { column(modifier = AdModifier.fillMaxWidth()) { media( modifier = AdModifier .fillMaxWidth() .aspectRatio(16f / 9f) .clipRounded(8.dp) ) spacer(modifier = AdModifier.height(8.dp)) headline(maxLines = 2) body(maxLines = 3) row(spacing = 8.dp) { icon(modifier = AdModifier.size(24.dp)) advertiser() adBadge() } callToAction(modifier = AdModifier.fillMaxWidth()) } }}
NativeAdView( placement = placement, itemKey = "feed_slot_1", layout = feedAdLayout, modifier = Modifier.fillMaxWidth(), onEvent = { event -> /* Loaded, Impression, Clicked, Paid, Video* */ },)Two rules a reader will otherwise learn the hard way. First,
itemKey must be stable per slot — it is what drives pool
acquire and release, so it should identify the position in the list,
not the ad. Second, the placement id must be static and finite:
controllers are cached per id for the manager’s lifetime and are
never auto-evicted, so generating "feed_item_$index" leaks a
controller per row. For a feed, reuse one placement id and let the
pool serve per-item ads.
The maxCacheSize parameter on the placement sets the pool’s
budget, not the layout’s content. It is the upper bound on
available-plus-in-use ads, so a value of 5 means the feed can
have at most five live ad rows at any moment. The default of 1
is rarely right for a feed; the recommended value is “the number
of ad rows that can be on screen at once, plus one”, which gives
the pool enough headroom to refill while the user is scrolling
without inflating memory use.
NativeAdView is a thin wrapper over the pool: it acquires on
attach, releases on dispose, and re-runs the acquisition on
availableAds changes. The pool’s accounting is what the
composable enforces, so a misuse of itemKey (a key that changes
on every recomposition, for example) shows up as a slow leak of
ads rather than a crash. The troubleshooting
guide has the symptom table.
How does the adLayout DSL work?
Section titled “How does the adLayout DSL work?”Three properties. Each adLayout { … } construction recursively
validates the tree and computes a structural identity string, so it
is not free; retain custom layouts with
remember { adLayout { … } }, or
remember(variant) { adLayout { … } } for dynamic variants, to avoid
revalidating and rebuilding platform views on every recomposition.
Nodes are functions with named arguments (headline(maxLines = 2)),
not property-assignment blocks — a detail worth stating explicitly
because the shape looks like Compose but is not. And every node takes
an AdModifier, a separate modifier type from Compose’s Modifier,
because the tree is serialised down to native views.
The structural identity is the validator’s input. Two layouts that
look identical but differ in tree shape — one inlining an
advertiser() call where the other uses a row { advertiser() } —
have different identity strings and are treated as different layouts.
That matters for caching: the validator does not re-run when a
layout’s identity string matches a previously-validated one, and the
platform-side view construction can be reused.
Which layout nodes are available?
Section titled “Which layout nodes are available?”| Node | Asset | Notes |
|---|---|---|
headline() | Headline | The only required asset |
body(), advertiser(), price(), store() | Text assets | Hidden or collapsed per visibilityPolicy when missing |
starRating() | Star rating | Text asset; no maxLines parameter |
icon() | App icon | |
media() | Image or video media view | Use mediaInfo() for the real aspect ratio |
callToAction() | Call-to-action button | |
adBadge() | The “Ad” label | Required by AdMob policy; the validator warns if absent |
adChoices() | AdChoices icon | |
row(), column(), box(), spacer(), text() | Containers and statics | row and column take a spacing argument |
The AdModifier paragraph: sizing (fillMaxWidth, size, height,
width, weight, sizeIn, aspectRatio), spacing (padding,
margin), decoration (background, border, cornerRadius,
clipRounded, clipCircle, elevation, alpha), and visibility
(visible, invisible, gone).
Two modifiers that are easy to misuse. aspectRatio(16f / 9f)
forces a fixed ratio on the media view, which is the right choice
when the ad unit is configured to serve a known creative shape and
the wrong choice when the unit mixes orientations. The native
media view will letterbox or pillarbox to fit, which is rarely what
the app wants for a feed. The right answer for a feed is to
query mediaInfo(token).aspectRatio and use the actual creative
ratio at render time. cornerRadius and clipRounded are
similar — the former is a generic value, the latter is a
shape-aware alternative that maps to CALayer.cornerRadius on iOS
and to a Shape drawable on Android.
Do I have to write a custom layout?
Section titled “Do I have to write a custom layout?”No. AdTemplates ships four ready-made layouts: AdTemplates.mediaCard
(the NativeAdView default), AdTemplates.feedCard,
AdTemplates.medium and AdTemplates.compact. The shortest
possible integration:
NativeAdView( placement = placement, itemKey = "feed_slot_1", layout = AdTemplates.compact,)Custom layouts are for cases where the templates do not match the app’s visual language. The DSL is small enough that a custom layout rarely takes more than a screen of code, and the validator catches the structural mistakes a custom layout is most likely to make.
A note on AdTemplates reuse: the templates are vals, not
factories. They can be used as-is across the whole app without
revalidation, and that is the recommended pattern for the common
case. A custom layout that needs to vary by feed context — a
“compact” mode for one section and a “media-heavy” mode for
another — is a different shape: build each variant in its own
remember { adLayout { … } } and select the right one at the
call site, rather than parameterising a single layout.
How does the native ad pool work?
Section titled “How does the native ad pool work?”Native ads are single-use, so a native placement is served by a
pool rather than a single controller. NativeAdView acquires
and releases automatically via itemKey. Manual control looks like
this:
val pool = adManager.nativeAd(placement)
scope.launch { pool.preload(count = 5) }
val token = pool.acquire() ?: returntry { val info = pool.mediaInfo(token) // aspectRatio, hasVideoContent, durationSeconds // render using `token`} finally { pool.release(token) // every acquired token MUST be released}The accounting the diagram shows:
AdCachePolicy.maxSizebudgets available + in-use ads together, not just cached ones.release()destroys the ad, because native ads are single-use. It therefore frees amaxSizeslot without incrementingavailableAds.clear()drains available inventory only. Ads currently leased stay alive until theirrelease(), because a live view is still rendering them. A consequence worth stating plainly: clearing a fully-leased pool frees no capacity until those views dispose.- Pooled ads expire per
AdExpirationPolicy.nativeTtl, one hour by default, and are evicted on access.
A subtle point on requestOptions and nativeOptions. The
requestOptions parameter is the same AdRequestOptions shape used
by every other controller — it carries custom targeting, publisher-
provided ids, and category exclusions. The nativeOptions parameter
is native-specific: it carries AdChoicesPosition, MediaAspectRatio,
and VideoOptions for the platform SDK’s native ad options object.
The two options are layered — request options apply to the network
request, native options apply to the creative rendering — and
neither is required. The defaults are conservative: AdChoices at
top-right, media at the platform default aspect ratio, video
options set by GMA.
Why does acquire() return null, and how do I recover?
Section titled “Why does acquire() return null, and how do I recover?”Set up the failure exactly: with maxSize = 1 and one row holding
the ad, preload() returns early and acquire() returns null for
every other row — deterministically, not as a race. acquire()
returning null used to be silent and terminal for that composition,
leaving those rows blank forever.
pool.availableAds is the signal that lets a view recover. Key the
acquisition effect on it:
val availableAds by pool.availableAds.collectAsState()
LaunchedEffect(pool, itemKey, availableAds) { pool.preload(count = 1) val token = pool.acquire() // …render, then release on dispose}The sentence that makes it correct rather than merely plausible:
re-run preload, not just acquire. Because release()
destroys the ad, it frees a maxSize slot without incrementing
availableAds, so an effect that only retries acquire() waits for
a signal that will never arrive. The built-in NativeAdView
composables already do this, which is why the manual path is the
one that needs the warning.
Size maxSize to the number of ad slots that can be on screen at
once, plus one, not to the number of rows in the feed.
A feed that scrolls infinitely and shows ad rows at intervals
should size maxSize to “the number of ad rows the user is likely
to see in a single screen” plus one. A short feed with three
visible rows should size it to four. A paged carousel with one ad
per page should size it to one, with reloadAfterShow enabled so
the next page has warm inventory. The wrong size — too small —
shows blank rows; the wrong size — too large — wastes memory and
inflates the time-to-first-ad.
How do I read media info and detect video?
Section titled “How do I read media info and detect video?”val info = pool.mediaInfo(token)if (info?.hasVideoContent == true) { println("video, ${info.durationSeconds}s, ratio ${info.aspectRatio}")}NativeMediaInfo has exactly three fields: aspectRatio: Float?,
hasVideoContent: Boolean, durationSeconds: Double?. The
practical use is to reserve layout height from aspectRatio rather
than hardcoding 16:9, so a portrait creative does not letterbox.
mediaInfo(token) is the only way to query these fields. There
is no batch query — each call hits the platform’s native-ad
metadata for that specific token, and the result is stable for
the lifetime of the token. A common pattern is to read mediaInfo
once after acquire() and use the values to drive the layout’s
measured height, so the row does not jump when the image first
loads.
Why do native video events only fire on iOS?
Section titled “Why do native video events only fire on iOS?”mediaInfo(token).hasVideoContent is cross-platform, so “does
this ad have video” is answerable everywhere even though “has the
video started” is not. The two answers diverge on Android: an
Android native ad with video content exists, the media view plays
it, but no event is emitted. Cross-platform telemetry should query
hasVideoContent for inventory purposes and skip the video-event
stream on Android.
What does AdMob policy require of a native layout?
Section titled “What does AdMob policy require of a native layout?”Three rules, all enforced by AdLayoutValidator:
adBadge()is required by AdMob policy and the validator warns when it is missing. The badge is the visual attribution the user needs to recognise the row as an ad, and its absence can invalidate a publisher’s AdMob account.- Every registered asset must stay fully inside the platform native-ad root, and ad attribution must appear at the top. The built-in templates enforce this on both platforms; custom layouts should keep the badge at the top and avoid offsets that push registered assets outside the root bounds.
- On debug builds Google may display its own Native Ad Validator overlay. The overlay is informational and only shows on debug builds, so it is safe to leave enabled.
How to read the validation report:
val layout = remember { adLayout { /* … */ } }
LaunchedEffect(layout) { layout.validation.warnings.forEach { issue -> println("${issue.code} at ${issue.nodePath}: ${issue.message}") }}AdLayout.validation is an AdLayoutValidationReport with
isValid, hasWarnings, errors and warnings, and each
AdLayoutValidationIssue carries code, nodePath and message.
A warning is informational; an error means the layout will not
behave correctly. The validator never throws — it produces a
report, and the caller decides what to do with the warnings.
The nodePath is a stable identifier for the offending node: it
is the same identifier the structural identity string uses, so a
report and a code change can be correlated directly. The code is
a stable string identifier suitable for filtering — for example, a
linter rule could fail CI on ad_badge_missing while allowing
media_placeholder_default_aspect.