7. 从 0-1 开发 Android-端播放器(支持多协议网络拉流-本地文件)

现在一个 APP 玩的花样是越来越多了几乎都离不开音频、视频、图片等数据显示,该篇就介绍其中的音视频播放,音视频播放可以用已经成熟开源的播放器,(推荐一个不错的播放器开源项目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(实时消息传输协议)的首字母缩写。该协议基于 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. 通过 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

下一节:最近这几年做直播和短视频领域是真的很火,而且直播的领域也很广泛,可以预见,未来的音视频技术将会作为一种基础技术应用到更广泛的场景中。它可以与 AR/VR 结合,让你在远端体验虚拟与现实,如虚拟服装体验;也可以与人工智能结合用于提高服务质量,如用于教学上帮助老师提高教学质量;它还可以与物联网结合,用在自动驾驶、家庭办公等领域。那么这么火范围这么广的领域我们可不可以参与一下呢,肯定是可以的,下面我们借助 Nginx 和 nginx-http-flv-module 搭建一个简易的直播服务器,当然如果对并发要求不是太高的,这个完全可以满足了。