Android: 从零搭建 CCTV-1 直播播放器

3,555 阅读14分钟

本文的目标

从 0 搭建一款直播播放器的流程是什么,需要学习什么技术。 先看一下效果图:

本文涉及到的知识

  • 交叉编译
  • JNI
  • FFmpeg

如何编译一个 c/c++ 源文件?

比如下面有一段代码 test.c

#include<stdio.h>
int main(){
	printf("excute success \n");
return 0;
}

在 Mac 上只需要执行下面命令即可得到可执行文件:

gcc test.c -o test

执行上面的命令后就会发现多了一个可执行文件 test

# 执行这个 shell 脚本,成功打印出 excute success
./test 

编译器

上面用的 gcc 是什么呢?

它是 c/c++ 编译器,可以将源文件编译成可执行文件或者库文件也就是 .so .a 等。

它有一些常用命令:15 个 gcc 常用命令

除了 gcc 外还有其他的编译器,比如:

  • g++
  • clang

它们的使用和 gcc 类似,比如使用 clang 编译一个可执行文件如下:

clang test.c -o test

交叉编译

先抛出一个问题:可执行文件 test 可以在 android 上执行吗?

可以将上面编译成的可执行文件 test push 到 android 手机上试一试:

# /data/local/tmp 这个文件夹不需要 root 就可以执行脚本文件
adb push test /data/local/tmp
adb shell
cd /data/local/tmp
./test

结果你会看到报错。

那么换 ndk 下的编译工具链试试呢?

将 gcc 换成 ndk 下的 clang,比如:

/Users/wangzhen/Library/Android/sdk/android-ndk-r20b/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android29-clang test.c -o test_android

此时将 test_android push 到 android 上就可以执行成功了。

什么是交叉编译

在一个平台上编译出另一个平台上可以执行的二级制文件的过程叫做交叉编译。比如在 MacOS 上编译出 android 上可用的库文件。

Android 平台的交叉编译

如果想要编译出可以在 android 平台上运行的库文件就需要使用 ndk。

那么在 Android 上的交叉编译有几种方式呢?首先我们需要先知道交叉编译通常涉及到的源文件和主要流程是什么。

抽象编译的主要流程

交叉编译设计到的文件结构通常如下,它包含一些头文件、库文件以及你自己写的源文件:

├── include
│   └── util.h
├── lib
│   └── libutil.a
│		└── libextra.so
└── src
    └── main.cpp
    └── extra.cpp

而我们所要做的工作可以抽象为下面的流程:

image-20200907215642070

下面的三种编译方式都是按照这种流程来工作的。

手动编译

编译多个源文件: gcc lib_source.c source.c -o source

将源文件编译成动态库 so :gcc -fPIC -shared extra_source2.c -o libextrasource2.so

编译多个源文件应引用第三方 so: gcc extra_source1.c source.c -o source -L. -lextrasource2

-L 代表给编译器设置库文件、头文件的搜索路径,”.“代表是当前的文件夹

-l 代表需要连接的库文件,比如上面链接的是 extrasource2

.so 命名:必须为 libxxx.so,上面链接的 extrasource2 真实的文件名为 libextrasource2.so

gcc 参数顺序问题:

gcc -L. -lextrasource2 extra_source1.c source.c -o source wrong order

gcc extra_source1.c source.c -o source -L. -lextrasource2 right order

更多关于 order 的问题可以参考 stackoverflow

Makefile

Makefile 就是“自动化编译”:一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中,Makefile 定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要后编译,如何进行链接等等操作。 Android 使用 Android.mk 文件来配置 makefile,下面是一个最简单的 Android.mk:

# 源文件在的位置。宏函数 my-dir 返回当前目录(包含 Android.mk 文件本身的目录)的路径。
LOCAL_PATH := $(call my-dir)

# 引入其他makefile文件。CLEAR_VARS 变量指向特殊 GNU Makefile,可为您清除许多 LOCAL_XXX 变量
# 不会清理 LOCAL_PATH 变量
include $(CLEAR_VARS)

