ANR 率が品質基準の 9 倍 — 犯人は runApp() の前で await していた AdMob 初期化だった
| 開発記録 | ピアノ
タグ: #Flutter #Android #ANR #AdMob #google_mobile_ads #Play Console #個人開発
Play Console の ANR 率が 4.41%、Google のしきい値の約 9 倍でした。犯人は main() で AdMob 初期化を await して first frame を遅らせていた 1 行。「開始」と「完了待ち」を分ける AdsInitializer で直し、レビューで見つかった 3 つの穴を塞ぎ、公開後はバージョン別 ANR 率 0.65% で効果を確かめるまでの記録です。
Play Console を開いたら ANR 率が 4.41% だった
ピアノアプリ(録音機能つき)の Play Console を開いたら、「ユーザーが認識した ANR 率」が 4.41% でした。Google のしきい値は 0.47%。約 9 倍です。正直、震えました。
この数字を超えたままだと、ストアの表示順位に影響すると案内されています。それ以前に、直近 28 日で数十人のユーザーがアプリの固まりを体験している計算です。申し訳なさが先に来ました。
クラッシュなら Crashlytics にスタックトレースが上がってきます。ANR は違います。「メインスレッドが 5 秒応答しなかった」というだけの現象で、何が悪いのかはダンプを読むしかありません。
犯人は main() に書いた 1 行の await でした。この記事は、それを見つけて、「開始」と「完了待ち」を分ける AdsInitializer で直して、公開後にバージョン別の ANR 率で効いたことを確かめるまでの記録です。
ダンプの main スレッドは「アイドル」だった
Play Console の ANR 画面でクラスタを見ました。全体の約 95% が nativePollOnce を先頭にした「Input dispatching timed out (No focused window)」ファミリーです。ダンプを開いても、main スレッドは MessageQueue.nativePollOnce で待っているだけ。重い処理はどこにもありません。
「main スレッドが忙しくないのに ANR」。ここが最初の引っかかりでした。
ヒントはメッセージ後半の No focused window です。これは「タップなどの入力が届いたのに、受け取るフォーカス済みウィンドウがまだ無い」状態が 5 秒続くと出ます。つまり、アプリは起動しているのに最初の画面がまだ描画されていない。その空白の間にユーザーが画面を触った、という筋書きです。
発生端末は RAM 2〜4GB 帯のエントリー機に集中していました。ハイエンド端末なら一瞬で終わる処理が、低スペック端末では数秒かかる。空白が 5 秒を超える。そう考えると辻褄が合います。
犯人: runApp() の前で AdMob 初期化を待っていた
修正前の main() はこうです。Firebase と AdMob を並列にしたから速いはず、と思い込んで書いたコードです(ぜんぜん速くなかった)。
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 画面設定を先に実行(軽量、UI に必須)
await Future.wait([
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]),
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
]);
// Firebase と AdMob を並列初期化(最大のボトルネック)
await Future.wait([
Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform),
MobileAds.instance.initialize(),
]);
runApp(const ProviderScope(child: MyApp()));
}
並列にはなっています。でも await Future.wait である以上、遅いほうが終わるまで runApp() は呼ばれません。そして MobileAds.instance.initialize() は見た目以上に重い処理です。Android では広告 SDK の初期化に WebView プロセスの起動が含まれます。エントリー機だと数秒かかります。
その間、Flutter の最初のフレームは描画されません。ネイティブ側にはフォーカスを持つウィンドウが無い。そこでユーザーが画面をタップすると「No focused window」で ANR。これが全部の流れでした。
コメントに「最大のボトルネック」と自分で書いています。書いておいて、first frame の前に置いたままでした。1 年前の自分のコードが気に入らないのはいつものことですが、これは気に入らないというより恥ずかしい。
修正: 「開始」と「完了待ち」を分離する
方針はシンプルです。AdMob の初期化は main() で開始だけする。完了は、広告を実際にロードする側で待つ。
ただし MobileAds.instance.initialize() の Future をグローバル変数に放り込むだけだと、二重開始や失敗時の扱いが曖昧になります。なので小さなクラスに閉じ込めました。
/// AdMob SDK の初期化を「開始」と「完了待ち」に分離して一元管理する。
/// main() では start() で開始だけ行い、広告ロード側が whenInitialized で完了を待つ。
class AdsInitializer {
AdsInitializer._();
static Future<void>? _future;
/// テストから差し替えられるようにした実初期化処理
@visibleForTesting
static Future<void> Function() initializeFn = () async {
await MobileAds.instance.initialize();
};
/// 初期化を開始する(main() から呼ぶ)。二重開始は無視される
static void start() {
_future ??= _beginInitialization();
}
/// 初期化完了を待つ。start() 未呼び出しでも自己回復的に開始する
static Future<void> get whenInitialized => _future ??= _beginInitialization();
/// 失敗時はキャッシュを破棄し、次回の whenInitialized で再試行できるようにする
static Future<void> _beginInitialization() {
late final Future<void> future;
future = () async {
try {
await initializeFn();
} catch (_) {
if (identical(_future, future)) {
_future = null;
}
rethrow;
}
}();
return future;
}
}
main() 側は「開始だけ」になります。Firebase の runApp() 前の await は残しました。Crashlytics のエラーハンドラ登録が Firebase に依存するためです。Firebase の初期化は AdMob と違って WebView を起動しないので、ここの待ちは許容範囲と判断しました。
// AdMob 初期化は開始のみ(await しない)。
// WebView プロセス起動を含み低スペック端末で数秒かかるため、first frame を
// ブロックすると ANR「Input dispatching timed out (No focused window)」になる。
// 広告ロード側(banner / rewarded)が AdsInitializer.whenInitialized で完了を待つ
AdsInitializer.start();
// Firebase は Crashlytics のエラーハンドラ登録が依存するため runApp 前に await
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const ProviderScope(child: MyApp()));
広告をロードする側では、リクエストの直前に完了を待ちます。タイムアウトは念のためです。超過したら、既存の「ロード失敗 → 60 秒後にリトライ」の経路にそのまま乗ります。
Future<void> _loadBannerAd() async {
// SDK 初期化の完了を待つ(main() では開始しかしていない)
await AdsInitializer.whenInitialized.timeout(const Duration(seconds: 15));
// await の後は必ず mounted を再確認。dispose 後に広告リクエストを飛ばさない
if (!mounted) {
_isLoadingAd = false;
return;
}
// ... BannerAd.load() へ
}
レビューで見つかった 3 つの穴
実装コミットを積んでからコードレビューにかけました。返ってきたのは P2 が 3 件。どれも「初期化の完了時点が main() から広告ロード側へ動いた」ことで、新しく生まれた問題です。直したつもりで穴を 3 つ掘っていました。
1. テストデバイス ID の設定が初期化開始より後になっていた
テストモードでは MobileAds.instance.updateRequestConfiguration() でテストデバイス ID を登録します。これを AdsInitializer.start() の後に呼ぶ形になっていました。初期化がバックグラウンドで進むようになった結果、最初の広告リクエストがデフォルト設定で走る可能性があります。設定コードを start() より前へ移動しました。
2. 初期化の失敗が恒久的にキャッシュされていた
最初の実装では、失敗した Future も _future にそのまま残っていました。するとバナーやリワードのリトライ経路が whenInitialized を待つたびに同じ失敗を受け取ります。二度と初期化に到達できません。上のコードで catch の中で _future = null に戻しているのがその対策です。identical で「自分がキャッシュされている Future か」を確認してから破棄しています。
3. 初期化待ちの間に Widget / Provider が破棄される窓が広がった
以前は広告ロードの await は GDPR 同意チェックくらいでした。そこに最大 15 秒の初期化待ちが加わりました。その間に画面遷移で dispose() が走ると、破棄済みのオブジェクトに広告が届いてリークします。リワード広告のサービスに _disposed フラグを入れ、loadAd の入口、各 await の後、onAdLoaded、showAd でガードしました。遅れて届いた広告は ad.dispose() して捨てます。
この 3 件は、それぞれ独立したコミットで積んでいます。「実装」と「レビュー由来の修正」を git 履歴で分けておくと、あとで何が問題だったかを辿りやすいからです。
AdsInitializer にはユニットテストを 6 本付けました。start() を二重に呼んでも初期化は 1 回だけ。whenInitialized を何度待っても 1 回だけ。start() なしで whenInitialized が自己開始する。失敗はキャッシュされず、次回で再試行される。initializeFn をテストから差し替えられるようにしたのは、最後のケースを再現するためです。
公開後の効果判定は「バージョン別の率」で見る
修正を含むバージョンを両ストアに提出しました。Play では段階公開を経て、3 日後に 100% へ拡大。ここで一つだけ決めていたことがあります。全体の ANR 率で判定しない。
公開直後の全体 ANR 率は 4.32% でした。ほとんど下がっていません。一瞬、直っていないのかと思いました。でも内訳を見ると、旧バージョンのユーザーが数万人規模で残っていて、そちらの率が 4.7% 台。新バージョンはまだ配信数が少なく、上位に出てきません。全体率は 28 日移動平均です。旧版の遺産で数週間は高止まりして見えます。
代わりに見たのは 2 つです。
- クラスタ画面: 旧主因だった「No focused window」の大型クラスタが解体され、アーカイブ側に数人分が残るだけになった。アクティブ側は「1 ユーザー・1 イベント」の単発ばかりで、系統的な問題は消えた
- アーティファクト(バージョン)別の ANR 率: 他のバージョンが 2.3〜4.7% のなか、新バージョンが 1% 未満なら成功。この基準を事前に決めておいた
100% 公開から数日後、新バージョンの ANR 率は 0.65% でした。基準の 1% を下回りました。修正前の 4.4% から見れば、だいぶ改善です。Google のしきい値 0.47% にはまだ届いていません。それでも旧主因が消えたことは確認できたので、この対応はクローズにしました。やっと安心してビールが飲めます。
副産物: sqlite3 がメインアイソレートで動いていた
クラスタを一つずつ潰す過程で、別の小さなクラスタも見つけました。sqlite3_step で止まっている ANR が 1 件。原因は Drift のデータベース接続を NativeDatabase(file) で開いていたことです。これはクエリをメインアイソレートで実行します。録音データの読み書きが重なると、ごく稀にメインスレッドを塞ぎます。
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'piano_app.sqlite'));
// NativeDatabase.createInBackground(file) に切り替えれば
// クエリが別アイソレートで走り、メインスレッドを塞がなくなる
return NativeDatabase(file);
});
}
こちらは件数が少ないので、今回のリリースには含めていません。次回以降に NativeDatabase.createInBackground(file) へ切り替える候補として記録してあります。ANR ダンプを読む習慣がつくと、こういう「今は小さいけど構造的に良くない」ものも拾えるようになります。
学び
main()のawaitは全部 first frame を遅らせる。並列化してもFuture.waitで待つ限り、遅いほうがボトルネックのまま残る。runApp()の前に置いていいのは、最初の画面が本当に依存するものだけ- ANR の「No focused window」は重い処理ではなく「画面がまだ無い」サイン。main スレッドがアイドルなダンプを見たら、起動シーケンスを疑う
- 「開始」と「完了待ち」を分けると、重い初期化を画面描画の裏に隠せる。ただし完了待ちの位置が動くと、設定順序・失敗のキャッシュ・破棄タイミングという新しい問題が生まれる。そこはレビューに見てもらう
- 効果判定は全体率ではなくバージョン別で。28 日移動平均の全体率は旧版に引きずられて数週間動かない。「どの数字が何%なら成功か」を先に決めておくと、公開後に迷わない
「並列にしたから速い」と思い込んでいたコードが、エントリー機のユーザーには数秒の空白として体験されていました。ハイエンド端末で開発していると、ぜんぜん気付けない種類の問題です。Play Console の vitals は、それを教えてくれる数少ない窓です。これからはもう少しまめに見ます。たぶん。