0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UIBackgroundModes: audio で 2.5.4 に落ちた。総点検したら AVAudioSession の二重管理が壊れていた

0
Posted at

きっかけは審査のリジェクトでした

個人開発の英会話アプリを提出したところ、ガイドライン 2.5.4 で落ちました。指摘文はこれです。

Guideline 2.5.4 - Performance - Software Requirements

Issue Description

The app declares support for audio in the UIBackgroundModes key in the Info.plist, but we are unable to play any audible content when the app is running in the background.

Background audio is intended for use by apps that provide audible content to the user while in the background, such as music player, music creation, or streaming audio apps.

Next Steps

If the app has a feature that requires persistent audio, reply to this message and add a screen recording showing the persistent background audio usage on a physical device. Navigate to the Home Screen to show the background mode in use in the recording. Include the recording in the Notes field of the App Review Information section in App Store Connect for future submissions.

If the app does not have a feature that requires persistent audio, it would be appropriate to remove the "audio" setting from the UIBackgroundModes key.

(Review Device: iPad Air 11-inch (M4))

先に結論を書いておくと、この指摘の直接の原因はコードではありませんでした。 聞き流し機能は実装されていて、実機では正常にバックグラウンド再生されます。審査員がその画面までたどり着けなかった、というだけの話です。決着のさせ方は最後に書きます。

ただ、この指摘をきっかけにバックグラウンド音声まわりを総点検したところ、まったく別の壊れ方が見つかりました。そちらのほうが記事として価値があるので、以下はその話です。

見つかった症状

次の 2 つが同時に出ていました。

  1. 読み上げ中にコントロールセンターを引くと、裏で流していた音楽が二度と再開しない
  2. アプリに戻ってもう一度読み上げると、無音のまま何も鳴らない

どちらも「たまに起きる」ので、しばらく原因が分かりませんでした。結論から書くと、AVAudioSession参照カウントと所有フラグを二重に持っていたことが原因です。片方だけを一括リセットする経路があり、そこで状態が食い違っていました。

同じ構成にしている人はそれなりにいると思うので、経路と直し方を残しておきます。

環境

  • iOS 18.0+ / Swift 6 / SwiftUI
  • AVSpeechSynthesizer(読み上げ)、SFSpeechRecognizer + AVAudioEngine(音声認識)、AVAudioEngine + AVAudioPlayerNode(聞き流し再生)
  • UIBackgroundModes: audio あり

前提となっていた設計

セッションの設定があちこちに散らばると破綻するので、AVAudioSession を触るのは AudioSessionCoordinator 1 箇所に集約していました。用途ごとに参照カウントを持ち、0 になったときだけ解放します。

final class AudioSessionCoordinator {
    static let shared = AudioSessionCoordinator()

    enum Use { case playback, record }

    private var playbackCount = 0
    private var recordCount = 0

    func begin(_ use: Use) throws { /* カウントを増やし、必要なら setCategory + setActive(true) */ }
    func end(_ use: Use)          { /* カウントを減らし、0 なら deactivate() */ }
    func endAll()                 { playbackCount = 0; recordCount = 0; deactivate() }
}

ここまでは特に問題ありません。問題は呼ぶ側でした。

同じ再生を二重に数えないよう、各所有者が「自分が今セッションを持っているか」を Bool で持っていました。

// SpeechManager
private var hasActiveSession = false

func speak(_ text: String) {
    if !hasActiveSession {
        try? AudioSessionCoordinator.shared.begin(.playback)
        hasActiveSession = true
    }
    // ...
}

private func releaseSessionIfNeeded() {
    guard hasActiveSession else { return }
    hasActiveSession = false
    AudioSessionCoordinator.shared.end(.playback)
}

同じ事実(セッションを持っているか)を、Coordinator 側のカウンタと所有者側のフラグの 2 箇所で表現している。 これが後で効いてきます。

壊れる経路

さらに ContentView に、背面へ回るときの一括解放がありました。

