Skip to content

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.

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.

NodeAssetNotes
headline()HeadlineThe only required asset
body(), advertiser(), price(), store()Text assetsHidden or collapsed per visibilityPolicy when missing
starRating()Star ratingText asset; no maxLines parameter
icon()App icon
media()Image or video media viewUse mediaInfo() for the real aspect ratio
callToAction()Call-to-action button
adBadge()The “Ad” labelRequired by AdMob policy; the validator warns if absent
adChoices()AdChoices icon
row(), column(), box(), spacer(), text()Containers and staticsrow 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.

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.

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() ?: return
try {
val info = pool.mediaInfo(token) // aspectRatio, hasVideoContent, durationSeconds
// render using `token`
} finally {
pool.release(token) // every acquired token MUST be released
}
Native ad pool lifecycle and maxSize accountingpreload, acquire, render and release for the native ad pool, and how maxSize budgets available plus in-use ads.pool.preload(count)one batch requestedplatform.loadBatch()locking stays platform-sidepool inventoryTTL 1h, FIFOpool.acquire()returns a tokenavailablein-use (leased)maxSize = available + in-usetokenNativeAdViewrenders the leased adpool.release(token)destroys the adslot freedavailableAds unchangedre-run preload(), not just acquire()maxSize accountingAdCachePolicy.maxSize budgets availableplus in-use ads. Do not redefine it.pool.availableAds: StateFlow<Int> publishesthe available half only.release() destroys the ad — native ads aresingle-use — so it frees a maxSize slotwithout incrementing availableAds.Views key their acquisition effect onavailableAds and re-run preload().clear() contractclear() drains and destroys availableinventory only.Leased ads stay alive until their release()— peek(token) keeps resolving afterwards.Clearing a fully-leased pool frees nocapacity until those views dispose.A generation counter invalidates in-flightbatches, so a completing load cannotrepopulate a pool that was just cleared.Batch-assembly locking (the pending list and the cancelled flag) and iOS delegate retention stay platform-side by construction;the core never sees a delegate — CLAUDE.md invariants #3, #4 and #8.
Native ad pool lifecycle and maxSize accountingCLAUDE.md invariants #3, #4, #8Read this diagram in words

The accounting the diagram shows:

  • AdCachePolicy.maxSize budgets available + in-use ads together, not just cached ones.
  • release() destroys the ad, because native ads are single-use. It therefore frees a maxSize slot without incrementing availableAds.
  • clear() drains available inventory only. Ads currently leased stay alive until their release(), 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.