实战项目二:音视频播放器:FFmpeg解码、OpenSL ES音频输出、ANativeWindow视频渲染、同步机制

说实话,做音视频播放器这个项目,是我当年从应用层转向底层开发时,最过瘾的一次实战。你想想看,一个视频文件从磁盘读进来,经过解码、渲染、同步,最终在屏幕上流畅播放——这中间每一步都踩在系统底层的关键节点上。今天我就带你把这个流程拆开揉碎,看看FFmpeg、OpenSL ES、ANativeWindow这些组件到底是怎么协同工作的。

一、整体架构:播放器的四层骨架

先画个图,让你对整个播放器有个全局印象。我个人习惯把播放器分成四层:

音视频播放器四层架构 应用层(Java/Kotlin) MediaPlayer API · 播放控制 · UI交互 解码层(FFmpeg + NDK) avformat_open_input · avcodec_decode_video2 · 音视频分离 渲染层(OpenSL ES + ANativeWindow) 音频PCM输出 · 视频RGB渲染 · 双缓冲 同步层(音画同步机制) PTS对齐 · 时钟基准 · 丢帧策略

这四层各司其职,又环环相扣。应用层负责用户交互,解码层把压缩数据变成原始帧,渲染层把数据送到耳朵和眼睛,同步层保证嘴型和声音对得上。嗯,这里要注意——同步层是最容易被忽视的,但恰恰是它决定了播放器是「能用」还是「好用」。

二、FFmpeg解码:从文件到原始帧

FFmpeg解码这块,说白了就是三步走:打开、读取、解码。我刚开始接触时,总觉得avformat_open_input这个函数名字太长,后来习惯了反而觉得它把「打开输入格式」这件事说得很清楚。

2.1 初始化与打开文件

先看一段核心代码,这是解码的起点:

// 注册所有编解码器、复用/解复用器
av_register_all();
avformat_network_init();

AVFormatContext *pFormatCtx = avformat_alloc_context();
if (avformat_open_input(&pFormatCtx, filePath, NULL, NULL) != 0) {
    // 打开失败,常见原因是文件不存在或格式不支持
    return -1;
}

// 查找流信息
if (avformat_find_stream_info(pFormatCtx, NULL) < 0) {
    return -1;
}

// 遍历流,找到视频流和音频流的索引
int videoStream = -1, audioStream = -1;
for (int i = 0; i < pFormatCtx->nb_streams; i++) {
    if (pFormatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
        videoStream = i;
    } else if (pFormatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
        audioStream = i;
    }
}
避坑指南:我曾经在avformat_open_input上栽过跟头——Android的asset目录不能直接用文件路径打开。正确的做法是把asset里的文件拷贝到应用私有目录,或者用Android的AAssetManager读取内存数据。否则你会看到返回-2,百思不得其解。

2.2 解码循环与帧处理

找到流索引后,就要为每个流创建解码器上下文。这里有个细节:老版本FFmpeg用avcodec_copy_context,新版本改用avcodec_parameters_to_context。我建议你直接用新API,因为FFmpeg 4.0以后老API已经标记为废弃了。

// 为视频流创建解码器
AVCodecContext *pVideoCodecCtx = avcodec_alloc_context3(NULL);
avcodec_parameters_to_context(pVideoCodecCtx, 
    pFormatCtx->streams[videoStream]->codecpar);
AVCodec *pVideoCodec = avcodec_find_decoder(pVideoCodecCtx->codec_id);
avcodec_open2(pVideoCodecCtx, pVideoCodec, NULL);

// 解码主循环
AVPacket packet;
AVFrame *pFrame = av_frame_alloc();
while (av_read_frame(pFormatCtx, &packet) >= 0) {
    if (packet.stream_index == videoStream) {
        int ret = avcodec_send_packet(pVideoCodecCtx, &packet);
        if (ret == 0) {
            while (avcodec_receive_frame(pVideoCodecCtx, pFrame) == 0) {
                // 拿到一帧视频数据,准备渲染
                // pFrame->data, pFrame->linesize 包含YUV数据
                // pFrame->pts 是显示时间戳
                renderVideoFrame(pFrame);
            }
        }
    } else if (packet.stream_index == audioStream) {
        // 音频解码逻辑类似,略
    }
    av_packet_unref(&packet);
}

你想想看,avcodec_send_packet和avcodec_receive_frame这个模式,其实是生产者-消费者模型。发送端往解码器塞压缩包,接收端从解码器取原始帧。这种设计的好处是解码器内部可以缓冲,遇到B帧时能自动重排顺序。

三、OpenSL ES音频输出:让声音响起来

