Start Debugging

So reservieren Sie Platz für ein adaptives AdMob-Banner in Flutter, um Layout-Verschiebungen zu vermeiden

Das offizielle google_mobile_ads-Beispiel rendert nichts bis onAdLoaded, sodass Ihr Inhalt Sekunden nach dem Erscheinen des Bildschirms um 150 dp springt. Fragen Sie zuerst die verankerte adaptive Größe ab, reservieren Sie sie mit einer SizedBox, speichern Sie die Höhe über App-Starts hinweg und laden Sie erst dann die Anzeige. Gemessen mit Flutter 3.44.8 und google_mobile_ads 9.1.0.

Kurze Antwort: Die Höhe eines verankerten adaptiven Banners ist bekannt, bevor die Anzeige geladen wird, also reservieren Sie sie. Rufen Sie AdSize.getLargeAnchoredAdaptiveBannerAdSize(width) auf, sobald Sie eine Breite ungleich null haben, setzen Sie sofort eine SizedBox mit genau dieser Höhe in den Banner-Platz und tauschen Sie das AdWidget in die Box ein, wenn onAdLoaded auslöst. Speichern Sie die Höhe mit shared_preferences, damit schon der erste Frame beim nächsten Kaltstart den Platz enthält, und behalten Sie den Platz bei einem fehlgeschlagenen Ladevorgang bei, statt ihn einzuklappen. Alles Folgende wurde mit Flutter 3.44.8 (Dart 3.12.2) und google_mobile_ads 9.1.0 auf einem Android-16-Emulator (API 36) gemessen.

Warum der Bildschirm springt, wenn das Banner eintrifft

Googles Banner-Anleitung für das Flutter-Plugin lädt die Anzeige und fügt sie erst in onAdLoaded zum Baum hinzu. Das Anzeige-Snippet wird durch if (_bannerAd != null) geschützt, und die Größe der Box stammt aus _bannerAd!.size. Bis der Netzwerk-Roundtrip abgeschlossen ist, ist der Banner-Platz null Pixel hoch. Sobald die Anzeige eintrifft, wächst der Platz, der Scaffold-Body schrumpft um denselben Betrag, und alles, was der Nutzer weiter unten betrachtet hat, verschiebt sich.

Diese Verschiebung ist aus zwei Gründen schlimmer als früher:

  1. Große verankerte adaptive Banner sind hoch. Seit google_mobile_ads 8.0.0 ist getCurrentOrientationAnchoredAdaptiveBannerAdSize zugunsten von getLargeAnchoredAdaptiveBannerAdSize als veraltet markiert. Googles Anleitung beschreibt die große Variante mit “up to 20% of screen height, between 50 and 150 dp”. Auf einem typischen Smartphone ist das etwa doppelt so viel wie der frühere Streifen von 50 bis 64 dp.
  2. Die Anzeige trifft spät ein. Die Anfrage geht nach MobileAds.instance.initialize(), einer Anzeigenauktion und einem Creative-Download hinaus. Bei einem Kaltstart dauert das Sekunden, nicht Frames, und genau dann beginnen Nutzer zu lesen oder zu tippen.

Die Verschiebung messen

Für echte Zahlen habe ich eine Probe-App gebaut: ein Scaffold, dessen Body eine ListView in einem LayoutBuilder ist, der jede Änderung der Body-Höhe protokolliert, mit dem Banner in bottomNavigationBar. Der Emulator hatte einen Bildschirm mit 1080x2400 bei 420 dpi, was Flutter als 411,43 x 914,29 logische Pixel sieht. Verwendet wurden die offizielle Test-Anzeigenblock-ID für adaptive Banner ca-app-pub-3940256099942544/9214589741 und ein Release-Build.

Zuerst die Höhen, die das SDK für verschiedene Breiten zurückgibt (alle Werte in dp):

Angeforderte BreiteGroß verankert (aktuelle Ausrichtung)Groß, QuerformatStandard verankert (veraltet)
3201008250
3601138256
4111288264
4121298264
6001508277
8001508290

Im Hochformat folgt die große Größe dem auf 320x100 hochskalierten Seitenverhältnis 320x50 und wird bei 150 gekappt. Im Querformat ist sie auf 82 festgelegt, das sind 20 % der Querformat-Höhe von 411 dp. Nichts davon benötigt einen Netzwerkaufruf: Die Größe wird auf dem Gerät berechnet und liegt nach wenigen Millisekunden vor.

Dann der Verlauf der Body-Höhe für das Muster aus dem Tutorial im Vergleich zu einem reservierten Platz:

