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 の後、onAdLoadedshowAd でガードしました。遅れて届いた広告は 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% にはまだ届いていません。それでも旧主因が消えたことは確認できたので、この対応はクローズにしました。やっと安心してビールが飲めます。

ユーザーが認識した ANR 率の比較。修正前の全体 4.41%、公開直後の全体 4.32%、旧バージョン群 2.3〜4.7% に対し、新バージョンは 0.65%。Google のしきい値は 0.47%
図: Play Console の「ユーザーが認識した ANR 率」。全体率は旧版ユーザーが残るため下がって見えず、新バージョン単体では 0.65% まで下がった

副産物: 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 は、それを教えてくれる数少ない窓です。これからはもう少しまめに見ます。たぶん。