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 restituiretrue: 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 quandogetPrivacyOptionsRequirementStatus()è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
AdWidgetper ogniBannerAd: riutilizzare la stessa istanza in due punti dell'albero provoca un'eccezione. - Chiamare sempre
dispose()sull'oggettoAd, non solo sul widget: altrimenti si accumulano view native e memoria. - Riservare lo spazio nel layout (ad esempio un
SizedBoxcon 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
- Non mostrare annunci agli utenti paganti. Se esiste una versione premium, il controllo sull'abbonamento deve precedere ogni richiesta di annuncio.
- 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.
- Attenzione alle policy sui posizionamenti. Banner appiccicati a pulsanti, annunci sotto scroll infiniti senza separazione o annunci su schermate di caricamento sono violazioni comuni.
- 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.
- Conformità per i minori. Se l'app rientra in Families Policy, imposta
tagForChildDirectedTreatmentnellaRequestConfiguratione 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.