【声 明】
首先,这一系列文章均基于自己的理解和实践,可能有不对的地方,欢迎大家指正。
其次,这是一个入门系列,涉及的知识也仅限于够用,深入的知识网上也有许许多多的博文供大家学习了。
最后,写文章过程中,会借鉴参考其他人分享的文章,会在文章最后列出,感谢这些作者的分享。
码字不易,转载请注明出处!
教程代码:【Github传送门】 |
---|
目录
一、Android音视频硬解码篇:
- 1,音视频基础知识
- 2,音视频硬解码流程:封装基础解码框架
- 3,音视频播放:音视频同步
- 4,音视频解封和封装:生成一个MP4
二、使用OpenGL渲染视频画面篇
- 1,初步了解OpenGL ES
- 2,使用OpenGL渲染视频画面
- 3,OpenGL渲染多视频,实现画中画
- 4,深入了解OpenGL之EGL
- 5,OpenGL FBO数据缓冲区
- 6,Android音视频硬编码:生成一个MP4
三、Android FFmpeg音视频解码篇
- 1,FFmpeg so库编译
- 2,Android 引入FFmpeg
- 3,Android FFmpeg视频解码播放
- 4,Android FFmpeg+OpenSL ES音频解码播放
- 5,Android FFmpeg+OpenGL ES播放视频
- 6,Android FFmpeg简单合成MP4:视屏解封与重新封装
- 7,Android FFmpeg视频编码
本文你可以了解到
基于 FFmpeg 4.x 的音视频解码流程,重点讲解如何实现视频的播放。
前言
Hi~ 久等了!
本文很长,因为可能有比较多的小伙伴对 JNI
C/C++
不是很熟悉,所以本文比较详细的对 FFmpeg
用到的代码进行讲解,完整的演示了一遍 FFmpeg
的解码和渲染过程,并且对解码过程进行了封装。
为了方便讲解和阅读理解,代码采取分块的方式进行讲解,也就是说,不会直接将整个类的内容完整的贴出来。
但是每部分代码都会在开头注明是属于那个文件,哪个类的。如果想要看完整的代码,请直接查看 【Github 仓库】。
本文需要 C/C++
基础知识,对 C/C++
不熟悉的可以查看本人的另一篇文章: 【Android NDK入门:C++基础知识】。
请耐心地阅读,相信看完后可以对 FFmpeg
解码有可观的理解。
一、FFmpeg 相关库简介
在 上一篇文章 中,把 FFmpeg
相关的库都引入到 Android
工程中了,有以下几个库:
库 | 介绍 |
---|---|
avcodec | 音视频编解码核心库 |
avformat | 音视频容器格式的封装和解析 |
avutil | 核心工具库 |
swscal | 图像格式转换的模块 |
swresampel | 音频重采样 |
avfilter | 音视频滤镜库 如视频加水印、音频变声 |
avdevice | 输入输出设备库,提供设备数据的输入与输出 |
FFmpeg 就是依靠以上几个库,实现了强大的音视频编码、解码、编辑、转换、采集等能力。
二、FFMpeg 解码流程简介
在前面的系列文章中,利用了 Android
提供的原生硬解码能力,使用实现了视频的解码和播放。
总结起来有以下的流程:
- 初始化解码器
- 读取
Mp4
文件中的编码数据,并送入解码器解码 - 获取解码好的帧数据
- 将一帧画面渲染到屏幕上
FFmpeg
解码无非也就是以上过程,只不过FFmpeg
是利用CPU
的计算能力来解码而已。
1. FFmpeg 初始化
FFmpeg
初始化的流程相对 Android
原生硬解码来说还是比较琐碎的,但是流程都是固定的,一旦封装起来就可以直接套用了。
首先来看一下初始化的流程图
其实就是根据待解码文件的格式,进行一系列参数的初始化。
其中,有几个 结构体
比较重要,分别是 AVFormatContext
(format_ctx)、AVCodecContext
(codec_ctx)、AVCodec
(codec)
结构体 :FFmpeg 是基于
C
语言开发的,我们知道C
语言是面向过程的语言,也就是说不像C++
有类来封装内部数据。但是C
提供了结构体,可以用来实现数据的封装,达到类似于类的效果。
AVFormatContext:隶属于
avformat
库,存放这码流数据的上下文,主要用于音视频的封装
和解封
。AVCodecContext:隶属于
avcodec
库,存放编解码器参数上下文,主要用于对音视频数据进行编码
和解码
。AVCodec:隶属于
avcodec
库,音视频编解码器,真正编解码执行者。
2. FFmpeg 解码循环
同样的,通过一个流程图来说明具体解码过程:
在初始化完 FFmpeg
后,就可以进行具体的数据帧解码了。
从上图可以看到,FFmpeg
首先将数据提取为一个 AVPacket
(avpacket),然后通过解码,将数据解码为一帧可以渲染的数据,称为 AVFrame
(frame)。
同样的,AVPacket
和 AVFrame
也是两个结构体,里面封装了具体的数据。
三、封装解码类
有了以上对解码流程的了解,就可以根据上面的 流程图
来编写代码了。
根据以往的经验,既然 FFmepg
的初始化和解码流程都是一些琐碎重复的工作,那么我们必然是要对其进行封装的,以便更好的复用和拓展。
解码流程封装
1. 定义解码状态: decode_state.h
在src/main/cpp/media/decoder
目录上,右键 New
-> C++ Header File
,输入 decode_state
//decode_state.h
#ifndef LEARNVIDEO_DECODESTATE_H
#define LEARNVIDEO_DECODESTATE_H
enum DecodeState {
STOP,
PREPARE,
START,
DECODING,
PAUSE,
FINISH
};
#endif //LEARNVIDEO_DECODESTATE_H
这是一个枚举,定义了解码器解码的状态
2. 定义解码器的基础功能:i_decoder.h
:
在src/main/cpp/media/decoder
目录上,右键 New
-> C++ Header File
,输入 i_decoder
。
// i_decoder.h
#ifndef LEARNVIDEO_I_DECODER_H
#define LEARNVIDEO_I_DECODER_H
class IDecoder {
public:
virtual void GoOn() = 0;
virtual void Pause() = 0;
virtual void Stop() = 0;
virtual bool IsRunning() = 0;
virtual long GetDuration() = 0;
virtual long GetCurPos() = 0;
};
这是一个纯虚类,类似 Java
的 interface
(具体可查看 Android NDK入门:C++ 基础知识),定义了解码器该有的基础方法。
3. 定义一个解码器基础类 base_decoder
。
在src/main/cpp/media/decoder
目录上,右键 New
-> C++ Class
输入 base_decoder
,该类用于封装解码中最基础的流程。
会生成两个文件:base_decoder.h
、base_decoder.cpp
。
- 定义头文件:
base_decoder.h
//base_decoder.h
#ifndef LEARNVIDEO_BASEDECODER_H
#define LEARNVIDEO_BASEDECODER_H
#include
#include
#include
#include "../../utils/logger.h"
#include "i_decoder.h"
#include "decode_state.h"
extern "C" {
#include
#include
#include
#include
};
class BaseDecoder: public IDecoder {
private:
const char *TAG = "BaseDecoder";
//-------------定义解码相关------------------------------
// 解码信息上下文
AVFormatContext *m_format_ctx = NULL;
// 解码器
AVCodec *m_codec = NULL;
// 解码器上下文
AVCodecContext *m_codec_ctx = NULL;
// 待解码包
AVPacket *m_packet = NULL;
// 最终解码数据
AVFrame *m_frame = NULL;
// 当前播放时间
int64_t m_cur_t_s = 0;
// 总时长
long m_duration = 0;
// 开始播放的时间
int64_t m_started_t = -1;
// 解码状态
DecodeState m_state = STOP;
// 数据流索引
int m_stream_index = -1;
// 省略其他
// ......
}
注意:在引入
FFmpeg
相关库的头文件时,需要注意把#include
放到extern "C" {}
中。因为FFmpeg
是C
语言写的,所以在引入到C++
文件中的时候,需要标记以C
的方式来编译,否则会导致编译出错。
在头文件中,先声明在 cpp
需要用到的相关变量,重点就是上一节提到的几个解码相关的结构体。
- 定义初始化和解码循环相关的方法:
//base_decoder.h
class BaseDecoder: public IDecoder {
private:
const char *TAG = "BaseDecoder";
//-------------定义解码相关------------------------------
//省略....
//-----------------私有方法------------------------------
/**
* 初始化FFMpeg相关的参数
* @param env jvm环境
*/
void InitFFMpegDecoder(JNIEnv * env);
/**
* 分配解码过程中需要的缓存
*/
void AllocFrameBuffer();
/**
* 循环解码
*/
void LoopDecode();
/**
* 获取当前帧时间戳
*/
void ObtainTimeStamp();
/**
* 解码完成
* @param env jvm环境
*/
void DoneDecode(JNIEnv *env);
/**
* 时间同步
*/
void SyncRender();
// 省略其他
// ......
}
- 这个解码基础类继承自
i_decoder
,还需要实现其中规定的通用方法。
//base_decoder.h
class BaseDecoder: public IDecoder {
//省略其他
//......
public:
//--------构造方法和析构方法-------------
BaseDecoder(JNIEnv *env, jstring path);
virtual ~BaseDecoder();
//--------实现基础类方法-----------------
void GoOn() override;
void Pause() override;
void Stop() override;
bool IsRunning() override;
long GetDuration() override;
long GetCurPos() override;
}
- 定义解码线程
我们知道,解码是一个非常耗时的操作,就像原生硬解一样,我们需要开启一个线程来承载解码任务。所以,先在头文件中定义好线程相关的变量和方法。
//base_decoder.h
class BaseDecoder: public IDecoder {
private:
//省略其他
//......
// -------------------定义线程相关-----------------------------
// 线程依附的JVM环境
JavaVM *m_jvm_for_thread = NULL;
// 原始路径jstring引用,否则无法在线程中操作
jobject m_path_ref = NULL;
// 经过转换的路径
const char *m_path = NULL;
// 线程等待锁变量
pthread_mutex_t m_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t m_cond = PTHREAD_COND_INITIALIZER;
/**
* 新建解码线程
*/
void CreateDecodeThread();
/**
* 静态解码方法,用于解码线程回调
* @param that 当前解码器
*/
static void Decode(std::shared_ptr that);
protected:
/**
* 进入等待
*/
void Wait(long second = 0);
/**
* 恢复解码
*/
void SendSignal();
}
- 定义子类需要实现的虚函数
//base_decoder.h
class BaseDecoder: public IDecoder {
protected:
/**
* 子类准备回调方法
* @note 注:在解码线程中回调
* @param env 解码线程绑定的JVM环境
*/
virtual void Prepare(JNIEnv *env) = 0;
/**
* 子类渲染回调方法
* @note 注:在解码线程中回调
* @param frame 视频:一帧YUV数据;音频:一帧PCM数据
*/
virtual void Render(AVFrame *frame) = 0;
/**
* 子类释放资源回调方法
*/
virtual void Release() = 0;
}
以上,就定义好了解码类的基础结构:
-
FFmpeg
解码相关的结构体参数 - 解码器基本方法
- 解码线程
- 规定子类需要实现的方法
4. 实现基础解码器
在 base_decoder.cpp
中,实现头文件中声明的方法
- 初始化解码线程
// base_decoder.cpp
#include "base_decoder.h"
#include "../../utils/timer.c"
BaseDecoder::BaseDecoder(JNIEnv *env, jstring path) {
Init(env, path);
CreateDecodeThread();
}
BaseDecoder::~BaseDecoder() {
if (m_format_ctx != NULL) delete m_format_ctx;
if (m_codec_ctx != NULL) delete m_codec_ctx;
if (m_frame != NULL) delete m_frame;
if (m_packet != NULL) delete m_packet;
}
void BaseDecoder::Init(JNIEnv *env, jstring path) {
m_path_ref = env->NewGlobalRef(path);
m_path = env->GetStringUTFChars(path, NULL);
//获取JVM虚拟机,为创建线程作准备
env->GetJavaVM(&m_jvm_for_thread);
}
void BaseDecoder::CreateDecodeThread() {
// 使用智能指针,线程结束时,自动删除本类指针
std::shared_ptr that(this);
std::thread t(Decode, that);
t.detach();
}
构造函数很简单,传入 JNI
环境变量,以及待解码文件路径。
在 Init
方法中,因为 jstring
并非 C++
的标准类型,需要将 jstring
类型的 path
转换为 char
类型,才能使用。
说明:由于
JNIEnv
和线程
是一一对应的,也就是说,在Android
中,JNI环境
是和线程绑定的,每一个线程都有一个独立的JNIEnv
环境,并且互相之间不可访问。所以如果要在新的线程中访问JNIEnv
,需要为这个线程创建一个新的JNIEnv
。
在 Init
方法的最后,通过 env->GetJavaVM(&m_jvm_for_thread)
获取到 JavaVM
实例,保存到 m_jvm_for_thread
,该实例是所有共享的 ,通过它就可以为解码线程获取一个新的 JNIEnv
环境。
在 C++
中创建线程非常简单,只需两句话,就可以启动一个线程:
std::thread t(静态方法, 静态方法参数);
t.detach();
也就是说,这个线程需要一个静态方法作为参数,启动以后,会回调这个静态方法,并且可以给这个静态方法传递参数。
另外,CreateDecodeThread
方法中的第一代码,是用于创建一个智能指针。
我们知道,
C++
new
出来的指针对象是需要我们手动delete
删除的,否则就会出现内存泄漏。而智能指针的作用就是帮我们实现内存管理。
当这个指针的引用计数为 0 时,就会自动销毁。也就是说,不需要我们自己去手动 delete
。
std::shared_ptr that(this);
这里将 this
封装成名为 that
的智能指针,那么在外部使用解码器的时候,就不需要手动释放内存了,当解码线程退出的时候,会自动销毁,并调用析构函数。
- 封装解码流程
// base_decoder.cpp
void BaseDecoder::Decode(std::shared_ptr that) {
JNIEnv * env;
//将线程附加到虚拟机,并获取env
if (that->m_jvm_for_thread->AttachCurrentThread(&env, NULL) != JNI_OK) {
LOG_ERROR(that->TAG, that->LogSpec(), "Fail to Init decode thread");
return;
}
// 初始化解码器
that->InitFFMpegDecoder(env);
// 分配解码帧数据内存
that->AllocFrameBuffer();
// 回调子类方法,通知子类解码器初始化完毕
that->Prepare(env);
// 进入解码循环
that->LoopDecode();
// 退出解码
that->DoneDecode(env);
//解除线程和jvm关联
that->m_jvm_for_thread->DetachCurrentThread();
}
在 base_decoder.h
头文件声明中, Decode
是一个静态的成员方法。
首先为解码线程创建了 JNIEnv
,失败则直接退出解码。
以上 Decode
方法中就是分步调用对应的方法,很简单,看注释即可。
接下来看具体的分步调用的内容。
- 初始化解码器
void BaseDecoder::InitFFMpegDecoder(JNIEnv * env) {
//1,初始化上下文
m_format_ctx = avformat_alloc_context();
//2,打开文件
if (avformat_open_input(&m_format_ctx, m_path, NULL, NULL) != 0) {
LOG_ERROR(TAG, LogSpec(), "Fail to open file [%s]", m_path);
DoneDecode(env);
return;
}
//3,获取音视频流信息
if (avformat_find_stream_info(m_format_ctx, NULL) < 0) {
LOG_ERROR(TAG, LogSpec(), "Fail to find stream info");
DoneDecode(env);
return;
}
//4,查找编解码器
//4.1 获取视频流的索引
int vIdx = -1;//存放视频流的索引
for (int i = 0; i < m_format_ctx->nb_streams; ++i) {
if (m_format_ctx->streams[i]->codecpar->codec_type == GetMediaType()) {
vIdx = i;
break;
}
}
if (vIdx == -1) {
LOG_ERROR(TAG, LogSpec(), "Fail to find stream index")
DoneDecode(env);
return;
}
m_stream_index = vIdx;
//4.2 获取解码器参数
AVCodecParameters *codecPar = m_format_ctx->streams[vIdx]->codecpar;
//4.3 获取解码器
m_codec = avcodec_find_decoder(codecPar->codec_id);
//4.4 获取解码器上下文
m_codec_ctx = avcodec_alloc_context3(m_codec);
if (avcodec_parameters_to_context(m_codec_ctx, codecPar) != 0) {
LOG_ERROR(TAG, LogSpec(), "Fail to obtain av codec context");
DoneDecode(env);
return;
}
//5,打开解码器
if (avcodec_open2(m_codec_ctx, m_codec, NULL) < 0) {
LOG_ERROR(TAG, LogSpec(), "Fail to open av codec");
DoneDecode(env);
return;
}
m_duration = (long)((float)m_format_ctx->duration/AV_TIME_BASE * 1000);
LOG_INFO(TAG, LogSpec(), "Decoder init success")
}
看起来好像很复杂,实际上套路都是一样的,一开始看会感到不适应,主要是因为这些方法是面向过程的调用方法,和平时使用的面向对象语言使用习惯不太一样。
举个例子:
上面代码中,打开文件的方法是这样的:
avformat_open_input(&m_format_ctx, m_path, NULL, NULL);
而如果是面向对象的话,代码通常是这样的:
// 注意:以下为伪代码,仅用于举例说明
m_format_ctx.avformat_open_input(m_path);
那么怎么理解 C
中的这种面向过程的调用呢?
我们知道 m_format_ctx
是结构体,封装了具体的数据,那么 avformat_open_input
这个方法其实就是操作这个结构体的方法,不同的方法调用,是对结构体中不同数据的操作。
具体流程请看上面的注释,不在细说,其实就是第一节中 【初始化流程图】 中步骤的实现。
有两点需要注意的:
-
FFmpeg
中带有alloc
字样的方法,通常只是初始化对应的结构体,但是具体的参数和数据缓存区,一般都要经过另外方法的初始化才能使用,
比如 m_format_ctx
, m_codec_ctx
:
// 创建
m_format_ctx = avformat_alloc_context();
// 初始化流信息
avformat_open_input(&m_format_ctx, m_path, NULL, NULL)
-------------------------------------------------------
// 创建
m_codec_ctx = avcodec_alloc_context3(m_codec);
//初始化具体内容
avcodec_parameters_to_context(m_codec_ctx, codecPar);
- 关于代码中注释的第 4 点
我们知道音视频数据通常封装在不同的轨道中,所以,要想获取到正确的音视频数据,就需要先获取到对应的索引。
音视频的数据类型,通过虚函数 GetMediaType()
获取,具体实现是在子类中,分别为:
视频:AVMediaType.AVMEDIA_TYPE_VIDEO
音频:AVMediaType.AVMEDIA_TYPE_AUDIO
- 创建待解码和解码数据结构
// base_decoder.cpp
void BaseDecoder::AllocFrameBuffer() {
// 初始化待解码和解码数据结构
// 1)初始化AVPacket,存放解码前的数据
m_packet = av_packet_alloc();
// 2)初始化AVFrame,存放解码后的数据
m_frame = av_frame_alloc();
}
很简单,通过两个方法分配了内存,供后面解码的时候使用。
- 解码循环
// base_decoder.cpp
void BaseDecoder::LoopDecode() {
if (STOP == m_state) { // 如果已被外部改变状态,维持外部配置
m_state = START;
}
LOG_INFO(TAG, LogSpec(), "Start loop decode")
while(1) {
if (m_state != DECODING &&
m_state != START &&
m_state != STOP) {
Wait();
// 恢复同步起始时间,去除等待流失的时间
m_started_t = GetCurMsTime() - m_cur_t_s;
}
if (m_state == STOP) {
break;
}
if (-1 == m_started_t) {
m_started_t = GetCurMsTime();
}
if (DecodeOneFrame() != NULL) {
SyncRender();
Render(m_frame);
if (m_state == START) {
m_state = PAUSE;
}
} else {
LOG_INFO(TAG, LogSpec(), "m_state = %d" ,m_state)
if (ForSynthesizer()) {
m_state = STOP;
} else {
m_state = FINISH;
}
}
}
}
可以看到,这里进入 while
死循环,其中融合了部分时间同步的代码,同步的逻辑在之前硬解的文章有详细的说明,具体参考 音视频同步。
不再细说,这里只看其中最重要的一个方法:DecodeOneFrame()
。
- 解码一帧数据
看具体代码之前,来看看 FFmpeg
是如何实现解码的,分别是三个方法:
++av_read_frame(m_format_ctx, m_packet)++:
从 m_format_ctx
中读取一帧解封好的待解码数据,存放在 m_packet
中;
++avcodec_send_packet(m_codec_ctx, m_packet)++:
将 m_packet
发送到解码器中解码,解码好的数据存放在 m_codec_ctx
中;
++avcodec_receive_frame(m_codec_ctx, m_frame)++:
接收一帧解码好的数据,存放在 m_frame
中。
// base_decoder.cpp
AVFrame* BaseDecoder::DecodeOneFrame() {
int ret = av_read_frame(m_format_ctx, m_packet);
while (ret == 0) {
if (m_packet->stream_index == m_stream_index) {
switch (avcodec_send_packet(m_codec_ctx, m_packet)) {
case AVERROR_EOF: {
av_packet_unref(m_packet);
LOG_ERROR(TAG, LogSpec(), "Decode error: %s", av_err2str(AVERROR_EOF));
return NULL; //解码结束
}
case AVERROR(EAGAIN):
LOG_ERROR(TAG, LogSpec(), "Decode error: %s", av_err2str(AVERROR(EAGAIN)));
break;
case AVERROR(EINVAL):
LOG_ERROR(TAG, LogSpec(), "Decode error: %s", av_err2str(AVERROR(EINVAL)));
break;
case AVERROR(ENOMEM):
LOG_ERROR(TAG, LogSpec(), "Decode error: %s", av_err2str(AVERROR(ENOMEM)));
break;
default:
break;
}
int result = avcodec_receive_frame(m_codec_ctx, m_frame);
if (result == 0) {
ObtainTimeStamp();
av_packet_unref(m_packet);
return m_frame;
} else {
LOG_INFO(TAG, LogSpec(), "Receive frame error result: %d", av_err2str(AVERROR(result)))
}
}
// 释放packet
av_packet_unref(m_packet);
ret = av_read_frame(m_format_ctx, m_packet);
}
av_packet_unref(m_packet);
LOGI(TAG, "ret = %d", ret)
return NULL;
}
知道了解码过程,其他的其实就是处理异常的情况,比如:
解码需要等待时,则重新将数据发送到解码器,然后再取数据;
解码发生异常,读取下一帧数据,然后继续解码;
如果解码完成了,返回空数据
NULL
;
最后,非常重要的是,解码完一帧数据的时候,一定要调用 av_packet_unref(m_packet);
释放内存,否则会导致内存泄漏。
- 解码完毕,释放资源
解码完毕后,需要释放所有 FFmpeg
相关的资源,关闭解码器。
还有一点要注意的是,在初始化的时候,将 jstring
转换得到的文件路径也要释放,并且要删除全局引用。
// base_deocder.cpp
void BaseDecoder::DoneDecode(JNIEnv *env) {
LOG_INFO(TAG, LogSpec(), "Decode done and decoder release")
// 释放缓存
if (m_packet != NULL) {
av_packet_free(&m_packet);
}
if (m_frame != NULL) {
av_frame_free(&m_frame);
}
// 关闭解码器
if (m_codec_ctx != NULL) {
avcodec_close(m_codec_ctx);
avcodec_free_context(&m_codec_ctx);
}
// 关闭输入流
if (m_format_ctx != NULL) {
avformat_close_input(&m_format_ctx);
avformat_free_context(m_format_ctx);
}
// 释放转换参数
if (m_path_ref != NULL && m_path != NULL) {
env->ReleaseStringUTFChars((jstring) m_path_ref, m_path);
env->DeleteGlobalRef(m_path_ref);
}
// 通知子类释放资源
Release();
}
以上,将解码器的基础结构封装好,只要继承并实现规定的虚函数,即可实现视频的解码了。
四、视频播放
视频解码器
这里有两个重要的地方需要说明:
1. 视频数据转码
我们知道,视频解码出来以后,数据格式是 YUV
,而屏幕显示的时候需要 RGBA
,因此视频解码器中,需要对数据做一层转换。
使用的是 FFmpeg
中的 SwsContext
工具,转换方法为 sws_scale
,他们都隶属于 swresampel
工具包。
sws_scale
既可以实现数据格式的转化,同时可以对画面宽高进行缩放。
2. 声明渲染器
经过转换,视频帧数据变成 RGBA
,就可以渲染到手机屏幕上了,这里有两种方法:
- 一是,通过本地窗口,直接渲染数据,这种方式无法实现对画面的重新编辑
- 二是,通过
OpenGL ES
渲染,可实现对画面的编辑
本文使用的是前者,
OpenGL ES
渲染的方式将在后面的文章单独讲解。
新建目录 src/main/cpp/decoder/video
,并新建视频解码器 v_decoder
。
看头文件 v_decoder.h
// base_decoder.cpp
#ifndef LEARNVIDEO_V_DECODER_H
#define LEARNVIDEO_V_DECODER_H
#include "../base_decoder.h"
#include "../../render/video/video_render.h"
#include
#include
#include
extern "C" {
#include
#include
};
class VideoDecoder : public BaseDecoder {
private:
const char *TAG = "VideoDecoder";
//视频数据目标格式
const AVPixelFormat DST_FORMAT = AV_PIX_FMT_RGBA;
//存放YUV转换为RGB后的数据
AVFrame *m_rgb_frame = NULL;
uint8_t *m_buf_for_rgb_frame = NULL;
//视频格式转换器
SwsContext *m_sws_ctx = NULL;
//视频渲染器
VideoRender *m_video_render = NULL;
//显示的目标宽
int m_dst_w;
//显示的目标高
int m_dst_h;
/**
* 初始化渲染器
*/
void InitRender(JNIEnv *env);
/**
* 初始化显示器
* @param env
*/
void InitBuffer();
/**
* 初始化视频数据转换器
*/
void InitSws();
public:
VideoDecoder(JNIEnv *env, jstring path, bool for_synthesizer = false);
~VideoDecoder();
void SetRender(VideoRender *render);
protected:
AVMediaType GetMediaType() override {
return AVMEDIA_TYPE_VIDEO;
}
/**
* 是否需要循环解码
*/
bool NeedLoopDecode() override;
/**
* 准备解码环境
* 注:在解码线程中回调
* @param env 解码线程绑定的jni环境
*/
void Prepare(JNIEnv *env) override;
/**
* 渲染
* 注:在解码线程中回调
* @param frame 解码RGBA数据
*/
void Render(AVFrame *frame) override;
/**
* 释放回调
*/
void Release() override;
const char *const LogSpec() override {
return "VIDEO";
};
};
#endif //LEARNVIDEO_V_DECODER_H
接下来看 v_deocder.cpp
实现,先看初始化相关的代码:
// v_deocder.cpp
VideoDecoder::VideoDecoder(JNIEnv *env, jstring path, bool for_synthesizer)
: BaseDecoder(env, path, for_synthesizer) {
}
void VideoDecoder::Prepare(JNIEnv *env) {
InitRender(env);
InitBuffer();
InitSws();
}
构造函数很简单,把相关的参数传递给父类 base_decoder
即可。
接下来是 Prepare
方法,这个方法是父类 base_decoder
中规定的子类必须实现的方法,在初始化完解码器之后调用,回顾一下:
// base_decoder.cpp
void BaseDecoder::Decode(std::shared_ptr that) {
// 省略无关代码...
that->InitFFMpegDecoder(env);
that->AllocFrameBuffer();
//子类初始化方法调用
that->Prepare(env);
that->LoopDecode();
that->DoneDecode(env);
// 省略无关代码...
}
在 Prepare
中,初始化渲染器 InitRender
的先略过,后面详细再讲。
看看数据格式转化相关的初始化。
- 存放数据缓存初始化:
// base_decoder.cpp
void VideoDecoder::InitBuffer() {
m_rgb_frame = av_frame_alloc();
// 获取缓存大小
int numBytes = av_image_get_buffer_size(DST_FORMAT, m_dst_w, m_dst_h, 1);
// 分配内存
m_buf_for_rgb_frame = (uint8_t *) av_malloc(numBytes * sizeof(uint8_t));
// 将内存分配给RgbFrame,并将内存格式化为三个通道后,分别保存其地址
av_image_fill_arrays(m_rgb_frame->data, m_rgb_frame->linesize,
m_buf_for_rgb_frame, DST_FORMAT, m_dst_w, m_dst_h, 1);
}
通过 av_frame_alloc
方法初始化一块 AVFrame
,注意该方法没有分配缓存内存;
然后通过 av_image_get_buffer_size
方法计算所需内存块大小,其中
AVPixelFormat DST_FORMAT = AV_PIX_FMT_RGBA
m_dst_w: 为目标画面宽度(即画面显示时的实际宽度,将通过后续渲染器中具体的窗户大小计算得出)
m_dst_h:为目标画面高度(即画面显示时的实际高度,将通过后续渲染器中具体的窗户大小计算得出)
接着通过 av_malloc
真正分配一块内存;
最后,通过 av_image_fill_arrays
将得到的这块内存给到 AVFrame
,至此,内存分配完成。
- 数据转换工具初始化
// base_decoder.cpp
void VideoDecoder::InitSws() {
// 初始化格式转换工具
m_sws_ctx = sws_getContext(width(), height(), video_pixel_format(),
m_dst_w, m_dst_h, DST_FORMAT,
SWS_FAST_BILINEAR, NULL, NULL, NULL);
}
这个很简单,只要将原画面数据和目标画面数据的长宽、格式等传递进去即可。
- 释放相关资源
在解码完毕以后,父类会调用子类 Release
方法,以释放子类中相关的资源。
// v_deocder.cpp
void VideoDecoder::Release() {
LOGE(TAG, "[VIDEO] release")
if (m_rgb_frame != NULL) {
av_frame_free(&m_rgb_frame);
m_rgb_frame = NULL;
}
if (m_buf_for_rgb_frame != NULL) {
free(m_buf_for_rgb_frame);
m_buf_for_rgb_frame = NULL;
}
if (m_sws_ctx != NULL) {
sws_freeContext(m_sws_ctx);
m_sws_ctx = NULL;
}
if (m_video_render != NULL) {
m_video_render->ReleaseRender();
m_video_render = NULL;
}
}
初始化和资源释放已经完成,就剩下最后的渲染器配置了。
渲染器
刚刚上面说过,一般有两种方式渲染画面,那么就先把渲染器先定义好,方便后面扩展。
定义视频渲染器
新建目录 src/main/cpp/media/render/video
,并创建头文件 video_render.h
。
#ifndef LEARNVIDEO_VIDEORENDER_H
#define LEARNVIDEO_VIDEORENDER_H
#include
#include
#include "../../one_frame.h"
class VideoRender {
public:
virtual void InitRender(JNIEnv *env, int video_width, int video_height, int *dst_size) = 0;
virtual void Render(OneFrame *one_frame) = 0;
virtual void ReleaseRender() = 0;
};
#endif //LEARNVIDEO_VIDEORENDER_H
该类同样是纯虚类,类似 Java
的 interface
。
这里只是规定了几个接口,分别是初始化、渲染、释放资源。
实现本地窗口渲染器
新建目录 src/main/cpp/media/render/video/native_render
,并创建头文件 native_render
类。
native_render
头文件:
// native_render.h
#ifndef LEARNVIDEO_NATIVE_RENDER_H
#define LEARNVIDEO_NATIVE_RENDER_H
#include
#include
#include
#include "../video_render.h"
#include "../../../../utils/logger.h"
extern "C" {
#include
};
class NativeRender: public VideoRender {
private:
const char *TAG = "NativeRender";
// Surface引用,必须使用引用,否则无法在线程中操作
jobject m_surface_ref = NULL;
// 存放输出到屏幕的缓存数据
ANativeWindow_Buffer m_out_buffer;
// 本地窗口
ANativeWindow *m_native_window = NULL;
//显示的目标宽
int m_dst_w;
//显示的目标高
int m_dst_h;
public:
NativeRender(JNIEnv *env, jobject surface);
~NativeRender();
void InitRender(JNIEnv *env, int video_width, int video_height, int *dst_size) override ;
void Render(OneFrame *one_frame) override ;
void ReleaseRender() override ;
};
可以看到,渲染器中持有一个 Surface
引用,这就是我们非常熟悉的东西,前面一系列文章中,画面渲染都是使用了它。
另外还有一个就是本地窗口 ANativeWindow
,只要将 Surface
绑定给 ANativeWindow
,就可以通过本地窗口实现 Surface
渲染了。
看看渲染器的实现 native_render.cpp
。
- 初始化
// native_render.cpp
ativeRender::NativeRender(JNIEnv *env, jobject surface) {
m_surface_ref = env->NewGlobalRef(surface);
}
NativeRender::~NativeRender() {
}
void NativeRender::InitRender(JNIEnv *env, int video_width, int video_height, int *dst_size) {
// 初始化窗口
m_native_window = ANativeWindow_fromSurface(env, m_surface_ref);
// 绘制区域的宽高
int windowWidth = ANativeWindow_getWidth(m_native_window);
int windowHeight = ANativeWindow_getHeight(m_native_window);
// 计算目标视频的宽高
m_dst_w = windowWidth;
m_dst_h = m_dst_w * video_height / video_width;
if (m_dst_h > windowHeight) {
m_dst_h = windowHeight;
m_dst_w = windowHeight * video_width / video_height;
}
LOGE(TAG, "windowW: %d, windowH: %d, dstVideoW: %d, dstVideoH: %d",
windowWidth, windowHeight, m_dst_w, m_dst_h)
//设置宽高限制缓冲区中的像素数量
ANativeWindow_setBuffersGeometry(m_native_window, windowWidth,
windowHeight, WINDOW_FORMAT_RGBA_8888);
dst_size[0] = m_dst_w;
dst_size[1] = m_dst_h;
}
重点来看 InitRender
方法:
通过 ANativeWindow_fromSurface
将 Surface
绑定给本地窗口;
通过 ANativeWindow_getWidth
ANativeWindow_getHeight
可以获取到 Surface
可显示区域的宽高;
然后,根据原始视频画面的宽高 video_width
video_height
以及可现实区域的宽高,进行画面缩放,可以计算出最终显示的画面的宽高,并赋值给解码器。
视频解码器
v_decoder
在获取到目标画面宽高之后,就可以去初始化数据转化缓存区的大小了。
最后,通过 ANativeWindow_setBuffersGeometry
设置一下本地窗口缓存区大小,完成初始化。
- 渲染
两个重要的本地方法:
ANativeWindow_lock
锁定窗口,并获取到输出缓冲区 m_out_buffer
。
ANativeWindow_unlockAndPost
释放窗口,并将缓冲数据绘制到屏幕上。
// native_render.cpp
void NativeRender::Render(OneFrame *one_frame) {
//锁定窗口
ANativeWindow_lock(m_native_window, &m_out_buffer, NULL);
uint8_t *dst = (uint8_t *) m_out_buffer.bits;
// 获取stride:一行可以保存的内存像素数量*4(即:rgba的位数)
int dstStride = m_out_buffer.stride * 4;
int srcStride = one_frame->line_size;
// 由于window的stride和帧的stride不同,因此需要逐行复制
for (int h = 0; h < m_dst_h; h++) {
memcpy(dst + h * dstStride, one_frame->data + h * srcStride, srcStride);
}
//释放窗口
ANativeWindow_unlockAndPost(m_native_window);
}
渲染过程看起来很复杂,主要是因为这里有一个 stride
的概念,指的是一帧画面每一行数据的宽度大小。
比如这里的数据格式是
RGBA
,一行画面的像素是 8 个,那么总共的stride
宽度就是 8*4 = 32 。
为什么需要转换呢?原因是本地窗口的stride
大小可能和视频画面数据的stride
不一致,直接将视频画面数据给到本地窗口时,可能会导致数据读取不一致,最终导致花屏。
所以,这里需要根据本地窗口的 dstStride
和视频画面数据的 srcStride
,将数据一行一行复制(memcpy)。
渲染器调用
最后来看下,视频解码器 v_decoder
中对渲染器的调用
// v_decoder.cpp
void VideoDecoder::SetRender(VideoRender *render) {
this->m_video_render = render;
}
void VideoDecoder::InitRender(JNIEnv *env) {
if (m_video_render != NULL) {
int dst_size[2] = {-1, -1};
m_video_render->InitRender(env, width(), height(), dst_size);
m_dst_w = dst_size[0];
m_dst_h = dst_size[1];
if (m_dst_w == -1) {
m_dst_w = width();
}
if (m_dst_h == -1) {
m_dst_w = height();
}
LOGI(TAG, "dst %d, %d", m_dst_w, m_dst_h)
} else {
LOGE(TAG, "Init render error, you should call SetRender first!")
}
}
void VideoDecoder::Render(AVFrame *frame) {
sws_scale(m_sws_ctx, frame->data, frame->linesize, 0,
height(), m_rgb_frame->data, m_rgb_frame->linesize);
OneFrame * one_frame = new OneFrame(m_rgb_frame->data[0], m_rgb_frame->linesize[0], frame->pts, time_base(), NULL, false);
m_video_render->Render(one_frame);
}
一是,将渲染设置给视频解码器;
二是,调用渲染器的 InitRender
方法初始化渲染器,并获得目标画面宽高
最后是,调用渲染器 Render
方法,进行渲染。
其中,OneFrame
是自定义类,用来封装一帧数据相关的内容,知道即可,具体可以查看【工程源码】。
编写播放器
以上,完成了 :
-
基础解码器
的封装 -->视频解码器
的实现; -
渲染器
的定义 -->本地渲染窗口
的实现。
最后就差把他们整合在一起,实现播放了。
在 src/main/cpp/media
目录下新建一个播放器 player
,如下:
// player.h
#ifndef LEARNINGVIDEO_PLAYER_H
#define LEARNINGVIDEO_PLAYER_H
#include "decoder/video/v_decoder.h"
class Player {
private:
VideoDecoder *m_v_decoder;
VideoRender *m_v_render;
public:
Player(JNIEnv *jniEnv, jstring path, jobject surface);
~Player();
void play();
void pause();
};
#endif //LEARNINGVIDEO_PLAYER_H
播放器持有一个视频解码器和一个视频渲染器,以及一个播放和暂停方法。
// player.cpp
#include "player.h"
#include "render/video/native_render/native_render.h"
Player::Player(JNIEnv *jniEnv, jstring path, jobject surface) {
m_v_decoder = new VideoDecoder(jniEnv, path);
m_v_render = new NativeRender(jniEnv, surface);
m_v_decoder->SetRender(m_v_render);
}
Player::~Player() {
// 此处不需要 delete 成员指针
// 在BaseDecoder中的线程已经使用智能指针,会自动释放
}
void Player::play() {
if (m_v_decoder != NULL) {
m_v_decoder->GoOn();
}
}
void Player::pause() {
if (m_v_decoder != NULL) {
m_v_decoder->Pause();
}
}
代码很简单,就是把解码器和渲染器关联起来。
将源代码加入编译
虽然上面完成了各个功能模块的编写,但是编译器不会自动把它们加入编译。要想让 C++
代码加入编译,需要手动在 CMakeLists.txt
文件中配置,配置的位置和默认的 native-lib.cpp
相同,罗列在后面即可。
# CMakeLists.txt
// 省略无关配置
//......
# 配置目标so库编译信息
add_library( # Sets the name of the library.
native-lib
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
native-lib.cpp
# 工具
${CMAKE_SOURCE_DIR}/utils/logger.h
${CMAKE_SOURCE_DIR}/utils/timer.c
# 播放器
${CMAKE_SOURCE_DIR}/media//player.cpp
# 解码器
${CMAKE_SOURCE_DIR}/media//one_frame.h
${CMAKE_SOURCE_DIR}/media/decoder/i_decoder.h
${CMAKE_SOURCE_DIR}/media/decoder/decode_state.h
${CMAKE_SOURCE_DIR}/media/decoder/base_decoder.cpp
${CMAKE_SOURCE_DIR}/media/decoder/video/v_decoder.cpp
# 渲染器
${CMAKE_SOURCE_DIR}/media/render/video/video_render.h
${CMAKE_SOURCE_DIR}/media/render/video/native_render/native_render.cpp
)
// 省略无关配置
//......
如果类只有
.h
头文件的话,就只写.h
文件,如果类既有头文件,又有.cpp
实现文件,则只需要配置.cpp
文件
需要注意的是:在创建好每个类的时候,就需要将其配置到 CMakeLists.txt
中,否则在编写代码的时,可能无法导入相关的库头文件,也就没法通过编译。
编写 JNI 接口
接下来就需要将播放器暴露给 Java
层使用了,这时候就需要用到 JNI
的接口文件 native-lib.cpp
了。
开始编写 JNI
接口之前,先在 FFmpegActivity
中写好相应的接口:
// FFmpegActivity.kt
class FFmpegActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ffmpeg_info)
tv.text = ffmpegInfo()
initSfv()
}
private fun initSfv() {
sfv.holder.addCallback(object: SurfaceHolder.Callback {
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
}
override fun surfaceCreated(holder: SurfaceHolder) {
if (player == null) {
player = createPlayer(path, holder.surface)
play(player!!)
}
}
})
}
//------------ JNI 相关接口方法 ----------------------
private external fun ffmpegInfo(): String
private external fun createPlayer(path: String, surface: Surface): Int
private external fun play(player: Int)
private external fun pause(player: Int)
companion object {
init {
System.loadLibrary("native-lib")
}
}
}
接口很简单:
createPlayer(path: String, surface: Surface): Int: 创建播放器,并返回播放器对象地址
play(player: Int):播放,参数为播放器对象
pause(player: Int): 暂停,参数为播放器对象
播放器的创建时机为 SurfaceView
初始化完成时: surfaceCreated
。
页面布局 xml
如下:
接下来,就根据以上三个接口,在 JNI
中编写对应的接口。
// native-lib.cpp
#include
#include
#include
#include "media/player.h"
extern "C" {
JNIEXPORT jint JNICALL
Java_com_cxp_learningvideo_FFmpegActivity_createPlayer(JNIEnv *env,
jobject /* this */,
jstring path,
jobject surface) {
Player *player = new Player(env, path, surface);
return (jint) player;
}
JNIEXPORT void JNICALL
Java_com_cxp_learningvideo_FFmpegActivity_play(JNIEnv *env,
jobject /* this */,
jint player) {
Player *p = (Player *) player;
p->play();
}
JNIEXPORT void JNICALL
Java_com_cxp_learningvideo_FFmpegActivity_pause(JNIEnv *env,
jobject /* this */,
jint player) {
Player *p = (Player *) player;
p->pause();
}
}
很简单,相信大家都看得懂,其实就是初始化一个播放器对象指针,然后返回给 Java
层保存,后面的播放和暂停操作都是 Java
层将这个播放器指针再传给 JNI
层做具体操作。
五、总结
代码很多,但是其实如果看过前面系列原生硬解的文章的话,应该也比较好理解了。
最后,简单做一下总结吧:
-
初始化:根据
FFmpeg
提供的一些功能接口,对解码器做初始化- 输入文件码流上下文 AVFormatContext
- 解码器上下文 AVCodecContext
- 解码器 AVCodec
- 分配数据缓存空间 AVPacket(存放待解码数据) 和 AVFrame (存放已解码数据)
-
解码:通过
FFmpeg
提供的解码接口进行解码- av_read_frame 读取待解码数据到 AVPacket
- avcodec_send_packet 发送 AVPacket 到解码器解码
- avcodec_receive_frame 读取解码好的数据到 AVFrame
-
转码和缩放:通过
FFmpeg
提供的转码接口将 YUV 转换为 RGBA- sws_getContext 初始化转化工具 SwsContext
- sws_scale 执行数据转换
-
渲染:通过
Android
提供的接口将视频数据渲染到屏幕上- ANativeWindow_fromSurface 绑定 Surface 到本地窗口
- ANativeWindow_getWidth/ANativeWindow_getWidth 获取 Surface 宽高
- ANativeWindow_setBuffersGeometry 设置屏幕缓冲区大小
- ANativeWindow_lock 锁定窗口,获取显示缓冲区
- 根据
Stride
将数据复制(memcpy)到缓冲区 - ANativeWindow_unlockAndPost 解锁窗口,并显示