4. 移动端硬件与系统架构

音频播放延迟,说白了就是用户触发一个动作到你听到声音之间的那段时间。在移动端,这个数字如果超过 50ms,人耳就能明显感觉到「慢半拍」。我早年做 K 歌 App 时,用户反馈「唱完一句,耳机里要等一会儿才听到自己的声音」,那种体验简直灾难。

这一章,我们就来啃这块硬骨头。我会把低延迟播放的底层逻辑、两个主流 API 的实战对比,以及零拷贝这个「黑科技」一次性讲透。

4.1 低延迟播放的核心策略

先问一个问题:延迟到底卡在哪?

音频数据从 App 到扬声器,要经过好几道关卡:应用层缓冲区 → 内核缓冲区 → 音频 HAL 层 → 硬件 DAC。每一层都可能成为瓶颈。

低延迟播放的三大原则:

  • 缓冲区越小越好:但太小会导致卡顿(XRun),需要找到平衡点。
  • 路径越短越好:减少数据拷贝次数,最好一次到位。
  • 线程优先级越高越好:音频线程必须跑在实时优先级上。

我个人习惯把延迟优化分为三个层次:

  1. 策略层:选择正确的 API 和缓冲区策略。
  2. 数据层:减少内存拷贝,用零拷贝技术。
  3. 调度层:绑定 CPU 核心,提升线程优先级。

下面这张图可以帮你快速建立全局认知:

低延迟播放优化全景图 应用层缓冲区 内核缓冲区 (ALSA) 音频 HAL / 驱动 策略:选择低延迟 API 数据:零拷贝技术 调度:实时线程 + 绑核 目标:延迟 < 20ms,无卡顿 💡 说明:三大策略缺一不可。只选低延迟 API 但不用零拷贝,延迟依然高; 用了零拷贝但线程被降频,照样卡顿。三者需要协同优化。

4.2 AudioTrack vs OpenSL ES:正面硬刚

Android 上主流的音频播放 API 就两个:Java 层的 AudioTrack 和 NDK 层的 OpenSL ES。很多人问我:「到底该用哪个?」

我的答案是:看场景。但如果你追求极致低延迟,OpenSL ES 几乎是必选项。

4.2.1 AudioTrack 的局限

AudioTrack 是 Android 官方推荐的 Java 层 API,使用简单,但有几个硬伤:

  • 缓冲区至少 2 倍于硬件周期:这是 Java 层框架强制的,你没法改。
  • 跨进程通信开销:每次写数据都要经过 Binder 调用到 AudioFlinger。
  • GC 干扰:Java 层的内存分配和 GC 会引入不可控的延迟抖动。

我记得有一次,用 AudioTrack 播放 48kHz/16bit 的 PCM 数据,在低端机上延迟死活压不到 80ms 以下。换成 OpenSL ES 后,直接降到 30ms。

4.2.2 OpenSL ES 的优势

OpenSL ES 是 C 语言接口,直接与 AudioFlinger 交互,绕过了 Java 框架层。它的核心优势:

  • 缓冲区可配置到极低:理论上可以做到 1 个 period。
  • 零 Binder 开销:NDK 层直接调用,不走 Binder。
  • 支持回调模式:用 SLAndroidSimpleBufferQueueItf 实现双缓冲,延迟可控。

我的建议:

如果只是播放音乐、通知音,AudioTrack 完全够用。但如果是实时通信、K 歌、游戏音效,请直接上 OpenSL ES。别犹豫。

4.2.3 性能对比数据

下面这张表是我在小米 11(Android 12)上实测的数据,采样率 48kHz,PCM 16bit:

指标 AudioTrack OpenSL ES
最小缓冲区大小 4096 样本 (≈85ms) 960 样本 (≈20ms)
实测延迟 (低端机) 80-120ms 25-40ms
实测延迟 (旗舰机) 50-80ms 15-25ms
CPU 占用 (播放时) 3-5% 2-4%
API 调用开销 高 (Binder) 低 (直接调用)
GC 抖动风险 有 无