MusterErster echter FrameÄnderung der Body-HöheZeitpunkt
Nichts rendern bis onAdLoaded302 ms914,29 -> 762,295338 ms, als die Anzeige geladen wurde
Größe reservieren, in didChangeDependencies abgerufen439 ms914,29 -> 762,29448 ms, einen Frame später
Größe reservieren, Höhe aus shared_preferences wiederhergestellt546 ms0 -> 762,29 direktüberhaupt keine Verschiebung

Der Rückgang um 152 dp setzt sich aus dem 128-dp-Banner und dem 24-dp-Einzug der Gestennavigation zusammen, den SafeArea darunter hinzufügt. In der naiven Version geschieht er mehr als fünf Sekunden, nachdem der Bildschirm erschienen ist. Das Reservieren der Größe verlegt ihn auf den Frame direkt nach dem ersten, und das Speichern der Höhe beseitigt ihn.

Schritt 1: Die Größe abrufen, bevor etwas geladen wird

Die Größenmethoden von AdSize sind statisch und asynchron, weil sie den Plattformkanal durchqueren, hängen aber nicht davon ab, dass eine Anzeige geladen ist. Rufen Sie sie auf, sobald MediaQuery eine Breite liefert:

// Flutter 3.44.8, google_mobile_ads 9.1.0
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  final size = MediaQuery.sizeOf(context);
  final padding = MediaQuery.paddingOf(context);
  final width = (size.width - padding.left - padding.right).truncate();
  // Android can report a 0x0 window on the very first frame.
  if (width <= 0 || width == _width) return;
  _width = width;
  unawaited(_load(width));
}

Die Bedingung width <= 0 ist keine Dekoration. Im Release-Build lief das erste build mit einer MediaQuery-Größe von 0x0: Android hatte die Fenstermaße noch nicht geliefert. Sogar platformDispatcher.implicitView in main() meldete vor runApp eine physicalSize von 0 und ein devicePixelRatio von 1.0. Die Anfrage eines großen verankerten Banners mit Breite 0 lieferte 0x100, und das Laden dieser Größe schlug mit LoadAdError(code: 3, ... "Ad request doesn't meet size requirements") fehl. Der Tutorial-Code sendet seine erste Anfrage aus diesem Frame mit Breite null und ist nur erfolgreich, weil didChangeDependencies einen Frame später mit echten Maßen erneut läuft.

Ziehen Sie vor der Abfrage den horizontalen Safe-Area-Abstand ab. Googles Anleitung zu Inline-Adaptive betont ausdrücklich, dass die Breite “should take into account the device width and any safe areas that are applicable”, und im Querformat auf einem Smartphone mit Display-Aussparung ist MediaQuery.paddingOf(context).left nicht null.

Schritt 2: Einen Platz bauen, der seine Höhe selbst besitzt

Der Platz ist eine SizedBox mit der reservierten Höhe, die immer im Baum ist. Das AdWidget kommt erst hinein, nachdem die Anzeige geladen wurde, weil AdWidget sonst “AdWidget requires Ad.load to be called before AdWidget is inserted into the tree” wirft.

// Flutter 3.44.8, google_mobile_ads 9.1.0, shared_preferences 2.5.5
class AnchoredBannerSlot extends StatefulWidget {
  const AnchoredBannerSlot({super.key, required this.adUnitId});

  final String adUnitId;

  @override
  State<AnchoredBannerSlot> createState() => _AnchoredBannerSlotState();
}

class _AnchoredBannerSlotState extends State<AnchoredBannerSlot> {
  BannerAd? _ad;
  bool _loaded = false;
  int? _width;
  int? _height;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final size = MediaQuery.sizeOf(context);
    final padding = MediaQuery.paddingOf(context);
    final width = (size.width - padding.left - padding.right).truncate();
    // Android can report a 0x0 window on the very first frame.
    if (width <= 0 || width == _width) return;
    _width = width;
    _height = BannerHeightCache.lookup(width);
    unawaited(_load(width));
  }

  Future<void> _load(int width) async {
    final size = await AdSize.getLargeAnchoredAdaptiveBannerAdSize(width);
    if (!mounted || width != _width || size == null) return;
    BannerHeightCache.remember(width, size.height);

    final previous = _ad;
    setState(() {
      _height = size.height;
      _ad = null;
      _loaded = false;
    });
    await previous?.dispose();
    if (!mounted || width != _width) return;

    final ad = BannerAd(
      adUnitId: widget.adUnitId,
      request: const AdRequest(),
      size: size,
      listener: BannerAdListener(
        onAdLoaded: (ad) {
          if (!mounted || ad != _ad) {
            ad.dispose();
            return;
          }
          setState(() => _loaded = true);
        },
        onAdFailedToLoad: (ad, error) {
          ad.dispose();
          // Keep the reserved height: collapsing now would be a layout shift.
          if (mounted && ad == _ad) setState(() => _ad = null);
        },
      ),
    );
    _ad = ad;
    await ad.load();
  }

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

  @override
  Widget build(BuildContext context) {
    final height = _height;
    if (height == null) return const SizedBox.shrink();
    final ad = _ad;
    return SafeArea(
      top: false,
      child: SizedBox(
        height: height.toDouble(),
        child: Center(
          child: _loaded && ad != null
              ? SizedBox(
                  width: ad.size.width.toDouble(),
                  height: ad.size.height.toDouble(),
                  child: AdWidget(ad: ad),
                )
              : null,
        ),
      ),
    );
  }
}

