google_mobile_ads_kit
Google Mobile Ads (AdMob) for DartNative — the native
Google Mobile Ads SDKs behind the familiar google_mobile_ads API. iOS and Android.
Why you'll like it
- Real native ads — a banner or native ad is an
AdView/BannerViewmounted straight into the native view hierarchy. No PlatformView, no compositing, no MethodChannel: puredart:ffi. - Familiar API —
BannerAd,InterstitialAd,RewardedAd,NativeAd,AdSize,AdRequest, the preloaders… named and shaped likegoogle_mobile_ads, so most Flutter code ports unchanged. See Migrating from Flutter. - Less code — put
BannerAd(...)in the widget tree and it loads itself. NoAdWidget, no manualload()/dispose(). - Every format — banner, native, interstitial, rewarded, rewarded interstitial, app open, and preloading for the four full-screen formats.
Supported ad formats
| Format | Description | Android | iOS |
|---|---|---|---|
| App open | Full-page ad shown while the app loads or resumes | ✅ | ✅ |
| Banner | Fixed sizes and anchored adaptive banners, in the widget tree | ✅ | ✅ |
| Native | Built-in small / medium templates, or your own native layout via a factory | ✅ | ✅ |
| Interstitial | Full-page ad at natural transitions | ✅ | ✅ |
| Rewarded | Video ad that grants an in-app reward | ✅ | ✅ |
| Rewarded interstitial | Rewarded ad without an opt-in prompt | ✅ | ✅ |
| Preloading | InterstitialAdPreloader, RewardedAdPreloader, RewardedInterstitialAdPreloader, AppOpenAdPreloader |
✅ | ✅ |
| Not yet | |
|---|---|
| 🚧 Inline adaptive / collapsible banners | Anchored adaptive banners are supported |
| 🚧 Mediation | The Next-Gen SDK is AdMob-only |
| 🚧 Ad Manager (GAM) | AdManagerBannerAd and friends |
Requires iOS 15.0+ / Android minSdk 24. On other platforms (web, desktop) every
call is a no-op rather than an error, so shared code keeps running. iOS
preloading is backed by the SDK's Beta preloader module, so keep a
pollAd == null fallback.
Install
dependencies:
google_mobile_ads_kit: ^0.1.0 # from dartpub.dev
dn pub get
import 'package:google_mobile_ads_kit/google_mobile_ads_kit.dart';
void main() {
DartNativePluginRegistrant.registerAll(); // generated by dn pub get
MobileAds.instance.initialize(); // required; do not await before runApp
runApp(const MyApp());
}
The plugin registers itself through the generated
lib/dartnative_plugin_registrant.dart. initialize() is required — the
Android SDK refuses ad requests before it — and the plugin runs it off the
main thread for you. The SDK queues requests made during startup.
Quick look
A banner goes straight into the tree. It loads when it mounts and is destroyed when it unmounts:
BannerAd(
adUnitId: 'ca-app-pub-3940256099942544/6300978111', // Google test unit
size: AdSize.banner,
request: const AdRequest(),
listener: BannerAdListener(
onAdLoaded: (ad) => dnLog('loaded'),
onAdFailedToLoad: (ad, error) => dnLog('failed: $error'),
),
)
An interstitial loads with a callback and shows once:
InterstitialAd.load(
adUnitId: 'ca-app-pub-3940256099942544/1033173712', // Google test unit
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
);
ad.show();
},
onAdFailedToLoad: (error) => dnLog('failed: $error'),
),
);
Platform setup
⚠️ Both platforms: set your AdMob App ID natively
DartNative has no manifest-merge step, so you add the App ID to both native
projects yourself. Without it the SDK crashes on startup. Find it in the AdMob
console under Apps → App settings — it contains ~; ad unit IDs contain /.
ios/Runner/Info.plist:
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-################~##########</string>
android/app/src/main/AndroidManifest.xml, inside <application>:
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-################~##########"/>
On Android the plugin reads this <meta-data> and hands it to the Next-Gen
SDK, so the entry is the same as for Flutter. If it is missing,
MobileAds.instance.initialize() fails with a message naming it.
iOS
Deployment target 15.0+, in both
ios/Podfile(platform :ios, '15.0') and the Xcode project'sIPHONEOS_DEPLOYMENT_TARGET. Targeting lower fails atpod installwith "requires a higher minimum iOS deployment version than your application is targeting".On Xcode 26/27, raise your pods too. Several packages in a DartNative app still declare iOS 12–14 and recent Xcode refuses to build below 15.0 — the build stops at "Target Integrity" before compiling anything. Add this to the
post_installblock inios/Podfile(example/ios/Podfilehas it in context):target.build_configurations.each do |config| current = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] if current.nil? || current.to_f < 15.0 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' end endTracking and SKAdNetwork. The SDK links
AppTrackingTransparencyandAdSupport. AddNSUserTrackingUsageDescriptionand Google'sSKAdNetworkItemstoInfo.plistper the AdMob iOS quick start.
Android
The plugin targets the GMA Next-Gen SDK (ads-mobile-sdk), which needs
minSdk 24+, compileSdk 35+ and Kotlin 1.9+ in your app's
android/app/build.gradle(.kts). Nothing else to configure. See
Android SDK choice for why there is no legacy switch.
Usage
Banner
Use the AdSize constants (banner, largeBanner, mediumRectangle,
fullBanner, leaderboard). A hand-built AdSize(width: 320, height: 50) is
not the same request: AdMob reads a custom size as a flexible slot and may
fill it with a differently shaped creative.
For an anchored adaptive banner, compute the size from the available width:
LayoutBuilder(
builder: (context, constraints) {
final size = AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(
constraints.maxWidth.truncate(),
);
if (size == null) return const SizedBox.shrink(); // null off Android/iOS
return BannerAd(adUnitId: '...', size: size, listener: BannerAdListener(...));
},
)
That call is synchronous here (it is a pure calculation; FFI has no async
mode). The Future-returning AdSize.getAnchoredAdaptiveBannerAdSize(width)
exists too, so ported code compiles unchanged.
The creative is drawn at its own size and centred in the slot — AdMob forbids scaling or cropping ads.
Native
A native ad's layout is built natively: AdMob requires every asset view to be registered with the SDK (for click handling and viewability), which cannot be done from Dart. Two ways to supply the layout.
Built-in template — two layouts ship with the plugin, styled from Dart:
NativeAd(
adUnitId: 'ca-app-pub-3940256099942544/2247696110', // Google test unit
nativeTemplateStyle: const NativeTemplateStyle(
templateType: TemplateType.medium, // or .small
mainBackgroundColor: 0xFFFFFFFF, // 32-bit ARGB, not a dart:ui Color
cornerRadius: 12,
callToActionTextStyle: NativeTemplateTextStyle(
textColor: 0xFFFFFFFF,
backgroundColor: 0xFF2563EB,
),
),
listener: NativeAdListener(
onAdLoaded: (ad) => dnLog('loaded'),
onAdFailedToLoad: (ad, error) => dnLog('failed: $error'),
),
)
Your own layout — register a factory natively, name it from Dart, and say
how tall your layout is. On Android, in MainActivity.onCreate:
GoogleMobileAdsKitPlugin.registerNativeAdFactory(
this, "adFactoryExample", MyNativeAdFactory(layoutInflater))
On iOS, in AppDelegate.application(_:didFinishLaunchingWithOptions:):
import google_mobile_ads_kit
GMAKMobileAds.registerNativeAdFactory("adFactoryExample", factory: MyNativeAdFactory())
Register under the same id on both platforms; Dart names it once:
NativeAd(
adUnitId: '...',
factoryId: 'adFactoryExample',
height: 120,
listener: NativeAdListener(),
)
The factory builds a NativeAdView and assigns each asset view (headlineView,
iconView, callToActionView, …). That assignment is AdMob policy, not
bookkeeping: an unregistered asset is not clickable and records no impression.
Android calls registerNativeAd last; on iOS the plugin binds the ad for you
once your factory returns, so do not set nativeAd yourself. An id that was
never registered fails the load immediately, before a request goes out.
A native ad's height is not known before it loads, so the widget reserves one:
the template default (small 144 on iOS / 90 on Android, medium 350) or the
height you pass.
Interstitial
See Quick look. An ad shows once. Dispose it when it is spent — after dismissal, and after a failed show — then load another.
Rewarded
The reward callback goes to show and fires only once the user has watched
enough. Grant the reward there and nowhere else.
RewardedAd.load(
adUnitId: 'ca-app-pub-3940256099942544/5224354917', // Google test unit
request: const AdRequest(),
rewardedAdLoadCallback: RewardedAdLoadCallback(
onAdLoaded: (ad) {
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
);
ad.show(onUserEarnedReward: (ad, reward) => grantCoins(reward.amount));
},
onAdFailedToLoad: (error) => dnLog('failed: $error'),
),
);
RewardedInterstitialAd has the same shape; AdMob requires you to show a reward
announcement before it.
App open
AppOpenAd.load(
adUnitId: 'ca-app-pub-3940256099942544/9257395921', // Google test unit
request: const AdRequest(),
adLoadCallback: AppOpenAdLoadCallback(
onAdLoaded: (ad) => _pendingAd = ad,
onAdFailedToLoad: (error) => dnLog('failed: $error'),
),
);
AdMob expires an app open ad four hours after it loads — record the load time and discard a stale one. Show it on a cold start or a return to the foreground, never over content the user is already using.
Preloading
A preloader keeps a small buffer filled in the background, so showing an ad is instant. Start it once, then poll whenever you need one:
await InterstitialAdPreloader.start(
preloadId: 'level-end',
preloadConfiguration: const PreloadConfiguration(
adUnitId: 'ca-app-pub-3940256099942544/1033173712', // Google test unit
bufferSize: 2,
),
callback: PreloadCallback(
onAdPreloaded: (id, responseInfo) => dnLog('ready: $id'),
onAdsExhausted: (id) => dnLog('empty: $id'),
onAdFailedToPreload: (id, error) => dnLog('failed: $error'),
),
);
final ad = await InterstitialAdPreloader.pollAd('level-end');
if (ad != null) {
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
);
await ad.show();
} else {
// buffer empty — it refills on its own; fall back to a normal load
}
Keep bufferSize small: a buffered ad that is never shown is a wasted
impression opportunity, and AdMob measures that. RewardedAdPreloader,
RewardedInterstitialAdPreloader and AppOpenAdPreloader work the same way.
On iOS the preloader lives in the SDK's Beta module (
GoogleMobileAds_Private), so its API may change between SDK releases; the podspec's~> 13.0pin bounds that. Keep thepollAd == nullfallback either way — an empty buffer is normal on both platforms.
Targeting and global settings
AdRequest(keywords:, contentUrl:, neighboringContentUrls:, nonPersonalizedAds:, extras:). SetnonPersonalizedAds: truewhen the user has not consented — obtaining consent is the app's job; this only forwards the decision.MobileAds.instance.setAppMuted(bool).ad.responseInfo,ad.onPaidEventon every format;setImmersiveMode(Android only) andsetServerSideOptionson the rewarded formats.
Banners inside scrolling lists
⚠️ Read this before putting a banner or native ad in a list.
FastList, FastGrid and MasonryFastGrid recycle their cells (they are
backed by RecyclerView / UITableView). A recycled ad is unmounted and
remounted as it scrolls out and back, and each remount issues a new ad
request — wasted inventory, a lower match rate, and an invalid-traffic risk.
Teardown is deferred by one frame so a cell that is recycled straight back in keeps its ad, but a cell that scrolls away and returns is a new request. Prefer a non-recycling container:
SingleChildScrollView(
child: Column(children: [ ...content, BannerAd(...) ]),
)
If you must use a recycling list, leave keepAliveCount unset.
Test ad units
Never use production ad units during development — it can get your account suspended. Google's always-fill test units:
| Format | Android | iOS |
|---|---|---|
| App open | ca-app-pub-3940256099942544/9257395921 |
ca-app-pub-3940256099942544/5575463023 |
| Banner | ca-app-pub-3940256099942544/6300978111 |
ca-app-pub-3940256099942544/2934735716 |
| Native | ca-app-pub-3940256099942544/2247696110 |
ca-app-pub-3940256099942544/3986624511 |
| Interstitial | ca-app-pub-3940256099942544/1033173712 |
ca-app-pub-3940256099942544/4411468910 |
| Rewarded | ca-app-pub-3940256099942544/5224354917 |
ca-app-pub-3940256099942544/1712485313 |
| Rewarded interstitial | ca-app-pub-3940256099942544/5354046379 |
ca-app-pub-3940256099942544/6978759866 |
Differences from google_mobile_ads
google_mobile_ads |
google_mobile_ads_kit |
|
|---|---|---|
| Banner / native placement | AdWidget(ad: ...) after load() |
The widget goes directly in the tree; the lifecycle owns load and dispose |
| Adaptive banner size | await AdSize.getAnchoredAdaptiveBannerAdSize(orientation, width) |
AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(width) — synchronous, so it fits inside LayoutBuilder (the Future form is kept too) |
| Native ad factory registration | registerNativeAdFactory(engine, id, factory) |
Android registerNativeAdFactory(context, id, factory) — no FlutterEngine exists; iOS GMAKMobileAds.registerNativeAdFactory(id, factory:). The factory body is unchanged on both |
| Preloading | Android + iOS | Both, plus RewardedInterstitialAdPreloader, which upstream wires on Android only |
| Template colors | dart:ui Color |
32-bit ARGB int |
| Native ad height | Comes from the platform view | Reserved by you (template default or height:) |
| Rendering / transport | PlatformView / MethodChannel | Native view in the native hierarchy / dart:ffi |
| Ad Manager (GAM), mediation | Supported | Out of scope for 1.0 |
NativeAdOptions.shouldRequestMultipleImages, requestCustomMuteThisAd |
Supported | Not exposed — no Next-Gen equivalent |
| Android SDK | Legacy by default, USE_NEXT_GEN_SDK flag |
Next-Gen only |
Full list with before/after code: doc/migration_from_flutter.md.
Android SDK choice
Flutter's plugin compiles from source inside your app, so a --dart-define can
pick the SDK. A DartNative plugin ships as one prebuilt .aar, so the choice is
made here, once: the GMA Next-Gen SDK, which Google documents as the default
and labels play-services-ads as legacy. Consequences: minSdk 24 / compileSdk
35+; mediation is AdMob-only (out of scope for 1.0 anyway); nothing changes in
your Dart code — the SDK's background-thread callbacks are marshalled back to
the main thread inside the plugin.
Example
The example/ app exercises every format and preloading against
Google's test ad units, including a custom native ad factory on both platforms
— borrow from it freely.
Documentation
- Migrating from Flutter
- Design specification — architecture, decisions, and what was learned on device
- Research notes — the DartNative and SDK internals this is built on (repository only)
- AI agent skill:
dart run skills@ getin an app that depends on this package installsgoogle-mobile-ads-kit-usage, a compact reference for coding agents — setup, every format, and the pitfalls above.
Contributing
.claude/skills/dartnative-plugin/SKILL.md documents the plugin-authoring
patterns this repository relies on (FFI, the dispatcher-slot callback contract,
the JNI bridge, NativeElement). Read it before touching native code.
Credits & license
MIT — see LICENSE. The public API surface follows
google_mobile_ads (Apache-2.0)
to ease migration; no code is derived from it. The plugin ships its own native
bridge — the Google Mobile Ads SDK 13.x on iOS, the GMA Next-Gen SDK on Android
— over FFI, built on the DartNative framework's runtime.