# 指定库名称,如果模块名称的开头已是 lib,则构建系统不会附加额外的前缀 lib;而是按原样采用模块名称,并添加 .so 扩展名。
LOCAL_MODULE := hello
# 包含要构建到模块中的 C 和/或 C++ 源文件列表 以空格分开
LOCAL_SRC_FILES := hello.c
# 构建动态库
include $(BUILD_SHARED_LIBRARY)

当项目非常庞大,目录结构非常复杂时,手写 Makefile 就是一件非常繁琐的事情,你需要在不同的目录底下写不同的 Makefile,这可能有非常多的文件。

目前 Google 推荐开发者使用 CMake 来代替 makefile 进行交叉编译了。

CMake

CMake是一个跨平台的构建工具,可以用简单的语句来描述所有平台的安装(编译过程)。

Cmake 并不直接建构出最终的软件,而是产生其他工具的脚本(如Makefile ),然后再依这个工具的构建方式使用。

Android Studio利用 CMake 生成的是ninja,ninja是一个小型的关注速度的构建系统。我们不需要关心ninja的脚本,知道怎么配置cmake就可以了。

CMake 使用 CMakeLists.txt 文件来描述配置,一个最简单的 CMakeLists.txt 如下:

# 设置 cmake 最小支持版本
cmake_minimum_required(VERSION 3.4.1)

# 创建一个库
add_library( # 库名称,比如现在会生成 native-lib.so
             native-lib

             # 设置是动态库(SHARED)还是静态库(STATIC)
             SHARED

             # 设置源文件的相对路径
             native-lib.cpp
            )
             
 # 搜索并指定预构建库并将路径存储为变量。
 # NDK中已经有一部分预构建库(比如 log),并且ndk库已经是被配置为cmake搜索路径的一部分
 # 可以不写 直接在 target_link_libraries 写上log
 find_library( # 设置路径变量的名称
              log-lib

              # 指定要CMake定位的NDK库的名称
              log )
              
 # 指定CMake应链接到目标库的库。你可以链接多个库,例如构建脚本、预构建的第三方库或系统库。
 target_link_libraries( # Specifies the target library.
                       native-lib
                       ${log-lib} )

关于 CMake 的编译过程可以参看这篇博客

交叉编译实战 - 编译 Android 平台下的 FFmpeg

FFmpeg 支持 make file 的编译方式,下面就开始编译 FFmpeg 吧。

从官网下载好 FFmpeg 并解压好之后可以发现一个 configure 文件:

configure 脚本

该文件是一个 shell 脚本,它用来生成 mk 文件,它包含了丰富的配置选项,下面列出一些常用选项:

工具链选项

这些选项都是和编译器有关的(往往是必须的):

optiondesc
--arch=ARCH架构,比如 arm
--cpu=CPUABI,比如 arm-v7
--target-os=OS目标平台,比如 android,iOS
--enable-cross-compile使用交叉编译
--cross-prefix交叉编译工具链位置
--sysroot=PATH链接头文件和库文件的路径,android 平台下要到 ndk 下查找
--cc=CCc 编译器,比如 clang
--cxx=CXXc++ 编译器
--extra-cflags=ECFLAGS传给编译器的参数,比如指导编译器在指定目录查找头文件/库文件

配置选型

optiondesc
--disable-static不要构建静态库[默认 false]
--enable-shared构建动态库[默认 false]
--enable-small优化大小

组件选项

在这里你可以选择自己需要的功能并关闭不需要的功能,这样可以减小库文件大小。

optiondesc
--disable-avdevice操作摄像头等采集设备,android 下不支持,可以选择 disable
--disable-avfilter禁用水印字幕

单个组件选项

optiondesc
--disable-encoders禁止编码,比如播放只需要解码,不需要编码
--enable-decoder=NAME选择解码器
--enable-filter=NAME选择过滤器
--disable-filter=NAME禁用某个过滤器
--disable-filters禁用所有的过滤器
--disable-muxers禁用混合器,关闭混合封装,比如将图像 + 音频 到 MP4 的过程,播放的时候只需要解封装

