iOS 音频中断与路由变化:测试播放器状态机

iOS 音频中断与路由变化:测试播放器状态机

播放器在云端 Mac 的自动化测试中一路通过,用户拔下耳机后却从扬声器继续播放:问题可能不在解码器,而在「收到路由变化后该做什么」没有被单独测试。系统音频中断也类似;中断结束不等于用户希望恢复。把系统通知和播放决策拆开,才能在没有耳机的构建节点上稳定检查这部分逻辑。

先定义事件与预期行为

先列出产品规则,再写通知处理代码。本文采用保守规则:播放期间发生中断就暂停,并记住此前正在播放;结束通知允许恢复时才恢复。如果用户在中断期间主动按下暂停,原先的恢复意图必须作废。输出设备断开则直接暂停,不能仅凭新路由名称猜测用户是否愿意从扬声器继续听。

输入事件 播放状态 恢复意图
播放中收到中断开始 暂停 记录此前正在播放
中断结束且允许恢复 按恢复意图决定 清除
用户主动暂停 暂停 清除
旧输出设备不可用 暂停 清除

「旧输出设备不可用」是事件原因,不是对所有路由变化的统称。例如切换到另一台已连接设备,不应未经产品决策就套用拔耳机规则。

用纯状态机隔离播放决策

把通知解析留在适配层,核心逻辑只接收明确的事件。下面的类型可放进应用目标,并由测试目标导入。isPlaying 表示应用期望的播放状态;真正调用播放器的副作用应由外层根据状态变化执行。

enum AudioEvent {
    case interruptionBegan
    case interruptionEnded(shouldResume: Bool)
    case outputDisconnected
    case userPaused
}

struct PlaybackState {
    var isPlaying = false
    var resumeAfterInterruption = false

    mutating func apply(_ event: AudioEvent) {
        switch event {
        case .interruptionBegan:
            resumeAfterInterruption = isPlaying
            isPlaying = false
        case .interruptionEnded(let shouldResume):
            isPlaying = resumeAfterInterruption && shouldResume
            resumeAfterInterruption = false
        case .outputDisconnected, .userPaused:
            isPlaying = false
            resumeAfterInterruption = false
        }
    }
}

这段示例只覆盖一轮中断。如果应用会同时处理多个播放会话,或有暂停后仍可操作的队列,应先扩充状态定义,再决定事件的先后关系;不要让一个布尔值承担全部会话语义。

把系统通知翻译成事件

适配层监听 AVAudioSession.interruptionNotification,从 userInfo 读取中断类型;结束时读取恢复选项,并将结果交给状态机。监听 AVAudioSession.routeChangeNotification 时检查原因,只把 oldDeviceUnavailable 翻译为 outputDisconnected。缺失或无法识别的字段要留待记录与排查,不要擅自当成「允许恢复」。

通知回调和界面操作可能来自不同执行上下文。应将事件串行送入同一状态所有者,再根据新旧状态调用暂停或播放,避免「用户暂停」与「中断结束」并发时由回调到达顺序意外覆盖用户意图。

在构建节点上运行边界测试

先用 XCTest 覆盖最容易遗漏的两条路径:中断前未播放时不能自行开播;用户在中断期间暂停后不能自动恢复。测试不依赖真实耳机,也不要求系统按特定时间发出通知。

import XCTest

final class PlaybackStateTests: XCTestCase {
    func testInterruptionDoesNotStartIdlePlayer() {
        var state = PlaybackState()
        state.apply(.interruptionBegan)
        state.apply(.interruptionEnded(shouldResume: true))
        XCTAssertFalse(state.isPlaying)
    }

    func testUserPauseCancelsResume() {
        var state = PlaybackState(isPlaying: true)
        state.apply(.interruptionBegan)
        state.apply(.userPaused)
        state.apply(.interruptionEnded(shouldResume: true))
        XCTAssertFalse(state.isPlaying)
    }

    func testDisconnectedOutputStopsPlayback() {
        var state = PlaybackState(isPlaying: true)
        state.apply(.outputDisconnected)
        XCTAssertFalse(state.isPlaying)
        XCTAssertFalse(state.resumeAfterInterruption)
    }
}

在云端 Mac 上先用 xcodebuild -list -project App.xcodeproj 核对工程和 Scheme,再用团队实际共享的 Scheme 执行 xcodebuild test -project App.xcodeproj -scheme App -destination 'platform=iOS Simulator,name=<已安装的设备名称>'。示例中的工程名、Scheme 和设备名称必须替换;可通过 xcrun simctl list devices available 查看当前可用设备。测试目标要能访问状态机类型,Scheme 也要包含该测试目标。

单元测试通过,只能说明给定事件序列产生了预期决策;它不能证明系统通知一定送达,也不能证明声音实际从哪个设备输出。

在设备上补齐集成验收

实机检查至少安排四步:播放时拔下耳机,确认扬声器没有意外接管;触发一次可控的系统音频中断,观察开始和结束通知;在中断期间手动暂停,确认结束后仍保持暂停;重新连接输出设备,确认应用不会仅因连接成功就自行播放。记录收到的事件类型、原因和最终播放状态即可,日志不要写入用户内容或访问凭据。

如果单元测试通过而实机仍出错,先核对通知监听是否在会话生命周期内有效,再检查通知字段解析、事件投递顺序和播放器实际状态是否与 PlaybackState 同步。特别要区分「状态机要求暂停」与「播放器确实完成暂停」:前者由测试保证,后者需要适配层检查和设备验收。

把检查项留在合并流程里

合并前确认:状态机测试涵盖空闲、中断、用户暂停和设备断开;通知适配层对未知字段采取保守处理;所有事件进入同一串行执行路径;团队记录了至少一次目标设备验收结果。云端 Mac 适合反复运行确定性的状态测试,实际音频路由则仍以设备观察为准。两层验证分开,失败时才能知道应查业务规则、通知转换,还是声音输出本身。

常见问题

模拟器单元测试能证明耳机断开时一定会暂停吗?

不能。单元测试只能证明收到指定路由事件后的应用决策;通知是否到达、路由原因是否符合预期,仍需在目标设备上验证。

系统音频中断结束后应该立即恢复播放吗?

不应无条件恢复。至少同时满足中断前正在播放、结束事件允许恢复,且期间没有用户主动暂停,才应考虑恢复。

UDPMac 云端 Mac

把下一次 Xcode 构建放到独享物理机上。

选择 Apple Silicon 机型、节点和租期;通过远程连接运行自己的构建流程。实际可用条件以订购时控制台返回为准。

查看云端 Mac 租用方案