播放器在雲端 Mac 的自動化測試一路過關,使用者拔下耳機後,聲音卻繼續從揚聲器播放:問題可能不在解碼器,而是沒有單獨測試「收到輸出路由變更通知後該怎麼做」。系統音訊中斷也是如此;中斷結束,不代表使用者希望恢復播放。將系統通知與播放決策分開,才能在沒有耳機的建置節點上穩定驗證這段邏輯。
先定義事件與預期行為
先列出產品規則,再撰寫通知處理程式碼。本文採用保守規則:播放期間發生中斷就暫停,並記住中斷前正在播放;只有結束通知允許恢復時,才依此恢復播放。如果使用者在中斷期間主動按下暫停,原本的恢復意圖就必須作廢。輸出裝置中斷連線時則直接暫停,不能只憑新路由的名稱,猜測使用者是否願意改從揚聲器繼續收聽。
| 輸入事件 | 播放狀態 | 恢復意圖 |
|---|---|---|
| 播放中收到中斷開始通知 | 暫停 | 記錄中斷前正在播放 |
| 中斷結束且允許恢復 | 依恢復意圖決定 | 清除 |
| 使用者主動暫停 | 暫停 | 清除 |
| 舊輸出裝置無法使用 | 暫停 | 清除 |
「舊輸出裝置無法使用」是事件原因,不是所有路由變更的統稱。例如切換到另一台已連線的裝置時,不應在產品尚未決定處理方式前,就套用拔下耳機的規則。
用純狀態機隔離播放決策
將通知解析留在轉接層,核心邏輯只接收定義明確的事件。以下型別可放入應用程式 target,再由測試 target 匯入。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 查看目前可用的裝置。測試 target 必須能存取狀態機型別,Scheme 也必須包含該測試 target。
單元測試通過,只能證明給定的事件序列產生了預期決策;無法證明系統通知一定會送達,也無法證明聲音實際從哪個裝置輸出。
在裝置上補齊整合驗收
實機檢查至少安排四個步驟:播放時拔下耳機,確認揚聲器沒有意外接手播放;觸發一次可控制的系統音訊中斷,觀察開始與結束通知;在中斷期間手動暫停,確認中斷結束後仍維持暫停;重新連接輸出裝置,確認應用程式不會只因連接成功就自行播放。記錄收到的事件類型、原因與最終播放狀態即可,不要在日誌中寫入使用者內容或存取憑證。
如果單元測試通過,實機卻仍出錯,先確認通知監聽在工作階段的生命週期內持續有效,再檢查通知欄位解析、事件傳遞順序,以及播放器的實際狀態是否與 PlaybackState 同步。尤其要區分「狀態機要求暫停」與「播放器確實完成暫停」:前者由測試驗證,後者需要檢查轉接層並在裝置上驗收。
將檢查項目留在合併流程中
合併前確認:狀態機測試涵蓋閒置、中斷、使用者暫停及裝置中斷連線;通知轉接層對未知欄位採取保守處理;所有事件都進入同一條依序執行的路徑;團隊已記錄至少一次目標裝置的驗收結果。雲端 Mac 適合反覆執行結果可預期的狀態測試,實際音訊路由仍須以裝置上的觀察為準。將兩層驗證分開,失敗時才能判斷該查業務規則、通知轉換,還是聲音輸出本身。
常見問題
模擬器單元測試能確認耳機斷線後的實際行為嗎?
不能完全確認。它能驗證收到指定事件後的程式決策;通知傳遞與裝置路由仍須在實體裝置上檢查。
音訊中斷結束後是否應自動播放?
不應無條件播放。須確認中斷前正在播放、結束事件允許恢復,而且使用者期間沒有主動暫停。
將下一次 Xcode 建置作業移至獨享實體主機。
選擇 Apple Silicon 機型、節點與租期,透過遠端連線執行自己的建置流程。實際可用情況以訂購時控制台顯示為準。