播放器在云端 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 适合反复运行确定性的状态测试,实际音频路由则仍以设备观察为准。两层验证分开,失败时才能知道应查业务规则、通知转换,还是声音输出本身。
常见问题
模拟器单元测试能证明耳机断开时一定会暂停吗?
不能。单元测试只能证明收到指定路由事件后的应用决策;通知是否到达、路由原因是否符合预期,仍需在目标设备上验证。
系统音频中断结束后应该立即恢复播放吗?
不应无条件恢复。至少同时满足中断前正在播放、结束事件允许恢复,且期间没有用户主动暂停,才应考虑恢复。
把下一次 Xcode 构建放到独享物理机上。
选择 Apple Silicon 机型、节点和租期;通过远程连接运行自己的构建流程。实际可用条件以订购时控制台返回为准。