Perché parlare di pubblicità in un'app Flutter

Gli acquisti in-app e gli abbonamenti non sono l'unica via per monetizzare: per molte app di utility, giochi casual o contenuti editoriali la pubblicità resta il modello più immediato. Su Flutter lo standard di fatto è google_mobile_ads, il plugin ufficiale di Google che espone l'SDK AdMob per Android e iOS.

Il punto delicato non è tecnico ma normativo e di UX: senza una corretta gestione del consenso (GDPR in Europa, App Tracking Transparency su iOS) le richieste di annunci vengono rifiutate o monetizzano pochissimo; senza una strategia di frequenza, gli interstitial fanno disinstallare l'app. Vediamo come fare le cose per bene.

Installazione e configurazione nativa

Aggiungiamo la dipendenza:

dependencies:
  google_mobile_ads: ^6.0.0

Android

In android/app/src/main/AndroidManifest.xml, dentro il tag <application>:

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

Attenzione: l'App ID usa la tilde (~), mentre gli Ad Unit ID usano lo slash (/). È l'errore più comune e provoca un crash all'avvio.

Il minSdkVersion richiesto dall'SDK recente è 23 o superiore.

iOS

In ios/Runner/Info.plist:

<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXXXXXXXXXXXXXX~ZZZZZZZZZZ</string>
<key>NSUserTrackingUsageDescription</key>
<string>Usiamo questo identificativo per mostrarti annunci più pertinenti.</string>

Vanno inoltre aggiunte le voci SKAdNetworkItems fornite nella documentazione AdMob (sono decine, servono per l'attribuzione delle installazioni).

Inizializzazione

L'inizializzazione va fatta una sola volta, ma dopo aver raccolto il consenso: altrimenti l'SDK potrebbe richiedere annunci personalizzati senza averne il diritto.

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

L'inizializzazione vera e propria la incapsuliamo in un servizio:

class AdsService {
  AdsService._();
  static final instance = AdsService._();

  bool _initialized = false;

  Future<void> initialize() async {
    if (_initialized) return;
    await MobileAds.instance.initialize();
    // Dispositivi di test: evitano click non validi durante lo sviluppo
    await MobileAds.instance.updateRequestConfiguration(
      RequestConfiguration(
        testDeviceIds: const ['IL_TUO_DEVICE_ID'],
      ),
    );
    _initialized = true;
  }
}

Gestione del consenso con UMP

Il plugin include le User Messaging Platform API, che mostrano il form di consenso richiesto da Google per il traffico SEE/UK e gestiscono anche il Consent Mode.

Future<void> requestConsent() async {
  final params = ConsentRequestParameters();

  final completer = Completer<void>();
  ConsentInformation.instance.requestConsentInfoUpdate(
    params,
    () async {
      // Mostra il form solo se necessario (area SEE/UK, consenso scaduto...)
      ConsentForm.loadAndShowConsentFormIfRequired((FormError? error) {
        if (error != null) {
          debugPrint('Errore form consenso: ${error.message}');
        }
        completer.complete();
      });
    },
    (FormError error) {
      debugPrint('Errore consenso: ${error.message}');
      completer.complete();
    },
  );

  await completer.future;

  if (await ConsentInformation.instance.canRequestAds()) {
    await AdsService.instance.initialize();
  }
}

Due accorgimenti importanti:

  • Se l'utente rifiuta il tracciamento, canRequestAds() può comunque restituire true: significa che si possono servire annunci non personalizzati. Non bloccare la monetizzazione in quel caso.
  • Nelle impostazioni dell'app vale la pena esporre un pulsante "Gestisci privacy" che richiami ConsentForm.showPrivacyOptionsForm, obbligatorio quando getPrivacyOptionsRequirementStatus() è required.

Su iOS, dopo UMP, va mostrato il prompt ATT (con il pacchetto app_tracking_transparency), rispettando l'ordine: prima il form Google, poi ATT.

Banner adattivi

Il formato banner classico a 320x50 è ormai superato: Google raccomanda gli anchored adaptive banner, che calcolano l'altezza ottimale in base alla larghezza dello schermo e rendono molto meglio.

class AdaptiveBanner extends StatefulWidget {
  const AdaptiveBanner({super.key});

  @override
  State<AdaptiveBanner> createState() => _AdaptiveBannerState();
}

class _AdaptiveBannerState extends State<AdaptiveBanner> {
  BannerAd? _ad;
  bool _loaded = false;

  static String get _unitId => Platform.isAndroid
      ? 'ca-app-pub-3940256099942544/9214589741' // test Android
      : 'ca-app-pub-3940256099942544/2435281174'; // test iOS

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _loadAd();
  }

  Future<void> _loadAd() async {
    if (_ad != null) return;

    final width = MediaQuery.sizeOf(context).width.truncate();
    final size = await AdSize
        .getCurrentOrientationAnchoredAdaptiveBannerAdSize(width);
    if (size == null || !mounted) return;

    final ad = BannerAd(
      adUnitId: _unitId,
      size: size,
      request: const AdRequest(),
      listener: BannerAdListener(
        onAdLoaded: (_) => setState(() => _loaded = true),
        onAdFailedToLoad: (ad, error) {
          ad.dispose();
          debugPrint('Banner fallito: ${error.code} ${error.message}');
        },
      ),
    );

    _ad = ad;
    await ad.load();
  }

  @override
  void dispose() {
    _ad?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (!_loaded || _ad == null) return const SizedBox.shrink();
    return SizedBox(
      width: _ad!.size.width.toDouble(),
      height: _ad!.size.height.toDouble(),
      child: AdWidget(ad: _ad!),
    );
  }
}