.onChange(of: scenePhase) { _, newPhase in
    if newPhase == .background || newPhase == .inactive {
        let audio = AudioSessionCoordinator.shared
        audio.stopRecordingHandler?()
        if !audio.isListeningPlaybackActive {
            audio.endAll()
        }
    }
}

読み上げ中にコントロールセンターを引くと scenePhase.inactive になります。ここから順に追うとこうなります。

  1. 読み上げ中。playbackCount == 1hasActiveSession == true
  2. .inactive になり endAll() が呼ばれる
  3. endAll()playbackCount = 0 にして deactivate() を呼ぶ
  4. しかし再生中の setActive(false)AVAudioSessionErrorCodeIsBusy で失敗する
  5. 読み上げが終わり、didFinish から releaseSessionIfNeeded() が呼ばれる
  6. end(.playback) に入るが guard playbackCount > 0 else { return } で早期リターン
  7. deactivate() が二度と呼ばれない → .notifyOthersOnDeactivation が一度も発火しない

これで症状 1(音楽が戻らない)になります。

さらに endAll() は Coordinator 側のカウンタしか触らないので、hasActiveSessiontrue のまま残ります。次に speak() を呼ぶと if !hasActiveSessionfalse になり、begin(.playback) がスキップされます。セッションの解放にたまたま成功していた場合、セッションが無い状態で読み上げようとして無音になります。これが症状 2 です。

なぜ気づけなかったか

4 番の失敗を、こう書いていたからです。

private func deactivate() {
    try? AVAudioSession.sharedInstance()
        .setActive(false, options: .notifyOthersOnDeactivation)
}

try? です。再生中の解放は日常的に失敗するのに、失敗したことが誰にも伝わらない。 ログにも出ないので、実機で音楽が止まったままなのを見て初めて気づきました。

AVAudioSession 周りは「失敗しても致命的ではないから try?」で書きがちですが、setActive(false) の失敗だけは他アプリに影響が出るので、握りつぶしてはいけない類のものでした。

直し方:状態を一方向にする

二重管理をやめて片方に寄せる、というのが素直な発想です。ただ、所有者側のフラグを全部消して Coordinator に問い合わせる形にすると、「誰が何個持っているか」を Coordinator が知る必要が出てきて、かえって複雑になりました。

採ったのは世代(generation)を導入する方法です。

  • begin() は「そのとき何世代目だったか」を返す
  • 所有者は Bool ではなくその世代番号を持つ
  • end() に世代を渡し、現在の世代と食い違っていたら無視する
  • endAll() は世代を 1 つ進めて、生きていた世代を全部無効化する

所有者側は「自分は持っている / 持っていない」ではなく「自分が持っていたのは何世代目か」を持つようになります。世代が進んでいれば、自分の持ち物はもう無効だと自分で判断できます。

Coordinator 側

private var playbackCount = 0
private var recordCount = 0

/// 強制解放(endAll)の世代。endAll を呼ぶたびに 1 つ進む。
private(set) var generation = 0

@discardableResult
func begin(_ use: Use) throws -> Int {
    // ... 従来どおりカウントを増やしてセッションを張る
    return generation
}

func end(_ use: Use, generation: Int) {
    // endAll のあとに遅れて届いた解放は無視する
    guard generation == self.generation else { return }
    // ... 従来どおりカウントを減らし、0 なら deactivate()
}

func endAll() {
    generation &+= 1     // この時点で生きていた begin() の戻り値を全部無効化する
    playbackCount = 0
    recordCount = 0
    deactivate()
}

/// その世代でセッションをまだ保持しているか。
/// nil(一度も取っていない)または世代違い(endAll 済み)なら false。
func holds(_ generation: Int?) -> Bool {
    guard let generation else { return false }
    return generation == self.generation
}

&+= にしているのは、極端に長いセッションでもオーバーフローでクラッシュさせないためです。世代番号は等値比較しかしないので、一周しても実害はありません。

所有者側

BoolInt? に置き換えます。

// before
private var hasActiveSession = false

// after
private var sessionGeneration: Int?

