25、AudioFlinger与MediaPlayer交互:MediaPlayer音频通路、NuPlayer与AudioSink、音视频同步实现

这一章,我们来聊聊MediaPlayer这条线。很多做上层应用开发的同事,对MediaPlayer的调用接口很熟悉,但一深入到Native层,就开始发懵了。MediaPlayer到底是怎么把音频数据喂给AudioFlinger的?NuPlayer和AudioSink又扮演了什么角色?音视频同步到底是怎么做到的?

我个人习惯把MediaPlayer的音频通路想象成一条“水管”。上游是解码器,下游是扬声器,中间需要各种阀门、泵和缓冲池。今天我们就来拆开这条水管,看看每一段是怎么工作的。

MediaPlayer的音频通路概览

先看一张整体流程图,帮你建立全局认知。

MediaPlayer音频通路架构图 MediaPlayer (Java层) MediaPlayer (Native层) NuPlayer (播放引擎) AudioDecoder (解码) VideoDecoder (解码) AudioSink AudioFlinger

这张图里,从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的缓冲区设置太小了。

核心流程:

  1. NuPlayer解码音频帧 → PCM数据
  2. 调用AudioSink::write()写入数据
  3. AudioSink内部通过AudioTrack写入共享内存
  4. AudioFlinger的MixerThread从共享内存读取数据
  5. 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的缓冲区大小由AudioTrackgetBufferSizeInFrames()决定。默认情况下,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