有没有遇到过这种情况:用 MediaPlayer 播放一段视频,seekTo 完之后画面卡在了之前的帧,音频倒是跳过去了?或者直播流有时候音画不同步,声音跑快了半秒钟?
这些问题的根源,都在 NuPlayer 的内部架构里。
NuPlayer 是 Android 5.0 之后 MediaPlayer 的核心实现,它把"播放"这件事拆成了三个相互独立又紧密协作的组件:Source(数据源) 、Decoder(解码器) 、Renderer(渲染器)。理解这三个组件如何协作,是定位和解决播放问题的关键。
本文将深入 Android AOSP 源码(frameworks/av/media/libmediaplayerservice/nuplayer/),把 NuPlayer 从里到外剥开来看。
MediaPlayer 架构演进
在深入 NuPlayer 之前,先回顾一下 Android 播放器的历史。
从 AwesomePlayer 到 NuPlayer
Android 历史上有三代播放器实现:
第一代:AwesomePlayer(Android 1.x ~ 4.x)
AwesomePlayer 基于 Stagefright 框架,采用同步模型,大量使用 mutex 和条件变量。名字里虽然有 "Awesome",但实际上 bug 不少,seek 不准、内存泄漏是家常便饭。
第二代:NuPlayer(Android 5.0+,至今)
NuPlayer 从零重新设计,引入了基于消息队列的异步模型(ALooper/AMessage),解决了 AwesomePlayer 的死锁问题。Android 5.0 用 NuPlayer 处理 HTTP 流和 RTSP,Android 6.0 之后 NuPlayer 全面替代 AwesomePlayer,成为默认实现。
第三代:MediaPlayer2(Android 9,已废弃)
Google 尝试在 MediaPlayer2 中提供更现代的 API,但开发者反馈不佳,Android 11 就基本废弃了。目前官方建议的替代方案是 ExoPlayer(现已改名为 Media3 ExoPlayer)。
所以现在你调用 MediaPlayer.create() 最终走的还是 NuPlayer。
NuPlayer 的核心设计哲学
NuPlayer 的整个设计围绕一个核心思想:消息驱动,异步解耦。
所有组件通信 → AMessage → ALooper 队列 → 顺序处理
没有直接调用,没有锁,只有消息
这个设计让 NuPlayer 避免了多线程同步的噩梦。你永远不用担心 Renderer 线程和 Decoder 线程同时访问同一个 Buffer------因为他们之间只通过消息传递。
NuPlayer 架构深度解析
整体架构