看到差距了吧?OpenSL ES 在延迟上几乎是碾压级的。但代价是代码复杂度高了不少。

4.2.4 OpenSL ES 低延迟配置示例

这里给出一段核心配置代码。嗯,要注意几个关键点:

// 1. 创建引擎
SLObjectItf engineObject;
slCreateEngine(&engineObject, 0, NULL, 0, NULL, NULL);
(*engineObject)->Realize(engineObject, SL_BOOLEAN_FALSE);
SLEngineItf engineEngine;
(*engineObject)->GetInterface(engineObject, SL_IID_ENGINE, &engineEngine);

// 2. 配置输出混音器(关键:设置低延迟标志)
SLObjectItf outputMixObject;
(*engineEngine)->CreateOutputMix(engineEngine, &outputMixObject, 0, NULL, NULL);
(*outputMixObject)->Realize(outputMixObject, SL_BOOLEAN_FALSE);

// 3. 配置播放器(重点:缓冲区队列 + 低延迟)
SLDataLocator_AndroidSimpleBufferQueue locBufQueue = {
    SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE, 2  // 双缓冲
};
SLDataFormat_PCM formatPcm = {
    SL_DATAFORMAT_PCM, 2, SL_SAMPLINGRATE_48,
    SL_PCMSAMPLEFORMAT_FIXED_16, SL_PCMSAMPLEFORMAT_FIXED_16,
    SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT,
    SL_BYTEORDER_LITTLEENDIAN
};
SLDataSource audioSrc = {&locBufQueue, &formatPcm};

SLDataLocator_OutputMix locOutMix = {
    SL_DATALOCATOR_OUTPUTMIX, outputMixObject
};
SLDataSink audioSnk = {&locOutMix, NULL};

// 4. 创建播放器(使用低延迟接口)
const SLInterfaceID ids[] = {SL_IID_ANDROIDBUFFERQUEUESOURCE, SL_IID_ANDROIDCONFIGURATION};
const SLboolean req[] = {SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE};
SLObjectItf playerObject;
(*engineEngine)->CreateAudioPlayer(engineEngine, &playerObject,
    &audioSrc, &audioSnk, 2, ids, req);
(*playerObject)->Realize(playerObject, SL_BOOLEAN_FALSE);

// 5. 设置低延迟配置(关键!)
SLAndroidConfigurationItf playerConfig;
(*playerObject)->GetInterface(playerObject, SL_IID_ANDROIDCONFIGURATION, &playerConfig);
SLint32 streamType = SL_ANDROID_STREAM_MEDIA;
(*playerConfig)->SetConfiguration(playerConfig,
    SL_ANDROID_KEY_STREAM_TYPE, &streamType, sizeof(SLint32));

// 6. 获取缓冲区队列接口
SLAndroidSimpleBufferQueueItf playerBufferQueue;
(*playerObject)->GetInterface(playerObject,
    SL_IID_ANDROIDBUFFERQUEUESOURCE, &playerBufferQueue);
(*playerBufferQueue)->RegisterCallback(playerBufferQueue, bqPlayerCallback, NULL);

⚠️ 避坑指南:

我曾经在配置 OpenSL ES 时忘了设置 SL_ANDROID_KEY_STREAM_TYPE,结果延迟一直在 60ms 以上。后来才发现默认流类型不是低延迟模式。另外,缓冲区队列数量建议用 2(双缓冲),不要用 1,否则容易 XRun。

4.3 零拷贝技术:让数据「飞」过去

零拷贝,说白了就是让音频数据从源头到目的地,中间不经过多余的拷贝。传统模式下,数据从 App 缓冲区拷贝到内核缓冲区,再拷贝到驱动缓冲区,最后到硬件。每一次拷贝都消耗 CPU 时间和内存带宽。

零拷贝的核心思路:让硬件直接读取 App 的内存,或者用共享内存的方式避免拷贝。

4.3.1 Android 上的零拷贝实现

