教程代码:https://github.com/ChenLittlePing/LearningVideo

一、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 仓库: https://github.com/ChenLittlePing/LearningVideo】。

本文需要 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_Henum 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_Hclass 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.hbase_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.hclass 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.hclass 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.hclass 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.hclass 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.cppvoid 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 这个方法其实就是操作这个结构体的方法,不同的方法调用,是对结构体中不同数据的操作。

具体流程请看上面的注释,不在细说,其实就是第一节中 【初始化流程图】 中步骤的实现。

有两点需要注意的:

  1. 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);
  1. 关于代码中注释的第 4 点

我们知道音视频数据通常封装在不同的轨道中,所以,要想获取到正确的音视频数据,就需要先获取到对应的索引。

音视频的数据类型,通过虚函数 GetMediaType() 获取,具体实现是在子类中,分别为:

视频:AVMediaType.AVMEDIA_TYPE_VIDEO

音频:AVMediaType.AVMEDIA_TYPE_AUDIO

  • 创建待解码和解码数据结构

// base_decoder.cppvoid BaseDecoder::AllocFrameBuffer() {    // 初始化待解码和解码数据结构    // 1)初始化AVPacket,存放解码前的数据    m_packet = av_packet_alloc();    // 2)初始化AVFrame,存放解码后的数据    m_frame = av_frame_alloc();}

很简单,通过两个方法分配了内存,供后面解码的时候使用。

  • 解码循环

// base_decoder.cppvoid 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.cppAVFrame* 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.cppvoid 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.cppVideoDecoder::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.cppvoid BaseDecoder::Decode(std::shared_ptr that) {    // 省略无关代码...        that->InitFFMpegDecoder(env);    that->AllocFrameBuffer();        //子类初始化方法调用    that->Prepare(env);         that->LoopDecode();    that->DoneDecode(env);        // 省略无关代码...}

在 Prepare 中,初始化渲染器 InitRender 的先略过,后面详细再讲。

看看数据格式转化相关的初始化。

  • 存放数据缓存初始化:

// base_decoder.cppvoid 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_RGBAm_dst_w: 为目标画面宽度(即画面显示时的实际宽度,将通过后续渲染器中具体的窗户大小计算得出)m_dst_h:为目标画面高度(即画面显示时的实际高度,将通过后续渲染器中具体的窗户大小计算得出)

接着通过 av_malloc 真正分配一块内存

最后,通过 av_image_fill_arrays 将得到的这块内存给到 AVFrame,至此,内存分配完成。

  • 数据转换工具初始化

// base_decoder.cppvoid 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.cppvoid 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.cppativeRender::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.cppvoid 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.cppvoid 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 是自定义类,用来封装一帧数据相关的内容,知道即可,具体可以查看【工程源码】。

编写播放器

以上,完成了 :

  1. 基础解码器 的封装 --> 视频解码器 的实现;

  2. 渲染器的定义 --> 本地渲染器 的实现。

最后就差把他们整合在一起,实现播放了。

在 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.ktclass 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 解锁窗口,并显示


技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。

推荐阅读:

音视频面试基础题

OpenGL ES 学习资源分享

一文读懂 YUV 的采样与格式

OpenGL 之 GPUImage 源码分析

推荐几个堪称教科书级别的 Android 音视频入门项目

觉得不错,点个在看呗~

更多相关文章

  1. “罗永浩抖音首秀”销售数据的可视化大屏是怎么做出来的呢?
  2. Nginx系列教程(三)| 一文带你读懂Nginx的负载均衡
  3. 不吹不黑!GitHub 上帮助人们学习编码的 12 个资源,错过血亏...
  4. Android(安卓)根文件系统启动分析
  5. android 数据封装类-Parcelable 使用和学习
  6. Android(安卓)MVVM
  7. Android中多个Activity间的数据共享
  8. Android(安卓)SQLite教程:内部架构及SQLite使用办法
  9. Android在开发中的实用技巧之Parcelable的使用以及如何传递复杂

随机推荐

  1. android SQLite数据库基本操作示例
  2. android使用GPS
  3. android 虚拟摇杆绘制
  4. Android 加载服务器上的图片
  5. android中的状态保存
  6. Android Studio Error:Execution failed
  7. How to Build FFmpeg for Android
  8. android中动画效果的运用
  9. Android(安卓)异步更新UI----handler+thr
  10. mac平台adb、tcpdump捕手android移动网络