取得側は「持っているか」ではなく「まだ有効な世代を持っているか」を聞きます。

if !AudioSessionCoordinator.shared.holds(sessionGeneration) {
    // 世代違いの古い値はここで捨てる(end は呼ばない。カウンタは既に 0)
    sessionGeneration = nil
    do {
        sessionGeneration = try AudioSessionCoordinator.shared.begin(.playback)
    } catch {
        // セッションを取れなくても読み上げ自体は試みる(音が出ないだけ)
    }
}

解放側は世代を渡すだけです。食い違っていれば Coordinator が黙って捨てます。

private func releaseSessionIfNeeded() {
    guard let generation = sessionGeneration else { return }
    sessionGeneration = nil
    AudioSessionCoordinator.shared.end(.playback, generation: generation)
}

これで、endAll() が所有者側の状態を明示的に触らなくても、所有者が自分で「無効になった」と判断できるようになりました。状態の流れが Coordinator → 所有者の一方向になり、二重管理が消えます。

解放の失敗を握りつぶさない

try? をやめて、失敗したときだけ 1 度リトライします。

private func deactivate() {
    let session = AVAudioSession.sharedInstance()
    do {
        try session.setActive(false, options: .notifyOthersOnDeactivation)
    } catch {
        // 再生中の解放は AVAudioSessionErrorCodeIsBusy で失敗する。
        // ここを握りつぶすと .notifyOthersOnDeactivation が一度も発火せず、
        // 利用者が聞いていた音楽・Podcast が二度と再開しない。
        Task { @MainActor [weak self] in
            try? await Task.sleep(for: .milliseconds(400))
            guard let self, self.playbackCount == 0, self.recordCount == 0 else { return }
            try? session.setActive(false, options: .notifyOthersOnDeactivation)
        }
    }
}

リトライの時点で誰かが使い始めていたら何もしません。ここで無条件に解放すると、新しく始まった再生を殺してしまいます。

そもそも Busy にしない

リトライは保険で、本命は解放より先に再生を止めることです。

if !audio.isListeningPlaybackActive {
    speech.stop()      // 先に止める
    audio.endAll()
}

setActive(false) を呼ぶ前に再生を止めておけば、そもそも Busy になりません。順序を入れ替えるだけで、症状の大半は消えました。

ついでに見つかった 2 件

一時停止中に audio バックグラウンドモードを持ち続けていた

UIBackgroundModes: audio を宣言していると、背面でもセッションを保持できます。うちでは「聞き流し再生中は背面でも鳴らす」ために isListeningPlaybackActive というフラグを立て、これが true のときは endAll() をスキップしていました。

問題は一時停止したまま背面に回したときです。フラグが true のままなので endAll() がスキップされ、音を出していないのに .playback セッションを保持し続ける状態になります。バックグラウンドモードの誤用ですし、審査でも突かれる部分です。

pause() でフラグを落とすようにしました。

func pause() {
    interruptedLine = currentLine
    synthesizer.stopSpeaking(at: .immediate)
    // 一時停止中は「バックグラウンドで鳴らし続けてよい再生」ではない。
    // resume() は activateAudioSession() の中で再び true にする。
    AudioSessionCoordinator.shared.markListeningPlayback(false)
}

installTap が 0Hz を掴んでクラッシュする

他アプリがマイクを掴んでいる直後や、通話終了直後に、inputNode.outputFormat(forBus: 0)sampleRate 0 / channelCount 0 を返すことがあります。

その値のまま installTap すると Objective-C 例外になり、Swift 側では do-catch で捕まえられずにクラッシュします。ガードを入れるしかありません。

let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
guard format.sampleRate > 0, format.channelCount > 0 else {
    errorMessage = "いまマイクを利用できません。少し待ってからもう一度お試しください。"
    request = nil
    releaseRecordSession()
    return
}
input.removeTap(onBus: 0)

実機で確認したこと

この手の修正はシミュレータでは検証できません。すべて音楽アプリで音楽を流しながら確認しました。

