25、AudioFlinger与MediaPlayer交互:MediaPlayer音频通路、NuPlayer与AudioSink、音视频同步实现
这一章,我们来聊聊MediaPlayer这条线。很多做上层应用开发的同事,对MediaPlayer的调用接口很熟悉,但一深入到Native层,就开始发懵了。MediaPlayer到底是怎么把音频数据喂给AudioFlinger的?NuPlayer和AudioSink又扮演了什么角色?音视频同步到底是怎么做到的?
我个人习惯把MediaPlayer的音频通路想象成一条“水管”。上游是解码器,下游是扬声器,中间需要各种阀门、泵和缓冲池。今天我们就来拆开这条水管,看看每一段是怎么工作的。
MediaPlayer的音频通路概览
先看一张整体流程图,帮你建立全局认知。
这张图里,从MediaPlayer到AudioFlinger,中间经过了NuPlayer、解码器、AudioSink。每一层都有自己职责。我见过不少开发者,遇到播放卡顿问题,直接怀疑AudioFlinger,其实问题往往出在NuPlayer和AudioSink的交互上。
NuPlayer与AudioSink:音频数据的“最后一公里”
NuPlayer是Android 4.0之后引入的播放引擎,替代了旧的StagefrightPlayer。它内部维护了一个状态机,处理播放、暂停、seek等操作。但音频数据最终怎么送到AudioFlinger?靠的就是AudioSink。
AudioSink是一个接口,定义在media/libmedia/include/media/AudioSink.h里。它的核心方法就几个:open()、write()、start()、stop()、pause()、flush()。说白了,它就是一个音频输出的抽象层。
NuPlayer内部有一个NuPlayer::Decoder,解码后的音频PCM数据,会通过AudioSink的write()方法写入。AudioSink的实现类通常是AudioOutput,它内部会调用AudioTrack,最终通过AudioTrack::write()把数据送到AudioFlinger的共享内存中。
嗯,这里有个关键点:AudioSink的write()是阻塞的。如果AudioFlinger消费速度跟不上,write()就会卡住。我在项目中遇到过一个问题,播放高码率音频时,write()返回很慢,导致NuPlayer的音频解码线程被阻塞,最终出现视频播放卡顿。排查了半天,才发现是AudioSink的缓冲区设置太小了。
核心流程:
- NuPlayer解码音频帧 → PCM数据
- 调用AudioSink::write()写入数据
- AudioSink内部通过AudioTrack写入共享内存
- AudioFlinger的MixerThread从共享内存读取数据
- AudioFlinger混音后写入HAL输出到扬声器
音视频同步:一场精密的“时间赛跑”
音视频同步,简称AVSync,是播放器里最头疼的问题之一。你想想看,视频帧和音频帧是独立解码的,怎么保证它们在同一时刻被呈现?
Android里,NuPlayer采用“音频为主,视频为辅”的同步策略。说白了,就是让音频时钟做主时钟,视频去对齐音频。
为什么选音频做主时钟?因为人耳对音频抖动的敏感度远高于人眼。视频稍微跳帧或延迟,你可能感觉不到;但音频一旦出现断续或不同步,你立刻就能察觉。所以,让音频保持连续播放,视频去追音频的时间戳,这是最稳妥的做法。
具体实现上,NuPlayer内部维护了一个MediaClock对象。音频解码器每解码一帧,会记录下该帧的PTS(Presentation Time Stamp),然后通过MediaClock更新当前播放时间。视频解码器在渲染每一帧之前,会查询MediaClock的当前时间,如果视频帧的PTS落后于音频时钟,就立即渲染;如果超前,就等待。
我贴一段简化后的伪代码,帮你理解这个逻辑:
// NuPlayer音频解码线程
void onAudioFrameDecoded(AudioFrame *frame) {
// 更新MediaClock的播放时间
mMediaClock->updateTime(frame->mPTS, frame->mDuration);
// 写入AudioSink
mAudioSink->write(frame->mData, frame->mSize);
}
// NuPlayer视频渲染线程
void onVideoFrameDecoded(VideoFrame *frame) {
int64_t nowUs = mMediaClock->getRealTimeUs();
int64_t diffUs = frame->mPTS - nowUs;
if (diffUs < -5000) {
// 视频帧落后超过5ms,直接丢弃
ALOGW("Video frame too late, dropping");
return;
} else if (diffUs > 0) {
// 视频帧超前,等待
usleep(diffUs);
}
// 渲染视频帧
renderVideoFrame(frame);
}
这段代码虽然简单,但核心思想就是:音频驱动时钟,视频对齐时钟。实际实现中,还要考虑网络流、seek、快进快退等复杂场景,但基本原理不变。
避坑指南:我曾经遇到一个case,播放某些MP4文件时,视频画面明显比音频快。排查发现,是视频解码器输出的PTS单位不一致——有些文件用微秒,有些用纳秒,而NuPlayer默认按微秒处理。最后在解析视频帧时加了一个单位转换,问题解决。所以,处理时间戳时一定要确认单位!
AudioSink的缓冲区策略
AudioSink内部有一个环形缓冲区(Ring Buffer),用于暂存解码后的PCM数据。这个缓冲区的大小直接影响播放延迟和卡顿风险。
缓冲区越大,抗抖动能力越强,但延迟也越大。缓冲区越小,延迟低,但容易因为解码不及时导致卡顿。这是一个典型的“延迟 vs 稳定性”的权衡。
Android里,AudioSink的缓冲区大小由AudioTrack的getBufferSizeInFrames()决定。默认情况下,AudioTrack会根据采样率、通道数、格式计算出一个合适的缓冲区大小。但你可以通过setBufferSizeInFrames()手动调整。
我个人的经验是:对于本地播放,缓冲区可以设大一点(比如512ms),保证流畅性;对于实时通信场景,缓冲区要尽量小(比如20ms),降低延迟。
| 场景 | 推荐缓冲区大小 | 延迟 | 抗抖动能力 |
|---|---|---|---|
| 本地音乐播放 | 512ms ~ 1024ms | 高 | 强 |
| 视频播放 | 128ms ~ 256ms | 中 | 中 |
| 实时通话 | 20ms ~ 40ms | 低 | 弱 |
注意:不要随意调小缓冲区。如果你把缓冲区设得太小,AudioFlinger来不及消费,AudioSink的write()就会频繁阻塞,导致NuPlayer解码线程被拖慢,最终出现音视频不同步。我曾经在低端设备上踩过这个坑,调小缓冲区后,播放高码率视频直接卡成PPT。
音视频同步的进阶:动态调整
实际播放过程中,音视频时钟的偏差是动态变化的。比如,音频解码器突然遇到一个复杂帧,解码时间变长,音频时钟就会滞后。这时候,视频渲染线程需要检测到偏差,并做出调整。
NuPlayer里有一个AVSyncController,它负责监控音频时钟和视频时钟的偏差。如果偏差超过阈值(默认40ms),就会触发调整策略:
- 音频超前,视频落后:加快视频渲染速度(比如跳过部分帧),或者减慢音频播放速度(通过调整AudioTrack的播放速率)。
- 视频超前,音频落后:减慢视频渲染速度(等待),或者加快音频播放速度。
调整音频播放速率,在Android里是通过AudioTrack::setPlaybackRate()实现的。这个接口可以微调播放速度,范围通常在0.5x到2.0x之间。微调幅度很小(比如1.01x),人耳几乎察觉不到,但足以纠正时钟偏差。
我记得有一次,客户反馈播放某些流媒体时,音视频不同步越来越严重。排查发现,是音频时钟的更新频率太低——音频解码器每解码一帧才更新一次时钟,而视频帧率比音频帧率高,导致视频时钟追不上音频时钟。最后在MediaClock里增加了一个插值算法,在音频帧之间估算中间时间点,问题解决。
总结一下
MediaPlayer的音频通路,说白了就是一条从解码器到AudioFlinger的数据管道。NuPlayer负责调度,AudioSink负责传输,AudioFlinger负责混音输出。音视频同步的核心是“音频为主,视频为辅”,通过MediaClock和AVSyncController动态调整。
这一章的内容偏原理,但理解这些原理,能帮你快速定位播放相关的问题。下次遇到音视频不同步、播放卡顿、延迟过高等问题,你就知道该从哪个环节入手了。
公众号:蓝海资料掘金营,微信deep3321