## 音視頻學習 (九) 從 0 ~ 1 開發一款 Android 端播放器(支持多協議網絡拉流/本地文件)
## 前言
現在一個 APP 玩的花樣是越來越多了幾乎都離不開音頻、視頻、圖片等數據顯示,該篇就介紹其中的音視頻播放,音視頻播放可以用已經成熟開源的播放器,(推薦一個不錯的播放器開源項目[GSYVideoPlayer](https://github.com/CarGuo/GSYVideoPlayer))。如果用已開源的播放器就沒有太大的學習意義了,該篇文章會介紹基于 FFmpeg 4.2.2 、Librtmp 庫從 0~1 開發一款 Android 播放器的流程和實例代碼編寫。
開發一款播放器你首先要具備的知識有:
> * FFmpeg RTMP 混合交叉編譯
> * C/C++ 基礎
> * NDK、JNI
> * 音視頻解碼、同步
學完之后我們的播放器大概效果如下:

效果看起來有點卡,這跟實際網絡環境有關,此播放器已具備 rtmp/http/URL/File 等協議播放。
## RTMP 與 FFmpeg 混合編譯
### RTMP
**介紹:**
RTMP 是 Real Time Messaging Protocol(實時消息傳輸[協議](https://baike.baidu.com/item/%E5%8D%8F%E8%AE%AE/13020269))的首字母縮寫。該協議基于 TCP,是一個協議族,包括 RTMP 基本協議及 RTMPT/RTMPS/RTMPE 等多種變種。RTMP 是一種設計用來進行實時數據通信的網絡協議,主要用來在 Flash/AIR 平臺和支持 RTMP 協議的流媒體/交互服務器之間進行音視頻和數據通信。支持該協議的軟件包括 Adobe Media Server/Ultrant Media Server/red5 等。RTMP 與 HTTP 一樣,都屬于 TCP/IP 四層模型的應用層。
**下載:**
~~~
git clone https://github.com/yixia/librtmp.git
復制代碼
~~~
**腳本編寫:**
~~~
#!/bin/bash
#配置NDK 環境變量
NDK_ROOT=$NDK_HOME
#指定 CPU
CPU=arm-linux-androideabi
#指定 Android API
ANDROID_API=17
TOOLCHAIN=$NDK_ROOT/toolchains/$CPU-4.9/prebuilt/linux-x86_64
export XCFLAGS="-isysroot $NDK_ROOT/sysroot -isystem $NDK_ROOT/sysroot/usr/include/arm-linux-androideabi -D__ANDROID_API__=$ANDROID_API"
export XLDFLAGS="--sysroot=${NDK_ROOT}/platforms/android-17/arch-arm "
export CROSS_COMPILE=$TOOLCHAIN/bin/arm-linux-androideabi-
make install SYS=android prefix=`pwd`/result CRYPTO= SHARED= XDEF=-DNO_SSL
復制代碼
~~~
如果出現如下效果就證明編譯成功了:

### 混合編譯
上一篇文章咱們編譯了 FFmpeg 靜態庫,那么該小節咱們要把 librtmp 集成到 FFmpeg 中編譯,首先我們需要到**configure**腳本中把 librtmp 模塊注釋掉,如下:

**修改 FFmpeg 編譯腳本:**
~~~
#!/bin/bash
#NDK_ROOT 變量指向ndk目錄
NDK_ROOT=$NDK_HOME
#TOOLCHAIN 變量指向ndk中的交叉編譯gcc所在的目錄
TOOLCHAIN=$NDK_ROOT/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64
#指定android api版本
ANDROID_API=17
#此變量用于編譯完成之后的庫與頭文件存放在哪個目錄
PREFIX=./android/armeabi-v7a
#rtmp路徑
RTMP=/root/android/librtmp/result
#執行configure腳本,用于生成makefile
#--prefix : 安裝目錄
#--enable-small : 優化大小
#--disable-programs : 不編譯ffmpeg程序(命令行工具),我們是需要獲得靜態(動態)庫。
#--disable-avdevice : 關閉avdevice模塊,此模塊在android中無用
#--disable-encoders : 關閉所有編碼器 (播放不需要編碼)
#--disable-muxers : 關閉所有復用器(封裝器),不需要生成mp4這樣的文件,所以關閉
#--disable-filters :關閉視頻濾鏡
#--enable-cross-compile : 開啟交叉編譯
#--cross-prefix: gcc的前綴 xxx/xxx/xxx-gcc 則給xxx/xxx/xxx-
#disable-shared enable-static 不寫也可以,默認就是這樣的。
#--sysroot:
#--extra-cflags: 會傳給gcc的參數
#--arch --target-os : 必須要給
./configure \
--prefix=$PREFIX \
--enable-small \
--disable-programs \
--disable-avdevice \
--disable-encoders \
--disable-muxers \
--disable-filters \
--enable-librtmp \
--enable-cross-compile \
--cross-prefix=$TOOLCHAIN/bin/arm-linux-androideabi- \
--disable-shared \
--enable-static \
--sysroot=$NDK_ROOT/platforms/android-$ANDROID_API/arch-arm \
--extra-cflags="-isysroot $NDK_ROOT/sysroot -isystem $NDK_ROOT/sysroot/usr/include/arm-linux-androideabi -D__ANDROID_API__=$ANDROID_API -U_FILE_OFFSET_BITS -DANDROID -ffunction-sections -funwind-tables -fstack-protector-strong -no-canonical-prefixes -march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16 -mthumb -Wa,--noexecstack -Wformat -Werror=format-security -O0 -fPIC -I$RTMP/include" \
--extra-ldflags="-L$RTMP/lib" \
--extra-libs="-lrtmp" \
--arch=arm \
--target-os=android
#上面運行腳本生成makefile之后,使用make執行腳本
make clean
make
make install
復制代碼
~~~
如果出現如下,證明開始編譯了:

如果出現如下,證明編譯成功了:

可以從上圖中看到靜態庫和頭文件庫都已經編譯成功了,下面我們就進入編寫代碼環節了。
## 播放器開發
### 流程圖
想要實現一個網絡/本地播放器,我們必須知道它的流程,如下圖所示:

### 項目準備
1. 創建一個新的 Android 項目并導入各自庫

2. CmakeLists.txt 編譯腳本編寫
~~~
cmake_minimum_required(VERSION 3.4.1)
#定義 ffmpeg、rtmp 、yk_player 目錄
set(FFMPEG ${CMAKE_SOURCE_DIR}/ffmpeg)
set(RTMP ${CMAKE_SOURCE_DIR}/librtmp)
set(YK_PLAYER ${CMAKE_SOURCE_DIR}/player)
#指定 ffmpeg 頭文件目錄
include_directories(${FFMPEG}/include)
#指定 ffmpeg 靜態庫文件目錄
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${FFMPEG}/libs/${CMAKE_ANDROID_ARCH_ABI}")
#指定 rtmp 靜態庫文件目錄
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${RTMP}/libs/${CMAKE_ANDROID_ARCH_ABI}")
#批量添加自己編寫的 cpp 文件,不要把 *.h 加入進來了
file(GLOB ALL_CPP ${YK_PLAYER}/*.cpp)
#添加自己編寫 cpp 源文件生成動態庫
add_library(YK_PLAYER SHARED ${ALL_CPP})
#找系統中 NDK log庫
find_library(log_lib
log)
#最后才開始鏈接庫
target_link_libraries(
YK_PLAYER
# 寫了此命令不用在乎添加 ffmpeg lib 順序問題導致應用崩潰
-Wl,--start-group
avcodec avfilter avformat avutil swresample swscale
-Wl,--end-group
z
rtmp
android
#音頻播放
OpenSLES
${log_lib}
)
復制代碼
~~~
3. 定義 native 函數
~~~
/**
* 當前 ffmpeg 版本
*/
public native String getFFmpegVersion();
/**
* 設置 surface
* @param surface
*/
public native void setSurfaceNative(Surface surface);
/**
* 做一些準備工作
* @param mDataSource 播放氣質
*/
public native void prepareNative(String mDataSource);
/**
* 準備工作完成,開始播放
*/
public native void startNative();
/**
* 如果點擊停止播放,那么就調用該函數進行恢復播放
*/
public native void restartNative();
/**
* 停止播放
*/
public native void stopNative();
/**
* 釋放資源
*/
public native void releaseNative();
/**
* 是否正在播放
* @return
*/
public native boolean isPlayerNative();
復制代碼
~~~
### 解封裝
根據之前我們的流程圖得知在調用設置數據源了之后,ffmpeg 就開始解封裝 (可以理解為收到快遞包裹,我們需要把包裹打開看看里面是什么,然后拿出來進行歸類放置),這里就是把一個數據源分解成經過編碼的音頻數據、視頻數據、字幕等,下面通過 FFmpeg API 來進行分解數據,代碼如下:
~~~
/**
* 該函數是真正的解封裝,是在子線程開啟并調用的。
*/
void YKPlayer::prepare_() {
LOGD("第一步 打開流媒體地址");
//1. 打開流媒體地址(文件路徑、直播地址)
// 可以初始為NULL,如果初始為NULL,當執行avformat_open_input函數時,內部會自動申請avformat_alloc_context,這里干脆手動申請
// 封裝了媒體流的格式信息
formatContext = avformat_alloc_context();
//字典: 鍵值對
AVDictionary *dictionary = 0;
av_dict_set(&dictionary, "timeout", "5000000", 0);//單位是微妙
/**
*
* @param AVFormatContext: 傳入一個 format 上下文是一個二級指針
* @param const char *url: 播放源
* @param ff_const59 AVInputFormat *fmt: 輸入的封住格式,一般讓 ffmpeg 自己去檢測,所以給了一個 0
* @param AVDictionary **options: 字典參數
*/
int result = avformat_open_input(&formatContext, data_source, 0, &dictionary);
//result -13--> 沒有讀寫權限
//result -99--> 第三個參數寫 NULl
LOGD("avformat_open_input--> %d,%s", result, data_source);
//釋放字典
av_dict_free(&dictionary);
if (result) {//0 on success true
// 你的文件路徑,或,你的文件損壞了,需要告訴用戶
// 把錯誤信息,告訴給Java層去(回調給Java)
if (pCallback) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_CAN_NOT_OPEN_URL);
}
return;
}
//第二步 查找媒體中的音視頻流的信息
LOGD("第二步 查找媒體中的音視頻流的信息");
result = avformat_find_stream_info(formatContext, 0);
if (result < 0) {
if (pCallback) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_CAN_NOT_FIND_STREAMS);
return;
}
}
//第三步 根據流信息,流的個數,循環查找,音頻流 視頻流
LOGD("第三步 根據流信息,流的個數,循環查找,音頻流 視頻流");
//nb_streams = 流的個數
for (int stream_index = 0; stream_index < formatContext->nb_streams; ++stream_index) {
//第四步 獲取媒體流 音視頻
LOGD("第四步 獲取媒體流 音視頻");
AVStream *stream = formatContext->streams[stream_index];
//第五步 從 stream 流中獲取解碼這段流的參數信息,區分到底是 音頻還是視頻
LOGD("第五步 從 stream 流中獲取解碼這段流的參數信息,區分到底是 音頻還是視頻");
AVCodecParameters *codecParameters = stream->codecpar;
//第六步 通過流的編解碼參數中的編解碼 ID ,來獲取當前流的解碼器
LOGD("第六步 通過流的編解碼參數中的編解碼 ID ,來獲取當前流的解碼器");
AVCodec *codec = avcodec_find_decoder(codecParameters->codec_id);
//有可能不支持當前解碼
//找不到解碼器,重新編譯 ffmpeg --enable-librtmp
if (!codec) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_FIND_DECODER_FAIL);
return;
}
//第七步 通過拿到的解碼器,獲取解碼器上下文
LOGD("第七步 通過拿到的解碼器,獲取解碼器上下文");
AVCodecContext *codecContext = avcodec_alloc_context3(codec);
if (!codecContext) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_ALLOC_CODEC_CONTEXT_FAIL);
return;
}
//第八步 給解碼器上下文 設置參數
LOGD("第八步 給解碼器上下文 設置參數");
result = avcodec_parameters_to_context(codecContext, codecParameters);
if (result < 0) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_CODEC_CONTEXT_PARAMETERS_FAIL);
return;
}
//第九步 打開解碼器
LOGD("第九步 打開解碼器");
result = avcodec_open2(codecContext, codec, 0);
if (result) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_OPEN_DECODER_FAIL);
return;
}
//媒體流里面可以拿到時間基
AVRational baseTime = stream->time_base;
//第十步 從編碼器參數中獲取流類型 codec_type
LOGD("第十步 從編碼器參數中獲取流類型 codec_type");
if (codecParameters->codec_type == AVMEDIA_TYPE_AUDIO) {
audioChannel = new AudioChannel(stream_index, codecContext,baseTime);
} else if (codecParameters->codec_type == AVMEDIA_TYPE_VIDEO) {
//獲取視頻幀 fps
//平均幀率 == 時間基
AVRational frame_rate = stream->avg_frame_rate;
int fps_value = av_q2d(frame_rate);
videoChannel = new VideoChannel(stream_index, codecContext, baseTime, fps_value);
videoChannel->setRenderCallback(renderCallback);
}
}//end for
//第十一步 如果流中沒有音視頻數據
LOGD("第十一步 如果流中沒有音視頻數據");
if (!audioChannel && !videoChannel) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_NOMEDIA);
return;
}
//第十二步 要么有音頻 要么有視頻 要么音視頻都有
LOGD("第十二步 要么有音頻 要么有視頻 要么音視頻都有");
// 準備完畢,通知Android上層開始播放
if (this->pCallback) {
pCallback->onPrepared(THREAD_CHILD);
}
}
復制代碼
~~~
上面的注釋我標注的很全面,這里我們直接跳到第十步,我們知道可以通過如下`codecParameters->codec_type`函數來進行判斷數據屬于什么類型,進行進行單獨操作。
### 獲取待解碼數據(如:H264、AAC)
在解封裝完成之后我們把待解碼的數據放入隊列中,如下所示:
~~~
/**
* 讀包 、未解碼、音頻/視頻 包 放入隊列
*/
void YKPlayer::start_() {
// 循環 讀音視頻包
while (isPlaying) {
if (isStop) {
av_usleep(2 * 1000);
continue;
}
LOGD("start_");
//內存泄漏點 1,解決方法 : 控制隊列大小
if (videoChannel && videoChannel->videoPackages.queueSize() > 100) {
//休眠 等待隊列中的數據被消費
av_usleep(10 * 1000);
continue;
}
//內存泄漏點 2 ,解決方案 控制隊列大小
if (audioChannel && audioChannel->audioPackages.queueSize() > 100) {
//休眠 等待隊列中的數據被消費
av_usleep(10 * 1000);
continue;
}
//AVPacket 可能是音頻 可能是視頻,沒有解碼的數據包
AVPacket *packet = av_packet_alloc();
//這一行執行完畢, packet 就有音視頻數據了
int ret = av_read_frame(formatContext, packet);
/* if (ret != 0) {
return;
}*/
if (!ret) {
if (videoChannel && videoChannel->stream_index == packet->stream_index) {//視頻包
//未解碼的 視頻數據包 加入隊列
videoChannel->videoPackages.push(packet);
} else if (audioChannel && audioChannel->stream_index == packet->stream_index) {//語音包
//將語音包加入到隊列中,以供解碼使用
audioChannel->audioPackages.push(packet);
}
} else if (ret == AVERROR_EOF) { //代表讀取完畢了
//TODO----
LOGD("拆包完成 %s", "讀取完成了")
isPlaying = 0;
stop();
release();
break;
} else {
LOGD("拆包 %s", "讀取失敗")
break;//讀取失敗
}
}//end while
//最后釋放的工作
isPlaying = 0;
isStop = false;
videoChannel->stop();
audioChannel->stop();
}
復制代碼
~~~
通過上面源碼我們知道,通過 FFmpeg API`av_packet_alloc();`拿到待解碼的指針類型`AVPacket`然后放入對應的音視頻隊列中,等待解碼。
### 視頻
#### 解碼
上一步我們知道,解封裝完成之后把對應的數據放入了待解碼的隊列中,下一步我們就從隊列中拿到數據進行解碼,如下圖所示:
~~~
/**
* 視頻解碼
*/
void VideoChannel::video_decode() {
AVPacket *packet = 0;
while (isPlaying) {
if (isStop) {
//線程休眠 10s
av_usleep(2 * 1000);
continue;
}
//控制隊列大小,避免生產快,消費滿的情況
if (isPlaying && videoFrames.queueSize() > 100) {
// LOGE("視頻隊列中的 size :%d", videoFrames.queueSize());
//線程休眠等待隊列中的數據被消費
av_usleep(10 * 1000);//10s
continue;
}
int ret = videoPackages.pop(packet);
//如果停止播放,跳出循環,出了循環,就要釋放
if (!isPlaying) {
LOGD("isPlaying %d", isPlaying);
break;
}
if (!ret) {
continue;
}
//開始取待解碼的視頻數據包
ret = avcodec_send_packet(pContext, packet);
if (ret) {
LOGD("ret %d", ret);
break;//失敗了
}
//釋放 packet
releaseAVPacket(&packet);
//AVFrame 拿到解碼后的原始數據包
AVFrame *frame = av_frame_alloc();
ret = avcodec_receive_frame(pContext, frame);
if (ret == AVERROR(EAGAIN)) {
//從新取
continue;
} else if (ret != 0) {
LOGD("ret %d", ret);
releaseAVFrame(&frame);//內存釋放
break;
}
//解碼后的視頻數據 YUV,加入隊列中
videoFrames.push(frame);
}
//出循環,釋放
if (packet)
releaseAVPacket(&packet);
}
復制代碼
~~~
通過上面代碼我們得到,主要把待解碼的數據放入`avcodec_send_packet`中,然后通過`avcodec_receive_frame`函數來進行接收,最后解碼完成的 YUV 數據又放入原始數據隊列中,進行轉換格式
#### YUV 轉 RGBA
在 Android 中并不能直接播放 YUV, 我們需要把它轉換成 RGB 的格式然后在調用本地 nativeWindow 或者 OpenGL ES 來進行渲染,下面就直接調用 FFmpeg API 來進行轉換,代碼如下所示:
~~~
void VideoChannel::video_player() {
//1. 原始視頻數據 YUV ---> rgba
/**
* sws_getContext(int srcW, int srcH, enum AVPixelFormat srcFormat,
int dstW, int dstH, enum AVPixelFormat dstFormat,
int flags, SwsFilter *srcFilter,
SwsFilter *dstFilter, const double *param)
*/
SwsContext *swsContext = sws_getContext(pContext->width, pContext->height,
pContext->pix_fmt,
pContext->width, pContext->height, AV_PIX_FMT_RGBA,
SWS_BILINEAR, NULL, NULL, NULL);
//2. 給 dst_data 申請內存
uint8_t *dst_data[4];
int dst_linesize[4];
AVFrame *frame = 0;
/**
* pointers[4]:保存圖像通道的地址。如果是RGB,則前三個指針分別指向R,G,B的內存地址。第四個指針保留不用
* linesizes[4]:保存圖像每個通道的內存對齊的步長,即一行的對齊內存的寬度,此值大小等于圖像寬度。
* w: 要申請內存的圖像寬度。
* h: 要申請內存的圖像高度。
* pix_fmt: 要申請內存的圖像的像素格式。
* align: 用于內存對齊的值。
* 返回值:所申請的內存空間的總大小。如果是負值,表示申請失敗。
*/
int ret = av_image_alloc(dst_data, dst_linesize, pContext->width, pContext->height,
AV_PIX_FMT_RGBA, 1);
if (ret < 0) {
printf("Could not allocate source image\n");
return;
}
//3. YUV -> rgba 格式轉換 一幀一幀的轉換
while (isPlaying) {
if (isStop) {
//線程休眠 10s
av_usleep(2 * 1000);
continue;
}
int ret = videoFrames.pop(frame);
//如果停止播放,跳出循環,需要釋放
if (!isPlaying) {
break;
}
if (!ret) {
continue;
}
//真正轉換的函數,dst_data 是 rgba 格式的數據
sws_scale(swsContext, frame->data, frame->linesize, 0, pContext->height, dst_data,
dst_linesize);
//開始渲染,顯示屏幕上
//渲染一幀圖像(寬、高、數據)
renderCallback(dst_data[0], pContext->width, pContext->height, dst_linesize[0]);
releaseAVFrame(&frame);//渲染完了,frame 釋放。
}
releaseAVFrame(&frame);//渲染完了,frame 釋放。
//停止播放 flag
isPlaying = 0;
av_freep(&dst_data[0]);
sws_freeContext(swsContext);
}
復制代碼
~~~
上面代碼就是直接通過`sws_scale`該函數來進行 YUV -> RGBA 轉換。
#### 渲染 RGBA
轉換完之后,我們直接調用 ANativeWindow 來進行渲染,代碼如下所示:
~~~
/**
* 設置播放 surface
*/
extern "C"
JNIEXPORT void JNICALL
Java_com_devyk_player_1common_PlayerManager_setSurfaceNative(JNIEnv *env, jclass type,
jobject surface) {
LOGD("Java_com_devyk_player_1common_PlayerManager_setSurfaceNative");
pthread_mutex_lock(&mutex);
if (nativeWindow) {
ANativeWindow_release(nativeWindow);
nativeWindow = 0;
}
//創建新的窗口用于視頻顯示窗口
nativeWindow = ANativeWindow_fromSurface(env, surface);
pthread_mutex_unlock(&mutex);
}
復制代碼
~~~
渲染:
~~~
/**
*
* 專門渲染的函數
* @param src_data 解碼后的視頻 rgba 數據
* @param width 視頻寬
* @param height 視頻高
* @param src_size 行數 size 相關信息
*
*/
void renderFrame(uint8_t *src_data, int width, int height, int src_size) {
pthread_mutex_lock(&mutex);
if (!nativeWindow) {
pthread_mutex_unlock(&mutex);
}
//設置窗口屬性
ANativeWindow_setBuffersGeometry(nativeWindow, width, height, WINDOW_FORMAT_RGBA_8888);
ANativeWindow_Buffer window_buffer;
if (ANativeWindow_lock(nativeWindow, &window_buffer, 0)) {
ANativeWindow_release(nativeWindow);
nativeWindow = 0;
pthread_mutex_unlock(&mutex);
return;
}
//填數據到 buffer,其實就是修改數據
uint8_t *dst_data = static_cast<uint8_t *>(window_buffer.bits);
int lineSize = window_buffer.stride * 4;//RGBA
//下面就是逐行 copy 了。
//一行 copy
for (int i = 0; i < window_buffer.height; ++i) {
memcpy(dst_data + i * lineSize, src_data + i * src_size, lineSize);
}
ANativeWindow_unlockAndPost(nativeWindow);
pthread_mutex_unlock(&mutex);
}
復制代碼
~~~
視頻渲染就完成了。
### 音頻
#### 解碼
音頻的流程跟視頻一樣,拿到解封裝之后的 AAC 數據開始進行解碼,代碼如下所示:
~~~
/**
* 音頻解碼
*/
void AudioChannel::audio_decode() {
//待解碼的 packet
AVPacket *avPacket = 0;
//只要正在播放,就循環取數據
while (isPlaying) {
if (isStop) {
//線程休眠 10s
av_usleep(2 * 1000);
continue;
}
//這里有一個 bug,如果生產快,消費慢,就會造成隊列數據過多容易造成 OOM,
//解決辦法:控制隊列大小
if (isPlaying && audioFrames.queueSize() > 100) {
// LOGE("音頻隊列中的 size :%d", audioFrames.queueSize());
//線程休眠 10s
av_usleep(10 * 1000);
continue;
}
//可以正常取出
int ret = audioPackages.pop(avPacket);
//條件判斷是否可以繼續
if (!ret) continue;
if (!isPlaying) break;
//待解碼的數據發送到解碼器中
ret = avcodec_send_packet(pContext,
avPacket);//@return 0 on success, otherwise negative error code:
if (ret)break;//給解碼器發送失敗了
//發送成功,釋放 packet
releaseAVPacket(&avPacket);
//拿到解碼后的原始數據包
AVFrame *avFrame = av_frame_alloc();
//將原始數據發送到 avFrame 內存中去
ret = avcodec_receive_frame(pContext, avFrame);//0:success, a frame was returned
if (ret == AVERROR(EAGAIN)) {
continue;//獲取失敗,繼續下次任務
} else if (ret != 0) {//說明失敗了
releaseAVFrame(&avFrame);//釋放申請的內存
break;
}
//將獲取到的原始數據放入隊列中,也就是解碼后的原始數據
audioFrames.push(avFrame);
}
//釋放packet
if (avPacket)
releaseAVPacket(&avPacket);
}
復制代碼
~~~
音視頻的邏輯都是一樣的就不在多說了。
#### 渲染 PCM
渲染 PCM 可以使用 Java 層的 AudioTrack ,也可以使用 NDK 的 OpenSL ES 來渲染,我這里為了性能和更好的對接,直接都在 C++ 中實現了,代碼如下:
~~~
/**
* 音頻播放 //直接使用 OpenLS ES 渲染 PCM 數據
*/
void AudioChannel::audio_player() {
//TODO 1. 創建引擎并獲取引擎接口
// 1.1創建引擎對象:SLObjectItf engineObject
SLresult result = slCreateEngine(&engineObject, 0, NULL, 0, NULL, NULL);
if (SL_RESULT_SUCCESS != result) {
return;
}
// 1.2 初始化引擎
result = (*engineObject)->Realize(engineObject, SL_BOOLEAN_FALSE);
if (SL_BOOLEAN_FALSE != result) {
return;
}
// 1.3 獲取引擎接口 SLEngineItf engineInterface
result = (*engineObject)->GetInterface(engineObject, SL_IID_ENGINE, &engineInterface);
if (SL_RESULT_SUCCESS != result) {
return;
}
//TODO 2. 設置混音器
// 2.1 創建混音器:SLObjectItf outputMixObject
result = (*engineInterface)->CreateOutputMix(engineInterface, &outputMixObject, 0, 0, 0);
if (SL_RESULT_SUCCESS != result) {
return;
}
// 2.2 初始化 混音器
result = (*outputMixObject)->Realize(outputMixObject, SL_BOOLEAN_FALSE);
if (SL_BOOLEAN_FALSE != result) {
return;
}
// 不啟用混響可以不用獲取混音器接口
// 獲得混音器接口
// result = (*outputMixObject)->GetInterface(outputMixObject, SL_IID_ENVIRONMENTALREVERB,
// &outputMixEnvironmentalReverb);
// if (SL_RESULT_SUCCESS == result) {
// 設置混響 : 默認。
// SL_I3DL2_ENVIRONMENT_PRESET_ROOM: 室內
// SL_I3DL2_ENVIRONMENT_PRESET_AUDITORIUM : 禮堂 等
// const SLEnvironmentalReverbSettings settings = SL_I3DL2_ENVIRONMENT_PRESET_DEFAULT;
// (*outputMixEnvironmentalReverb)->SetEnvironmentalReverbProperties(
// outputMixEnvironmentalReverb, &settings);
// }
//TODO 3. 創建播放器
// 3.1 配置輸入聲音信息
// 創建buffer緩沖類型的隊列 2個隊列
SLDataLocator_AndroidSimpleBufferQueue loc_bufq = {SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE,
2};
// pcm數據格式
// SL_DATAFORMAT_PCM:數據格式為pcm格式
// 2:雙聲道
// SL_SAMPLINGRATE_44_1:采樣率為44100(44.1赫茲 應用最廣的,兼容性最好的)
// SL_PCMSAMPLEFORMAT_FIXED_16:采樣格式為16bit (16位)(2個字節)
// SL_PCMSAMPLEFORMAT_FIXED_16:數據大小為16bit (16位)(2個字節)
// SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT:左右聲道(雙聲道) (雙聲道 立體聲的效果)
// SL_BYTEORDER_LITTLEENDIAN:小端模式
SLDataFormat_PCM format_pcm = {SL_DATAFORMAT_PCM, 2, SL_SAMPLINGRATE_44_1,
SL_PCMSAMPLEFORMAT_FIXED_16,
SL_PCMSAMPLEFORMAT_FIXED_16,
SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT,
SL_BYTEORDER_LITTLEENDIAN};
// 數據源 將上述配置信息放到這個數據源中
SLDataSource audioSrc = {&loc_bufq, &format_pcm};
// 3.2 配置音軌(輸出)
// 設置混音器
SLDataLocator_OutputMix loc_outmix = {SL_DATALOCATOR_OUTPUTMIX, outputMixObject};
SLDataSink audioSnk = {&loc_outmix, NULL};
// 需要的接口 操作隊列的接口
const SLInterfaceID ids[1] = {SL_IID_BUFFERQUEUE};
const SLboolean req[1] = {SL_BOOLEAN_TRUE};
// 3.3 創建播放器
result = (*engineInterface)->CreateAudioPlayer(engineInterface, &bqPlayerObject, &audioSrc,
&audioSnk, 1, ids, req);
if (SL_RESULT_SUCCESS != result) {
return;
}
// 3.4 初始化播放器:SLObjectItf bqPlayerObject
result = (*bqPlayerObject)->Realize(bqPlayerObject, SL_BOOLEAN_FALSE);
if (SL_RESULT_SUCCESS != result) {
return;
}
// 3.5 獲取播放器接口:SLPlayItf bqPlayerPlay
result = (*bqPlayerObject)->GetInterface(bqPlayerObject, SL_IID_PLAY, &bqPlayerPlay);
if (SL_RESULT_SUCCESS != result) {
return;
}
//TODO 4. 設置播放器回調函數
// 4.1 獲取播放器隊列接口:SLAndroidSimpleBufferQueueItf bqPlayerBufferQueue
(*bqPlayerObject)->GetInterface(bqPlayerObject, SL_IID_BUFFERQUEUE, &bqPlayerBufferQueue);
// 4.2 設置回調 void bqPlayerCallback(SLAndroidSimpleBufferQueueItf bq, void *context)
(*bqPlayerBufferQueue)->RegisterCallback(bqPlayerBufferQueue, bqPlayerCallback, this);
//TODO 5. 設置播放狀態
(*bqPlayerPlay)->SetPlayState(bqPlayerPlay, SL_PLAYSTATE_PLAYING);
//TODO 6. 手動激活回調函數
bqPlayerCallback(bqPlayerBufferQueue, this);
}
復制代碼
~~~
設置渲染數據:
~~~
/**
* 獲取 PCM
* @return
*/
int AudioChannel::getPCM() {
//定義 PCM 數據大小
int pcm_data_size = 0;
//原始數據包裝類
AVFrame *pcmFrame = 0;
//循環取出
while (isPlaying) {
if (isStop) {
//線程休眠 10s
av_usleep(2 * 1000);
continue;
}
int ret = audioFrames.pop(pcmFrame);
if (!isPlaying)break;
if (!ret)continue;
//PCM 處理邏輯
pcmFrame->data;
// 音頻播放器的數據格式是我們在下面定義的(16位 雙聲道 ....)
// 而原始數據(是待播放的音頻PCM數據)
// 所以,上面的兩句話,無法統一,一個是(自己定義的16位 雙聲道 ..) 一個是原始數據,為了解決上面的問題,就需要重采樣。
// 開始重采樣
int dst_nb_samples = av_rescale_rnd(swr_get_delay(swr_ctx, pcmFrame->sample_rate) +
pcmFrame->nb_samples, out_sample_rate,
pcmFrame->sample_rate, AV_ROUND_UP);
//重采樣
/**
*
* @param out_buffers 輸出緩沖區,當PCM數據為Packed包裝格式時,只有out[0]會填充有數據。
* @param dst_nb_samples 每個通道可存儲輸出PCM數據的sample數量。
* @param pcmFrame->data 輸入緩沖區,當PCM數據為Packed包裝格式時,只有in[0]需要填充有數據。
* @param pcmFrame->nb_samples 輸入PCM數據中每個通道可用的sample數量。
*
* @return 返回每個通道輸出的sample數量,發生錯誤的時候返回負數。
*/
ret = swr_convert(swr_ctx, &out_buffers, dst_nb_samples, (const uint8_t **) pcmFrame->data,
pcmFrame->nb_samples);//返回每個通道輸出的sample數量,發生錯誤的時候返回負數。
if (ret < 0) {
fprintf(stderr, "Error while converting\n");
}
pcm_data_size = ret * out_sample_size * out_channels;
//用于音視頻同步
audio_time = pcmFrame->best_effort_timestamp * av_q2d(this->base_time);
break;
}
//渲染完成釋放資源
releaseAVFrame(&pcmFrame);
return pcm_data_size;
}
/**
* 創建播放音頻的回調函數
*/
void bqPlayerCallback(SLAndroidSimpleBufferQueueItf bq, void *context) {
AudioChannel *audioChannel = static_cast<AudioChannel *>(context);
//獲取 PCM 音頻裸流
int pcmSize = audioChannel->getPCM();
if (!pcmSize)return;
(*bq)->Enqueue(bq, audioChannel->out_buffers, pcmSize);
}
復制代碼
~~~
代碼編寫到這里,音視頻也都正常渲染了,但是這里還有一個問題,隨著播放的時間越久那么就會產生音視頻各渲染各的,沒有達到同步或者一直播放,這樣的體驗是非常不好的,所以下一小節我們來解決這個問題。
### 音視頻同步
音視頻同步市面上有 3 種解決方案: 音頻向視頻同步,視頻向音頻同步,音視頻統一向外部時鐘同步。下面就分別來介紹這三種對齊方式是如何實現的,以及各自的優缺點。
* 1. 音頻向視頻同步
先來看一下這種同步方式是如何實現的,音頻向視頻同步,顧名思義,就是視頻會維持一定的刷新頻率,或者根據渲染視頻幀的時長來決定當前視頻幀的渲染時長,或者說視頻的每一幀肯定可以全部渲染出來,當我們向 AudioChannel 模塊填充音頻數據的時候,會與當前渲染的視頻幀的時間戳進行比較,這個差值如果不在閥值得范圍內,就需要做對齊操作;如果其在閥值范圍內,那么就可以直接將本幀音頻幀填充到 AudioChannel 模塊,進而讓用戶聽到該聲音。那如果不在閥值范圍內,又該如何進行對齊操作呢?這就需要我們去調整音頻幀了,也就是說如果要填充的音頻幀的時間戳比當前渲染的視頻幀的時間戳小,那就需要進行跳幀操作(可以通過加快速度播放,也可以是丟棄一部分音頻幀);如果音頻幀的時間戳比當前渲染的視頻幀的時間戳大,那么就需要等待,具體實現可以向 AudioChannel 模塊填充空數據進行播放,也可以是將音頻的速度放慢播放給用戶聽,而此時視頻幀是繼續一幀一幀進行渲染的,一旦視頻的時間戳趕上了音頻的時間戳,就可以將本幀的音頻幀的數據填充到 AudioChannel 模塊了,這就是音頻向視頻同步的實現。
**優點:**視頻可以將每一幀都播放給用戶看,畫面看上去是最流暢的。
**缺點:**音頻會加速或者丟幀,如果丟幀系數小,那么用戶感知可能不太強,如果系數大,那么用戶感知就會非常的強烈了,發生丟幀或者插入空數據的時候,用戶的耳朵是可以明顯感覺到的。
* 2. 視頻向音頻同步
再來看一下視頻向音頻同步的方式是如何實現的,這與上面提到的方式恰好相反,由于不論是哪一個平臺播放音頻的引擎,都可以保證播放音頻的時間長度與實際這段音頻所代表的時間長度是一致的,所以我們可以依賴于音頻的順序播放為我們提供的時間戳,當客戶端代碼請求發送視頻幀的時候,會先計算出當前視頻隊列頭部的視頻幀元素的時間戳與當前音頻播放幀的時間戳的差值。如果在閥值范圍內,就可以渲染這一幀視頻幀;如果不在閥值范圍內,則要進行對齊操作。具體的對齊操作方法就是: 如果當前隊列頭部的視頻幀的時間戳小于當前播放音頻幀的時間戳,那么就進行跳幀操作;如果大于當前播放音頻幀的時間戳,那么就等待(睡眠、重復渲染、不渲染)的操作。
**優點**: 音頻可以連續的渲染。
**缺點**: 視頻畫面會有跳幀的操作,但是對于視頻畫面的丟幀和跳幀用戶的眼睛是不太容易分辨得出來的。
* 3. 音視頻統一向外部時鐘同步
這種策略其實更像是上述兩種方式對齊的合體,其實現就是在外部單獨維護一軌外部時鐘,我們要保證該外部時鐘的更新是按照時間的增加而慢慢增加的,當我們獲取音頻數據和視頻幀的時候,都需要與這個外部時鐘進行對齊,如果沒有超過閥值,那么就會直接返回本幀音頻幀或者視頻幀,如果超過閥值就要進行對齊操作,具體的對齊操作是: 使用上述兩種方式里面的對齊操作,將其分別應用于音頻的對齊和視頻的對齊。
**優點:**可以最大限度的保證音視頻都可以不發生跳幀的行為。
**缺點:**外部時鐘不好控制,極有可能引發音頻和視頻都跳幀的行為。
**同步總結:**
根據人眼睛和耳朵的生理構造因素,得出了一個結論,那就是人的耳朵比人的眼睛要敏感的多,那就是說,如果音頻有跳幀的行為或者填空數據的行為,那么我們的耳朵是非常容易察覺得到的;而視頻如果有跳幀或者重復渲染的行為,我們的眼睛其實不容易分別出來。根據這個理論,所以我們這里也將采用**視頻向音頻對齊**的方式。
根據得出的結論,我們需要在音頻、視頻渲染之前修改幾處地方,如下所示:
1. 通過 ffmpeg api 拿到音頻時間戳
~~~
//1. 在 BaseChannel 里面定義變量,供子類使用
//###############下面是音視頻同步需要用到的
//FFmpeg 時間基: 內部時間
AVRational base_time;
double audio_time;
double video_time;
//###############下面是音視頻同步需要用到的
//2. 得到音頻時間戳 pcmFrame 解碼之后的原始數據幀
audio_time = pcmFrame->best_effort_timestamp * av_q2d(this->base_time);
復制代碼
~~~
2. 視頻向音頻時間戳對齊(大于小于音頻時間戳的處理方式)
~~~
//視頻向音頻時間戳對齊---》控制視頻播放速度
//在視頻渲染之前,根據 fps 來控制視頻幀
//frame->repeat_pict = 當解碼時,這張圖片需要要延遲多久顯示
double extra_delay = frame->repeat_pict;
//根據 fps 得到延遲時間
double base_delay = 1.0 / this->fpsValue;
//得到當前幀的延遲時間
double result_delay = extra_delay + base_delay;
//拿到視頻播放的時間基
video_time = frame->best_effort_timestamp * av_q2d(this->base_time);
//拿到音頻播放的時間基
double_t audioTime = this->audio_time;
//計算音頻和視頻的差值
double av_time_diff = video_time - audioTime;
//說明:
//video_time > audioTime 說明視頻快,音頻慢,等待音頻
//video_time < audioTime 說明視頻慢,音屏快,需要追趕音頻,丟棄掉冗余的視頻包也就是丟幀
if (av_time_diff > 0) {
//通過睡眠的方式靈活等待
if (av_time_diff > 1) {
av_usleep((result_delay * 2) * 1000000);
LOGE("av_time_diff > 1 睡眠:%d", (result_delay * 2) * 1000000);
} else {//說明相差不大
av_usleep((av_time_diff + result_delay) * 1000000);
LOGE("av_time_diff < 1 睡眠:%d", (av_time_diff + result_delay) * 1000000);
}
} else {
if (av_time_diff < 0) {
LOGE("av_time_diff < 0 丟包處理:%f", av_time_diff);
//視頻丟包處理
this->videoFrames.deleteVideoFrame();
continue;
} else {
//完美
}
}
復制代碼
~~~
加上這段代碼之后,咱們音視頻就算是差不多同步了,不敢保證 100%。
## 總結
一個簡易的音視頻播放器已經實現完畢,咱們從`解封裝->解碼->音視頻同步->音視頻渲染`按照流程講解并編寫了實例代碼,相信你已經對播放器的流程和架構設計都已經有了一定的認識,等公司有需求的時候也可以自己設計一款播放器并開發出來了。
[完整代碼已上傳 GitHub](https://github.com/yangkun19921001/NDK_AV_SAMPLE/tree/master/myplayer)
- 前言
- JNI基礎知識
- C語言知識點總結
- ①基本語法
- ②數據類型
- 枚舉類型
- 自定義類型(類型定義)
- ③格式化輸入輸出
- printf函數
- scanf函數
- 編程規范
- ④變量和常量
- 局部變量和外部變量
- ⑤類型轉換
- ⑥運算符
- ⑦結構語句
- 1、分支結構(選擇語句)
- 2、循環結構
- 退出循環
- break語句
- continue語句
- goto語句
- ⑧函數
- 函數的定義和調用
- 參數
- 函數的返回值
- 遞歸函數
- 零起點學通C語言摘要
- 內部函數和外部函數
- 變量存儲類別
- ⑨數組
- 指針
- 結構體
- 聯合體(共用體)
- 預處理器
- 預處理器的工作原理
- 預處理指令
- 宏定義
- 簡單的宏
- 帶參數的宏
- 預定義宏
- 文件包含
- 條件編譯
- 內存中的數據
- C語言讀文件和寫文件
- JNI知識點總結
- 前情回顧
- JNI規范
- jni開發
- jni開發中常見的錯誤
- JNI實戰演練
- C++(CPP)在Android開發中的應用
- 掘金網友總結的音視頻開發知識
- 音視頻學習一、C 語言入門
- 1.程序結構
- 2. 基本語法
- 3. 數據類型
- 4. 變量
- 5. 常量
- 6. 存儲類型關鍵字
- 7. 運算符
- 8. 判斷
- 9. 循環
- 10. 函數
- 11. 作用域規則
- 12. 數組
- 13. 枚舉
- 14. 指針
- 15. 函數指針與回調函數
- 16. 字符串
- 17. 結構體
- 18. 共用體
- 19. typedef
- 20. 輸入 & 輸出
- 21.文件讀寫
- 22. 預處理器
- 23.頭文件
- 24. 強制類型轉換
- 25. 錯誤處理
- 26. 遞歸
- 27. 可變參數
- 28. 內存管理
- 29. 命令行參數
- 總結
- 音視頻學習二 、C++ 語言入門
- 1. 基本語法
- 2. C++ 關鍵字
- 3. 數據類型
- 4. 變量類型
- 5. 變量作用域
- 6. 常量
- 7. 修飾符類型
- 8. 存儲類
- 9. 運算符
- 10. 循環
- 11. 判斷
- 12. 函數
- 13. 數學運算
- 14. 數組
- 15. 字符串
- 16. 指針
- 17. 引用
- 18. 日期 & 時間
- 19. 輸入輸出
- 20. 數據結構
- 21. 類 & 對象
- 22. 繼承
- 23. 重載運算符和重載函數
- 24. 多態
- 25. 數據封裝
- 26. 接口(抽象類)
- 27. 文件和流
- 28. 異常處理
- 29. 動態內存
- 30. 命名空間
- 31. 預處理器
- 32. 多線程
- 總結
- 音視頻學習 (三) JNI 從入門到掌握
- 音視頻學習 (四) 交叉編譯動態庫、靜態庫的入門學習
- 音視頻學習 (五) Shell 腳本入門
- 音視頻學習 (六) 一鍵編譯 32/64 位 FFmpeg 4.2.2
- 音視頻學習 (七) 掌握音頻基礎知識并使用 AudioTrack、OpenSL ES 渲染 PCM 數據
- 音視頻學習 (八) 掌握視頻基礎知識并使用 OpenGL ES 2.0 渲染 YUV 數據
- 音視頻學習 (九) 從 0 ~ 1 開發一款 Android 端播放器(支持多協議網絡拉流/本地文件)
- 音視頻學習 (十) 基于 Nginx 搭建(rtmp、http)直播服務器
- 音視頻學習 (十一) Android 端實現 rtmp 推流
- 音視頻學習 (十二) 基于 FFmpeg + OpenSLES 實現音頻萬能播放器
- 音視頻學習 (十三) Android 中通過 FFmpeg 命令對音視頻編輯處理(已開源)