实战项目二:音视频播放器:FFmpeg解码、OpenSL ES音频输出、ANativeWindow视频渲染、同步机制
说实话,做音视频播放器这个项目,是我当年从应用层转向底层开发时,最过瘾的一次实战。你想想看,一个视频文件从磁盘读进来,经过解码、渲染、同步,最终在屏幕上流畅播放——这中间每一步都踩在系统底层的关键节点上。今天我就带你把这个流程拆开揉碎,看看FFmpeg、OpenSL ES、ANativeWindow这些组件到底是怎么协同工作的。
一、整体架构:播放器的四层骨架
先画个图,让你对整个播放器有个全局印象。我个人习惯把播放器分成四层:
这四层各司其职,又环环相扣。应用层负责用户交互,解码层把压缩数据变成原始帧,渲染层把数据送到耳朵和眼睛,同步层保证嘴型和声音对得上。嗯,这里要注意——同步层是最容易被忽视的,但恰恰是它决定了播放器是「能用」还是「好用」。
二、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;
}
}
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);
四、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);
五、同步机制:让音画不再各说各话
同步机制是整个播放器最考验功底的地方。说白了,就是要让音频和视频按照各自的时间戳(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;
}
六、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