Regole d'oro per i banner:

  • Un AdWidget per ogni BannerAd: riutilizzare la stessa istanza in due punti dell'albero provoca un'eccezione.
  • Chiamare sempre dispose() sull'oggetto Ad, non solo sul widget: altrimenti si accumulano view native e memoria.
  • Riservare lo spazio nel layout (ad esempio un SizedBox con l'altezza attesa) per evitare layout shift quando l'annuncio arriva.

Interstitial: precaricamento e frequency capping

Un interstitial va caricato in anticipo e mostrato in un momento naturale (fine di un livello, ritorno da un flusso completato), mai al primo avvio né durante un'interazione.

class InterstitialManager {
  InterstitialAd? _ad;
  int _retry = 0;
  DateTime? _lastShown;

  static const _minInterval = Duration(minutes: 3);

  void load() {
    InterstitialAd.load(
      adUnitId: 'ca-app-pub-3940256099942544/1033173712',
      request: const AdRequest(),
      adLoadCallback: InterstitialAdLoadCallback(
        onAdLoaded: (ad) {
          _ad = ad;
          _retry = 0;
        },
        onAdFailedToLoad: (error) {
          _ad = null;
          _retry++;
          if (_retry <= 3) {
            Future.delayed(Duration(seconds: 2 * _retry), load);
          }
        },
      ),
    );
  }

  Future<void> showIfReady() async {
    final ad = _ad;
    if (ad == null) return;

    final now = DateTime.now();
    if (_lastShown != null && now.difference(_lastShown!) < _minInterval) {
      return;
    }

    ad.fullScreenContentCallback = FullScreenContentCallback(
      onAdDismissedFullScreenContent: (ad) {
        ad.dispose();
        _ad = null;
        load(); // precarica il prossimo
      },
      onAdFailedToShowFullScreenContent: (ad, error) {
        ad.dispose();
        _ad = null;
        load();
      },
    );

    _lastShown = now;
    await ad.show();
  }
}

Il _minInterval è un frequency cap lato client: AdMob ne offre uno lato dashboard, ma averlo anche nel codice evita sorprese e permette di differenziarlo per tipo di utente.

Rewarded ad: il formato con il miglior rapporto UX/ricavi

Gli annunci con premio sono opt-in: l'utente sceglie di guardarli in cambio di qualcosa (una vita extra, un export senza watermark, crediti). Sono quelli meglio tollerati.

Future<void> showRewarded(VoidCallback onReward) async {
  RewardedAd.load(
    adUnitId: 'ca-app-pub-3940256099942544/5224354917',
    request: const AdRequest(),
    rewardedAdLoadCallback: RewardedAdLoadCallback(
      onAdLoaded: (ad) {
        ad.fullScreenContentCallback = FullScreenContentCallback(
          onAdDismissedFullScreenContent: (ad) => ad.dispose(),
          onAdFailedToShowFullScreenContent: (ad, _) => ad.dispose(),
        );
        ad.show(
          onUserEarnedReward: (ad, reward) {
            debugPrint('Premio: ${reward.amount} ${reward.type}');
            onReward();
          },
        );
      },
      onAdFailedToLoad: (error) => debugPrint('Rewarded KO: $error'),
    ),
  );
}

Mai assegnare il premio in onAdDismissedFullScreenContent: l'unico callback affidabile è onUserEarnedReward. Per premi di valore economico reale (valuta di gioco acquistabile) conviene attivare le Server-Side Verification e accreditare il premio dal backend.

App Open Ad e ciclo di vita

L'App Open Ad si mostra al rientro in foreground. Richiede attenzione: va evitato al primissimo avvio (dove c'è la splash) e non deve comparire se l'utente sta tornando da un altro annuncio o da un flusso di pagamento. Si combina con AppLifecycleListener per intercettare il passaggio a resumed, mantenendo un flag che indica se un altro annuncio full-screen è in corso.

