AudioTrack源码解析:从调用链到低延迟播放的完整指南

发布时间:2026/10/6 10:27:02
AudioTrack源码解析:从调用链到低延迟播放的完整指南 很多时候我们看AudioTrack源码不是因为它有多难而是因为它在Android音频链路里的位置太特殊了。应用层一个简单的new AudioTrack()到声音真正从扬声器出来中间要经过JNI、native层、AudioFlinger、HAL每一层都有对应的状态机和缓冲机制。这也是我为什么一直强调做音频开发不要只停在API调用层面因为你不读源码就永远说不清楚write()为什么会阻塞、延迟为什么降不下去、getMinBufferSize()为什么返回那个数字。这篇源码解析我打算按一条实际项目的排查链路来走先理清AudioTrack的内部结构和调用链再拆解它和AudioFlinger之间的异步协作机制最后落到FastTrack和buffer size这些性能相关的话题上。如果你是想优化播放延迟、排查音频卡顿或者单纯想把Android音频框架这块硬骨头啃下来这篇文章应该能给你一个比较完整的参考。1. 解析入口为什么搞懂构造过程比read代码本身更重要1.1 AudioTrack大类里你不能忽略的几个成员读AudioTrack源码建议先抓住它的几个核心成员变量因为类的骨架就是它们拉起来的。在frameworks/base/media/java/android/media/AudioTrack.java里有这几个异常关键的东西private int mSampleRate; // 采样率 private int mChannelConfiguration; // 通道配置 private int mAudioFormat; // 采样格式16bit/float等 private int mBufferSizeInBytes; // 缓冲区大小单位字节 private int mState; // 未初始化、初始化中、初始化完成 private int mPlayState; // 停止、暂停、播放 private long mNativePtr; // native层AudioTrack对象的指针很多人看代码时容易忽略mNativePtr觉得这就是个句柄存下来就行。但实际上Java层的几乎所有操作——play()、pause()、flush()、write()——最终都要通过mNativePtr找到native层对应的对象来干活。你可以把mNativePtr理解成一座桥Java层的AudioTrack对象只是手持遥控器真正干活的是native层的android::AudioTrack。构造过程中Java层还会做一件重要的事调用native_setup把样本率、通道掩码、格式、缓冲大小、会话ID等参数一次性传给native层。这部分源码看起来很长但其实逻辑就是参数校验 native句柄创建 状态置位三板斧。校验失败会直接抛IllegalArgumentException或UnsupportedOperationException这也是我们经常在崩溃日志里看到的异常来源。1.2 从构造参数反推设计意图AudioTrack的公开构造函数里有几个参数值得盯着看bufferSizeInBytes、sessionId、transferMode。其中transferMode是很容易被忽略的。它决定了数据怎么传给底层——是MODE_STATIC还是MODE_STREAM。MODE_STATIC一次性把所有音频数据写进共享内存适合短促的音效比如按键声、提示音延迟极低。MODE_STREAM数据持续写入边写边播适合音乐、语音这种长时间流式播放。读源码的时候你会发现MODE_STATIC的write()行为和MODE_STREAM差别极大前者直接native层调用createTrack时就把数据放进了静态缓冲区后者则要在后续反复通过write()往共享内存里灌数据。搞清楚这两种模式基本上就理解了AudioTrack设计的第一层逻辑它是拿空间换时间还是拿时间换空间。2. 调用链还原一个play()背后穿过的三个世界2.1 Java层状态机与play()的流转play()看起来就是调到native_play但Java层在调用前有个startPlayState的操作内部维护了一套状态机private void startPlayState() { if (mState STATE_UNINITIALIZED) { throw new IllegalStateException(play() called on uninitialized AudioTrack.); } synchronized (mPlayStateLock) { if (mPlayState ! PLAYSTATE_PLAYING) { mPlayState PLAYSTATE_PLAYING; mPosition 0; native_play(mNativePtr); } } }这里有个容易被忽略的点mPosition只在状态从非播放切到播放时清零如果你在播放中调用pause()再play()mPosition不会归零。这在做进度条功能时是个非常经典的坑——很多开发者以为重新play()就会从头播放其实不然要用reload()或flush()配合setPlaybackHeadPosition才行。2.2 JNI桥native_setup如何搭建native对象从Java层跳到C层入口在frameworks/base/core/jni/android_media_AudioTrack.cpp。native_setup内部不是直接new AudioTrack就完事了它需要先创建JNIDeviceCallback、解析音频属性然后调用到native层的AudioTrack::set。static jint android_media_AudioTrack_setup(JNIEnv *env, jobject thiz, jobject mediaRecorder, jint sampleRateInHertz, jint channelPositionMask, jint audioFormat, jint bufferSizeInBytes, jint mode, jint sessionId) { ... spAudioTrack lpTrack new AudioTrack(); status_t status lpTrack-set(...); ... }看懂这一层你就会知道JNI不是简单地把参数传下去它承担了Java数据结构和C数据结构之间的转换。特别是channelPositionMaskJava层用的是AudioFormat.CHANNEL_IN_STEREO这类常量native层要把它转成audio_channel_mask_t掩码。这个转换如果出了问题表现出来的现象常常不是崩溃而是声音变单声道、或者左右声道颠倒排查起来非常折磨人。2.3 native AudioTrack::set与AudioFlinger的初次握手真正的核心在frameworks/av/media/libaudioclient/AudioTrack.cpp的set()方法。这里做的事情可以概括为四步// 1. 创建I/O句柄 audio_io_handle_t output AudioSystem::getOutput(...); // 2. 通过AudioFlinger创建Track status_t status AudioFlinger::get().createTrack(...); // 3. 保存返回的track句柄和控制块 mAudioTrack iTrack; mCblk static_castAudioTrackClientProxy *(cblk); // 4. 初始化代理对象 mProxy new AudioTrackClientProxy(mCblk);这一步之后应用进程和AudioFlinger之间就有了一条共享内存通道。你在Java层操作的那些write()最终都是往这块共享内存里拷贝数据而AudioFlinger侧的工作线程从同一块内存里取数据混音输出。这个设计是AudioTrack整个机制里最精华的地方应用进程和系统服务不通过Binder逐帧传音频数据而是通过共享内存环形缓冲来协作。Binder只用来传控制命令比如play、pause、音量。这也是为什么AudioTrack能达到低延迟的根本原因——如果把每个音频帧都走Binder整体延迟会高出几个量级。3. 数据写入的真相write()并不直接写硬件3.1 环形缓冲与obtainBuffer/write的核心循环很多人读AudioTrack::write()的源码第一眼会被函数里各种分支绕晕。其实只要抓住主线write() - obtainBuffer() - 拷贝数据 - releaseBuffer()就很简单了。ssize_t AudioTrack::write(const void* buffer, size_t userSize, bool blocking) { while (userSize 0) { releaseBuffer(); ... status_t err obtainBuffer(audioBuffer, blocking ? mBufferTimeout : NULL); ... memcpy(audioBuffer.i8 mInWriteIndex, src, copySize); ... } }obtainBuffer的本质是向环形缓冲申请一块可写区域。如果缓冲满了当前线程就会等待等AudioFlinger侧消费完部分数据腾出空间。这就是write()会阻塞的根本原因——不是它在等什么资源而是它在等消费者把环形缓冲里的数据读走。这里我建议大家把环形缓冲想象成一个有人在不断倒水、有人在不断喝水的杯子。write()就是倒水的人AudioFlinger的混音线程就是喝水的人。如果倒水太快杯子满了倒水的人就得停下来等如果喝水太快杯子空了出声就会断续。AudioTrack设计精妙的地方在于它不是简单地阻塞而是通过AudioTrackClientProxy维护读索引和写索引让两个线程可以无锁协同。3.2 阻塞模式与非阻塞模式的使用边界源码里write()有两个路径blockingtrue和blockingfalse。之前看一些网上的解释说非阻塞模式就是写不进去就立刻返回错误这个说法对但不完整。实际上非阻塞模式在内部还有个超时机制默认值在AudioTrack.h里定义static const uint32_t DEFAULT_UNDERRUN_MS 10;这意味着如果你用非阻塞写且缓冲满了它会重试最多10毫秒超时了才返回WOULD_BLOCK。所以非阻塞模式并不是一秒都不等而是最多等你10毫秒。这个细节在播放器里处理音频数据源切换时很有用——宁可丢几毫秒的数据也不能让UI线程卡死。3.3 静音数据的处理write一次就全OK吗还有一个很多人在实际项目中踩过的坑音频数据没准备好时write应该传什么直接在write()里传一个全零的buffer是可以的但更高效的做法是用setVolume静音或者干脆用obtainBuffer拿到缓冲区后用memset清零再releaseBuffer。源码里其实暗示了这一点——write()内部对数据来源没有做任何校验它只认字节数和内存地址。这也引出一个安全性问题如果你传的userSize超过当前AudioTrack配置所能接受的范围底层会返回INVALID_OPERATION。所以write()的返回值检查非常有必要不要以为数据丢给系统就万事大吉。4. AudioFlinger侧消费共享内存与帧计数的异步协作4.1 控制块与客户端代理的配合前面说了App和AudioFlinger通过共享内存协作。这个共享内存里除了音频数据本身还有一个控制块control block。在Android 8.0以后的版本里控制块的封装是audio_utils::audio_utils_shared_pointer和AudioTrackClientProxy。我建议你重点看这个文件frameworks/av/media/libaudioclient/include/media/AudioTrackClientProxy.h这个文件里定义了几个计数变量是整个异步协作的核心mFramesReady服务端还有多少帧可以读mFramesWritten客户端已经写入了多少帧mFramesRead服务端已经读走了多少帧这些计数变量在无锁环境下由两个线程同时操作所以内部用了atomic操作。AudioFlinger的混音线程每消费完一块数据就会更新mFramesRead而客户端每次write()后更新mFramesWritten。两者相减就能得到当前缓冲区的填充程度从而控制读写节奏。4.2 帧计数与延迟计算的关系如果理解了帧计数延迟计算就非常直观了。延迟的本质就是数据从App写入到耳朵听到之间的时间差可以用一个简单公式估算延迟 填充帧数 / 采样率 硬件输出延迟当然实际还要考虑HAL的开销但它至少解释了为什么buffer越大延迟越高。很多人用getMinBufferSize()拿到的值来配置播放器其实getMinBufferSize本身就是根据采样率、通道数、格式算出来的它的计算逻辑在Java层是public static int getMinBufferSize(int sampleRateInHz, int channelConfig, int audioFormat) { int channelCount ...; int size native_get_min_buff_size(sampleRateInHz, channelCount, audioFormatInBytes); return size; }native层内部会通过AudioSystem::getOutputFrameCount结合采样率和硬件能力算出一个不会导致欠载的最小帧数再转成字节数。所以如果你看到某些设备上同一组参数算出来的getMinBufferSize特别大不代表你的代码有问题很可能是该设备的硬件输出缓冲本身偏大。4.3 AudioFlinger侧Track的读取路径AudioFlinger侧每个播放线程PlaybackThread会循环处理各个Track。核心逻辑在AudioFlinger::PlaybackThread::threadLoop()它会把所有活跃的Track的数据混合成一路输出。while (!threadLoopShouldStop()) { // 收集每个Track的buffer // mix所有Track数据到主缓冲 // 交给HAL写入硬件 }这个过程里有个很关键的制度AudioFlinger不会等某个Track的数据凑满才输出而是每个周期都去尝试读取读不到就静音填充。所以App端如果写入不及时表现出来就是卡顿和杂音而不是音频被拉长。读源码时你会看到很多关于underrun的统计逻辑这些统计最后会反馈到AudioTrack.getUnderrunCount()在API 24开始提供是排查播放卡顿的一个好帮手。5. FastTrack与低延迟通路怎样说服系统走快车道5.1 FastTrack的启用条件从Android 4.2开始AudioFlinger设计了FastTrack机制专门服务低延迟场景比如游戏音效、触摸反馈。FastTrack走单独的高优先级线程和更小的buffer整体延迟可以压到很低的水平。但系统不是对每个Track都启用FastTrack它有一堆判定条件具体看AudioFlinger::PlaybackThread::createTrack_l和Track::isFastTrack()。粗略归纳一下大致需要满足以下条件Track的flag带上了AUDIO_OUTPUT_FLAG_FASTTrack帧数不超过阈值通常是设备支持的最小周期帧数采样率、格式、声道数在设备直接支持的范围内没有开启需要重采样的效果处理没有使用特殊的offload或直接PCM之外的路径这里面最容易由应用侧控制的就是第一点也就是AudioTrack构造时设置AudioAttributes的setFlags(AUDIO_FLAG_LOW_LATENCY)。不过要强调这个flag只是申请系统是否批准取决于设备和当时系统的状态。你设了flag但没走FastTrack的情况很常见。5.2 实际项目中把延迟压下去的配置组合我在做实时K歌类App时最低延迟能做到多少其实不是一个参数决定的。这里分享一套我实测过多次的组合方案如果你需要低延迟可以这样配置使用AudioTrack.Builder指定setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)。采样率强制用设备原生支持的采样率通常是48000Hz不要做44.1kHz到48kHz的重采样。使用AudioFormat.ENCODING_PCM_FLOAT避免16bit转换的额外开销这个在部分设备上能明显降低延迟。buffer大小尽量贴近getMinBufferSize()或者用底层AudioTrack.getBufferSizeInFrames()参考。另外还有一个值得注意的底层参数frameCount它和bufferSizeInBytes相关。Native层在创建FastTrack时会要求帧数小于等于某个阈值。如果你设置的buffSize过大系统宁可走普通通路也不会给你FastTrack。所以想要低延迟buffer不是越大越稳反而是越小越接近目标但小到一定程度又会面临underrun风险这个平衡就得靠实测来定。5.3 代码层面如何判断是否走了低延迟想要确认当前Track是否走FastTrack公开API其实没有直接暴露。不过有个取巧的办法对比AudioTrack.getTimestamp()返回的framePosition推进速度或者间接观察getUnderrunCount()是否频繁跳变。如果你设了低延迟相关的flag但underrun计数疯狂增长基本说明它还是走了普通通路只是buffer给得太小了。想更精确验证就得在native层打日志看PlaybackThread的日志里有没有FastTrack字样。因为PlaybackThread在创建Track时会打类似createTrack_l() ... fast track的日志。如果你是在做系统开发这个日志很直观如果是在做应用开发那就只能靠对延迟的主观感受和上述间接指标来猜。实话讲这也是应用层做音频优化比较憋屈的地方。6. 源码阅读路线图与我在实际排障中积累的经验6.1 一份针对AudioTrack源码的推荐阅读顺序如果你现在想系统地啃AudioTrack源码我的建议不是从头到尾硬啃而是按使用路径倒着读先读Java层的AudioTrack公开方法重点是write()、play()、pause()、flush()、getMinBufferSize()。先搞清楚每个API的行为和状态变化。再读JNI层的android_media_AudioTrack.cpp看Java方法是怎么一步一步映射到native调用的。然后读native层AudioTrack.cpp的set()、write()、obtainBuffer()、releaseBuffer()。这是整个源码解析的重中之重。最后读AudioFlinger.cpp里和Track创建、mixer循环相关的部分以及AudioTrackClientProxy的计数逻辑。这套路线读下来你对App侧写数据和系统侧读数据两条线就有了完整 picture以后遇到音频卡顿、延迟高、声音异常这类问题定位思路会清晰很多。6.2 读源码时最容易忽略但值得多看两眼的细节点第一处是AudioTrack::obtainBuffer里对cblk状态的判断。它不只检查缓冲是否可写还会检查音频流是否已经停止、设备是否发生了重路由。如果你在耳机拔出、蓝牙切换这种场景出现write()返回异常多半是底层状态已经切走而上层没有同步处理。第二处是AudioTrack在MODE_STATIC下的write行为。静态模式不是边写边播而是写完后要play()底层才从静态缓冲区取数。很多新手在静态模式下反复write()结果返回INVALID_OPERATION这是对模式理解不到位导致的。第三处是flush()和pause()之间有个微妙的互动。源码里flush()如果是在暂停状态下调用表现和在播放状态下调用还不一样——在暂停状态下flush播放位置归零缓冲清空在播放状态下flush数据停止输出但状态依然是playing后续要继续播放需要重新play()。我见过不少人在这里栽跟头导致暂停恢复后音频从头开始放。6.3 一个亲测有效的underun调优流程最后分享一个我在项目里反复使用的排查音频卡顿的流程。无论你用的播放器是ExoPlayer还是自己写的AudioTrack播放器都可以按这个思路来定位是不是AudioTrack配置层出了问题。第一步抓取getUnderrunCount()。如果这个值在播放过程中持续增长说明音频数据供给速度跟不上消费速度铁定是写数据侧有瓶颈。第二步检查是否用了非阻塞写。如果是看一下数据源读取耗时。很多情况下卡顿不是因为buffer太小而是因为从网络或磁盘读数据太慢导致write()相隔太久。第三步把buffer调大试试。注意这里说的调大不是无脑调getMinBufferSize的倍数而是逐档增加。每次增加50ms左右的缓冲量同时监测延迟表现。找到既不卡顿延迟又可接受的中间值时就固定下来。第四步如果buffer加了还是卡就要怀疑是不是底层采样率不匹配了。强制换个采样率测试比如从44.1kHz切到48kHz换个说法就是绕开重采样链路。我遇到过不少设备重采样器在特定格式下表现糟糕换了原生采样率后问题直接消失。这套流程走下来大多数播放卡顿问题都能定位到是App层供给节奏的问题还是系统层重采样/混音的问题。至少我在自己的项目里靠它排除过好几次疑难杂症。还有一个提示想补充读源码不要只看某一个版本的代码。Android音频框架在几个大版本里都有结构性调整比如控制块从audio_track_cblk_t演进到AudioTrackClientProxy延迟相关从getLatency()拓展到getTimestamp()所以不同版本之间代码差异很大。你如果拿着旧版本的源码分析结论去套新版系统大概率会踩坑。建议动手前先确认设备对应的AOSP分支再定位相关文件。最后说个个人习惯。我每次阅读源码都会顺手画一张方法调用链的记录表比如新创建AudioTrack时某个参数在每一层会被怎么变形和传递。因为源码里函数跳转很多很容易看一段忘一段。而音频这个领域偏偏每一层都对最终声音质量有影响。把整个链路按方法为单位拆开记录遇到问题才能快速定位到具体那一层而不是把时间耗在反复翻代码上。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询