本文的目标
从 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
而我们所要做的工作可以抽象为下面的流程:
下面的三种编译方式都是按照这种流程来工作的。
手动编译
编译多个源文件: 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 sourcewrong order
gcc extra_source1.c source.c -o source -L. -lextrasource2right 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 文件,它包含了丰富的配置选项,下面列出一些常用选项:
工具链选项
这些选项都是和编译器有关的(往往是必须的):
| option | desc |
|---|---|
| --arch=ARCH | 架构,比如 arm |
| --cpu=CPU | ABI,比如 arm-v7 |
| --target-os=OS | 目标平台,比如 android,iOS |
| --enable-cross-compile | 使用交叉编译 |
| --cross-prefix | 交叉编译工具链位置 |
| --sysroot=PATH | 链接头文件和库文件的路径,android 平台下要到 ndk 下查找 |
| --cc=CC | c 编译器,比如 clang |
| --cxx=CXX | c++ 编译器 |
| --extra-cflags=ECFLAGS | 传给编译器的参数,比如指导编译器在指定目录查找头文件/库文件 |
配置选型
| option | desc |
|---|---|
| --disable-static | 不要构建静态库[默认 false] |
| --enable-shared | 构建动态库[默认 false] |
| --enable-small | 优化大小 |
组件选项
在这里你可以选择自己需要的功能并关闭不需要的功能,这样可以减小库文件大小。
| option | desc |
|---|---|
| --disable-avdevice | 操作摄像头等采集设备,android 下不支持,可以选择 disable |
| --disable-avfilter | 禁用水印字幕 |
单个组件选项
| option | desc |
|---|---|
| --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
编译产物
编译产物的文件目录如下:

主要包含两部分:头文件include 和库文件lib,其中各个库文件的功能如下:
libavformat
用于各种音视频封装格式的生成和解析,包括获取解码所需信息以生成解码上下文结构和读取音视频帧等功能;音视频的格式解析协议,为 libavcodec 分析码流提供独立的音频或视频码流源。
libavcodec
用于各种类型声音/图像编解码;该库是音视频编解码核心,实现了市面上可见的绝大部分解码器的功能,libavcodec 库被其他各大解码器 ffdshow,Mplayer 等所包含或应用。
libavfilter
filter(FileIO、FPS、DrawText)音视频滤波器的开发,如水印、倍速播放等。
libavutil
包含一些公共的工具函数的使用库,包括算数运算 字符操作;
libswresample
原始音频格式转码。
libswscale
(原始视频格式转换)用于视频场景比例缩放、色彩映射转换;图像颜色空间或格式转换,如 rgb565,rgb888 等与 yuv420 等之间转换。
使用 CMake 集成 FFmpeg
拷贝编译产物到工程下
-
拷贝头文件
-
拷贝库文件

配置 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
数字音频的产生

采样:每秒采集次数,人耳能够听到的范围: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在对照片或影片编码时,考虑到人类的感知能力,允许降低色度的带宽。
“Y”表示**明亮度(Luminance、Luma),“U”和“V”则是色度、浓度**(Chrominance、Chroma)
1280 * 720 的 RGBA_8888 视频帧的大小:
1280 * 720 * 1 + 1280 * 720 * 0.5 = 1.318 MB
视频影像大小
1.318 MB * 60 fps * 90 min * 60s = 417 GB
搭建一款简单的直播播放器
直播流程

直播分为推流和拉流,我们搭建直播播放器的话只需要考虑拉流部分,拉流播放主要进行的步骤如下:
- 解封装:将直播流解封装,拆分为视频流和音频流
- 解码拆分出的视频流,并进行格式转换,最后进行播放
- 解码拆分出的音频流,并进行格式转换,最后进行播放
拉流播放时序图 - 以解析视频流为例

时序图中主要涉及的类职责如下:
- 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 开发你所需要知道的基础