化方案)
1. 先搞懂AAudio到底在管什么1.1 從一次卡頓說起做Android音頻開發(fā)的兄弟大概率都遇到過這種場景你興致勃勃地把錄音或播放鏈路切到了AAudio一跑起來聲音倒是出來了可放不了幾秒鐘就“咔噠”一聲然后就是斷斷續(xù)續(xù)的爆音、遲緩、跟手度全無。更郁悶的是同一段代碼在A設(shè)備上跑得很穩(wěn)換到B設(shè)備上卡成幻燈片在C設(shè)備上又完全正常。這種玄學(xué)問題十有八九不是解碼器出Bug也不是硬件壞了而是音頻流的流控機制沒有吃透。AAudio是Android 8.0開始推出的原生音頻API目標(biāo)很明確用更低的延遲、更穩(wěn)定的調(diào)度替代老牌的OpenSL ES讓做樂器App、K歌、實時音效、專業(yè)錄音的人能有接近iOS那套AudioUnit的體驗。但“低延遲”這件事本身就是把雙刃劍——延遲越低緩沖區(qū)就越小緩沖區(qū)越小系統(tǒng)稍微抖一下卡頓就來了。這篇文章我不打算念文檔而是站在實際調(diào)過的項目角度把AAudio的流控機制拆開講清楚數(shù)據(jù)到底怎么流動、緩沖區(qū)和underrun之間的關(guān)系、卡頓出現(xiàn)時怎么定位以及最后能直接抄作業(yè)的解決方案。無論你是在寫播放器、錄音器還是做低延遲音效引擎這套思路基本通用。1.2 AAudio在音頻鏈條中的位置先對齊一下基礎(chǔ)位置。Android的音頻鏈路從上到下大概是這樣的你的App代碼Java層AudioTrack/AudioRecord或者NDK層的AAudio→ AudioFlinger系統(tǒng)混音服務(wù)→ Audio HAL硬件抽象層→ 底層驅(qū)動 → 聲卡或DSP。AAudio雖然掛在NDK層但它和AudioTrack最大的區(qū)別是AAudio支持“不受混音影響”的獨占路徑。默認(rèn)情況下App的聲音都要進AudioFlinger的Mixer里跟其他聲音混在一起再統(tǒng)一送到底層。這個過程很方便但混音、重采樣、通道轉(zhuǎn)換都會有額外開銷和延遲。AAudio開啟低延遲模式、占上獨占模式之后它會嘗試走一條更短的路徑——相當(dāng)于從你家小區(qū)門口直接上高速不再繞到市區(qū)轉(zhuǎn)一圈。最終能不能走上快捷路徑由系統(tǒng)根據(jù)硬件能力、當(dāng)前設(shè)備狀態(tài)決定但只要你把參數(shù)設(shè)對了大多數(shù)現(xiàn)代設(shè)備都會給你走MMAP這條相對直接的通道。理解了它在鏈條中的位置后面所有流控概念都好解釋了。2. 流控機制的核心數(shù)據(jù)是怎么流動的2.1 frames、burst和緩沖區(qū)AAudio里你打交道最多的是frames這個概念。一個frame在音頻里不是“一幀畫面”而是“一次采樣周期內(nèi)所有聲道分別采一個樣本”的集合。比如雙聲道16bit的音頻一個frame就是2個采樣點4個字節(jié)。你用采樣率44100就代表每秒要輸送44100個frame給硬件。AudioStream建立之后系統(tǒng)會告訴你兩個關(guān)鍵值framesPerBurst每次硬件中斷或者說一次burst期望接收的frame數(shù)量可以理解成“水管每次噴水的顆粒度”。bufferCapacityInFrames緩沖區(qū)最多能裝的frames數(shù)量相當(dāng)于水管的緩存池。但capacity只是上限真正決定延遲大小的是實際使用多少緩沖區(qū)。很多人在調(diào)低延遲時犯的錯誤是只改了capacity沒改buffer size導(dǎo)致緩沖區(qū)仍然很大延遲沒降下來卡頓倒是依然存在。一個比較經(jīng)典的目標(biāo)如果你的采樣率是48000每burst是192幀常見的FastMixer/MMAP里burst通常192或240幀那么一次burst持續(xù)的時間就是192÷480004ms。緩沖區(qū)如果設(shè)置成2個burst左右約384幀端側(cè)延遲大概8ms加上路徑上的固定開銷整體能在20ms上下——人耳對延遲能感知的閾值一般在20~30ms附近所以這個水平已經(jīng)相當(dāng)可用。2.2 兩種數(shù)據(jù)讀寫模式回調(diào)與阻塞AAudio提供了兩種數(shù)據(jù)交互方式流控行為完全不同。第一種是數(shù)據(jù)回調(diào)模式callback。你給stream注冊一個回調(diào)系統(tǒng)每個burst周期會調(diào)用你一次讓你往緩沖區(qū)里填數(shù)據(jù)播放或讀數(shù)據(jù)錄音。aaudio_data_callback_result_t myDataCallback( AAudioStream *stream, void *userData, void *audioData, int32_t numFrames) { // 把numFrames個frame的數(shù)據(jù)填充到audioData中 fillFrames(audioData, numFrames); return AAUDIO_CALLBACK_RESULT_CONTINUE; }回調(diào)模式的好處是回調(diào)線程由AAudio內(nèi)部管理優(yōu)先級高而且節(jié)奏跟硬件中斷對齊你不需要自己熬夜算延遲。壞處也很明顯回調(diào)里不能做任何可能阻塞的事否則就會拖垮整個管線。第二種是阻塞讀寫模式Blocking Read/Write。你主動調(diào)AAudioStream_write()或者AAudioStream_read()就像往水管里手動灌水。這種方式使用起來更直觀尤其是已經(jīng)有現(xiàn)成音頻處理代碼的人遷移成本低。int64_t written AAudioStream_write(stream, buffer, framesToWrite, timeoutNanos);阻塞模式下緩沖區(qū)大小、當(dāng)前水管里有多少積水是可以主動感知的。如果寫入太快會“憋住”寫入太慢就會斷流。那該選哪種我的習(xí)慣是做播放器且沒有復(fù)雜實時處理需求可以用阻塞寫做樂器、實時耳返、變聲這種對時序敏感的必須上回調(diào)。2.3 獨占/共享低延遲/省電AAudio builder里有幾個“命運開關(guān)”它們組合出來的流控行為差異非常大。共享模式AAUDIO_SHARING_MODE_SHARED會讓你的流跟其他App混流兼容性好但延遲高且可能被其他聲音干擾AAUDIO_SHARING_MODE_EXCLUSIVE試圖獨占硬件通道延遲低但并不是所有設(shè)備都能拿到獨占權(quán)限拿不到時系統(tǒng)會自動降級為共享。性能模式AAUDIO_PERFORMANCE_MODE_LOW_LATENCY是低延遲模式系統(tǒng)會盡量用小緩沖區(qū)、高優(yōu)先級調(diào)度AAUDIO_PERFORMANCE_MODE_NONE是功耗優(yōu)先延遲相對高適合后臺播放這種不敏感場景。實際項目里低延遲音樂應(yīng)用一般都這樣組合性能模式選LOW_LATENCY共享模式先請求EXCLUSIVE拿到好評測拿不到也接受SHARED。要注意的是這倆參數(shù)主要是“請求”不是“保證”。系統(tǒng)會根據(jù)硬件和當(dāng)前負(fù)載決定最終形態(tài)所以你以為自己在獨占低延遲運行時的實際參數(shù)可能已經(jīng)不是那么回事了。判斷最終值最簡單的方式就是在stream打開之后主動查一遍AAudioStream_getSharingMode(stream); AAudioStream_getPerformanceMode(stream);如果拿到的結(jié)果和你預(yù)期的差距很大后面調(diào)優(yōu)方向就要跟著變。3. 卡頓是怎么產(chǎn)生的流控鏈路中的瓶頸排查3.1 underrun與overrun音頻流的“斷糧”和“積壓”音頻流卡頓最直接的原因永遠(yuǎn)是同一個緩沖區(qū)里沒有足夠的frame可送或者送的頻率不穩(wěn)定。播放場景里硬件按固定節(jié)奏消費數(shù)據(jù)你負(fù)責(zé)往緩沖區(qū)里填數(shù)據(jù)。如果你填的速度跟不上硬件消費的速度緩沖區(qū)就被掏空了這時候就是underrun下溢。硬件發(fā)現(xiàn)沒數(shù)據(jù)可播只能中斷輸出你聽到的就是爆音、咔噠聲嚴(yán)重時整段丟音頻。錄音場景反過來硬件不停往緩沖區(qū)里塞數(shù)據(jù)如果你不及時讀走緩沖區(qū)滿了還繼續(xù)塞新的數(shù)據(jù)沒地方放只能丟掉這就是overrun上溢。錄音結(jié)果聽起來像是掉了幾個字、節(jié)奏忽快忽慢。這兩個概念一定得刻在腦子里。凡是碰到AAudio卡頓第一步不是猜設(shè)備而是確認(rèn)到底是哪種情況。3.2 真正讓音頻流卡頓的五個常見原因緩沖區(qū)設(shè)置過小。很多人一味追求低延遲把buffer size壓到1個burst甚至更低。設(shè)備調(diào)度稍微波動一次立刻underrun。低延遲和穩(wěn)定性之間必須有妥協(xié)點。回調(diào)里干了重活。在onAudioStreamReady回調(diào)里做解碼、網(wǎng)絡(luò)讀取、內(nèi)存分配、加鎖、打日志都會讓回調(diào)執(zhí)行時間超過一個burst周期。這樣做導(dǎo)致的后果是你的處理邏輯變成串行某個周期來不及供貨緩沖區(qū)就空了。系統(tǒng)調(diào)度抖動。Android不是硬實時系統(tǒng)。DDL、動畫、GC、其他App搶占CPU、系統(tǒng)服務(wù)突發(fā)訪問I/O都會讓你的音頻回調(diào)線程偶爾得不到CPU。如果緩沖區(qū)只有一個burst的余糧一小下抖動就能引爆卡頓。路徑未走低延遲快速通道。參數(shù)沒設(shè)對或設(shè)備不支持流走的是普通混音通道中間多了混音、重采樣、通道轉(zhuǎn)換環(huán)節(jié)。延遲變大卡頓感知也會放大尤其是在做一些對時序要求較高的交互時。并發(fā)音頻場景互相搶資源。比如你在用AAudio播放的時候系統(tǒng)來了一條通知音或者另一個App在錄屏、播放視頻、開沉浸式導(dǎo)航語音這時硬件資源被搶占stream也容易出問題。錄屏?xí)r掉幀、聲音卡頓一起出現(xiàn)已經(jīng)不是音頻單點問題了而是整機CPU和總線帶寬都緊張。4. 定位問題的實操流程4.1 先用日志和數(shù)據(jù)判斷是否真的underrun很多同學(xué)一遇卡頓就想著調(diào)大緩沖區(qū)但調(diào)大多少、往哪個方向調(diào)都憑感覺。正確做法是先量化。AAudio有一個很實用的查詢AAudioStream_getFramesWritten()和AAudioStream_getFramesRead()。播放場景里written表示應(yīng)用累計寫入的frames數(shù)read表示硬件累計消費的frames數(shù)。兩者的差值就是當(dāng)前緩沖區(qū)里滯留的frames數(shù)量。我通常會在一個專門的調(diào)試線程里每500ms采樣一次這兩個值。寫一個小探測函數(shù)int64_t framesWritten AAudioStream_getFramesWritten(stream); int64_t framesRead AAudioStream_getFramesRead(stream); int64_t bufferedFrames framesWritten - framesRead; int32_t bufferSize AAudioStream_getBufferSizeInFrames(stream); int32_t capacity AAudioStream_getBufferCapacityInFrames(stream);觀察一段時間之后你就能得出三個結(jié)論bufferedFrames經(jīng)常掉到0附近說明underrun頻繁緩沖區(qū)余量不足。bufferedFrames長期接近capacity說明寫入太激進或者消費速度跟不上需要加快讀或者減小寫入節(jié)奏。bufferedFrames穩(wěn)定在buffer size的一半左右且波動小說明供需平衡這個狀態(tài)最健康。音頻框架本身也可能有性能計數(shù)器。AAudio在logcat里會在出現(xiàn)underrun時打印類似UNDERRUN、overrun的日志但不同廠商ROM日志格式差異很大有的根本不打。所以不能只依賴logcat自己做的監(jiān)測才是最可靠的。4.2 用時間戳和frames計算延遲抖動除了數(shù)量上的監(jiān)控還要看時序穩(wěn)定性。AAudio提供了時間戳接口返回一組framePosition和對應(yīng)的timeNanoseconds可以理解成“某個時間點硬件處理到第幾個frame”。AAudioStream_getTimestamp(stream, CLOCK_MONOTONIC, framePosition, timeNanoseconds);通過連續(xù)取兩次時間戳可以估算出硬件的實際消費速度和間隔。間隔穩(wěn)定說明調(diào)度平穩(wěn)間隔忽大忽小說明系統(tǒng)調(diào)度有抖動這時候即使平均下來不卡也會偶發(fā)爆音。延時抖動是比underrun更難查的問題。建議做法是在回調(diào)入口和出口各記錄一次時間戳連續(xù)跑一分鐘統(tǒng)計回調(diào)周期的均值、最大值和標(biāo)準(zhǔn)差。如果最大周期經(jīng)常超過中位數(shù)的兩倍以上這系統(tǒng)調(diào)度就很不穩(wěn)必須從優(yōu)先級和緩沖區(qū)余量兩個方向補強。5. 解決卡頓的完整方案5.1 參數(shù)調(diào)整合理設(shè)置buffer capacity參數(shù)是能最快見效的部分。調(diào)優(yōu)時按這個順序來先確認(rèn)幀率和burst大小。以48000Hz、burst為192幀的設(shè)備為例理論上1個burst是4ms。如果目標(biāo)是低延遲建議buffer size從2個burst起跳也就是384幀左右。跑起來之后觀察underrun情況再每次增加64或96幀直到卡頓消失。這一步我用的是“最小穩(wěn)定值”思路從能接受的最低延遲開始逐步加緩沖區(qū)直到連續(xù)跑5分鐘無underrun為止。不要一上來就貪大緩沖區(qū)過大延遲就上來了交互類應(yīng)用會明顯覺得聲音發(fā)“木”。capacity的設(shè)定則要預(yù)留余量。一般設(shè)成buffer size的2到4倍這樣系統(tǒng)在極端抖動時還能通過臨時擴buffer來救場。不過capacity本身只是上限能不能在運行時擴容還要看driver是否支持代碼里可以先查詢bool isDynamic; AAudioStream_isBufferSizeRangeSupported(stream, isDynamic); if (isDynamic) { AAudioStream_setBufferSizeInFrames(stream, targetSize); }不支持運行時調(diào)整的設(shè)備只能關(guān)閉stream重新以目標(biāo)size打開。5.2 代碼側(cè)優(yōu)化別在回調(diào)里捅婁子回調(diào)函數(shù)是流控的心臟。我在代碼審查里最常說的話就是回調(diào)里只能有一類操作——把數(shù)據(jù)從這個地址搬去另一個地址。具體來說這幾個行為是絕對不能出現(xiàn)在回調(diào)里的內(nèi)存分配malloc、new、std::string都不行鎖操作pthread_mutex_lock、std::mutex都要避開文件I/O、網(wǎng)絡(luò)I/O過度復(fù)雜的算法或浮點運算密集操作例如高頻FFT如果做不過來就分包到普通工作線程打日志__android_log_print是有開銷的還可能導(dǎo)致線程阻塞如果數(shù)據(jù)處理很重正確姿勢是在回調(diào)里只做輕量拷貝或隊列寫入然后在另一個優(yōu)先級合適的線程里做解碼、重采樣、特效計算。數(shù)據(jù)交接用無鎖環(huán)形緩沖經(jīng)典的SPSC Queue就行。另外stream里的參數(shù)設(shè)置也值得留意。如果Builder默認(rèn)sample rate、channel count和format與你源數(shù)據(jù)不一致AAudio會專門開一個轉(zhuǎn)換器。重采樣和通道轉(zhuǎn)換是CPU開銷也是潛在的卡頓隱患。能提前把數(shù)據(jù)轉(zhuǎn)成與stream一致的格式比在回調(diào)里讓系統(tǒng)動態(tài)轉(zhuǎn)要穩(wěn)妥。5.3 架構(gòu)級調(diào)整切換路徑、線程、插拔保護代碼級優(yōu)化之后卡頓問題通常能解決百分之七八十。剩下的屬于架構(gòu)和外圍問題需要在更高層面做保護。請求低延遲路徑但做好降級預(yù)案。設(shè)備不支持獨占模式時系統(tǒng)會退回共享模式。代碼里要監(jiān)聽實際狀態(tài)如果發(fā)現(xiàn)實際性能模式或共享模式不符合預(yù)期提醒自己當(dāng)前是“降級”狀態(tài)不要再用最高規(guī)格的假設(shè)做性能調(diào)優(yōu)。管理App的音頻焦點。有通知進來、有電話、有其他播放器啟動時AAudio并不自動幫你停流。不做焦點處理的App會出現(xiàn)聲音突然變雜、被混疊、忽大忽小的情況。監(jiān)聽焦點的丟失及時暫停、清理或降低音量保證卡頓感知不明顯。設(shè)備插拔和路由變化保護。插入耳機、拔出耳機、連接藍(lán)牙音頻鏈路會重建stream狀態(tài)會變化。要對AAudioStream_state變化做監(jiān)聽鏈路斷開時及時重啟stream否則可能出現(xiàn)采集不到聲音或播放半天什么都沒出來。線程優(yōu)先級調(diào)整。有些設(shè)備上回調(diào)線程雖然不低但你自己創(chuàng)建的工作線程默認(rèn)優(yōu)先級不夠高數(shù)據(jù)處理不及時也會餓著回調(diào)。工作線程可以用最樸素的setPriority提高優(yōu)先級但不能濫用否則干擾到系統(tǒng)調(diào)度反而會加劇抖動。6. 常見問題速查與避坑經(jīng)驗6.1 卡頓場景速查表現(xiàn)象大概率原因排查方向解決手段播放出現(xiàn)周期性“咔噠”聲緩沖區(qū)過小導(dǎo)致underrun查看framesWritten-framesRead是否常為0逐步增大buffer size找到最小穩(wěn)定值錄音隨機丟字、掉時長overrun緩沖區(qū)滿了新數(shù)據(jù)被丟棄查看錄音側(cè)的framesRead是否追不上written增大錄音buffer或減少回調(diào)內(nèi)處理時間一按屏幕/開始動畫就卡系統(tǒng)調(diào)度抖動對比回調(diào)周期與動畫時段增加buffer余量優(yōu)化回調(diào)內(nèi)耗時插拔耳機后卡頓路由變化導(dǎo)致stream狀態(tài)異常檢查state和logcat的route切換日志監(jiān)聽狀態(tài)變化重建設(shè)備恢復(fù)stream鎖屏后一段時間再解鎖聲音變糊設(shè)備進入低功耗模式檢查性能模式與system suspend鎖屏期間暫停播放解鎖后重建或者使用WAKE_LOCK錄屏同時播放聲音卡頓掉幀CPU/總線競爭整機負(fù)載過高看系統(tǒng)負(fù)載和線程調(diào)度統(tǒng)計降低屏幕錄制碼率、限制后臺任務(wù)、增加音頻buffer余量后臺有App播放另一個流時被混音干擾共享模式下與其他AudioTrack混流確認(rèn)sharing mode是否為EXCLUSIVE請求獨占模式無法獨占時降低本App的延遲預(yù)期6.2 我踩過的幾個坑第一個坑是盲目相信系統(tǒng)日志。曾經(jīng)有個項目在低端機上產(chǎn)生大量underrunlogcat卻干干凈凈查了一天沒結(jié)果。后來自己做了frames差值統(tǒng)計才發(fā)現(xiàn)underrun確實存在只是廠商ROM把日志打到了別的地方?,F(xiàn)在我的習(xí)慣是日志只做參考自己主動統(tǒng)計才作數(shù)。第二個坑是在回調(diào)里順手做了一次數(shù)據(jù)拷貝和格式轉(zhuǎn)換當(dāng)時覺得就幾十個KB不至于出問題。結(jié)果在負(fù)載高的場景下偶爾一次耗時飆到十幾毫秒一個burst才4毫秒直接連續(xù)丟了好幾個burst。后來把格式轉(zhuǎn)換挪到了初始化階段同時用查表代替了部分計算卡頓立竿見影地消失了。第三個坑是關(guān)于藍(lán)牙設(shè)備。藍(lán)牙音頻的burst和幀率跟有線完全不一樣SBC/AAC/LC3編碼器的引入讓延遲和buffer需求都有變化。同一套buffer參數(shù)有線耳機不卡藍(lán)牙耳機卡到懷疑人生。處理這類場景至少要判斷當(dāng)前輸出設(shè)備給藍(lán)牙單獨準(zhǔn)備一套buffer策略不要把有線場景的參數(shù)拿過來硬套。6.3 我還想多說一句的調(diào)優(yōu)心法AAudio流控調(diào)優(yōu)本質(zhì)上是在延遲和穩(wěn)定性之間找平衡。你壓得越低風(fēng)險越大你給得越多延遲越高。沒有一個參數(shù)能同時滿足所有設(shè)備、所有場景。我個人在實際項目里會把設(shè)備分成三檔旗艦機用最小buffer跑低延遲中端機稍微放寬低端機再進一步放寬同時根據(jù)是否插耳機、是否錄屏、是否在充電等狀態(tài)動態(tài)調(diào)整。拿一個固定的參數(shù)值跑天下最后一定會在某些設(shè)備上翻車。再分享一個招上線前用自動壓力腳本跑混合場景——播放音頻的同時開動畫、循環(huán)切后臺、持續(xù)讀文件、模擬消息通知彈出連續(xù)跑一小時把underrun次數(shù)作為性能基線。如果這個基線的觸發(fā)頻率能控制在極低水平再上真機體驗基本都不會太差。這套方法比我見過的大部分所謂“優(yōu)化方案”都實用因為音頻卡頓最大的敵人不是某一項配置而是突發(fā)的系統(tǒng)不確定性。你能做的就是給不確定性留夠緩沖。