Help us understand the problem. What is going on with this article?

Flutterのfirebase_admobプラグインのバナー広告が使いにくいのでなんとかする

firebase_admobプラグインのバナー広告は使いにくい

このプラグインです。
https://pub.dev/packages/firebase_admob

これのBanner広告が使いにくいんです。公式なのに。

Banner以外の、InterstitialとRewardedVideoは、読み込み失敗時の処理とかを間違えなければ普通に使えると思うんですけどもね。

バナー広告がとにかく使いにくい。

使いにくい所

  • 自由に出したり消したりしにくい
  • 同じBannerAdインスタンスのdisposeメソッドを二度呼ぶと挙動がおかしくなる
  • おかげで「とにかく出てる広告全部消す」みたいなことができない
  • 全てのWidgetをガン無視して、画面一番手前にバンッと出る
  • おかげでレイアウトがしにくい
  • bottomに固定して出す以外、狙った位置に出せない
  • topに固定して出す機能も用意されているが、おかしな位置に出るので使えない
  • AnchorOffsetで調整しようにもその値をどうしたらいいかわからない

作ったWidget「AdmobBannerWidget」

ということで、狙った位置に狙ったタイミングでバナー広告を出したり消したりするためのWidgetを作りました。

ソースコードは一番下に置いておきます。Qiitaの規約上、勝手に自由に使って良いはずです。

こちらの画像では、広告を表示するためのスペースを黄色で表しています。

admob_widget_record.gif

特徴

Widgetが表示されるまさにその位置に広告が出る

AdmobBannerWidgetのbuildメソッドは単にContainerを返すだけですが、そのContainerの上にピッタリ重なって、同じ縦幅を持つ広告が出ます。

Widgetの縦幅と広告の縦幅が一致しているのでレイアウトしやすい

ボタンと重なってしまう、みたいな現象を防ぎます

WidgetがWidgetツリーから取り除かれると同時に広告も消える

広告そのものがWidgetの中にあるかのように扱えます。別途広告を消す処理を書かなくていい。

Navigatorに対応。別のページが上からpushされると広告も消える

この場合はAdmobBannerWidgetWithRouteの方を使ってください。(後述)

本体の向きの変化にも対応

本体を回転するなどして状況が変わると、まず広告を消し、新たな状況に合わせた位置に改めて出します。

画面遷移を連打してもクラッシュしない

連続でこのWidgetをdisposeしても大丈夫です。

広告サイズはSafeAreaの縦幅の1/8以内で表示可能な1番大きい物が出る

AdmobBannerのSmartBannerの基準を真似しました。

https://developers.google.com/admob/android/banner?hl=ja#smart_banners
https://developers.google.com/admob/ios/banner?hl=ja#smart_banners

でもSmartBannerを使っているわけではないです。大きさの判断基準の参考にしただけ。

SmartBannerを使わない理由は、バナーの縦幅をハッキリさせたかったからです。

「SmartBannerの選択基準があるんだから同じ基準でWidgetの縦幅を決めれば、SmartBannerを使っても問題ないはずだ」と思うかもしれませんし、僕も思ったんですが、実際にやってみると書いてある基準と矛盾する広告が出るケースがあったので止めました。

ちなみに、スマートフォンだと多くの場合最小のBannerという大きさのモノが出ます。iPhoneXsMaxの縦持ちだとLargeBannerが出ます。

できてないこと

Widgetの移動に合わせて広告が移動したりはしません。

出したり消えたりするだけです。

リストの一部に組み込んでスクロール、みたいなことはできません。

裏で出しっぱなしにする、みたいなこともできません。

あくまでWidgetの位置を計算して、同じ位置の最前面に出るだけです。隠せません。

使い方

まずアプリの起動処理のどこかで、

FirebaseAdMob.instance.initialize(appId:yourAppId):

を実行しておく必要があります。

yourAppIdはそれぞれのアプリに対応するものを設定してください。

その後、

