AUSamplerクラッシュとの戦い — オーディオ再初期化を整理する

| 開発記録 | ピアノ

タグ: #iOS #オーディオ #クラッシュ対応

Crashlytics を入れたら、AUSampler のクラッシュが一気に見えるようになりました。電話着信や Bluetooth 切替のたびに再初期化が連鎖していたのが原因です。旧サウンドフォントのアンロード、再初期化回数の上限、トリガを中断イベントだけに絞る。この三段構えで落ち着くまでの記録です。

ある日突然のクラッシュ通報

Firebase Crashlytics を導入したら、AUSampler 周りのクラッシュが一気に可視化されました。増えたのではありません。今まで見えていなかっただけです。ちょっと落ち込みました。

報告の多くは「電話着信→アプリ復帰」「Bluetooth デバイス切替」「割り込み発生」でした。どれもオーディオセッションが中断されるシチュエーションです。

原因仮説: 再初期化が走りすぎている

アプリは中断(AVAudioSession.interruptionNotification)を受け取ると、オーディオエンジンを再初期化する処理を持っていました。ところが特定の機種・特定の OS バージョンで、再初期化が連鎖的に走るケースが見つかりました。

さらに、旧サウンドフォントを解放しないまま新しいフォントを読み込み続けるとどうなるか。メモリが肥大化して、最終的に AUSampler がクラッシュします。こちらも確認できました。

対策 1: 旧サウンドフォントを明示的にアンロード

中断ハンドラ自体は Swift 側で次のように書いています。.began では何もせず、.ended でかつ shouldResume が真のときだけ再開する。ここが鉄則です。

@objc private func handleInterruption(_ note: Notification) {
  guard
    let info = note.userInfo,
    let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
    let type = AVAudioSession.InterruptionType(rawValue: raw)
  else { return }

  switch type {
  case .began:
    audioEngine.pause()
  case .ended:
    let optsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
    let options = AVAudioSession.InterruptionOptions(rawValue: optsRaw)
    guard options.contains(.shouldResume) else { return }
    try? AVAudioSession.sharedInstance().setActive(true)
    reinitializeSamplerIfAllowed()
  @unknown default:
    break
  }
}

再初期化のたびに、AUSampler.loadSoundBankInstrument() の前で明示的に旧フォントをアンロードする処理を入れました。これだけでメモリ使用量がぐっと下がります。

対策 2: 再初期化回数に上限を設ける

Dart 側にも上限ロジックを置きました。短時間に連続して走らないようガードします。Stopwatch で経過時間を測り、一定時間内に閾値を超えたら、以後の再初期化要求は黙って捨てます。

class AudioReinitGuard {
  static const _windowMs = 10000; // 10秒間
  static const _maxAttempts = 3;
  final _stopwatch = Stopwatch()..start();
  int _attempts = 0;

  bool tryReinit() {
    if (_stopwatch.elapsedMilliseconds > _windowMs) {
      _stopwatch.reset();
      _attempts = 0;
    }
    if (_attempts >= _maxAttempts) {
      _logSkipped();
      return false;
    }
    _attempts++;
    return true;
  }

  void _logSkipped() {
    FirebaseCrashlytics.instance.log('Skip reinit: too many attempts');
  }
}

短時間に何度も再初期化が走るなら、根本原因は別にあります。上限値を設けて、超えたらスキップ。あとはログ収集側で根本原因を追跡できるようにしました。

対策 3: 中断イベント限定でトリガを整理

そもそも「いつ再初期化を走らせるか」が曖昧でした。これが一番の問題です。コミット履歴を辿ると、当初はバックグラウンド遷移時にも再初期化していました。ぜんぜん過剰です。

中断イベント(割り込み終了)に限定し、それ以外のタイミングでは再初期化しない方針に整理しました。余計な再初期化が走らなくなり、AUSampler のクラッシュは大幅に減りました。

Crashlytics で見える化

クラッシュ報告そのものの数は、Crashlytics の導入前後で比べるとはっきり減っています。「直したつもり」が一番こわい。可視化できる仕組みは、もっと早く入れておくべきでした。

教訓

  • 解放と確保はペアで考える。とくにネイティブリソース
  • 「念のため」の再初期化は、積み重なると害になる
  • Crashlytics は早めに入れる。リリース前から仕込む

ピアノアプリは累計 700 万ダウンロードを超えています。想定外のデバイス、想定外の OS で動いています。コードが「正しい」だけでは足りません。あらゆる中断状況で生き残るコードでないと、レビューになって戻ってきます。