MediaPlayer 播放器架构:NuPlayer 的 Source/Decoder/Renderer 三驾马车

0 阅读14分钟

有没有遇到过这种情况:用 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 架构深度解析

整体架构

09-01-nuplayer-architecture.png

ALooper/AMessage 消息系统

这是理解 NuPlayer 的钥匙。每一个 NuPlayer 的组件都运行在自己的 ALooper 线程上:

// 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);
}

发消息的方式:

// 发一个延迟200ms的消息
sp<AMessage> msg = new AMessage(kWhatSeek, this);
msg->setInt64("seekTimeUs", seekTimeUs);
msg->post(200000LL); // 200ms 延迟

处理消息:

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,通用实现
HTTPLiveSourceHLS 直播/点播m3u8 解析,分片下载,自适应码率
RTSPSourceRTSP 流RTP 协议,UDP/TCP 传输

选择哪个 Source 是在 NuPlayer::setDataSourceAsync() 中根据 URI 决定的:

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 来解析容器格式:

// 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 里有一套缓冲水位控制逻辑:

// 缓冲水位阈值(典型值)
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 的包装层,负责:

  1. 创建并配置 MediaCodec
  2. 从 Source 拉取 Access Unit
  3. 投喂给 MediaCodec 的 Input Buffer
  4. 把 Output Buffer 送给 Renderer
// 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 工作的核心循环,采用异步回调模式:

// 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 选择以音频为主时钟,有充分的理由:

  1. 人耳对时间的感知比眼睛敏感——音频抖动 20ms 人就能察觉,视频延迟 100ms 用户感受才明显
  2. AudioTrack 有精确的时间戳——getTimestamp() 可以获取播放位置到微秒级
  3. 音频一旦送出去就不能反悔——数据已经进入 AudioFlinger 的 ring buffer,不能撤回

Renderer 获取音频时钟的方式:

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 时,会根据当前时钟位置决定它的命运:

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 主动丢弃了"来不及"的帧。

09-02-playback-flow.png


播放控制生命周期

prepare:最容易踩坑的操作

MediaPlayer.prepare() 是同步阻塞的,prepareAsync() 才是正确用法:

// ❌ 错误:主线程阻塞
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:内部发生了什么

mediaPlayer.seekTo(30_000) // seek 到第 30 秒

这个看似简单的调用,内部需要做很多事:

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 的一个经典坑:

Idle → setDataSource() → Initialized
→ prepareAsync() → Preparing → Prepared
→ start() → Started
→ pause() → Paused
→ stop() → Stopped      ← 从这里只能 reset() 或 release()
→ reset() → Idle        ← 可以重新 setDataSource()

stop() 之后不能直接 start(),必须先 reset() 再重新 prepare()。这个状态机是很多开发者踩坑的地方:

// ❌ stop 后直接 start 会抛异常
mediaPlayer.stop()
mediaPlayer.start() // IllegalStateException!

// ✅ stop 后如果想重播同一个文件
mediaPlayer.stop()
mediaPlayer.reset()
mediaPlayer.setDataSource(path) // 重新设置
mediaPlayer.prepareAsync()

MediaPlayerService:幕后管家

服务架构

MediaPlayerServicemedia.player 系统服务,通过 Binder IPC 接受 Java 层 MediaPlayer 的调用。它的核心职责是管理多个并发的播放实例:

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(可信执行环境)通信,获取解密密钥。解密后的内容直接写入安全内存,普通应用无法访问。

// 带 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);
    }
}

实战:自定义播放器

基于上面的知识,来实现一个能处理常见问题的播放器封装:

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 可以定位到任意帧而不只是关键帧:

// 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 确实更好。

架构对比

维度NuPlayerExoPlayer
所在层Native(C++,Framework 内部)Java/Kotlin(应用层)
扩展性极差(需改 AOSP)极佳(Renderer/Extractor 均可替换)
格式支持取决于 MediaExtractor软件解码器可全量覆盖
自适应流HLS/DASH 基本支持DASH/HLS/SmoothStreaming 完整
缓冲控制水位策略,不可配置LoadControl 完全可定制
画质切换需重新 prepareTrackSelector 无缝切换
DRMWidevine L1/L3MediaDrm + Widevine,OkHttp 可定制
调试工具dumpsys media.player内置 EventLogger + 自定义 Analytics

ExoPlayer 的架构亮点

ExoPlayer 把 NuPlayer 中紧耦合的 Source/Decoder/Renderer 解耦成了可插拔的模块:

// 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 的每个参数都可以调整,这对直播场景(需要极低延迟)和点播场景(需要流畅体验)可以分别优化。

实际选型建议

需要系统级集成(NotificationManager 显示媒体控件)?
    → 用 MediaSession + ExoPlayer(Media3)

开发纯 App 内视频播放?
    → ExoPlayer(更好的格式支持和控制能力)

需要处理 DRM L1 安全视频?
    → 两者都支持,ExoPlayer 的 DRM 配置更灵活

嵌入式/系统应用需要直接操作 NuPlayer?
    → 修改 AOSP,不建议

调试技巧

查看播放状态

# 查看当前所有 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

分析音视频同步问题

# Renderer 的丢帧日志
adb logcat | grep "dropping video frame"
# 输出示例:dropping video frame, lateByUs=45.23ms

# 音视频时钟差值
adb logcat | grep "audio/video"

Perfetto 性能分析

# 开始 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 的三驾马车设计是一个精妙的架构:

  1. Source:屏蔽了数据来源(本地/HTTP/RTSP)的差异,向上提供统一的 Access Unit 流,内置缓冲水位管理防止卡顿
  2. Decoder:封装 MediaCodec,作为 Source 和 Renderer 之间的数据转换器,维护 PTS 在整个流水线中的传递
  3. Renderer:以音频时钟为基准,通过"丢帧/延迟"策略在两个独立的解码流之间强制时间对齐

整个系统由 AMessage/ALooper 消息驱动,避免了多线程锁竞争,这是 NuPlayer 区别于老旧 AwesomePlayer 的核心设计。

下一步行动:

  • adb logcat | grep "dropping video frame" 看看你的设备播放时有多少帧被丢弃
  • 阅读源码:frameworks/av/media/libmediaplayerservice/nuplayer/NuPlayer.cpp
  • 如果在开发播放器 App,评估是否迁移到 Media3 ExoPlayer

系列下一篇将深入音视频同步与渲染:PTS 时间戳、VSYNC 信号、SurfaceFlinger 合成的完整链路。


参考资料