Navigatorを使うアプリの場合→AdmobBannerWidgetWithRoute
Navigatorを使わないアプリの場合→AdmobBannerWidget

を、広告を出したい位置に配置してください。

Navigatorを使ってないアプリでAdmobBannerWidgetWithRouteを使うと、内部で呼んでいるNavigator.of(context)がエラーになります

※2019-10-20追記
※MaterialAppを使用している場合、明示的に書かなくてもNavigatorを使用していることになります。
※Navigatorを使うアプリの場合、RouteObserverの設定が必要です。RouteObserverインスタンスをMaterialAppの引数に与えればOK。

MaterialApp(
  navigatorObservers: <NavigatorObserver>[
    RouteObserver<PageRoute<dynamic>>()
  ],
  home: Scaffold(appBar: AppBar(), body: MyBody()),
);

工夫した所

  • デフォルトのBannerAdを_SingleBannerでラップしてそれをシングルトンにすることで、BannerAdインスタンスが複数作られることを防止
  • 広告表示を「どのインスタンスからの指示で行ったか」を記録し、そこからのdisposeのみ受け付けることで、BannerAdのdisposeが何度も呼ばれることを防ぐ。
  • AnchorTypeは常にbottomしか使わず、RenderBoxでWidgetの表示位置を調べてAnchorOffsetで調整する。
    • AnchorType.topの方はBannerAdにとっての原点の位置が端末によってバラバラで調整不可能なため。
    • AnchorType.bottomの基準位置はSafeAreaの下端で固定なので、こちらを元に調整する。
  • 下記もAndroidでバッチリ発生したため対応。

ソースコード

重要ポイントにはコメントも付けました。

しっかし200行もあるな…。もっとシンプルにできるだろうな。きっと。

修正(2019-10-27)

import dart:ui;としてwindowというトップレベルプロパティを使っている部分があったのですが、これはしないべきだと公式に書いてあります。
代わりにWidgetsBinding.instance.windowなどを使うべき。
https://api.flutter.dev/flutter/dart-ui/window.html

下記ソースコードは修正済みです。

ソースコード

admob_banner_widget.dart
// このコードを使用して発生したいかなる問題についても責任は負いません。
import 'dart:async';

import 'package:firebase_admob/firebase_admob.dart';
import 'package:flutter/material.dart';

// BannerAdを2回以上disposeしないためのクラス。
// Singletonにすることで、間接的にBannerAd自体のインスタンスも一度に一つしか存在しないことを保証する。
// BannerAdをdisposeしたら、必ず、次をセットするかnullにする。
class _SingleBanner {
  factory _SingleBanner() {
    _instance ??= _SingleBanner._internal();
    return _instance;
  }
  _SingleBanner._internal();
  static _SingleBanner _instance;

  BannerAd _bannerAd;
  int _ownerHashCode; // 現在の所有者インスタンスは誰かを表す

  void show({
    @required int callerHashCode,
    @required String adUnitId,
    @required AdSize size,
    @required double anchorOffset,
    @required bool isMounted,
  }) {
    _bannerAd?.dispose(); // disposeしたら、必ず、次をセットするかnullにする。
    _bannerAd = BannerAd(
      adUnitId: adUnitId,
      size: size,
      listener: (MobileAdEvent event) {
        // loadが完了してからしかshowが呼ばれないようにリスナー登録
        // こうしないと、showを呼んでからロードが実際に完了するまでの間に画面が変化すると広告が消せなくなる
        if (event == MobileAdEvent.loaded) {
          if (isMounted) {
            _bannerAd.show(anchorOffset: anchorOffset);
          } else {
            _bannerAd = null;
          }
        }
      },
    );
    _ownerHashCode = callerHashCode;
    _bannerAd.load();
  }

  void dispose({@required int callerHashCode}) {
    // 最後に広告を生成したインスタンスが所有権を持ち、そこからしかdisposeできない。
    // 別のインスタンスが新たに広告生成を行った場合、所有権を失う。
    if (callerHashCode == _ownerHashCode) {
      _bannerAd?.dispose(); // disposeしたら、必ず、次をセットするかnullにする。
      _bannerAd = null;
    }
  }
}

