電話着信後にピアノが鳴らなくなる問題をネイティブ実装で解決
| 開発記録 | ピアノ
タグ: #iOS #ネイティブ #AVAudioSession
「電話に出た後にアプリに戻ると音が鳴らなくなる」。ピアノアプリのレビューに定期的に届く報告でした。プラグインの設定では直らず、最終的に iOS ネイティブで中断通知を購読して、割り込み終了後に setActive(true) を呼び直すところで決着します。Method Channel で Dart 側につないだ記録です。
レビューに繰り返し現れる「音が出ない」報告
「電話に出た後にアプリに戻ると音が鳴らなくなる」「Bluetoothを切り替えると無音になる」。ピアノアプリのレビューに、似たような報告が定期的に届いていました。
再現は意外と簡単でした。演奏中に電話を受けて切る。それだけで、確かに音が鳴らなくなります。自分の端末であっさり再現したときは、ちょっと申し訳なくなりました。
Flutter プラグインの限界
当初はオーディオ系プラグインの設定で何とかしようとしました。AVAudioSession の category を .playback にする。.mixWithOthers を外す。ひととおり試しましたが、根本解決には至りません。
プラグインが提供する API では、AVAudioSession.interruptionNotification の .ended 通知を受け取ってからの「適切な再開処理」が抜けていたのです。
ネイティブ実装で AVAudioSession を直接扱う
Swift 側のコアは一点に集約されます。中断通知を購読し、.ended の shouldResume を見て setActive(true) を呼び直す。それだけです。下記はその最小実装。
final class AudioInterruptionListener {
private let channel: FlutterMethodChannel
init(channel: FlutterMethodChannel) {
self.channel = channel
NotificationCenter.default.addObserver(
self,
selector: #selector(onInterruption(_:)),
name: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance()
)
}
@objc private func onInterruption(_ note: Notification) {
guard
let info = note.userInfo,
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: raw)
else { return }
if type == .ended {
try? AVAudioSession.sharedInstance().setActive(true)
channel.invokeMethod("interruptionEnded", arguments: nil)
} else {
channel.invokeMethod("interruptionBegan", arguments: nil)
}
}
}
最終的に、iOS 側にネイティブコード(Swift)を書き、AVAudioSession.interruptionNotification をリッスンする実装を入れました。
割り込み終了時の処理として:
AVAudioSession.sharedInstance().setActive(true)を呼び直す- オーディオエンジンを再起動
- Flutter 側にイベントを通知して、UI 状態を整合させる
の 3 段階を実装しました。
Method Channel で Dart 側と連携
MethodChannel('piano/audio_interrupt') を作り、ネイティブからの通知を Dart 側で受け取れるようにしました。Dart 側ではこれを購読し、Provider 経由で UI に伝えています。
Dart 側のラッパーはシンプルです。Method Channel のハンドラを setMethodCallHandler で登録するだけ。
class AudioInterruptBridge {
static const _channel = MethodChannel('piano/audio_interrupt');
final _controller = StreamController<AudioInterruptEvent>.broadcast();
Stream<AudioInterruptEvent> get events => _controller.stream;
void start() {
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'interruptionBegan':
_controller.add(AudioInterruptEvent.began);
case 'interruptionEnded':
_controller.add(AudioInterruptEvent.ended);
}
});
}
}
修正後のリリースノート
この修正はv1.7 のリリースに含めました。リリースノートには控えめに「電話着信後の音声停止問題を修正」と書きましたが、内部的にはけっこう大きな変更でした。
教訓
- iOS のオーディオ周りは、結局ネイティブで触らないと解決しない局面がある
- プラグインの守備範囲を見極める。超えるところは自前で書く
- 割り込み(interruption)は「終わったあと」をちゃんと面倒見ないと、音は戻らない
プラットフォームの API に深く踏み込むと、Flutter とネイティブの境界をどう設計するかが効いてきます。Method Channel は重宝します。ただ頻繁に使うと依存が増えるので、最小限に絞るのがコツです。