音視訊學習 (九) 從 0 ~ 1 開發一款 Android 端播放器(支援多協議網路拉流/本地檔案)

DevYK發表於2020-02-16

前言

現在一個 APP 玩的花樣是越來越多了幾乎都離不開音訊、視訊、圖片等資料顯示,該篇就介紹其中的音視訊播放,音視訊播放可以用已經成熟開源的播放器,(推薦一個不錯的播放器開源專案GSYVideoPlayer)。如果用已開源的播放器就沒有太大的學習意義了,該篇文章會介紹基於 FFmpeg 4.2.2 、Librtmp 庫從 0~1 開發一款 Android 播放器的流程和例項程式碼編寫。

開發一款播放器你首先要具備的知識有:

  • FFmpeg RTMP 混合交叉編譯
  • C/C++ 基礎
  • NDK、JNI
  • 音視訊解碼、同步

學完之後我們的播放器大概效果如下:

音視訊學習 (九) 從 0 ~ 1 開發一款 Android 端播放器(支援多協議網路拉流/本地檔案)

效果看起來有點卡,這跟實際網路環境有關,此播放器已具備 rtmp/http/URL/File 等協議播放。

RTMP 與 FFmpeg 混合編譯

RTMP

介紹:

RTMP 是 Real Time Messaging Protocol(實時訊息傳輸協議)的首字母縮寫。該協議基於 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 
複製程式碼

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

音視訊學習 (九) 從 0 ~ 1 開發一款 Android 端播放器(支援多協議網路拉流/本地檔案)

混合編譯

上一篇文章我們們編譯了 FFmpeg 靜態庫,那麼該小節我們們要把 librtmp 整合到 FFmpeg 中編譯,首先我們需要到 configure 指令碼中把 librtmp 模組註釋掉,如下:

音視訊學習 (九) 從 0 ~ 1 開發一款 Android 端播放器(支援多協議網路拉流/本地檔案)

修改 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
複製程式碼

如果出現如下,證明開始編譯了:

音視訊學習 (九) 從 0 ~ 1 開發一款 Android 端播放器(支援多協議網路拉流/本地檔案)

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

音視訊學習 (九) 從 0 ~ 1 開發一款 Android 端播放器(支援多協議網路拉流/本地檔案)

可以從上圖中看到靜態庫和標頭檔案庫都已經編譯成功了,下面我們就進入編寫程式碼環節了。

播放器開發

流程圖

想要實現一個網路/本地播放器,我們必須知道它的流程,如下圖所示:

音視訊學習 (九) 從 0 ~ 1 開發一款 Android 端播放器(支援多協議網路拉流/本地檔案)

專案準備

  1. 建立一個新的 Android 專案並匯入各自庫

    音視訊學習 (九) 從 0 ~ 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 模組了,這就是音訊向視訊同步的實現。

      優點: 視訊可以將每一幀都播放給使用者看,畫面看上去是最流暢的。

      缺點: 音訊會加速或者丟幀,如果丟幀係數小,那麼使用者感知可能不太強,如果係數大,那麼使用者感知就會非常的強烈了,發生丟幀或者插入空資料的時候,使用者的耳朵是可以明顯感覺到的。

    1. 視訊向音訊同步

      再來看一下視訊向音訊同步的方式是如何實現的,這與上面提到的方式恰好相反,由於不論是哪一個平臺播放音訊的引擎,都可以保證播放音訊的時間長度與實際這段音訊所代表的時間長度是一致的,所以我們可以依賴於音訊的順序播放為我們提供的時間戳,當客戶端程式碼請求傳送視訊幀的時候,會先計算出當前視訊佇列頭部的視訊幀元素的時間戳與當前音訊播放幀的時間戳的差值。如果在閥值範圍內,就可以渲染這一幀視訊幀;如果不在閥值範圍內,則要進行對齊操作。具體的對齊操作方法就是: 如果當前佇列頭部的視訊幀的時間戳小於當前播放音訊幀的時間戳,那麼就進行跳幀操作;如果大於當前播放音訊幀的時間戳,那麼就等待(睡眠、重複渲染、不渲染)的操作。

      優點 : 音訊可以連續的渲染。

      缺點 : 視訊畫面會有跳幀的操作,但是對於視訊畫面的丟幀和跳幀使用者的眼睛是不太容易分辨得出來的。

    1. 音視訊統一向外部時鐘同步

      這種策略其實更像是上述兩種方式對齊的合體,其實現就是在外部單獨維護一軌外部時鐘,我們要保證該外部時鐘的更新是按照時間的增加而慢慢增加的,當我們獲取音訊資料和視訊幀的時候,都需要與這個外部時鐘進行對齊,如果沒有超過閥值,那麼就會直接返回本幀音訊幀或者視訊幀,如果超過閥值就要進行對齊操作,具體的對齊操作是: 使用上述兩種方式裡面的對齊操作,將其分別應用於音訊的對齊和視訊的對齊。

      優點: 可以最大限度的保證音視訊都可以不發生跳幀的行為。

      缺點: 外部時鐘不好控制,極有可能引發音訊和視訊都跳幀的行為。

    同步總結:

    根據人眼睛和耳朵的生理構造因素,得出了一個結論,那就是人的耳朵比人的眼睛要敏感的多,那就是說,如果音訊有跳幀的行為或者填空資料的行為,那麼我們的耳朵是非常容易察覺得到的;而視訊如果有跳幀或者重複渲染的行為,我們的眼睛其實不容易分別出來。根據這個理論,所以我們這裡也將採用 視訊向音訊對齊 的方式。

    根據得出的結論,我們需要在音訊、視訊渲染之前修改幾處地方,如下所示:

    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

相關文章