// Navigatorを使わない場合はこちらを使ってください
class AdmobBannerWidget extends StatefulWidget {
  // Stateを外から挿入できるようにしておきます。挿入されなければ、普通にここで新しく作る。
  // Route使うバージョンの方で、外からこのStateにアクセスできるようにするため。
  const AdmobBannerWidget({_AdmobBannerWidgetState admobBannerWidgetState})
      : _admobBannerWidgetState = admobBannerWidgetState;
  final _AdmobBannerWidgetState _admobBannerWidgetState;

  @override
  _AdmobBannerWidgetState createState() =>
      _admobBannerWidgetState ?? _AdmobBannerWidgetState();
}

class _AdmobBannerWidgetState extends State<AdmobBannerWidget> {
  Timer _timer;
  double _bannerHeight;
  AdSize _adSize;
  // Navigatorスタックの最上位にいるのかどうかを示すフラグ
  bool isTop = true;

  void _loadAndShowBanner() {
    assert(_bannerHeight != null);
    assert(_adSize != null);
    _timer?.cancel();
    // Widgetのレンダリングが完了してなければ位置がわからないので、広告を表示しません。
    // レンダリングが完了するまでタイマーで繰り返します。
    _timer = Timer.periodic(Duration(seconds: 1), (Timer _thisTimer) async {
      final RenderBox _renderBox = context.findRenderObject();
      final bool _isRendered = _renderBox.hasSize;
      if (_isRendered) {
        _SingleBanner().show(
          isMounted: mounted,
          anchorOffset: _anchorOffset(),
          // TODO: 各自の広告IDに変更する必要があります。
          adUnitId: BannerAd.testAdUnitId,
          callerHashCode: hashCode,
          size: _adSize,
        );
        _thisTimer.cancel();
      }
    });
  }

  // ノッチとかを除いた範囲(SafeArea)の縦幅の1/8以内で最大の広告を表示します。
  // 広告の縦幅を明確にしたいのでSmartBannerは使いません。
  void _determineBannerSize() {
    final double _viewPaddingTop =
        WidgetsBinding.instance.window.viewPadding.top / MediaQuery.of(context).devicePixelRatio;
    final double _viewPaddingBottom =
        WidgetsBinding.instance.window.viewPadding.bottom / MediaQuery.of(context).devicePixelRatio;
    final double _screenWidth = MediaQuery.of(context).size.width;
    final double _availableScreenHeight = MediaQuery.of(context).size.height -
        _viewPaddingTop -
        _viewPaddingBottom;
    if (_screenWidth >= 728 && _availableScreenHeight >= 720) {
      _adSize = AdSize.leaderboard;
      _bannerHeight = 90;
    } else if (_screenWidth >= 468 && _availableScreenHeight >= 480) {
      _adSize = AdSize.fullBanner;
      _bannerHeight = 60;
    } else if (_screenWidth >= 320 && _availableScreenHeight >= 800) {
      _adSize = AdSize.largeBanner;
      _bannerHeight = 100;
    } else {
      _adSize = AdSize.banner;
      _bannerHeight = 50;
    }
  }

  // ノッチとかを除いた範囲(SafeArea)の下端を基準に、
  // このWidgetが論理ピクセルいくつ分だけ上に表示されているか計算します
  double _anchorOffset() {
    final RenderBox _renderBox = context.findRenderObject();
    assert(_renderBox.hasSize);
    final double _y = _renderBox.localToGlobal(Offset.zero).dy;
    final double _h = _renderBox.size.height;
    // viewPaddingだけ何故かMediaQueryで取得すると0だったので、windowから直接取得
    // 物理ピクセルが返るのでdevicePicelRatioで割って論理ピクセルに直す
    final double _vpb =
        WidgetsBinding.instance.window.viewPadding.bottom / MediaQuery.of(context).devicePixelRatio;
    final double _screenHeight = MediaQuery.of(context).size.height;
    return _screenHeight - _y - _h - _vpb;
  }