ALooper/AMessage 消息系统
这是理解 NuPlayer 的钥匙。每一个 NuPlayer 的组件都运行在自己的 ALooper 线程上:
cpp
// frameworks/av/media/libmediaplayerservice/nuplayer/NuPlayer.cpp
NuPlayer::NuPlayer(pid_t pid, const sp<MediaClock> &mediaClock)
: mLooper(new ALooper),
... {
mLooper->setName("NuPlayerDriver Looper");
mLooper->start(false, false, PRIORITY_AUDIO);
mLooper->registerHandler(this);
}
发消息的方式:
cpp
// 发一个延迟200ms的消息
sp<AMessage> msg = new AMessage(kWhatSeek, this);
msg->setInt64("seekTimeUs", seekTimeUs);
msg->post(200000LL); // 200ms 延迟
处理消息:
cpp
void NuPlayer::onMessageReceived(const sp<AMessage> &msg) {
switch (msg->what()) {
case kWhatSetDataSource:
onSetDataSource(msg);
break;
case kWhatPrepare:
onPrepare(msg);
break;
case kWhatSeek:
onSeek(msg);
break;
// ...
}
}
这种模式确保了所有状态修改都在同一个线程上顺序执行,完全不需要锁。
Source:数据源管理
Source 的三大实现
NuPlayer::Source 是一个抽象接口,有三个主要实现:
| Source 类型 | 使用场景 | 特点 |
|---|---|---|
GenericSource |
本地文件/DASH/HLS | 基于 MediaExtractor,通用实现 |
HTTPLiveSource |
HLS 直播/点播 | m3u8 解析,分片下载,自适应码率 |
RTSPSource |
RTSP 流 | RTP 协议,UDP/TCP 传输 |
选择哪个 Source 是在 NuPlayer::setDataSourceAsync() 中根据 URI 决定的:
cpp
void NuPlayer::setDataSourceAsync(const sp<IMediaHTTPService> &httpService,
const char *url, ...) {
sp<AMessage> msg = new AMessage(kWhatSetDataSource, this);
sp<Source> source;
if (!strncasecmp(url, "rtsp://", 7)) {
source = new RTSPSource(notify, httpService, url, headers, ...);
} else if ((!strncasecmp(url, "http://", 7) || ...) &&
!strncasecmp(url + strlen(url) - 5, ".m3u8", 5)) {
source = new HTTPLiveSource(notify, httpService, url, headers);
} else {
source = new GenericSource(notify, uid, mediaClock);
// 对 GenericSource 设置数据
((GenericSource *)source.get())->setDataSource(httpService, url, headers);
}
msg->setObject("source", source);
msg->post();
}
GenericSource:通用数据源
GenericSource 是最常用的 Source 实现,内部使用 MediaExtractor 来解析容器格式:
cpp
// GenericSource 的 prepare 流程
status_t GenericSource::initFromDataSource() {
sp<IMediaExtractor> extractor = MediaExtractor::Create(mDataSource);
// 遍历所有轨道
size_t numTracks = extractor->countTracks();
for (size_t i = 0; i < numTracks; ++i) {
sp<MetaData> meta = extractor->getTrackMetaData(i);
const char *mime;
meta->findCString(kKeyMIMEType, &mime);
if (!strncasecmp(mime, "video/", 6)) {
mVideoTrack.mExtractor = extractor->getTrack(i);
} else if (!strncasecmp(mime, "audio/", 6)) {
mAudioTrack.mExtractor = extractor->getTrack(i);
}
}
return OK;
}
GenericSource 维护了一个读取线程(readLoop),持续从 MediaExtractor 读取 Access Unit(AU,即一帧编码数据),缓存在 ABuffer 队列中。当 Decoder 请求数据时,从队列取出并通过 AMessage 送出。
缓冲策略:预防卡顿的关键
GenericSource 里有一套缓冲水位控制逻辑:
cpp
// 缓冲水位阈值(典型值)
static const int64_t kLowWaterMarkUs = 2000000LL; // 2秒
static const int64_t kHighWaterMarkUs = 5000000LL; // 5秒
static const int64_t kHighWaterMarkBytes = 4 * 1024 * 1024; // 4MB
void GenericSource::onPollBuffering() {
int64_t bufferedDurationUs = getLastReadPosition() - getSeekedPositionUs();
if (mPrepareBuffering) {
// prepare 阶段:等缓冲到低水位才回调 onPrepared
if (bufferedDurationUs >= kLowWaterMarkUs) {
notifyPrepared();
}
} else {
// 播放阶段:根据水位控制 Source 的暂停/恢复
if (bufferedDurationUs < kLowWaterMarkUs) {
// 缓冲不足,通知 NuPlayer 暂停渲染(转圈圈)
notifyBufferingUpdate(BUFFERING_UPDATE_START);
} else if (bufferedDurationUs >= kHighWaterMarkUs) {
// 缓冲充足,暂停读取(省带宽)
}
}
}
这套水位机制解释了为什么弱网下视频会转圈:缓冲低于 2 秒时,Renderer 被暂停,等到重新积累到水位线才继续播放。
Decoder:解码流程
NuPlayer::Decoder 的职责
NuPlayer::Decoder 是 MediaCodec 的包装层,负责:
- 创建并配置 MediaCodec
- 从 Source 拉取 Access Unit
- 投喂给 MediaCodec 的 Input Buffer
- 把 Output Buffer 送给 Renderer
cpp
// NuPlayer::Decoder 创建 MediaCodec
void NuPlayer::Decoder::onConfigure(const sp<AMessage> &format) {
AString mime;
format->findString("mime", &mime);
mCodec = MediaCodec::CreateByType(mCodecLooper, mime.c_str(),
false /* encoder */);
// 视频解码需要绑定 Surface 做零拷贝渲染
if (mSurface != NULL) {
mCodec->configure(format, mSurface, NULL /* crypto */, 0 /* flags */);
} else {
mCodec->configure(format, NULL, NULL, 0);
}
mCodec->setCallback(new DecoderCallback(this));
mCodec->start();
}
数据流转:从 Source 到 Output Buffer
这是 Decoder 工作的核心循环,采用异步回调模式:
cpp
// Input Buffer 就绪时(MediaCodec 请求数据)
void NuPlayer::Decoder::onInputBufferFetched(const sp<AMessage> &msg) {
size_t bufferIx;
msg->findSize("buffer-ix", &bufferIx);
// 从 Source 取一个 Access Unit
sp<ABuffer> accessUnit;
bool needMoreData = false;
status_t err = mSource->dequeueAccessUnit(mIsAudio, &accessUnit);
if (err == OK) {
// 把 AU 的数据拷贝到 codec 的 Input Buffer
sp<MediaCodecBuffer> codecBuffer;
mCodec->getInputBuffer(bufferIx, &codecBuffer);
memcpy(codecBuffer->data(), accessUnit->data(), accessUnit->size());
// 关键:设置 PTS(Presentation Time Stamp)
int64_t timeUs;
accessUnit->meta()->findInt64("timeUs", &timeUs);
codecBuffer->meta()->setInt64("timeUs", timeUs);
mCodec->queueInputBuffer(bufferIx, 0, accessUnit->size(),
timeUs, 0 /* flags */);
} else if (err == ERROR_END_OF_STREAM) {
// 发送 EOS
mCodec->queueInputBuffer(bufferIx, 0, 0, 0,
MediaCodec::BUFFER_FLAG_EOS);
}
}
// Output Buffer 就绪时(有解码帧可取)
void NuPlayer::Decoder::onOutputBufferDrained(const sp<AMessage> &msg) {
sp<MediaCodecBuffer> buffer;
size_t bufferIx;
msg->findSize("buffer-ix", &bufferIx);
mCodec->getOutputBuffer(bufferIx, &buffer);
// 取出 PTS
int64_t timeUs;
buffer->meta()->findInt64("timeUs", &timeUs);
// 包装成 ABuffer 送给 Renderer
sp<AMessage> notify = mNotify->dup();
notify->setInt32("what", kWhatOutputBufferDrained);
notify->setObject("buffer", buffer);
notify->setInt64("timeUs", timeUs);
notify->post();
}
音视频解码的并行性
NuPlayer 同时运行两个 Decoder:视频 Decoder 和音频 Decoder,各自有独立的 ALooper 线程。它们都向同一个 Renderer 发送输出帧,由 Renderer 统一做同步仲裁。
Renderer:渲染的精髓
Renderer 是 NuPlayer 中最复杂的组件,它负责解决一个根本性难题:视频帧和音频帧是独立产出的,如何让它们在时间上对齐?
音频时钟:同步基准
Android 选择以音频为主时钟,有充分的理由:
- 人耳对时间的感知比眼睛敏感------音频抖动 20ms 人就能察觉,视频延迟 100ms 用户感受才明显
- AudioTrack 有精确的时间戳 ------
getTimestamp()可以获取播放位置到微秒级 - 音频一旦送出去就不能反悔------数据已经进入 AudioFlinger 的 ring buffer,不能撤回
Renderer 获取音频时钟的方式:
cpp
int64_t NuPlayer::Renderer::getPlayedOutAudioDurationUs(int64_t nowUs) {
// 获取 AudioTrack 的精确播放位置
uint32_t numFramesPlayed;
int64_t numFramesPlayedAt;
AudioTimestamp ts;
status_t res = mAudioSink->getTimestamp(ts);
if (res == OK) {
numFramesPlayed = ts.mPosition;
numFramesPlayedAt = convertTimespecToUs(ts.mTime);
// 换算成微秒
int64_t durationUs = (int64_t)numFramesPlayed * 1000000LL / mAudioSampleRate;
// 加上"从采样到现在"的时间差
durationUs += (nowUs - numFramesPlayedAt);
return durationUs;
}
// 降级:用写入量估算
...
}
视频帧的命运:渲染还是丢弃?
每个视频帧到达 Renderer 时,会根据当前时钟位置决定它的命运:
cpp
void NuPlayer::Renderer::onDrainVideoQueue() {
// 从队列取出最早的视频帧
QueueEntry &entry = *mVideoQueue.begin();
int64_t realTimeUs = entry.mTimeUs; // 帧的 PTS
// 当前音频时钟位置
int64_t nowUs = getCurrentPosition();
// 计算这帧应该在什么时候渲染
int64_t lateByUs = nowUs - realTimeUs;
if (lateByUs > 40000LL) {
// 落后超过 40ms:这帧已经过时,直接丢弃
// (releaseOutputBuffer 传 false,不渲染到 Surface)
mCodec->releaseOutputBuffer(entry.mBufferIx, false /* render */);
mVideoQueue.erase(mVideoQueue.begin());
ALOGV("dropping video frame, lateByUs=%.2fms", lateByUs / 1E3);
return;
}
if (lateByUs < -30000LL) {
// 早到超过 30ms:还不到时候,等待
// 重新 post 一个延迟消息
sp<AMessage> msg = new AMessage(kWhatDrainVideoQueue, this);
msg->post(-lateByUs - 30000LL);
return;
}
// 正好在窗口内:渲染!
// releaseOutputBuffer(true) 表示渲染到绑定的 Surface
mCodec->releaseOutputBuffer(entry.mBufferIx, true /* render */);
mVideoQueue.erase(mVideoQueue.begin());
}
这段逻辑非常优雅:
- 太晚(>40ms):直接丢弃,宁可跳帧也不卡顿
- 太早(>30ms):等待,延迟投递渲染消息
- 刚好:立即渲染到 Surface
这就是为什么你的视频播放器在高负载时会掉帧但不卡顿------NuPlayer 主动丢弃了"来不及"的帧。