编译步骤

按照官方文档,编译 FFmpeg 主要分为 3 步

  • 使用 ./configure 进行配置

  • 输入 make 来构建 FFmpeg

  • 键入 make install 以安装构建的所有二进制文件和库。

./configure 的配置主要包含以下内容:

  • 交叉编译工具链相关配置,此为必须项
  • 选择需要的模块,精确的配置可以减小编译产物的大小
  • 设置编解码算法

编写脚本

#!/bin/bash
# 清空上次的编译
make clean

echo ">>>>>>>>> 开始编译 <<<<<<<<"
echo ">>>>>>>>> 使用的 NDK 版本是 r20b,使用的 ffmpeg 版本是 4.2.3 <<<<<<<<"
# 你的 NDK 的目录
export NDK=/Users/wangzhen/Library/Android/sdk/android-ndk-r20b
# NDK 下工具链的目录
TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64
# android api 版本
API=29

function build_android
{
echo "Compiling FFmpeg for $CPU ..."
./configure \
    --prefix=$PREFIX \
    --enable-small \
    --enable-neon \
    --enable-hwaccels \
    --enable-gpl \
    --disable-postproc \
    --enable-jni \
    --disable-doc \
    --enable-ffmpeg \
    --disable-muxers \
    --disable-ffplay \
    --disable-ffprobe \
    --disable-avdevice \
    --disable-doc \
    --disable-symver \
    --disable-filters \
    --cross-prefix=$CROSS_PREFIX \
    --target-os=android \
    --arch=$ARCH \
    --cpu=$CPU \
    --cc=$CC
    --cxx=$CXX
    --enable-cross-compile \
    --sysroot=$SYSROOT \
    --extra-cflags="-Os -fpic $OPTIMIZE_CFLAGS" \
    
make clean
make 
make install
}

# 编译 armv8-a 下的版本
ARCH=arm64
CPU=armv8-a
CC=$TOOLCHAIN/bin/aarch64-linux-android$API-clang
CXX=$TOOLCHAIN/bin/aarch64-linux-android$API-clang++
SYSROOT=$NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot
CROSS_PREFIX=$TOOLCHAIN/bin/aarch64-linux-android-
# 编译产物的保存路径
PREFIX=$(pwd)/android_arm_static/$CPU
OPTIMIZE_CFLAGS="-march=$CPU"
build_android

# #armv7-a
# ARCH=arm
# CPU=armv7-a
# CC=$TOOLCHAIN/bin/armv7a-linux-androideabi$API-clang
# CXX=$TOOLCHAIN/bin/armv7a-linux-androideabi$API-clang++
# SYSROOT=$NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot
# CROSS_PREFIX=$TOOLCHAIN/bin/arm-linux-androideabi-
# PREFIX=$(pwd)/android_arm_static/$CPU
# OPTIMIZE_CFLAGS="-mfloat-abi=softfp -mfpu=vfp -marm -march=$CPU "
# build_android

# #x86
# ARCH=x86
# CPU=x86
# CC=$TOOLCHAIN/bin/i686-linux-android$API-clang
# CXX=$TOOLCHAIN/bin/i686-linux-android$API-clang++
# SYSROOT=$NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot
# CROSS_PREFIX=$TOOLCHAIN/bin/i686-linux-android-
# PREFIX=$(pwd)/android/$CPU
# OPTIMIZE_CFLAGS="-march=i686 -mtune=intel -mssse3 -mfpmath=sse -m32"
# build_android

编译产物

编译产物的文件目录如下:

image-20200908105542975

主要包含两部分:头文件include 和库文件lib,其中各个库文件的功能如下:

libavformat

用于各种音视频封装格式的生成和解析,包括获取解码所需信息以生成解码上下文结构和读取音视频帧等功能;音视频的格式解析协议,为 libavcodec 分析码流提供独立的音频或视频码流源。