Einige Details, auf die es ankommt:

Die Verwendung ist eine Zeile im Scaffold:

// Flutter 3.44.8, google_mobile_ads 9.1.0
Scaffold(
  body: const ArticleList(),
  bottomNavigationBar: const AnchoredBannerSlot(
    adUnitId: 'ca-app-pub-3940256099942544/9214589741', // test unit
  ),
);

Schritt 3: Die Höhe speichern, damit der erste Frame stimmt

Schritt 2 hinterlässt beim Kaltstart noch eine Lücke von einem Frame: Der erste Frame mit echter Breite wird gerendert, bevor der Plattformkanal antwortet. In den Messungen betrug diese Lücke 9 bis 70 ms, was meist in die Zeit fällt, in der der erste Inhalt noch gezeichnet wird, aber es ist eine Verschiebung, und sie ist in Bildschirmaufnahmen sichtbar.

Die Lösung: Die verankerte Höhe ist eine reine Funktion von Gerät und Breite. Googles eigene Dokumentation sagt, dass die optimale Höhe “remains constant across different ad requests”. Die letzte Antwort des SDK ist also eine perfekte erste Schätzung für den nächsten Start:

// Flutter 3.44.8, shared_preferences 2.5.5
class BannerHeightCache {
  static const _prefix = 'admob.anchoredHeight.';
  static final Map<int, int> _heights = {};

  static Future<void> restore() async {
    final prefs = await SharedPreferences.getInstance();
    for (final key in prefs.getKeys().where((k) => k.startsWith(_prefix))) {
      final width = int.tryParse(key.substring(_prefix.length));
      final height = prefs.getInt(key);
      if (width != null && height != null) _heights[width] = height;
    }
  }

  static int? lookup(int width) => _heights[width];

  static void remember(int width, int height) {
    if (_heights[width] == height) return;
    _heights[width] = height;
    unawaited(
      SharedPreferences.getInstance()
          .then((prefs) => prefs.setInt('$_prefix$width', height)),
    );
  }
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await BannerHeightCache.restore();
  unawaited(MobileAds.instance.initialize());
  runApp(const MyApp());
}

Der Platz liest BannerHeightCache.lookup(width) bereits in didChangeDependencies, sodass beim zweiten Start der erste echte Frame mit einer 128-dp-Box gebaut wird und der Body direkt von 0 auf seine endgültigen 762,29 dp geht. Die Live-Abfrage läuft trotzdem weiter und überschreibt den zwischengespeicherten Wert, sodass eine falsche Schätzung (neue OS-Version, andere Anzeigegrößen-Einstellung) eine Verschiebung kostet und sich dann selbst korrigiert. Die In-Memory-Map hilft auch innerhalb einer Sitzung: Der zweite Bildschirm, der ein Banner hostet, erhält die Höhe synchron.

Versuchen Sie nicht, die Höhe selbst aus der obigen Tabelle zu berechnen. Das Verhältnis 100/320 und die Obergrenzen sind beobachtetes SDK-Verhalten, kein dokumentierter Vertrag, und Google hat die adaptive Größenberechnung schon früher geändert. Die Antwort des SDK zwischenzuspeichern liefert dieselbe Genauigkeit im ersten Frame, ohne auf eine Formel zu wetten.

Schritt 4: Entscheiden, was passiert, wenn keine Anzeige zurückkommt

Mit einem reservierten Platz hinterlässt ein fehlgeschlagener Ladevorgang einen leeren Streifen am unteren Rand. Sie haben zwei ehrliche Optionen:

  1. Den Streifen behalten. Das Layout bewegt sich nie, und die nächste Anfrage (bei Navigation, per Timer oder über die automatische Aktualisierung von AdMob, falls konfiguriert) füllt ihn. Das tut das obige Widget.
  2. Den Streifen einklappen. Sie erhalten den Platz zurück, zum Preis genau einer Layout-Verschiebung, und Sie müssen vor dem nächsten Versuch erneut reservieren.