Un altro dettaglio spesso dimenticato: gli annunci caricati scadono dopo circa 4 ore. Conviene salvare il timestamp di caricamento e ricaricare se l'annuncio è troppo vecchio.

Testare senza farsi bannare

  • In sviluppo usare sempre gli ad unit di test ufficiali di Google (quelli con prefisso ca-app-pub-3940256099942544).
  • Sui dispositivi reali registrare il test device ID (compare nei log dell'SDK al primo caricamento) e passarlo a RequestConfiguration.
  • Cliccare sui propri annunci di produzione è il modo più rapido per vedersi sospendere l'account AdMob.

Un pattern comodo è centralizzare gli ID:

abstract final class AdIds {
  static const _isTest = bool.fromEnvironment('ADS_TEST', defaultValue: true);

  static String get banner => _isTest
      ? 'ca-app-pub-3940256099942544/9214589741'
      : const String.fromEnvironment('BANNER_UNIT_ID');
}

Così con --dart-define si passa agli ID reali solo nelle build di release, idealmente collegandolo ai flavor dell'app.

Best practice finali

  1. Non mostrare annunci agli utenti paganti. Se esiste una versione premium, il controllo sull'abbonamento deve precedere ogni richiesta di annuncio.
  2. Misura il ritorno reale. Gli annunci full-screen alzano l'eCPM ma possono abbassare retention e sessioni: monitora entrambe le metriche prima di aumentare la frequenza.
  3. Attenzione alle policy sui posizionamenti. Banner appiccicati a pulsanti, annunci sotto scroll infiniti senza separazione o annunci su schermate di caricamento sono violazioni comuni.
  4. Gestisci gli errori in silenzio. Un annuncio che non carica non deve mai rompere la UI: in caso di fallimento il widget semplicemente non occupa spazio.
  5. Conformità per i minori. Se l'app rientra in Families Policy, imposta tagForChildDirectedTreatment nella RequestConfiguration e limita i formati.

Integrare AdMob in Flutter è tecnicamente semplice; la differenza tra un'app che monetizza e una che viene disinstallata sta tutta nella disciplina sui posizionamenti e in una gestione del consenso fatta a regola d'arte.