libavcodec

用于各种类型声音/图像编解码;该库是音视频编解码核心,实现了市面上可见的绝大部分解码器的功能,libavcodec 库被其他各大解码器 ffdshow,Mplayer 等所包含或应用。

libavfilter

 filter(FileIO、FPS、DrawText)音视频滤波器的开发,如水印、倍速播放等。

libavutil

包含一些公共的工具函数的使用库,包括算数运算 字符操作;

libswresample

原始音频格式转码。

libswscale

(原始视频格式转换)用于视频场景比例缩放、色彩映射转换;图像颜色空间或格式转换,如 rgb565,rgb888 等与 yuv420 等之间转换。

使用 CMake 集成 FFmpeg

拷贝编译产物到工程下

  • 拷贝头文件

  • 拷贝库文件

    image-20200908110526458

配置 CMakeLists.txt

在我们的 Android 项目中使用 CMake 方式来集成 FFmpeg,CMakeLists.txt 配置如下:

cmake_minimum_required(VERSION 3.4.1)

message("当前cmakel路径: ${CMAKE_SOURCE_DIR} \n cpu架构:${CMAKE_ANDROID_ARCH_ABI}")

# 将 cpp 文件夹下所有的源文件定义成了 SOURCE(后面的源文件使用相对路径)
file(GLOB SOURCE ./*.cpp )

add_library( # 工程名
        native-lib
        # 编译为动态库
        SHARED
        # 引入 SOURCE 下的所有源文件
        ${SOURCE}
        )

find_library( # Sets the name of the path variable.
        log-lib

        # Specifies the name of the NDK library that
        # you want CMake to locate.
        log)

# 引入 FFmpeg 的头文件
include_directories(${CMAKE_SOURCE_DIR}/include)

# 引入外部静态库,直接给 cmake 添加一个查找路径,在这个路径下可以找到所以的静态库
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/${CMAKE_ANDROID_ARCH_ABI}")

target_link_libraries( # Specifies the target library.
        native-lib
        # 添加编译好的 FFmpeg 的库文件
        avformat
        avcodec
        avfilter
        avutil
        swresample
        swscale
        # 其他用的库文件
        ${log-lib}
        z
        android
        OpenSLES)

音视频原理简介

一段视频的组成

图像 + 音频

  • 原始图像数据(RGB/YUV)
  • 原始音频数据(PCM)

为什么压缩?

数字音频

问题: mp3 是什么?

有损压缩,压缩比 4 - 12

数字音频的产生

pcm流程

采样:每秒采集次数,人耳能够听到的范围:20Hz - 20kHz,按照采样定理,建议值为 44.1 kHz

量化:每次采样的声波用多少位二进制数据表示,比如 16bit,32bit

声道:有几路声音

PCM 大小

比特率:44100 * 16 * 2 = 1378.125 kbps

1 分钟音频的需要的空间 = 1378.125 * 60 / 8 / 1024 = 10.09 MB

可以看到,1 分钟的音频大小就达到了 10M,所以要进行压缩。

图像

图像表示形式
  • RGB

    1280 * 720 的 RGBA_8888 图像的大小:

    1280 * 720 * 4 = 3.516 MB

  • YUV

    YUV在对照片或影片编码时,考虑到人类的感知能力,允许降低色度的带宽。

    “Y”表示**明亮度(Luminance、Luma),“U”和“V”则是色度浓度**(Chrominance、Chroma)

image-20200907215815035

1280 * 720 的 RGBA_8888 视频帧的大小:

1280 * 720 * 1 + 1280 * 720 * 0.5 = 1.318 MB

视频影像大小

1.318 MB * 60 fps * 90 min * 60s = 417 GB

搭建一款简单的直播播放器

直播流程

image-20200812112047911

直播分为推流和拉流,我们搭建直播播放器的话只需要考虑拉流部分,拉流播放主要进行的步骤如下:

  • 解封装:将直播流解封装,拆分为视频流和音频流
  • 解码拆分出的视频流,并进行格式转换,最后进行播放
  • 解码拆分出的音频流,并进行格式转换,最后进行播放

拉流播放时序图 - 以解析视频流为例

image-20200812164229126

时序图中主要涉及的类职责如下:

  • MediaManager
    • 初始化 FFmpeg,设置直播源
    • 解封装(图中使用绿色来表示)
      • 解封装的本质是开了一个子线程,开了一个 loop 循环不断从直播流中取数据解封
      • 解封装使用的 FFmpeg 的库是 avformat
  • VideoChannel
    • 用来解析视频流,它主要有四个成员变量:
      • AVPackageQueue:AVPackage 队列,AVPackage 是从直播流中解析出来的编码数据包
      • DecodeThread:解码线程,图中用蓝色区域标出,使用 FFmpeg 的 avcodec
      • AVFrameQueue:AVFrame 队列, AVPackage 解码后是 AVFrame
      • RenderThread:渲染线程,负责将 AVFrame 中的数据渲染到 SurfaceView 中,图中用紫色区域标出,使用 FFmpeg 的 swscale 库文件

简单描述一下时序图:

  • MediaManager 中开了个 looper ,不断的从直播流中拿编码数据包AVPackage,不断的存入 AVPackageQueue 中 。
  • DecodeThread 中开了个 looper,不断的从 AVPackageQueue中拿数据并解码成原始数据包AVFrame,解码后将AVFramePackage存入AVFrameQueue中。
  • RenderThread 中开了个 looper,不断的从AVFrameQueue中获取数据,并将AVFrame进行格式转换,最后显示在 SurfaceView中。

上述过程中的本质就是两个生产者-消费者

设置直播来源

这里选择的是 CCTV-1 的视频源:ivi.bupt.edu.cn/hls/cctv1hd…

打开直播源

    // ffmpeg 初始化网络
    avformat_network_init();

    // 1.打开多媒体流(本地文件/网络地址),dataSource 就是上面的 CCTV-1 直播 url
    formatContext = nullptr;
    int result = avformat_open_input(&formatContext, dataSource, nullptr, nullptr);
    if (result != 0) {
        LOGE("打开媒体失败:%s", av_err2str(result));
        mediaBridge->onError(THREAD_CHILD, result);
        return;
    }

解封装

解封装的目:

  • 遍历直播流中所有的流,比如一个直播流至少会包含视频流和音频流

  • 解析每个拆分流的解码器信息,并将解码参数设置给 FFmpeg

 // 3.处理包含的流,遍历
    for (int i = 0; i < formatContext->nb_streams; ++i) {
        AVStream *stream = formatContext->streams[i];
        // 解码这个流的相关信息
        AVCodecParameters *codecpar = stream->codecpar;
        // 查找该流的解码器
        AVCodec *decoder = avcodec_find_decoder(codecpar->codec_id);
        ...
        // 获取解码器上下文
        AVCodecContext *decoderContext = avcodec_alloc_context3(decoder);
        ...

        // 给解码器上下文设置参数
        result = avcodec_parameters_to_context(decoderContext, codecpar);
        ...

        // 打开解码器
        result = avcodec_open2(decoderContext, decoder, nullptr);
        ...
          
        AVRational time_base = stream->time_base;
        // 如果是音频流
        if (codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
            audioChannel = new AudioChannel(i, decoderContext, time_base);
        } 
      	// 如果是视频流
        else if (codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
            // 帧率:单位时间内 需要显示多少个图像
            AVRational frame_rate = stream->avg_frame_rate;
            int fps = av_q2d(frame_rate);
            videoChannel = new VideoChannel(i, decoderContext, time_base, fps);
            videoChannel->setRenderFrameCallback(callback);
        }
        ...
    }

解码

将 AVPackage 解码为 AVFrame:

void VideoChannel::decode() {
    AVPacket *packet = nullptr;
    while (isPlaying) {
        // 从队列中取出一个数据包
        int result = avPacketQueue.pop(packet);
        if (!isPlaying) {
            break;
        }
        // 取出失败
        if (!result) {
            continue;
        }
        // 把包丢给解码器
        result = avcodec_send_packet(avCodecContext, packet);
        // avcodec 处理完 packet 之后就可以将 packet 销毁了
        releaseAvPacket(&packet);
        // 重试
        if (result != 0) {
            break;
        }
        // 代表了一个图像 (将这个图像先输出来)
        AVFrame *frame = av_frame_alloc();
        // 从解码器中读取 解码后的数据包 AVFrame
        result = avcodec_receive_frame(avCodecContext, frame);
        // 需要更多的数据才能够进行解码
        if (result == AVERROR(EAGAIN)) {
            continue;
        } else if (result != 0) {
            break;
        }
        // 再开一个线程来播放 (为了保障流畅度)
        avFrameQueue.push(frame);
    }
    releaseAvPacket(&packet);
}

渲染

渲染主要做两件事:

  • 格式转换:将 AVFrame 中的 YUV -> RGB
  • 将 RGB 数据交给 SurfaceView 显示

格式转换

// 将图像转化为 RGBA 格式
    // 转换使用的是 swscale 这个库,先创建一个 swscale 的 context
    swsContext = sws_getContext(
            avCodecContext->width, avCodecContext->height, avCodecContext->pix_fmt,
            avCodecContext->width, avCodecContext->height, AV_PIX_FMT_RGBA,
            SWS_BILINEAR, nullptr, nullptr, nullptr);
...
 AVFrame *frame = nullptr;
// 指针数组
uint8_t *dst_data[4];
int dst_linesize[4];
// 由于 sws_scale 不接受二级指针的数据 buffer 数组,需要先申请一块内存,sws_scale 会向这块内存存数据
av_image_alloc(dst_data, dst_linesize, avCodecContext->width, avCodecContext->height, AV_PIX_FMT_RGBA, 1);
while (isPlaying) {
  int ret = avFrameQueue.pop(frame);
  if (!isPlaying) {
      break;
     }
  // src_linesize: 表示每一行存放的 字节长度
  sws_scale(swsContext, reinterpret_cast<const uint8_t *const *>(frame->data),
                  frame->linesize, 0,
                  avCodecContext->height,
                  dst_data,
                  dst_linesize);  
  ...
  // 回调出去进行播放
        callback(dst_data[0], dst_linesize[0], avCodecContext->width, avCodecContext->height);  
  

图像渲染

在 native 端直接渲染 java 端的 SurfaceView 需要使用 NativeWindow,它是 C/C++ 中定义的一个结构体,等同于Java中的Surface

  • 获取与surface对应的ANativeWindow

    ANativeWindow* ANativeWindow_fromSurface(JNIEnv* env, jobject surface);
    
  • 保持/释放ANativeWindow对象的引用

    void ANativeWindow_acquire(ANativeWindow* window);
    
    void ANativeWindow_release(ANativeWindow* window);
    
  • 向buffer中写入数据并提交

    int32_t ANativeWindow_lock(ANativeWindow* window, ANativeWindow_Buffer* outBuffer, ARect* inOutDirtyBounds); // 这之间的代码可以执行一些向buffer中写入数据的操作 
    int32_t ANativeWindow_unlockAndPost(ANativeWindow* window);
    
    
  • 获取Window Surface的信息:宽/高/像素格式

    int32_t ANativeWindow_getWidth(ANativeWindow* window); 
    int32_t ANativeWindow_getHeight(ANativeWindow* window); 
    int32_t ANativeWindow_getFormat(ANativeWindow* window);
    
  • 变Window Buffer的格式和大小

    int32_t ANativeWindow_setBuffersGeometry(ANativeWindow* window,
    int32_t width, int32_t height, int32_t format);
    

    更多信息可以参考 这篇博客

上面 callback 函数定义如下:

void render(uint8_t *data, int lineszie, int w, int h) {
    pthread_mutex_lock(&mutex);
    if (!window) {
        pthread_mutex_unlock(&mutex);
        return;
    }
    // 2.设置 buffer 的尺寸和格式
    ANativeWindow_setBuffersGeometry(window, w, h, WINDOW_FORMAT_RGBA_8888);
    ANativeWindow_Buffer window_buffer;
    // 3. 填充数据
    if (ANativeWindow_lock(window, &window_buffer, nullptr)) {
        ANativeWindow_release(window);
        window = 0;
        pthread_mutex_unlock(&mutex);
        return;
    }

    // 填充rgb数据给dst_data
    auto dst_data = static_cast<uint8_t *>(window_buffer.bits);
    // stride:一行多少个数据(RGBA)* 4
    int dst_linesize = window_buffer.stride * 4;
    // 一行一行的拷贝
    for (int i = 0; i < window_buffer.height; ++i) {
        memcpy(dst_data + i * dst_linesize, data + i * lineszie, dst_linesize);
    }
    ANativeWindow_unlockAndPost(window);
    pthread_mutex_unlock(&mutex);
}

流畅度优化

图像的解析可以看做是一个 生产者 - 消费者 模式,只要获取到一个包就会将它进行渲染,可以预料到这样的播放是不流畅的,会时快时慢,那如何来优化呢?

最简单的做法就是按照 fps 来进行播放,比如 fps 是 60,那代表每 16ms 播放一张图片,所以我们只需要在 RenderThread 中每隔 16ms 渲染一次即可,也就是没播放一张图片线程要 sleep 16ms:

double frame_period = 1.0 / fps;
// 单位是微秒
av_usleep(frame_period * 1000000)

FFmpeg 提供了参数可以更精确的计算出 sleep 的时间:

repeat_pict: 指示这个图像必须延时多长

 double frame_period = 1.0 / fps; 
 // 额外的间隔时间: 这个图像必须延时多长
 double extra_delay = frame->repeat_pict / (2 * fps); // NOLINT(bugprone-integer-division)
 // 真实需要的间隔时间
 double delays = extra_delay + frame_period;
 av_usleep(delays * 1000000);

解析音频

时序图

音频解析的时序图和图像解析的时序图基本一致。

Native 端播放音频

可以使用 OpenSLES 在 Native 端来实现音频的录制和播放,它支持各种音效。

具体的使用可以参考 GoogleNdkSample

音视频同步

先抛一个问题

上面音频播放和视频播放是分了两个线程独立进行的,大家觉得会出现什么问题?

可以预料到声音和图像是不同步的。

三种同步方案

  • 以视频进度为主
  • 以音频进度为主
  • 以外部时钟同步音视频

鉴于人的耳朵比眼睛要灵敏,耳朵更能发现音频不同步,所以这里采用的方案是以音频进度为主。

以音频进度为主的同步方案实现

  • 视频比音频快了

    增加视频线程的 sleep 时间

  • 视频比音频慢了

    • 减少视频的 sleep 时间
    • 主动丢包
      • 丢 AVPackage,需要考虑 IPB 帧类型
      • 丢 AVFrame
// 用多种方式估算出的帧的时间戳
 double relativeTime = frame->best_effort_timestamp * av_q2d(time_base);
// 音视频的时间差
double diff = relativeTime - audioChannel->relativeTime;
if(diff > 0){
   // 视频比音频快
   av_usleep((delays + diff) * 1000000);
}else{
   // 音频比视频快
   // 视频包积压的太多了,这时候需要主动丢掉一些视频帧
   if (fabs(diff) >= 0.05) {
        releaseAvFrame(&frame);
        // 主动丢包
        frames.sync();
        continue;
     }
}

JNI 主要用法

内容太多讲不完,感兴趣的话可以阅读这篇 Android - JNI 开发你所需要知道的基础

源码

FFmpegTutorial