Architecture
How is AdMob CMP put together?
Section titled “How is AdMob CMP put together?”| Package | Contents |
|---|---|
dev.avinya.ads | Public API: AdManager, the controllers, AdPlacement, AdConfig, events, errors, consent, diagnostics |
dev.avinya.ads.internal | FullScreenSlotCore — the shared load/show/cache state machine — and AdRetry, capped exponential backoff |
dev.avinya.ads.appopen | AppOpenAdCoordinator plus the expected foreground signal |
dev.avinya.ads.nativead | The NativeAdPool contract, options, and media info |
dev.avinya.ads.nativead.layout | The adLayout DSL, AdLayoutValidator, and AdTemplates |
dev.avinya.ads.ui | The expected composables BannerAdView and NativeAdView |
The platform side in prose: androidMain provides
AndroidGoogleAdManager with slots, pool and banner over the GMA
Next-Gen SDK, the AdMob.manager(context) singleton, and a
ProcessLifecycleOwner foreground signal. iosMain provides
IosGoogleAdManager over cinterop bindings, a Kotlin UIKit
native-ad renderer, and an NSNotificationCenter foreground
signal.
The published module split for readers who look at Maven:
dev.avinya.ads:admob-cmp is the umbrella artifact; admob-cmp-core
holds the engine and admob-cmp-compose the Compose surface, so
the controller API can be used without Compose.
The split’s design rationale: a server-side app, a CL tool, or a
test harness that wants the controllers but not Compose
Multiplatform depends on admob-cmp-core directly and skips
admob-cmp-compose entirely. The umbrella coordinate exists for
the common case, not to force Compose on consumers who do not
want it.
What lives in common code, and what is platform-specific?
Section titled “What lives in common code, and what is platform-specific?”The invariant that gives the library its shape: one state
machine, two thin platform adapters. Every full-screen slot
extends FullScreenSlotCore, which owns the load mutex, the
TTL’d FIFO cache bounded by AdCachePolicy.maxSize, retry,
consent gating, event emission and reloadAfterShow. Platform
classes implement only loadAd, presentAd, destroyAd and
canPresent.
Why this matters to a consumer rather than only to a maintainer: it is the reason interstitial, rewarded, rewarded interstitial and app-open behave identically across platforms, and the reason a bug fixed in caching is fixed everywhere at once. The same pattern applies to native ads through the pool, and to banners through the headless-controller path. A single cache invalidation bug, once found, is fixed in common code and ships to all formats.
The pattern is also the reason format-specific behaviour stays
format-specific. A platform’s loadAd is free to call
placementId differently from a banner’s, because the shared
core does not know about per-format custom targeting. The shared
core owns lifecycle, the platform adapter owns the SDK call, and
the call site owns the format.
Why does iOS ship bindings instead of binaries?
Section titled “Why does iOS ship bindings instead of binaries?”The iOS implementation compiles against the official GMA and UMP
XCFrameworks via Kotlin/Native cinterop. The
dev.avinya.ads.admob-cmp Gradle plugin downloads the zips into
build/admob-cmp-ios-frameworks/ — a version-stamped cache —
purely for headers. The published klib contains bindings,
never Google’s binaries. The consuming app links GMA and UMP
itself via Swift Package Manager.
Then the alternatives and why each was rejected, stated as engineering trade-offs:
- A CocoaPods plugin — the ecosystem and this repository are SPM-based.
- Embedding binaries with
staticLibraries— breaks mediation adapters with duplicate Objective-C classes, pins the GMA version for every consumer, and raises redistribution-licence questions. Evaluated and rejected.
The accepted costs, stated without spin: consumers must add two SPM packages, and the bound GMA major version must match the SPM-resolved one. The iOS setup guide has the executable steps; the roadmap explains where this distribution model goes next.
A note on staticLibraries specifically. The Kotlin/Native
cinterop system supports embedding the linked binary into the
consumer’s framework via staticLibraries in the .def file.
The library deliberately does not use this. The Android
counterpart would be implementation(files("…/GoogleMobileAds.aar"))
in the consumer’s build. Both shapes break mediation because the
embedded GMA is a different copy from the adapter’s GMA, and
the JVM and Objective-C runtimes resolve the “GMA” class
non-deterministically when two copies exist in the same process.
How does the full-screen state machine work?
Section titled “How does the full-screen state machine work?”The cache carries a generation counter, bumped by clear(). A
load or scheduled reload started before a clear() checks its
required generation before publishing results, so it can never
repopulate a cache the caller just asked to be emptied.
Presentation ownership is a one-shot handle: the core owns it
until the platform slot hands it off to the SDK’s callbacks
immediately before the actual show call; from then on only the
SDK’s terminal callback, or a cancellation that raced in before
hand-off, may close it. Two consequences observable from the
public API: show() is not reentrant per controller — a second
call while one presentation is active returns
AdShowResult.NotReady rather than queueing — and cancelling a
caller never decrements the process-wide presence signal once
hand-off has happened, so AppOpenAdCoordinator never sees “not
presenting” while an ad the SDK still owns is on screen.
What is the threading model?
Section titled “What is the threading model?”All GMA and UMP calls are wrapped in
Dispatchers.Main.immediate internally, so every public API is
main-safe and callable from any dispatcher. Registries are
lock-protected. On iOS, delegate objects are strongly retained
by their owners for exactly the Objective-C delegate’s lifetime,
because Objective-C delegates are weak references. Without that
retention, a delegate set on a transient object would be
deallocated before the platform SDK fires its terminal callback,
and the ad would never be destroyed.
The Main.immediate choice, not Main: when a call originates
on the main thread, Main.immediate runs the work without
posting to the message queue, so the call resolves synchronously
on the caller’s stack. When a call originates on another
dispatcher, Main.immediate posts as Main would. The
behaviour is transparent to the caller but matters for latency:
a load() called from a UI thread on an already-Ready manager
returns the cached state synchronously, which is what
StateFlow.collectAsState depends on.
A related note on coroutine cancellation. Every public API is
cancellable through its coroutine context. Cancelling a load()
cancels the network request, the generation check, and the
result-publication step. Cancelling a show() cancels the
suspension — but the presentation itself, once handed off to
the platform SDK, runs to completion regardless of caller
cancellation. That asymmetry is deliberate: a half-presented ad
is worse than a fully-presented ad the caller has stopped
caring about.
How do events flow?
Section titled “How do events flow?”platform SDK callback → slot / pool / controller → controller.events (per-placement SharedFlow) → AdManager.events (global SharedFlow)The sealed AdEvent set is Loaded, LoadFailed, ShowFailed,
Impression, Clicked, OpenedFullScreen, ClosedFullScreen,
RewardEarned, Paid, and the iOS-only Video* family — see
Revenue and paid events for the
per-event shapes and the analytics patterns.
A note on SharedFlow versus Flow. The two event surfaces
are SharedFlow<AdEvent> rather than Flow<AdEvent> so
multiple collectors can attach to the same controller without
each collector driving its own callback chain. The platform SDK
fires a callback once; the controller re-emits it to every
collector. For a single-collector pattern, the behaviour is
indistinguishable from a Flow; for a multi-collector pattern
— analytics + UI state + a test harness — SharedFlow is
what makes the cost linear in the number of events rather
than linear in the number of collectors.
What were the key design decisions?
Section titled “What were the key design decisions?”| Decision | Rationale |
|---|---|
| Suspend functions and Flow instead of listener callbacks | One paradigm. show() suspending until dismissal collapses five callback interfaces into one call. |
| Placement-keyed, long-lived controllers | The caching, retry and lifecycle layer every app otherwise rebuilds badly. |
| Consent integrated into initialization | Requesting ads before consent becomes impossible by construction. |
| Bindings-only iOS distribution | Mediation safety and version freedom. |
| Caching in the slot layer, not the SDK preload APIs | Those APIs were beta and asymmetric across platforms at design time. |
| No bundled mediation adapters | Adapters are platform binaries tied to your GMA version and your contracts. |
explicitApi() plus a committed ABI dump | The public surface cannot drift silently. |
The ABI dump is committed at
admob-cmp-core/api/admob-cmp-core.klib.api and the matching
file for the Compose surface, and checkKotlinAbi runs against
it in CI. The
contributing guide has the workflow
when an intentional public change is part of a release.