# 手順 期待する結果
1 読み上げ中にコントロールセンターを引き下げる 読み上げが止まり、音楽が再開する
2 読み上げ中に App Switcher → 戻る → もう一度再生 音が出る(無音にならない)
3 聞き流し再生 → 一時停止 → 背面へ → 30 秒待つ 音楽が再開する。バックグラウンドで生き残らない
4 聞き流し再生したまま画面ロック 鳴り続ける
5 聞き流し再生中にイヤホンを抜く 一時停止する
6 録音中に電話着信 → 切る クラッシュしない。録音が止まっている
7 通話直後に録音を開始 クラッシュせず、エラー文言が出るか正常に始まる
8 10 往復して離脱 → 音楽アプリへ 音量が下がっていない

8 番目が地味に重要で、.duckOthers を使っていると解放漏れで音量が下がったままになることがあります。

2.5.4 はどう決着したか

冒頭のリジェクトに戻ります。こちらはコードの問題ではなく、審査員に機能を見つけてもらえなかったという問題でした。

指摘文の Next Steps に、対処が明記されています。

reply to this message and add a screen recording showing the persistent background audio usage on a physical device. Navigate to the Home Screen to show the background mode in use in the recording.

つまり 実機での画面収録を返信に添えれば済む。要求されているのは次の 3 点です。

  1. 実機であること(シミュレータの収録では通りません)
  2. バックグラウンド再生している様子が映っていること
  3. ホーム画面に戻る操作が入っていること。アプリを離れても鳴り続けている、というのが確認したい点なので、ここを省くと意味がありません

そして同じ文の後半が重要です。

Include the recording in the Notes field of the App Review Information section in App Store Connect for future submissions.

次回以降は App Review Information の Notes に最初から入れておけ、ということです。UIBackgroundModes: audio を宣言するなら、収録動画と「どの画面のどのボタンから再生できるか」の手順を毎回 Notes に書いておくのが正解でした。うちは書いていませんでした。

もう一点、見落としやすい記述があります。

The issues we've identified below are eligible to be resolved on your next update. If this submission includes bug fixes and you'd like to have it approved at this time, reply to this message and let us know. You do not need to resubmit your app for us to proceed.

バグ修正が含まれる提出なら、返信するだけで今回ぶんを通してもらえるという案内です。再提出は不要と明記されています。リジェクト通知が来ると反射的にビルドを作り直したくなりますが、まず全文を読んだほうがよさそうです。

なお審査端末は iPad Air 11-inch (M4) でした。iPhone 前提で導線を組んでいると、iPad のレイアウトで目的の機能にたどり着けないことがあります。バックグラウンド再生のように「特定の画面まで行かないと再現しない機能」は、審査端末が何になるか分からない前提で Notes を書いておくべきでした。

まとめ

  • 同じ事実を 2 箇所で持つと、片方だけを更新する経路がいつか生える。 今回は「セッションを持っているか」をカウンタとフラグで二重に持っていた
  • 一括リセット(endAll())のような強い操作を足すときは、それが触らない状態がないかを確認する
  • 直すときは片方を消すのが素直だが、消しづらいときは世代番号を渡して、古い要求を無視できるようにすると一方向に整理できる
  • AVAudioSessionsetActive(false) の失敗は try? で潰さない。他アプリに影響が出る唯一の呼び出し
  • setActive(false) の前に再生を止める。順序だけで Busy は避けられる
  • UIBackgroundModes: audio を宣言するなら、実機の画面収録と再現手順を App Review Information の Notes に最初から入れておく。実装が正しくても、たどり着いてもらえなければ落ちる

参照カウント自体は悪くありませんでした。壊れたのは、カウンタの外側にもう一つ状態を置いたところです。

そして審査のほうは、コードが正しいことと、それが伝わることは別問題だという、ごく当たり前の話でした。


作っているのは『オコジョ英会話』という、マイクに向かって英語を話すと採点が返ってくるアプリです。音声認識を端末内で完結させている都合で、この記事のようなセッション周りの話が多くなっています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?