Bei einem unten verankerten Banner bevorzuge ich die erste Variante. Nutzer nehmen einen leeren 128-dp-Streifen sehr schnell nicht mehr als “fehlend” wahr, aber sie hören nie auf, eine Liste zu bemerken, die ihnen unter dem Daumen wegspringt. Wenn Sie einklappen, animieren Sie es mit einer AnimatedSize, damit die Änderung wenigstens beabsichtigt wirkt.

Die Testanzeige war kleiner als der Platz

Eine weitere Sache, die die Probe zutage gefördert hat. Nach onAdLoaded meldete BannerAd.getPlatformAdSize() 411x64 für eine Anzeige, die mit 411x128 angefordert wurde. Das Test-Creative ist ein Banner in Standardhöhe. Die native View zentrierte es innerhalb der 128-dp-Anfrage, und das Rendern des AdWidget mit 128 dp oder mit der Plattformgröße sah identisch aus. Verkleinern Sie den Platz nach dem Laden nicht auf die Plattformgröße: Das wäre eine Layout-Verschiebung in die andere Richtung, und die nächste Aktualisierung liefert möglicherweise ein Creative in voller Höhe.

Inline-adaptive Banner in scrollenden Inhalten

Inline-adaptive Banner sind die andere adaptive Familie, gedacht für die Platzierung innerhalb eines Feeds. Ihre Höhe wird vom Server gewählt, daher liefert AdSize.getCurrentOrientationInlineAdaptiveBannerAdSize(width) eine Größe mit Höhe 0, und die echte Höhe erfahren Sie erst nach dem Laden aus getPlatformAdSize(). Googles Beispiel löst das, indem es bis zum Laden einen leeren Container() rendert und dann die Box dimensioniert, was jedes darunterliegende Element verschiebt.

Eine unbekannte Höhe lässt sich nicht reservieren, aber man kann sie begrenzen. Verwenden Sie AdSize.getInlineAdaptiveBannerAdSize(width, maxHeight) und reservieren Sie maxHeight:

// Flutter 3.44.8, google_mobile_ads 9.1.0
class InlineBannerSlot extends StatefulWidget {
  const InlineBannerSlot({super.key, required this.adUnitId, this.maxHeight = 250});

  final String adUnitId;
  final int maxHeight;

  @override
  State<InlineBannerSlot> createState() => _InlineBannerSlotState();
}

class _InlineBannerSlotState extends State<InlineBannerSlot> {
  BannerAd? _ad;
  AdSize? _platformSize;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_ad != null) return;
    final width = MediaQuery.sizeOf(context).width.truncate();
    if (width <= 0) return;
    _ad = BannerAd(
      adUnitId: widget.adUnitId,
      request: const AdRequest(),
      size: AdSize.getInlineAdaptiveBannerAdSize(width, widget.maxHeight),
      listener: BannerAdListener(
        onAdLoaded: (ad) async {
          final size = await (ad as BannerAd).getPlatformAdSize();
          if (mounted) setState(() => _platformSize = size);
        },
        onAdFailedToLoad: (ad, error) => ad.dispose(),
      ),
    )..load();
  }

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

  @override
  Widget build(BuildContext context) {
    final ad = _ad;
    final size = _platformSize;
    return SizedBox(
      height: widget.maxHeight.toDouble(),
      child: Center(
        child: ad != null && size != null
            ? SizedBox(
                width: size.width.toDouble(),
                height: size.height.toDouble(),
                child: AdWidget(ad: ad),
              )
            : null,
      ),
    );
  }
}

Der Kompromiss ist Letterboxing, wenn das Creative niedriger ist als maxHeight. Wenn das in Ihrem Design falsch aussieht, besteht die Alternative darin, die Anfrage lange vor dem Hineinscrollen des Elements zu starten (eine ListView baut Elemente innerhalb von cacheExtent des Viewports, standardmäßig 250 Pixel) und in Kauf zu nehmen, dass eine Verschiebung oberhalb des Viewports die Scroll-Position bewegt. Google warnt außerdem, dass Inline-Banner in Scroll-Views unter Android 9 und darunter schlecht performen können, während verankerte Banner davon unberührt sind.

Stolperfallen

Wenn Sie Banner auch aus .NET MAUI betreiben, gilt dieselbe Überlegung dort: Die MAUI-AdMob-Anleitung für Banner, Interstitials und Rewarded Ads verwendet die nativen Views, die dieselben APIs zur Größenbestimmung vor dem Laden besitzen.

Verwandte Artikel

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück