easy_flutter_ads

A production-grade wrapper around google_mobile_ads.

The SDK gives you load() and show(). Everything between them — caching, expiry, retries, consent ordering, and the placement rules AdMob enforces — is left to you, and that is where apps get their ad serving limited. easy_flutter_ads is that layer, written once.

await EasyAds.instance.initialize(
  config: EasyAdsConfig(adUnitIds: const EasyAdUnitIds.test()),
);

EasyAds.instance.preloadAll();
EasyAds.instance.appOpen.startWatchingAppState();

await EasyAds.instance.interstitial.show();
final reward = await EasyAds.instance.rewarded.showForReward();

What it handles for you

Expiry Interstitial/rewarded ads expire ~1h after the request, App Open ads after 4h. Cached ads carry a TTL with a safety margin, and an expired ad is disposed instead of shown — showing one fails at render time.
Single-flight loading One in-flight request per format. Concurrent callers share it. In mediation, every duplicate load() fans out to every network.
Backoff No-fill retries use a capped exponential backoff (2s → 4s → … → 32s), never a tight loop.
Timeout The SDK has no load timeout; this package does. A request on a dead network cannot pin the retry loop forever.
Consent ordering UMP consent is gathered before MobileAds.initialize() and before the first request. Ads requested without a consent string cost you EEA/UK/CH fill.
Initialization ordering Every load waits behind a memoized initialize(), so mediation adapters participate in the first request.
Placement policy The App Open rules AdMob actually enforces — see below.
Revenue onPaidEvent is wired automatically for every format. Retrofitting impression-level revenue later is painful.
Kill switches One enabled flag for subscribers, one per format, all hot-swappable from Remote Config.
Frequency Cooldowns, rolling windows of any length, and daily caps, per format — all enforced client side, so a capped request is never sent (AdMob's dashboard cap returns a no-fill instead). Preloading follows the same rules: a spent daily cap stops it, a cooldown loads through, and a spent window decides on TTL.
Never crashes Every path swallows its exceptions and reports them via onError. A broken ad stack degrades to "no ads".

App Open ads: the part most implementations get wrong

AdMob's placement policy for this format is specific, and two rules are routinely broken:

1. The ad must render over a loading screen, not over app content.

"The preferred way to use app open ads on cold starts is to use a loading screen to load your game or app assets, and to only show the ad from the loading screen."

So showOnColdStart() belongs on your splash screen, before you navigate:

final navigator = Navigator.of(context); // capture before awaiting
await AppController.instance.init();
await EasyAds.instance.appOpen.showOnColdStart();
navigator.pushReplacementNamed(Routes.home);

The wait is capped by appOpenColdStartMaxWait (5s default). If the ad is not ready in time the show is cancelled, not deferred — it can never appear later, on top of content the user is already using. The load keeps running in the background, so the ad is kept for the next opportunity.

Note what this rules out: Future.timeout() around a plain show() does not work. Futures in Dart cannot be cancelled, so the ad still appears when it eventually loads — over your home screen.

2. No ad immediately before or after another ad.

When a user taps an interstitial or rewarded ad, the app is backgrounded (Play Store or browser opens). On return, the SDK reports a foreground transition — and a naive resume handler shows an App Open ad on top of the ad the user just came back from. minGapAfterFullScreenAd (120s default) suppresses exactly that, and startWatchingAppState() only ever shows an already-cached ad, so an ad can never surface seconds into a session.

3. No ad on a return your own app caused.

An image picker, the camera, a sign-in flow, the Play Store, a share sheet — every one of them backgrounds your app and comes back as a plain foreground transition. minGapAfterFullScreenAd does not help here: it only knows about ads this package showed. Announce those trips:

EasyAds.instance.appOpen.suppressResume();
final file = await ImagePicker().pickImage(source: source);

The whole window (5 min default) is suppressed rather than just the next foreground event, because a permission dialog in front of the picker fires a background/foreground pair of its own. It expires by itself, so an abandoned flow does not disable the resume ad for the rest of the session.

There is no numeric frequency rule for App Open ads in AdMob's policies — the "4 hours" in Google's docs is the ad object's expiry, and the other "4 hours" on the policy page describes which apps the format suits ("apps with frequent opens see the best performance"). Frequency is therefore a UX/revenue decision: appOpenCooldown defaults to zero (no gate) — set it yourself if you want one. Interstitials are different: interstitialCooldown defaults to 180 seconds, because two screens that each open one put two full screen ads seconds apart the moment a user taps through them quickly. Both last impressions are persisted, so killing and relaunching the app does not hand the user a fresh cooldown.

What does bound the burst is appOpenWindowCap (2 by default), because the format's trigger is a burst by nature: a user alternating between your app and a messenger produces a foreground transition every time, and without it the whole day's inventory is spent before they have used the app once with intent. A cooldown would space those out too, but it charges the user who returns twice all day exactly as much as the one who returns twice a minute — the window only bites the second.

The window length is yours: appOpenWindow (and interstitialWindow) default to an hour, but any duration works, so a policy is written directly instead of being reverse-engineered into a cooldown.

appOpenWindowCap: 2,
appOpenWindow: Duration(hours: 4),      // at most 2 in any 4 hours
appOpenCooldown: Duration(seconds: 180) // and never less than 3 min apart

Three independent gates, all of which must pass: how many per window, how long that window is, and the minimum gap between two ads. Only the last cap impressions are stored, in a ring buffer whose cursor points at the oldest, so the check costs two reads no matter how long the window or how heavily the app is used.

One thing to keep an eye on: a window (or cooldown) longer than the format's TTL — 3h30m for App Open, 50 min for the rest — means an ad preloaded right after an impression expires before the gate lifts, so the request is wasted.

Configuration

Every field of EasyAdsConfig is documented inline and hot-swappable:

// Firebase Remote Config → package
await EasyAds.instance.updateConfig(
  EasyAds.instance.config.copyWith(
    appOpenCooldown: Duration(seconds: remote.getInt('app_open_cooldown')),
    interstitialEnabled: remote.getBool('interstitial_enabled'),
  ),
);

// The user just subscribed
await EasyAds.instance.setAdsEnabled(false);

Persistence is on by default

Session thresholds (appOpenMinSessions), daily caps, rolling windows and cooldowns are all persistence problems first — a cap that forgets is not a cap — so initialize stores them for you, through shared_preferences. Nothing to wire up:

await EasyAds.instance.initialize(config: config); // counters survive cold starts

This matters most for App Open: it shows precisely at cold start, the moment an in-memory counter would have just been wiped, so an app without persistence effectively had no App Open cap at all.

Keys are namespaced under easy_ads.. If the platform channel is unavailable the store degrades to memory and reports through onError rather than failing your startup. To keep the counters elsewhere — a database, an encrypted store, a backend the caps are shared with — implement EasyAdsStore and pass it; to deliberately forget them every launch, pass MemoryEasyAdsStore():

await EasyAds.instance.initialize(config: config, store: MyStore());

Banners

const EasyBannerAd()                                        // anchored adaptive
const EasyBannerAd(type: EasyBannerType.inlineAdaptive)     // inside scrollables
const EasyBannerAd(type: EasyBannerType.fixed,
                   fixedSize: AdSize.mediumRectangle)
const EasyBannerAd(collapse: EasyBannerCollapse.bottom)     // collapsible

EasyBannerAd loads on mount, re-requests when the available width changes (rotation, split screen), retries failures with backoff, disposes itself, and renders nothing until an ad is actually on screen — so no empty box is reserved for a banner that may never fill.

It has no refresh timer on purpose. Refresh rate belongs on the ad unit in the AdMob dashboard; refreshing from client code double-counts requests and can breach the 60-second minimum.

Collapsible banners are granted one slot per session (collapsibleBannerOncePerSession) since they start expanded over content. Only Google demand fills them; a mediated fill renders as a normal banner.

Events and revenue

EasyAdsConfig(
  onEvent: (event) => analytics.logEvent(name: 'ad_${event.type.name}', parameters: {
    'format': event.format.name,
    if (event.skipReason != null) 'skip_reason': event.skipReason!.name,
  }),
  onPaidEvent: (revenue) => analytics.logEvent(name: 'ad_impression', parameters: {
    'value': revenue.value,
    'currency': revenue.currencyCode,
  }),
  onError: (error, stack) =>
      FirebaseCrashlytics.instance.recordError(error, stack),
)

skipped events are the interesting ones: they tell you how often a show was blocked and by which gate — numbers AdMob's dashboard cannot show you, because those requests never left the device.

Testing

EasyAdUnitIds.test() returns Google's official test IDs. testDeviceIds is ignored in release builds unless you explicitly opt in, so a device left on the list cannot silently stop earning.

To exercise the EEA consent form, set forceConsentDebugGeographyEea: true with your device in testDeviceIds, and use EasyAds.instance.consent.reset() between runs (debug builds only — it asserts).

Setup

Add the AdMob app ID to android/app/src/main/AndroidManifest.xml:

<meta-data
    android:name="com.google.android.gms.ads.APPLICATION_ID"
    android:value="ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY"/>

and to ios/Runner/Info.plist:

<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY</string>

Not included

  • Native ads. They need platform-side layout factories, which would make this a plugin with native code rather than a pure Dart package.
  • The SDK's own preloading API. InterstitialAdPreloader and friends are merged into the plugin's main branch but are not in a released version yet. The manual cache here does the same job; when the API ships, it becomes an implementation detail behind the same façade.

License

MIT

Libraries

easy_flutter_ads
A production-grade AdMob wrapper for Flutter.