【Android 音視頻開發:FFmpeg音視頻編解碼篇】三、Android FFmpeg視頻解碼播放
- 2020 年 4 月 2 日
- 筆記
【聲 明】
首先,這一系列文章均基於自己的理解和實踐,可能有不對的地方,歡迎大家指正。 其次,這是一個入門系列,涉及的知識也僅限於夠用,深入的知識網上也有許許多多的博文供大家學習了。 最後,寫文章過程中,會借鑒參考其他人分享的文章,會在文章最後列出,感謝這些作者的分享。
碼字不易,轉載請註明出處!
教程代碼:【Github傳送門】 |
---|
目錄
一、Android音視頻硬解碼篇:
二、使用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
原生硬解碼來說還是比較瑣碎的,但是流程都是固定的,一旦封裝起來就可以直接套用了。
首先來看一下初始化的流程圖
FFmpeg初始化
其實就是根據待解碼文件的格式,進行一系列參數的初始化。
其中,有幾個 結構體
比較重要,分別是 AVFormatContext
(format_ctx)、AVCodecContext
(codec_ctx)、AVCodec
(codec)
結構體 :FFmpeg 是基於
C
語言開發的,我們知道C
語言是面向過程的語言,也就是說不像C++
有類來封裝內部數據。但是C
提供了結構體,可以用來實現數據的封裝,達到類似於類的效果。
- AVFormatContext:隸屬於
avformat
庫,存放這碼流數據的上下文,主要用於音視頻的封裝
和解封
。 - AVCodecContext:隸屬於
avcodec
庫,存放編解碼器參數上下文,主要用於對音視頻數據進行編碼
和解碼
。 - AVCodec:隸屬於
avcodec
庫,音視頻編解碼器,真正編解碼執行者。
2. FFmpeg 解碼循環
同樣的,通過一個流程圖來說明具體解碼過程:
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 <jni.h> #include <string> #include <thread> #include "../../utils/logger.h" #include "i_decoder.h" #include "decode_state.h" extern "C" { #include <libavcodec/avcodec.h> #include <libavformat/avformat.h> #include <libavutil/frame.h> #include <libavutil/time.h> }; 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<BaseDecoder> 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<BaseDecoder> 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<BaseDecoder> that(this);
這裡將 this
封裝成名為 that
的智能指針,那麼在外部使用解碼器的時候,就不需要手動釋放內存了,當解碼線程退出的時候,會自動銷毀,並調用析構函數。
- 封裝解碼流程
// base_decoder.cpp void BaseDecoder::Decode(std::shared_ptr<BaseDecoder> 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 <jni.h> #include <android/native_window_jni.h> #include <android/native_window.h> extern "C" { #include <libavutil/imgutils.h> #include <libswscale/swscale.h> }; 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<BaseDecoder> 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 <stdint.h> #include <jni.h> #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 <android/native_window.h> #include <android/native_window_jni.h> #include <jni.h> #include "../video_render.h" #include "../../../../utils/logger.h" extern "C" { #include <libavutil/mem.h> }; 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
如下:
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <ScrollView android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <SurfaceView android:id="@+id/sfv" android:layout_width="match_parent" android:layout_height="200dp" /> <TextView android:id="@+id/tv" android:layout_width="match_parent" android:layout_height="match_parent"/> </LinearLayout> </ScrollView> </android.support.constraint.ConstraintLayout>
接下來,就根據以上三個接口,在 JNI
中編寫對應的接口。
// native-lib.cpp #include <jni.h> #include <string> #include <unistd.h> #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 解鎖窗口,並顯示
我的博客即將同步至騰訊雲+社區,邀請大家一同入駐:https://cloud.tencent.com/developer/support-plan?invite_code=33ty0omf3nuos