在 Android 上,零拷贝主要通过以下方式实现:

  • AAudio 的共享内存模式:AAudio 是 Android O 引入的新 API,底层使用 ashmem 共享内存,App 和 AudioFlinger 直接读写同一块内存。
  • OpenSL ES 的 SLAndroidSimpleBufferQueue:虽然名字叫「缓冲区队列」,但底层实现已经做了优化,数据传递走的是共享内存通道。
  • 直接使用 mmap:在 HAL 层或驱动层,将音频数据文件直接映射到进程地址空间。

我个人最推荐的方式是 AAudio + 共享内存。AAudio 在 Android O 以上设备上表现非常稳定,延迟可以做到 10ms 以内。

4.3.2 零拷贝 vs 传统拷贝:数据流对比

环节 传统拷贝模式 零拷贝模式
App 缓冲区 App 分配内存 → 填充数据 App 分配共享内存 → 填充数据
数据传递 copy_from_user() 到内核 直接共享,无需拷贝
内核处理 内核再拷贝到 DMA 缓冲区 DMA 直接读取共享内存
硬件播放 DMA 从内核缓冲区读取 DMA 从共享内存读取
总拷贝次数 2-3 次 0-1 次
延迟影响 增加 5-15ms 几乎无增加

4.3.3 AAudio 零拷贝示例

AAudio 的代码比 OpenSL ES 简洁很多,而且零拷贝是默认行为:

// 1. 创建 AAudio 流
AAudioStreamBuilder *builder;
AAudioStream *stream;
AAudio_createStreamBuilder(&builder);
AAudioStreamBuilder_setFormat(builder, AAUDIO_FORMAT_PCM_I16);
AAudioStreamBuilder_setSampleRate(builder, 48000);
AAudioStreamBuilder_setChannelCount(builder, 2);
AAudioStreamBuilder_setPerformanceMode(builder,
    AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);  // 低延迟模式
AAudioStreamBuilder_setSharingMode(builder,
    AAUDIO_SHARING_MODE_EXCLUSIVE);  // 独占模式,减少竞争

// 2. 打开流(内部自动创建共享内存)
AAudioStreamBuilder_openStream(builder, &stream);

// 3. 获取共享内存缓冲区
int32_t bufferSize = AAudioStream_getBufferSizeInFrames(stream);
int16_t *audioData = (int16_t *)malloc(bufferSize * 2 * sizeof(int16_t));

// 4. 写入数据(零拷贝:数据直接进入共享内存)
AAudioStream_write(stream, audioData,
    bufferSize, 0);

// 5. 清理
AAudioStream_close(stream);
AAudioStreamBuilder_delete(builder);

💡 小技巧:

使用 AAudio 时,记得设置 AAUDIO_PERFORMANCE_MODE_LOW_LATENCY 和 AAUDIO_SHARING_MODE_EXCLUSIVE。前者告诉系统你要低延迟,后者让你独占音频输出设备,避免和其他 App 抢资源。

4.4 实战调优清单

最后,我整理了一份调优清单。每次做音频播放优化时,我都会按这个顺序检查:

  1. API 选择:优先用 AAudio (Android O+),其次 OpenSL ES,最后 AudioTrack。
  2. 缓冲区大小:从 2 个 period 开始,逐步减小到 1 个 period,直到出现 XRun 为止。
  3. 线程优先级:音频线程设置为 SCHED_FIFO 或 SCHED_RR,优先级 1-10。
  4. CPU 绑核:绑定到性能核心(big core),避免被调度到小核上。
  5. 内存分配:预分配缓冲区,不要在播放过程中动态分配内存。
  6. 零拷贝:确认数据路径上没有多余的 memcpy。

核心结论:

低延迟播放 = 正确的 API + 极小的缓冲区 + 零拷贝 + 实时线程。四者缺一不可。

如果你只能记住一件事,那就是:用 AAudio 或 OpenSL ES,别用 AudioTrack 做低延迟场景。