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

# (2020-12-21・2021-01-07追記)

いろいろ改良を加えて、パッケージ化しました！！是非お使いください！

https://pub.dev/packages/admob_banner_stabilizer

なお、いろいろ改良した結果、この記事の説明はパッケージの説明と整合しておりませんので、使う場合はパッケージの方の説明をお読みください。

例えば、パッケージの方には`AdmobBannerWidgetWithRoute`は入っておらず、[AdMobBannerWidget](https://pub.dev/documentation/admob_banner_stabilizer/latest/admob_banner_stabilizer/AdMobBannerWidget-class.html)に統合されております。


#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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/05e8e6cb-0aec-1f3c-aa49-29238347fe6c.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の位置を計算して、同じ位置の最前面に出るだけです。隠せません。

#使い方

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

```dart
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。

```dart:例
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でバッチリ発生したため対応。
     -  [【Flutter】firebase_admobでdisposeしても広告が消えない](https://qiita.com/Karibash/items/b72e72f5b5fca28a3e42)


#ソースコード
重要ポイントにはコメントも付けました。

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

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

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

##ソースコード

```dart: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;
  }
}

```

#余談

(2020-12-21追記)パッケージ化して公開しました！
https://pub.dev/packages/admob_banner_stabilizer

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

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

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

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




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

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

 - [Stateのライフサイクル](https://api.flutter.dev/flutter/widgets/State-class.html)
 - [RenderObject・RenderBox](https://api.flutter.dev/flutter/rendering/RenderBox-class.html)
     - [FlutterでWidgetのグローバル座標を取得する](https://qiita.com/niusounds/items/deffed961127755e97a8)
 - [SafeArea・Padding・viewPadding・viewInset](https://api.flutter.dev/flutter/dart-ui/Window-class.html)
 - [logical pixel・devicePixelRatio](https://api.flutter.dev/flutter/widgets/MediaQueryData/devicePixelRatio.html)
 - [RouteObserver・RouteAware](https://api.flutter.dev/flutter/widgets/RouteObserver-class.html)
     - [Flutterで画面の遷移のタイミングで処理を行う](https://qiita.com/najeira/items/89d8014a30102204babb)
 - GestureRecognizerなどによるエラーキャッチ
 - 一般的なasync関数のエラーハンドリング
     - [async/awaitがモナドのDo記法と同じっぽいので検証。throwとcatchもモナドに対応させる](https://qiita.com/agajo/items/5adf2a4ad28ee73659c5)

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

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