  @override
  Widget build(BuildContext context) {
    // 広告のスペースを確保するためのContainer。
    // TODO: 背景色を変えるなりSizedBoxにするなり、アプリに合わせて変更してください。
    return Container(height: _bannerHeight, color: Colors.yellow);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // MediaQueryの変化を受けて呼ばれる。pushやpop、本体の回転でも呼ばれる。
    // 変更を検知したらまず即座に広告を消す。
    disposeBanner();
    if (isTop) {
      _determineBannerSize();
      _loadAndShowBanner();
    }
  }

  @override
  void dispose() {
    disposeBanner();
    super.dispose();
  }

  void disposeBanner() {
    _SingleBanner().dispose(callerHashCode: hashCode);
    _timer?.cancel();
  }
}

// Navigatorを使用する場合はこちらを使用してください。
class AdmobBannerWidgetWithRoute extends StatefulWidget {
  const AdmobBannerWidgetWithRoute();
  @override
  _AdmobBannerWidgetWithRouteState createState() =>
      _AdmobBannerWidgetWithRouteState();
}

class _AdmobBannerWidgetWithRouteState extends State<AdmobBannerWidgetWithRoute>
    with RouteAware {
  final _AdmobBannerWidgetState _admobBannerWidgetState =
      _AdmobBannerWidgetState();
  AdmobBannerWidget _admobBannerWidget;
  RouteObserver<dynamic> _routeObserver;

  @override
  void initState() {
    super.initState();
    _admobBannerWidget =
        AdmobBannerWidget(admobBannerWidgetState: _admobBannerWidgetState);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Observerが一つじゃない場合、firstでいいのかどうか判断・変更する必要アリ
    _routeObserver = Navigator.of(context).widget.observers.first;
    _routeObserver.subscribe(this, ModalRoute.of(context));
  }

  @override
  void dispose() {
    assert(_routeObserver != null);
    _routeObserver.unsubscribe(this);
    super.dispose();
  }

  @override
  void didPushNext() {
    // AdmobBannerWidgetState経由で呼ばないと、
    // callerHashCodeに入るのがAdmobBannerWidgetWithRouteStateのものになり不整合
    _admobBannerWidgetState.disposeBanner();
    _admobBannerWidgetState.isTop = false;
  }

  @override
  void didPopNext() {
    _admobBannerWidgetState.isTop = true;
    _admobBannerWidgetState._determineBannerSize();
    _admobBannerWidgetState._loadAndShowBanner();
  }

  @override
  void didPop() {
    _admobBannerWidgetState.disposeBanner();
  }

  @override
  Widget build(BuildContext context) {
    return _admobBannerWidget;
  }
}

余談

こういうのってプラグインにして公開するといいんですかね?

プラグインの作り方はまったく勉強していないな。簡単にできるのかしら。

ただ、どちらかと言うと、プラグインにしてメンテナンスしていくというよりは、同じ問題を抱えてる人に、方法論を参考にしてもらえればそれで良いです。

…その割には複雑になってしまいました。「こうすれば簡単に解決できるよ〜〜」みたいに終わる予定だったんですけどね〜〜〜〜〜

ここ一週間、仕事と生活以外の時間はほとんどこれに費やしていました。(執筆日2019-09-24)

おかげでFlutterの理解が結構進みました。

下記リンク先あたりの時期に並んでるツイートが全部それ関連です。ひぇ〜〜

https://twitter.com/search?q=from%3Aagajo_tech%20until%3A2019-09-24&src=typed_query&f=live

agajo
あんなに勉強して、親に高い予備校代も出してもらって東大に入り、卒業したのに、今では家と食事を親に頼りながら、年金と住民税を払うためにトイレ掃除をしている者です。
https://portal.oka-ryunoske.work/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした