1. 先搞懂AAudio到底在管什么
1.1 从一次卡顿说起
做Android音频开发的兄弟,大概率都遇到过这种场景:你兴致勃勃地把录音或播放链路切到了AAudio,一跑起来声音倒是出来了,可放不了几秒钟就“咔哒”一声,然后就是断断续续的爆音、迟缓、跟手度全无。更郁闷的是,同一段代码在A设备上跑得很稳,换到B设备上卡成幻灯片,在C设备上又完全正常。
这种玄学问题,十有八九不是解码器出Bug,也不是硬件坏了,而是音频流的流控机制没有吃透。
AAudio是Android 8.0开始推出的原生音频API,目标很明确:用更低的延迟、更稳定的调度,替代老牌的OpenSL ES,让做乐器App、K歌、实时音效、专业录音的人能有接近iOS那套AudioUnit的体验。但“低延迟”这件事,本身就是把双刃剑——延迟越低,缓冲区就越小,缓冲区越小,系统稍微抖一下,卡顿就来了。
这篇文章我不打算念文档,而是站在实际调过的项目角度,把AAudio的流控机制拆开讲清楚:数据到底怎么流动、缓冲区和underrun之间的关系、卡顿出现时怎么定位,以及最后能直接抄作业的解决方案。无论你是在写播放器、录音器,还是做低延迟音效引擎,这套思路基本通用。
1.2 AAudio在音频链条中的位置
先对齐一下基础位置。Android的音频链路从上到下大概是这样的:你的App代码(Java层AudioTrack/AudioRecord,或者NDK层的AAudio)→ AudioFlinger(系统混音服务)→ Audio HAL(硬件抽象层)→ 底层驱动 → 声卡或DSP。
AAudio虽然挂在NDK层,但它和AudioTrack最大的区别是:AAudio支持“不受混音影响”的独占路径。默认情况下,App的声音都要进AudioFlinger的Mixer里跟其他声音混在一起,再统一送到底层。这个过程很方便,但混音、重采样、通道转换都会有额外开销和延迟。
AAudio开启低延迟模式、占上独占模式之后,它会尝试走一条更短的路径——相当于从你家小区门口直接上高速,不再绕到市区转一圈。最终能不能走上快捷路径,由系统根据硬件能力、当前设备状态决定,但只要你把参数设对了,大多数现代设备都会给你走MMAP这条相对直接的通道。
理解了它在链条中的位置,后面所有流控概念都好解释了。
2. 流控机制的核心:数据是怎么流动的
2.1 frames、burst和缓冲区
AAudio里你打交道最多的是frames这个概念。一个frame在音频里不是“一帧画面”,而是“一次采样周期内,所有声道分别采一个样本”的集合。比如双声道16bit的音频,一个frame就是2个采样点,4个字节。你用采样率44100,就代表每秒要输送44100个frame给硬件。
AudioStream建立之后,系统会告诉你两个关键值:
framesPerBurst:每次硬件中断(或者说一次burst)期望接收的frame数量,可以理解成“水管每次喷水的颗粒度”。bufferCapacityInFrames:缓冲区最多能装的frames数量,相当于水管的缓存池。
但capacity只是上限,真正决定延迟大小的是实际使用多少缓冲区。很多人在调低延迟时犯的错误是:只改了capacity,没改buffer size,导致缓冲区仍然很大,延迟没降下来,卡顿倒是依然存在。
一个比较经典的目标:如果你的采样率是48000,每burst是192帧(常见的FastMixer/MMAP里burst通常192或240帧),那么一次burst持续的时间就是192÷48000=4ms。缓冲区如果设置成2个burst左右(约384帧),端侧延迟大概8ms,加上路径上的固定开销,整体能在20ms上下——人耳对延迟能感知的阈值一般在20~30ms附近,所以这个水平已经相当可用。
2.2 两种数据读写模式:回调与阻塞
AAudio提供了两种数据交互方式,流控行为完全不同。
第一种是数据回调模式(callback)。你给stream注册一个回调,系统每个burst周期会调用你一次,让你往缓冲区里填数据(播放)或读数据(录音)。
aaudio_data_callback_result_t myDataCallback( AAudioStream *stream, void *userData, void *audioData, int32_t numFrames) { // 把numFrames个frame的数据填充到audioData中 fillFrames(audioData, numFrames); return AAUDIO_CALLBACK_RESULT_CONTINUE; }回调模式的好处是:回调线程由AAudio内部管理,优先级高,而且节奏跟硬件中断对齐,你不需要自己熬夜算延迟。坏处也很明显:回调里不能做任何可能阻塞的事,否则就会拖垮整个管线。
第二种是阻塞读写模式(Blocking Read/Write)。你主动调AAudioStream_write()或者AAudioStream_read(),就像往水管里手动灌水。这种方式使用起来更直观,尤其是已经有现成音频处理代码的人,迁移成本低。
int64_t written = AAudioStream_write(stream, buffer, framesToWrite, timeoutNanos);阻塞模式下,缓冲区大小、当前水管里有多少积水,是可以主动感知的。如果写入太快会“憋住”,写入太慢就会断流。
那该选哪种?我的习惯是:做播放器且没有复杂实时处理需求,可以用阻塞写;做乐器、实时耳返、变声这种对时序敏感的,必须上回调。
2.3 独占/共享,低延迟/省电
AAudio builder里有几个“命运开关”,它们组合出来的流控行为差异非常大。
- 共享模式:
AAUDIO_SHARING_MODE_SHARED会让你的流跟其他App混流,兼容性好,但延迟高,且可能被其他声音干扰;AAUDIO_SHARING_MODE_EXCLUSIVE试图独占硬件通道,延迟低,但并不是所有设备都能拿到独占权限,拿不到时系统会自动降级为共享。 - 性能模式:
AAUDIO_PERFORMANCE_MODE_LOW_LATENCY是低延迟模式,系统会尽量用小缓冲区、高优先级调度;AAUDIO_PERFORMANCE_MODE_NONE是功耗优先,延迟相对高,适合后台播放这种不敏感场景。
实际项目里,低延迟音乐应用一般都这样组合:性能模式选LOW_LATENCY,共享模式先请求EXCLUSIVE,拿到好评测,拿不到也接受SHARED。
要注意的是:这俩参数主要是“请求”,不是“保证”。系统会根据硬件和当前负载决定最终形态,所以你以为自己在独占低延迟,运行时的实际参数可能已经不是那么回事了。判断最终值最简单的方式,就是在stream打开之后主动查一遍:
AAudioStream_getSharingMode(stream); AAudioStream_getPerformanceMode(stream);如果拿到的结果和你预期的差距很大,后面调优方向就要跟着变。
3. 卡顿是怎么产生的:流控链路中的瓶颈排查
3.1 underrun与overrun:音频流的“断粮”和“积压”
音频流卡顿最直接的原因永远是同一个:缓冲区里没有足够的frame可送,或者送的频率不稳定。
播放场景里,硬件按固定节奏消费数据,你负责往缓冲区里填数据。如果你填的速度跟不上硬件消费的速度,缓冲区就被掏空了,这时候就是underrun(下溢)。硬件发现没数据可播,只能中断输出,你听到的就是爆音、咔哒声,严重时整段丢音频。
录音场景反过来,硬件不停往缓冲区里塞数据,如果你不及时读走,缓冲区满了还继续塞,新的数据没地方放,只能丢掉,这就是overrun(上溢)。录音结果听起来像是掉了几个字、节奏忽快忽慢。
这两个概念一定得刻在脑子里。凡是碰到AAudio卡顿,第一步不是猜设备,而是确认到底是哪种情况。
3.2 真正让音频流卡顿的五个常见原因
缓冲区设置过小。很多人一味追求低延迟,把buffer size压到1个burst甚至更低。设备调度稍微波动一次,立刻underrun。低延迟和稳定性之间必须有妥协点。
回调里干了重活。在
onAudioStreamReady回调里做解码、网络读取、内存分配、加锁、打日志,都会让回调执行时间超过一个burst周期。这样做导致的后果是:你的处理逻辑变成串行,某个周期来不及供货,缓冲区就空了。系统调度抖动。Android不是硬实时系统。DDL、动画、GC、其他App抢占CPU、系统服务突发访问I/O,都会让你的音频回调线程偶尔得不到CPU。如果缓冲区只有一个burst的余粮,一小下抖动就能引爆卡顿。
路径未走低延迟快速通道。参数没设对或设备不支持,流走的是普通混音通道,中间多了混音、重采样、通道转换环节。延迟变大,卡顿感知也会放大,尤其是在做一些对时序要求较高的交互时。
并发音频场景互相抢资源。比如你在用AAudio播放的时候,系统来了一条通知音,或者另一个App在录屏、播放视频、开沉浸式导航语音,这时硬件资源被抢占,stream也容易出问题。录屏时掉帧、声音卡顿一起出现,已经不是音频单点问题了,而是整机CPU和总线带宽都紧张。
4. 定位问题的实操流程
4.1 先用日志和数据判断是否真的underrun
很多同学一遇卡顿就想着调大缓冲区,但调大多少、往哪个方向调,都凭感觉。正确做法是先量化。
AAudio有一个很实用的查询:AAudioStream_getFramesWritten()和AAudioStream_getFramesRead()。播放场景里,written表示应用累计写入的frames数,read表示硬件累计消费的frames数。两者的差值,就是当前缓冲区里滞留的frames数量。
我通常会在一个专门的调试线程里每500ms采样一次这两个值。写一个小探测函数:
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);观察一段时间之后你就能得出三个结论:
- bufferedFrames经常掉到0附近,说明underrun频繁,缓冲区余量不足。
- bufferedFrames长期接近capacity,说明写入太激进或者消费速度跟不上,需要加快读或者减小写入节奏。
- bufferedFrames稳定在buffer size的一半左右,且波动小,说明供需平衡,这个状态最健康。
音频框架本身也可能有性能计数器。AAudio在logcat里会在出现underrun时打印类似UNDERRUN、overrun的日志,但不同厂商ROM日志格式差异很大,有的根本不打。所以不能只依赖logcat,自己做的监测才是最可靠的。
4.2 用时间戳和frames计算延迟抖动
除了数量上的监控,还要看时序稳定性。AAudio提供了时间戳接口,返回一组framePosition和对应的timeNanoseconds,可以理解成“某个时间点,硬件处理到第几个frame”。
AAudioStream_getTimestamp(stream, CLOCK_MONOTONIC, &framePosition, &timeNanoseconds);通过连续取两次时间戳,可以估算出硬件的实际消费速度和间隔。间隔稳定说明调度平稳;间隔忽大忽小,说明系统调度有抖动,这时候即使平均下来不卡,也会偶发爆音。
延时抖动是比underrun更难查的问题。建议做法是:在回调入口和出口各记录一次时间戳,连续跑一分钟,统计回调周期的均值、最大值和标准差。如果最大周期经常超过中位数的两倍以上,这系统调度就很不稳,必须从优先级和缓冲区余量两个方向补强。
5. 解决卡顿的完整方案
5.1 参数调整:合理设置buffer capacity
参数是能最快见效的部分。调优时按这个顺序来:
先确认帧率和burst大小。以48000Hz、burst为192帧的设备为例,理论上1个burst是4ms。如果目标是低延迟,建议buffer size从2个burst起跳,也就是384帧左右。跑起来之后观察underrun情况,再每次增加64或96帧,直到卡顿消失。
这一步我用的是“最小稳定值”思路:从能接受的最低延迟开始,逐步加缓冲区,直到连续跑5分钟无underrun为止。不要一上来就贪大,缓冲区过大延迟就上来了,交互类应用会明显觉得声音发“木”。
capacity的设定则要预留余量。一般设成buffer size的2到4倍,这样系统在极端抖动时还能通过临时扩buffer来救场。不过capacity本身只是上限,能不能在运行时扩容,还要看driver是否支持,代码里可以先查询:
bool isDynamic; AAudioStream_isBufferSizeRangeSupported(stream, &isDynamic); if (isDynamic) { AAudioStream_setBufferSizeInFrames(stream, targetSize); }不支持运行时调整的设备,只能关闭stream重新以目标size打开。
5.2 代码侧优化:别在回调里捅娄子
回调函数是流控的心脏。我在代码审查里最常说的话就是:回调里只能有一类操作——把数据从这个地址搬去另一个地址。
具体来说,这几个行为是绝对不能出现在回调里的:
- 内存分配(
malloc、new、std::string都不行) - 锁操作(
pthread_mutex_lock、std::mutex都要避开) - 文件I/O、网络I/O
- 过度复杂的算法或浮点运算密集操作(例如高频FFT,如果做不过来就分包到普通工作线程)
- 打日志(
__android_log_print是有开销的,还可能导致线程阻塞)
如果数据处理很重,正确姿势是在回调里只做轻量拷贝或队列写入,然后在另一个优先级合适的线程里做解码、重采样、特效计算。数据交接用无锁环形缓冲,经典的SPSC Queue就行。
另外,stream里的参数设置也值得留意。如果Builder默认sample rate、channel count和format与你源数据不一致,AAudio会专门开一个转换器。重采样和通道转换是CPU开销,也是潜在的卡顿隐患。能提前把数据转成与stream一致的格式,比在回调里让系统动态转要稳妥。
5.3 架构级调整:切换路径、线程、插拔保护
代码级优化之后,卡顿问题通常能解决百分之七八十。剩下的属于架构和外围问题,需要在更高层面做保护。
- 请求低延迟路径但做好降级预案。设备不支持独占模式时,系统会退回共享模式。代码里要监听实际状态,如果发现实际性能模式或共享模式不符合预期,提醒自己当前是“降级”状态,不要再用最高规格的假设做性能调优。
- 管理App的音频焦点。有通知进来、有电话、有其他播放器启动时,AAudio并不自动帮你停流。不做焦点处理的App,会出现声音突然变杂、被混叠、忽大忽小的情况。监听焦点的丢失,及时暂停、清理或降低音量,保证卡顿感知不明显。
- 设备插拔和路由变化保护。插入耳机、拔出耳机、连接蓝牙,音频链路会重建,stream状态会变化。要对
AAudioStream_state变化做监听,链路断开时及时重启stream,否则可能出现采集不到声音或播放半天什么都没出来。 - 线程优先级调整。有些设备上回调线程虽然不低,但你自己创建的工作线程默认优先级不够高,数据处理不及时也会饿着回调。工作线程可以用最朴素的setPriority提高优先级,但不能滥用,否则干扰到系统调度反而会加剧抖动。
6. 常见问题速查与避坑经验
6.1 卡顿场景速查表
| 现象 | 大概率原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 播放出现周期性“咔哒”声 | 缓冲区过小导致underrun | 查看framesWritten-framesRead是否常为0 | 逐步增大buffer size,找到最小稳定值 |
| 录音随机丢字、掉时长 | overrun,缓冲区满了新数据被丢弃 | 查看录音侧的framesRead是否追不上written | 增大录音buffer,或减少回调内处理时间 |
| 一按屏幕/开始动画就卡 | 系统调度抖动 | 对比回调周期与动画时段 | 增加buffer余量,优化回调内耗时 |
| 插拔耳机后卡顿 | 路由变化导致stream状态异常 | 检查state和logcat的route切换日志 | 监听状态变化,重建设备恢复stream |
| 锁屏后一段时间再解锁声音变糊 | 设备进入低功耗模式 | 检查性能模式与system suspend | 锁屏期间暂停播放,解锁后重建,或者使用WAKE_LOCK |
| 录屏同时播放声音卡顿掉帧 | CPU/总线竞争,整机负载过高 | 看系统负载和线程调度统计 | 降低屏幕录制码率、限制后台任务、增加音频buffer余量 |
| 后台有App播放另一个流时被混音干扰 | 共享模式下与其他AudioTrack混流 | 确认sharing mode是否为EXCLUSIVE | 请求独占模式;无法独占时降低本App的延迟预期 |
6.2 我踩过的几个坑
第一个坑是盲目相信系统日志。曾经有个项目在低端机上产生大量underrun,logcat却干干净净,查了一天没结果。后来自己做了frames差值统计,才发现underrun确实存在,只是厂商ROM把日志打到了别的地方。现在我的习惯是:日志只做参考,自己主动统计才作数。
第二个坑是在回调里顺手做了一次数据拷贝和格式转换,当时觉得就几十个KB,不至于出问题。结果在负载高的场景下,偶尔一次耗时飙到十几毫秒,一个burst才4毫秒,直接连续丢了好几个burst。后来把格式转换挪到了初始化阶段,同时用查表代替了部分计算,卡顿立竿见影地消失了。
第三个坑是关于蓝牙设备。蓝牙音频的burst和帧率跟有线完全不一样,SBC/AAC/LC3编码器的引入让延迟和buffer需求都有变化。同一套buffer参数,有线耳机不卡,蓝牙耳机卡到怀疑人生。处理这类场景至少要判断当前输出设备,给蓝牙单独准备一套buffer策略,不要把有线场景的参数拿过来硬套。
6.3 我还想多说一句的调优心法
AAudio流控调优,本质上是在延迟和稳定性之间找平衡。你压得越低,风险越大;你给得越多,延迟越高。没有一个参数能同时满足所有设备、所有场景。
我个人在实际项目里会把设备分成三档:旗舰机用最小buffer跑低延迟,中端机稍微放宽,低端机再进一步放宽,同时根据是否插耳机、是否录屏、是否在充电等状态动态调整。拿一个固定的参数值跑天下,最后一定会在某些设备上翻车。
再分享一个招:上线前用自动压力脚本跑混合场景——播放音频的同时开动画、循环切后台、持续读文件、模拟消息通知弹出,连续跑一小时,把underrun次数作为性能基线。如果这个基线的触发频率能控制在极低水平,再上真机体验,基本都不会太差。这套方法比我见过的大部分所谓“优化方案”都实用,因为音频卡顿最大的敌人不是某一项配置,而是突发的系统不确定性。你能做的,就是给不确定性留够缓冲。