播放控制生命周期
prepare:最容易踩坑的操作
MediaPlayer.prepare() 是同步阻塞的,prepareAsync() 才是正确用法:
kotlin
// ❌ 错误:主线程阻塞
mediaPlayer.setDataSource(path)
mediaPlayer.prepare() // 可能阻塞数秒!
// ✅ 正确:异步 prepare
mediaPlayer.setDataSource(path)
mediaPlayer.setOnPreparedListener { player ->
player.start() // 在 prepared 回调里 start
}
mediaPlayer.prepareAsync()
内部流程:prepareAsync() → kWhatPrepare 消息 → NuPlayer::onPrepare() → Source.prepare() → MediaExtractor 解析容器 → 选择 Decoder → onPrepared 回调
seekTo:内部发生了什么
kotlin
mediaPlayer.seekTo(30_000) // seek 到第 30 秒
这个看似简单的调用,内部需要做很多事:
markdown
1. NuPlayer 发送 kWhatSeek 消息
2. Source.seekTo(timeUs):
- GenericSource:调用 MediaExtractor.seekTo(),找到最近的关键帧
- HTTPLiveSource:可能需要切换分片,等待下载
3. flush Video Decoder:清空解码器缓存的旧帧
4. flush Audio Decoder:同上
5. Renderer.flush():清空渲染队列的旧帧
6. 重新开始投喂数据给 Decoder
7. 等待第一帧解码完成,更新时钟
Seek 后画面停在旧位置的 bug,通常是步骤 3/4/5 的 flush 没有执行完,旧 Buffer 还在队列里等待渲染。
stop 与 reset 的区别
这是 MediaPlayer 的一个经典坑:
scss
Idle → setDataSource() → Initialized
→ prepareAsync() → Preparing → Prepared
→ start() → Started
→ pause() → Paused
→ stop() → Stopped ← 从这里只能 reset() 或 release()
→ reset() → Idle ← 可以重新 setDataSource()
stop() 之后不能直接 start(),必须先 reset() 再重新 prepare()。这个状态机是很多开发者踩坑的地方:
kotlin
// ❌ stop 后直接 start 会抛异常
mediaPlayer.stop()
mediaPlayer.start() // IllegalStateException!
// ✅ stop 后如果想重播同一个文件
mediaPlayer.stop()
mediaPlayer.reset()
mediaPlayer.setDataSource(path) // 重新设置
mediaPlayer.prepareAsync()
MediaPlayerService:幕后管家
服务架构
MediaPlayerService 是 media.player 系统服务,通过 Binder IPC 接受 Java 层 MediaPlayer 的调用。它的核心职责是管理多个并发的播放实例:
scss
MediaPlayerService (单例)
├── Client 1 (App A 的 MediaPlayer)
│ └── NuPlayer 实例 1
├── Client 2 (App B 的 MediaPlayer)
│ └── NuPlayer 实例 2
└── Client 3 (App C 的 MediaPlayer)
└── NuPlayer 实例 3
每个 Client 对象持有一个 NuPlayer 实例,通过 Binder 代理接受 Java 层的控制指令。
资源管理与 DRM
MediaPlayerService 还负责两件重要的事:
1. 音频焦点(Audio Focus)
虽然音频焦点的申请是在 Java 层通过 AudioManager 完成的,但实际的音量控制是在 AudioFlinger 层执行的,MediaPlayerService 负责响应焦点变化事件,控制 AudioSink 的音量或暂停播放。
2. DRM(数字版权管理)
播放加密内容时(如 Widevine 保护的视频),MediaPlayerService 会创建 DrmSessionManager,通过 HIDL 接口与 TEE(可信执行环境)通信,获取解密密钥。解密后的内容直接写入安全内存,普通应用无法访问。
cpp
// 带 DRM 的 MediaCodec 配置
void NuPlayer::Decoder::onConfigure(const sp<AMessage> &format) {
// ...
if (mCrypto != NULL) {
// 安全解码模式:output buffer 写入安全内存
mCodec->configure(format, mSurface, mCrypto,
MediaCodec::CONFIGURE_FLAG_USE_BLOCK_MODEL);
}
}
实战:自定义播放器
基于上面的知识,来实现一个能处理常见问题的播放器封装:
kotlin
class RobustVideoPlayer(private val context: Context) {
private var mediaPlayer: MediaPlayer? = null
private var currentUri: Uri? = null
private var isReleased = false
// 状态追踪
private enum class State {
IDLE, PREPARING, PREPARED, PLAYING, PAUSED, STOPPED, ERROR
}
private var state = State.IDLE
fun setVideoUri(uri: Uri, surface: Surface) {
release() // 先释放旧实例
mediaPlayer = MediaPlayer().apply {
// 设置 Surface:这里 NuPlayer 会选择零拷贝路径
setSurface(surface)
setOnPreparedListener { player ->
state = State.PREPARED
player.start()
state = State.PLAYING
}
setOnErrorListener { _, what, extra ->
// what: MEDIA_ERROR_UNKNOWN / MEDIA_ERROR_SERVER_DIED
// extra: MEDIA_ERROR_IO / MEDIA_ERROR_MALFORMED etc.
Log.e("RobustPlayer", "Error: what=$what extra=$extra")
state = State.ERROR
true // 返回 true 表示已处理,不再回调 onCompletion
}
setOnBufferingUpdateListener { _, percent ->
// percent: 0~100,来自 GenericSource 的缓冲水位
onBufferingUpdate(percent)
}
setOnInfoListener { _, what, _ ->
when (what) {
MediaPlayer.MEDIA_INFO_BUFFERING_START -> showLoadingSpinner()
MediaPlayer.MEDIA_INFO_BUFFERING_END -> hideLoadingSpinner()
MediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START -> onFirstFrameRendered()
}
false
}
try {
setDataSource(context, uri)
state = State.PREPARING
prepareAsync() // 异步,不阻塞主线程
} catch (e: IOException) {
Log.e("RobustPlayer", "setDataSource failed", e)
state = State.ERROR
}
}
currentUri = uri
}
fun seekTo(positionMs: Long) {
if (state != State.PLAYING && state != State.PAUSED) return
// SEEK_CLOSEST 是 Android 8.0+ 新增的精确 seek 模式
// 默认的 seek 找关键帧,可能不精确
mediaPlayer?.seekTo(positionMs, MediaPlayer.SEEK_CLOSEST)
}
fun release() {
if (isReleased) return
mediaPlayer?.let {
if (state == State.PLAYING || state == State.PAUSED) {
it.stop()
}
it.reset()
it.release()
}
mediaPlayer = null
state = State.IDLE
isReleased = true
}
private fun showLoadingSpinner() { /* UI 逻辑 */ }
private fun hideLoadingSpinner() { /* UI 逻辑 */ }
private fun onFirstFrameRendered() { /* 首帧展示 */ }
private fun onBufferingUpdate(percent: Int) { /* 进度条更新 */ }
}
seekTo 精准控制
Android 8.0 引入了 MediaPlayer.SEEK_CLOSEST,让 seek 可以定位到任意帧而不只是关键帧:
kotlin
// Android 8.0+ 精准 seek
mediaPlayer.seekTo(positionMs, MediaPlayer.SEEK_CLOSEST)
// 早期版本:只能 seek 到最近的关键帧(I 帧)
// 这也是为什么 seek 后可能不准
mediaPlayer.seekTo(positionMs) // 等价于 SEEK_PREVIOUS_SYNC
对应 NuPlayer 内部,SEEK_CLOSEST 会在 seek 到关键帧后,继续解码但丢弃帧直到目标位置,保证精度。
ExoPlayer vs NuPlayer:如何选择
既然 Google 自己都在推 ExoPlayer,为什么还要了解 NuPlayer?因为系统级的播放都走 NuPlayer------MediaRecorder、Camera2 录像、系统铃声、视频通话,全都是。如果你在开发自己的播放器 App,那 ExoPlayer 确实更好。
架构对比
| 维度 | NuPlayer | ExoPlayer |
|---|---|---|
| 所在层 | Native(C++,Framework 内部) | Java/Kotlin(应用层) |
| 扩展性 | 极差(需改 AOSP) | 极佳(Renderer/Extractor 均可替换) |
| 格式支持 | 取决于 MediaExtractor | 软件解码器可全量覆盖 |
| 自适应流 | HLS/DASH 基本支持 | DASH/HLS/SmoothStreaming 完整 |
| 缓冲控制 | 水位策略,不可配置 | LoadControl 完全可定制 |
| 画质切换 | 需重新 prepare | TrackSelector 无缝切换 |
| DRM | Widevine L1/L3 | MediaDrm + Widevine,OkHttp 可定制 |
| 调试工具 | dumpsys media.player |
内置 EventLogger + 自定义 Analytics |
ExoPlayer 的架构亮点
ExoPlayer 把 NuPlayer 中紧耦合的 Source/Decoder/Renderer 解耦成了可插拔的模块:
kotlin
// ExoPlayer 的模块化配置
val exoPlayer = ExoPlayer.Builder(context)
.setRenderersFactory(
// 可以注入自定义 Renderer
DefaultRenderersFactory(context).apply {
setExtensionRendererMode(EXTENSION_RENDERER_MODE_PREFER)
}
)
.setTrackSelector(
// 自适应码率选择器
DefaultTrackSelector(context).apply {
setParameters(buildUponParameters().setMaxVideoSizeSd())
}
)
.setLoadControl(
// 完全控制缓冲策略
DefaultLoadControl.Builder()
.setBufferDurationsMs(
MIN_BUFFER_MS, // 最小缓冲
MAX_BUFFER_MS, // 最大缓冲
PLAYBACK_START_BUFFER_MS, // 开始播放所需缓冲
PLAYBACK_REBUFFER_MS // 卡顿恢复所需缓冲
)
.build()
)
.build()
NuPlayer 里你无法控制缓冲策略;ExoPlayer 里,LoadControl 的每个参数都可以调整,这对直播场景(需要极低延迟)和点播场景(需要流畅体验)可以分别优化。
实际选型建议
markdown
需要系统级集成(NotificationManager 显示媒体控件)?
→ 用 MediaSession + ExoPlayer(Media3)
开发纯 App 内视频播放?
→ ExoPlayer(更好的格式支持和控制能力)
需要处理 DRM L1 安全视频?
→ 两者都支持,ExoPlayer 的 DRM 配置更灵活
嵌入式/系统应用需要直接操作 NuPlayer?
→ 修改 AOSP,不建议
调试技巧
查看播放状态
bash
# 查看当前所有 MediaPlayer 实例
adb shell dumpsys media.player
# 查看 NuPlayer 的详细状态
adb shell dumpsys media.player | grep -A 30 "NuPlayer"
# 实时日志(非常详细)
adb logcat -s NuPlayer:V NuPlayerDecoder:V NuPlayerRenderer:V
分析音视频同步问题
bash
# Renderer 的丢帧日志
adb logcat | grep "dropping video frame"
# 输出示例:dropping video frame, lateByUs=45.23ms
# 音视频时钟差值
adb logcat | grep "audio/video"
Perfetto 性能分析
bash
# 开始 Perfetto 抓包
adb shell perfetto -o /data/local/tmp/trace.pb \
-t 10s \
"track_event,atrace:am,atrace:wm,atrace:gfx"
# 拉取并分析
adb pull /data/local/tmp/trace.pb ./
# 用 https://ui.perfetto.dev 打开
在 Perfetto 中搜索 NuPlayer 相关的 trace event,可以看到每一帧的解码时间和渲染时机,一目了然。
总结
NuPlayer 的三驾马车设计是一个精妙的架构:
- Source:屏蔽了数据来源(本地/HTTP/RTSP)的差异,向上提供统一的 Access Unit 流,内置缓冲水位管理防止卡顿
- Decoder:封装 MediaCodec,作为 Source 和 Renderer 之间的数据转换器,维护 PTS 在整个流水线中的传递
- Renderer:以音频时钟为基准,通过"丢帧/延迟"策略在两个独立的解码流之间强制时间对齐
整个系统由 AMessage/ALooper 消息驱动,避免了多线程锁竞争,这是 NuPlayer 区别于老旧 AwesomePlayer 的核心设计。
下一步行动:
- 用
adb logcat | grep "dropping video frame"看看你的设备播放时有多少帧被丢弃 - 阅读源码:
frameworks/av/media/libmediaplayerservice/nuplayer/NuPlayer.cpp - 如果在开发播放器 App,评估是否迁移到 Media3 ExoPlayer
系列下一篇将深入音视频同步与渲染:PTS 时间戳、VSYNC 信号、SurfaceFlinger 合成的完整链路。