Edited at

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

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

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

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

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


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の選択基準があるんだから同じ基準で縦幅を決めればいい」と思うかもしれませんし、僕も思ったんですが、実際にやってみると書いてある基準と矛盾する広告が出るケースがあったので止めました。

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


できてないこと


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

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

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


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

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


使い方

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

FirebaseAdMob.instance.initialize(appId:yourAppId):

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

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

その後、

Navigatorを使うアプリの場合→AdmobBannerWidgetWithRoute

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

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

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


工夫した所


  • デフォルトのBannerAdを_SingleBannerでラップしてそれをシングルトンにすることで、BannerAdインスタンスが複数作られることを防止

  • 広告表示を「どのインスタンスからの指示で行ったか」を記録し、そこからのdisposeのみ受け付けることで、BannerAdのdisposeが何度も呼ばれることを防ぐ。

  • AnchorTypeは常にbottomしか使わず、RenderBoxでWidgetの表示位置を調べてAnchorOffsetで調整する。


    • AnchorType.topの方はBannerAdにとっての原点の位置が端末によってバラバラで調整不可能なため。

    • AnchorType.bottomの基準位置はSafeAreaの下端で固定なので、こちらを元に調整する。



  • 下記もAndroidでバッチリ発生したため対応。




ソースコード

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

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


admob_banner_widget.dart

// このコードを使用して発生したいかなる問題についても責任は負いません。

import 'dart:async';
import 'dart:ui';

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 =
window.viewPadding.top / MediaQuery.of(context).devicePixelRatio;
final double _viewPaddingBottom =
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 =
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;
}
}



余談

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

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

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

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