音频输出这块,Android提供了两种底层API:AudioTrack和OpenSL ES。我个人更倾向OpenSL ES,因为它延迟更低,而且能在native层直接操作,省去了JNI调用的开销。

3.1 创建引擎与混音器

// 创建OpenSL ES引擎
SLObjectItf engineObject;
SLEngineItf engineEngine;
slCreateEngine(&engineObject, 0, NULL, 0, NULL, NULL);
(*engineObject)->Realize(engineObject, SL_BOOLEAN_FALSE);
(*engineObject)->GetInterface(engineObject, SL_IID_ENGINE, &engineEngine);

// 创建混音器
SLObjectItf outputMixObject;
(*engineEngine)->CreateOutputMix(engineEngine, &outputMixObject, 0, NULL, NULL);
(*outputMixObject)->Realize(outputMixObject, SL_BOOLEAN_FALSE);

3.2 配置音频播放器

配置音频播放器时,缓冲区大小是个关键参数。我记得有一次项目里音频总是断断续续,排查了半天才发现是缓冲区设得太小,导致OpenSL ES回调来不及填充数据。

// 配置音频源(PCM数据格式)
SLDataLocator_AndroidSimpleBufferQueue locBufQueue = {
    SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE, 2  // 双缓冲
};
SLDataFormat_PCM formatPcm = {
    SL_DATAFORMAT_PCM,
    2,                          // 声道数(立体声)
    SL_SAMPLINGRATE_44_1,       // 采样率44100Hz
    SL_PCMSAMPLEFORMAT_FIXED_16,// 16位采样
    SL_PCMSAMPLEFORMAT_FIXED_16,
    SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT,
    SL_BYTEORDER_LITTLEENDIAN
};
SLDataSource audioSrc = {&locBufQueue, &formatPcm};

// 配置音频输出
SLDataLocator_OutputMix locOutMix = {
    SL_DATALOCATOR_OUTPUTMIX, outputMixObject
};
SLDataSink audioSnk = {&locOutMix, NULL};

// 创建播放器
SLObjectItf playerObject;
(*engineEngine)->CreateAudioPlayer(engineEngine, &playerObject,
    &audioSrc, &audioSnk, 0, NULL, NULL);
(*playerObject)->Realize(playerObject, SL_BOOLEAN_FALSE);

// 获取缓冲队列接口
SLAndroidSimpleBufferQueueItf bufferQueue;
(*playerObject)->GetInterface(playerObject, 
    SL_IID_ANDROIDSIMPLEBUFFERQUEUE, &bufferQueue);

// 注册回调函数
(*bufferQueue)->RegisterCallback(bufferQueue, audioCallback, NULL);
(*playerObject)->SetPlayState(playerObject, SL_PLAYSTATE_PLAYING);
注意:OpenSL ES的回调是在音频线程中执行的,不要在回调里做耗时操作。我曾经在回调里直接调用avcodec_decode_audio4,结果导致音频卡顿。正确的做法是维护一个环形缓冲区,解码线程往缓冲区写数据,回调线程从缓冲区读数据。

四、ANativeWindow视频渲染:把画面画到屏幕上

视频渲染这块,Android提供了ANativeWindow接口,可以直接在native层操作Surface。相比用Java层的Canvas或OpenGL,这种方式效率更高,而且避免了JNI频繁传递数据。

4.1 获取ANativeWindow

从Java层传过来的Surface对象,在native层需要转换成ANativeWindow:

// Java层传递Surface对象
// JNI调用:native_setSurface(Surface surface)

// Native层处理
ANativeWindow *nativeWindow = ANativeWindow_fromSurface(env, surface);
if (nativeWindow == NULL) {
    // Surface无效,常见于Activity销毁后
    return;
}

// 设置缓冲区格式(这里用RGB565,兼容性好)
ANativeWindow_setBuffersGeometry(nativeWindow, 
    width, height, WINDOW_FORMAT_RGB_565);

4.2 YUV转RGB与渲染

FFmpeg解码出来的视频帧通常是YUV420P格式,而ANativeWindow需要RGB格式。所以中间需要做一次颜色空间转换。我建议用libyuv或者自己写一个简单的转换函数:

// 锁定缓冲区
ANativeWindow_Buffer buffer;
if (ANativeWindow_lock(nativeWindow, &buffer, NULL) != 0) {
    return;
}

// YUV420P转RGB565(简化版,实际项目中建议用libyuv)
uint8_t *dst = (uint8_t *)buffer.bits;
for (int y = 0; y < height; y++) {
    for (int x = 0; x < width; x++) {
        // 从YUV平面取数据,计算RGB值
        int Y = pFrame->data[0][y * pFrame->linesize[0] + x];
        int U = pFrame->data[1][(y/2) * pFrame->linesize[1] + (x/2)];
        int V = pFrame->data[2][(y/2) * pFrame->linesize[2] + (x/2)];
        // YUV转RGB公式
        int R = Y + 1.402 * (V - 128);
        int G = Y - 0.344 * (U - 128) - 0.714 * (V - 128);
        int B = Y + 1.772 * (U - 128);
        // 钳位到0-255并转为RGB565
        uint16_t pixel = ((R >> 3) << 11) | ((G >> 2) << 5) | (B >> 3);
        ((uint16_t *)dst)[x] = pixel;
    }
    dst += buffer.stride * 2; // RGB565每个像素2字节
}

// 解锁缓冲区,画面自动显示
ANativeWindow_unlockAndPost(nativeWindow);
性能优化:上面的逐像素转换效率很低。实际项目中,我建议用NEON指令集做SIMD优化,或者直接用libyuv库。我在一个720p视频的项目里,用NEON优化后转换时间从12ms降到了2ms,效果立竿见影。

五、同步机制:让音画不再各说各话

同步机制是整个播放器最考验功底的地方。说白了,就是要让音频和视频按照各自的时间戳(PTS)对齐播放。常见的同步策略有三种:音频为主、视频为主、外部时钟。我个人最常用的是音频为主同步,因为人耳对音频抖动的敏感度远高于视频。

5.1 音频时钟基准

以音频为基准,就是让视频去追赶音频的时间。音频播放是连续的,我们通过OpenSL ES的回调次数和采样率可以精确计算出当前播放位置:

// 音频时钟计算
double getAudioClock() {
    // bytesPlayed:已经写入OpenSL ES缓冲区的字节数
    // 每个采样点2字节,双声道,所以每个采样帧4字节
    double playedSec = (double)bytesPlayed / (44100 * 2 * 2);
    return audioPts + playedSec;
}

5.2 视频帧调度

视频渲染线程拿到一帧后,比较它的PTS和当前音频时钟:

void renderVideoWithSync(AVFrame *pFrame) {
    double videoPts = pFrame->pts * av_q2d(timeBase);
    double audioClock = getAudioClock();
    double diff = videoPts - audioClock;

    if (diff > 0.01) {
        // 视频快了,等待
        usleep((useconds_t)(diff * 1000000));
    } else if (diff < -0.01) {
        // 视频慢了,丢帧
        // 注意:不要连续丢帧,否则画面会跳跃
        if (++dropFrameCount > 3) {
            dropFrameCount = 0;
            return; // 跳过当前帧
        }
    }

    // 渲染当前帧
    renderToANativeWindow(pFrame);
    dropFrameCount = 0;
}
核心要点:同步不是精确到毫秒的,人眼对±20ms的偏差基本无感。我一般把阈值设在±15ms,超过这个范围才做调整。另外,丢帧策略要谨慎——连续丢帧会导致画面卡顿感,我建议最多连续丢3帧,然后强制显示一帧。

六、CMake构建配置

最后,别忘了把FFmpeg和OpenSL ES链接到你的native库中。CMakeLists.txt的配置如下:

cmake_minimum_required(VERSION 3.10)
project(MediaPlayer)

# 设置C++标准
set(CMAKE_CXX_STANDARD 11)

# 引入FFmpeg预编译库
add_library(avcodec SHARED IMPORTED)
set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION
    ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libavcodec.so)

add_library(avformat SHARED IMPORTED)
set_target_properties(avformat PROPERTIES IMPORTED_LOCATION
    ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libavformat.so)

add_library(avutil SHARED IMPORTED)
set_target_properties(avutil PROPERTIES IMPORTED_LOCATION
    ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libavutil.so)

add_library(swscale SHARED IMPORTED)
set_target_properties(swscale PROPERTIES IMPORTED_LOCATION
    ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libswscale.so)

# 创建native库
add_library(native-media
    SHARED
    src/main/cpp/media_player.cpp
    src/main/cpp/audio_renderer.cpp
    src/main/cpp/video_renderer.cpp
    src/main/cpp/sync_engine.cpp
)

# 链接库
target_link_libraries(native-media
    avcodec
    avformat
    avutil
    swscale
    OpenSLES
    android
    log
)

# 包含头文件
target_include_directories(native-media PRIVATE
    ${CMAKE_SOURCE_DIR}/include
)

嗯,到这里,一个完整的音视频播放器核心流程就搭建起来了。从FFmpeg解码到OpenSL ES音频输出,从ANativeWindow视频渲染到音画同步,每一步都有坑,但也每一步都有乐趣。我记得第一次看到自己写的播放器流畅播放1080p视频时,那种成就感是难以言表的。希望你在动手实践时,也能体会到这种快乐。


公众号:蓝海资料掘金营,微信deep3321