本文总结了笔者在 Android 音视频采集与软编码中的一些经验与技巧,包括移植 FFmpeg、YUV 视频帧处理、最新的 JNI 编写技巧、 ndk 开发技巧等,为了不扯太远本文不会对音视频编码的一些原理性东西进行剖析,也不会大量贴源码,更注重使用方法与流程的讲解。 文章最后将展示一个实现了音视频采集功能与本地视频压缩功能的完整项目。
众所周知, Android API 中有个 MediaCodec
的类,利用 MediaCodec
可实现硬编码,高效且方便,Android API 中还有个叫 MediaMuxer
的类,可以实现音频与视频的合成,但是 MediaCodec
使用的 API 最低要求是 16,MediaMuxer
则是 18,除此之外如果对视频想做更多的处理还需要做比较多的开发,当然这点纯粹是偷懒。如果使用软编码,那么 API 限制将不再那么苛刻,笔者开发时调到了 14,然后由于我用的是 FFmpeg 且移植了它的命令行工具,那么对视频很多的处理操作可以使用简单命令即可完成。视频编码我为 FFmpeg 配置的是 x264 ,其可编码为 H.264 视频格式,除此之外比较出名的还有下一代编码标准 HEVC、VP9。音频我为 FFmpeg 配置的是 libfdk-aac ,其 libfaac 在新版 FFmpeg 中已经被抛弃了。x264 现在在算法上基本达到了瓶颈,而软编效率很大程度上依赖算法与 cpu ,所以 cpu 就成了决定效率的关键,经过我实验,虽然其跟硬编码效率还是有差距,但是它在普通 64 位的 cpu 上表现还是不错的,处理 480P、帧率 30 、比特率 1000000 的视频帧基本没有延迟。
需要准备的依赖库有 FFmpeg
、libx264
、libfdk-aac
等,它们都是不能直接在 Android 上使用的,需要修改些东西并且需要用 NDK 编译成动态链接库或者静态链接库,修改的东西其实主要是一些命名上的东西,很多平台识别的格式不太一样,如 Android 上不能识别 ffmpeg.so.01 这样的库,对于 FFmpeg 的编译网上有非常多的脚本,但是如果你需求不是和他一模一样,建议还是自己定制下,其实定制无非是对一些功能的开开关关而已,其他平台可能无所谓,大不了功能全开呗,但是对于 Android 来说,编译个全功能的 FFmpeg 可不是什么好事,apk 将会随之变大很多,这是不能接受的。这里有一个项目里面包含了 FFmpeg
、libx264
、libfdk-aac
全平台的编译脚本与 Android 下使用的简单 Demo,https://github.com/mabeijianxi/FFmpeg4Android ,你只需要简单修改脚本即可定制自己的 FFmpeg 了。
如果你 Android Studio 版本大于 2.2 ,且在新建项目时如果勾选上了 Incude C++ Support
,那么 AS 将会默认使用 CMake 插件,我们一般情况下只需要对 CMakeLists.txt
进行编写即可完成 Native 编译。下面展示一段 CMakeLists.txt
编写的实用列子:
cmake_minimum_required(VERSION 3.4.1)
add_library( # Sets the name of the library.
jxffmpegrun
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
src/main/cpp/cmdutils.c
src/main/cpp/ffmpeg.c
src/main/cpp/ffmpeg_filter.c
src/main/cpp/ffmpeg_opt.c
src/main/cpp/jx_ffmpeg_cmd_run.c
)
add_library(
avcodec
SHARED
IMPORTED
)
if(${ANDROID_ABI} STREQUAL "armeabi")
set_target_properties(
avcodec
PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/src/main/jniLibs/armeabi/libavcodec.so
)
endif(${ANDROID_ABI} STREQUAL "armeabi")
if(${ANDROID_ABI} STREQUAL "armeabi-v7a")
set_target_properties(
avcodec
PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/src/main/jniLibs/armeabi-v7a/libavcodec.so
)
endif(${ANDROID_ABI} STREQUAL "armeabi-v7a")
if(${ANDROID_ABI} STREQUAL "arm64-v8a")
set_target_properties(
avcodec
PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/src/main/jniLibs/arm64-v8a/libavcodec.so
)
endif(${ANDROID_ABI} STREQUAL "arm64-v8a")
include_directories(
${CMAKE_SOURCE_DIR}/ffmpeg-3.2.5
)
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 )
target_link_libraries( # Specifies the target library.
jxffmpegrun
avcodec
# Links the target library to the log library
# included in the NDK.
${log-lib} )
简单分析下上面的脚本:
cmake_minimum_required
命令的作用是指定 CMake 使用的最小版本。add_library
命令的作用是添加一个库,首先指定的是库名,如 jxffmpegrun,根据规范最后会生成 libjxffmpegrun.so
或者 libjxffmpegrun.a
,指定链接库类型,如SHARED 代表动态链接库,STATIC 代表静态链接库,区别读者可自行查询其他资料。接着你可以添加 c/c++ 源文件,也可以添加预编译库。如上面列子中第一个 add_library
里条件了源文件,而第二个添加了预编译库文件。if() endif()
语句即可实现,如上面的 if(${ANDROID_ABI} STREQUAL "arm64-v8a") ....endif(${ANDROID_ABI} STREQUAL "arm64-v8a")
就表示在编译 ABI 为 arm64-v8a 时就设置一个与之对应的 arm64-v8a 预构建库,其他类型 ABI 也是如此操作。set_target_properties
命令顾名思义就是为目标配置一些属性,第一个参数是 target,比如我上面就指定了 avcodec 这个 library,然后 PROPERTIES 字段后面跟上键值对,键是属性,比如上面的 IMPORTED_LOCATION ,值是${CMAKE_SOURCE_DIR}/src/main/jniLibs/armeabi/libavcodec.so
,加起来就表示为avcodec 这个library 设置了一个本地导入的路径。这个路径必须是绝对路径,不然编译不过去的,然后为了可移植性,所以我在前面通过 ${CMAKE_SOURCE_DIR}
获取了 CMake 脚本目录。include_directories
命令则是指定头文件的搜索路径。find_library
命令用来定位 NDK 库,并将其路径存储为一个变量,您可以使用此变量在构建脚本的其他部分引用 NDK 库,比如上面搜索了一个 NDK 里面自带的 log库。target_link_libraries
命令指定要关联到的原生库,可以看到上面我们添加了 3 个库。 上面只简单介绍了常用的 CMakeLists.txt
编写,更多可以查阅官方文档,我这里贴出一个 CMake 3.4 的官方帮助文档地址 https://cmake.org/cmake/help/v3.4。
在 Android 上我们可以利用 Camera
类或者 camera2
来打开摄像头进行视频帧的采集,当然 camera2
最低 API 需要 21,音频可以用 AudioRecord
来打开麦克风进行音频帧的采集。
对于 Android 开发者来说 Camera
这个类并不陌生,我们只需要一些简单配置以后就可以配合 SurfaceView
来浏览摄像头所捕捉到的画面,我们这次的部分配置也许与往常的不同,因为我们需要自己处理每一帧视频,比如需要设置一个采样格式: Camera.Parameters.setPreviewFormat
,点进源码你会发现里面所支持的格式有很多,但是不幸的是 5.0 以前只支持 NV12 与 YV12 ,关于这两种采样模式等下会细讲。除此之外你还需添加缓冲区来存放临时的视频数据,并设置采样回调,如:
这里的 buffSize 大小与采样格式息息相关,不过 NV12 与 YV12 的每帧大小倒是刚好一样大的,都是3/2*H*W
,在 TODO 的位置我们就可以对视频进行编码与或者其他操作,但是千万别直接编码或者操作,很关键!你可以使用一个队列来储存数据,然后开启一个线程去读这个队列里面的数据,然后进行操作,因为你直接操作很可能阻塞这个线程,这个线程是主线程,虽然一般不会导致 ANR ,但是很可能造成丢帧,比如你采集的帧率是 30fps ,采集到第一帧的时候你阻塞了一会儿,那么很可能第二第三帧就会丢,然后你处理第四帧,第五第六帧就会丢,最后播放视频的时候就会像按了快进一样,并且处理完后我们需要及时把 buffer 归还给 Camera。
这里先贴出一段 AudioRecord 使用的代码残片:
与 Camera 的配置相比,AudioRecord 简单了很多,在实例化 AudioRecord 时需要指定采集源,笔者设定为了麦克风,然后指定采样率,笔者采用兼容性非常强的 44100Hz ,也就是每秒采集 44100 次,接着是配置音频通道,由于笔者对音频要求不是很高所以采用了 AudioFormat.CHANNEL_IN_MONO
代表单通道,当然也是支持双通道立体声采集的,只需传入 AudioFormat.CHANNEL_IN_STEREO
即可,然后再设置采样的数据格式,也就是每个采样值所占空间的大小,笔者选择了 16位 也就是 2 byte ,最后再配置上缓冲器大小,这个值一般不是写死的可以通过 AudioRecord.getMinBufferSize
来获取一个最小值。
接着只需要调用 AudioRecord.read
即可获取采集到的 PCM 视频。
先简要说说YUV格式,与RGB类似YUV也是一种颜色编码方法,Y:表示明亮度(Luminance或Luma),也就是灰度值;而 U 和 V :表示的则是色度(Chrominance或Chroma),作用是描述影像色彩及饱和度,用于指定像素的颜色。如果只有Y那么就是黑白音像。根据采样方式不同主要有YUV4:4:4,YUV4:2:2,YUV4:2:0。其YUV 4:4:4采样,每一个Y对应一组UV分量。 YUV 4:2:2采样,每两个Y共用一组UV分量。YUV 4:2:0采样,每四个Y共用一组UV分量 。举个例子,屏幕上有八个像素点,YUV4:4:4会有8个Y,8个U,8个V。YUV4:2:2会有8个Y,4个U,4个V。YUV4:2:0会有8个Y,2个U,2个V。
YV12与NV12,他们都是属于YUV420,只是其排列结构不同(下图是网上找的,原图有错,这里 P 了一下)。
可以看到Y1, Y2, Y7, Y8这些物理上相近的4个像素公用了同样的U1和V1,相似的Y3,Y4,Y9,Y10用的就是U2和V2。这里不同的颜色把这个特性刻画的非常形象,一 目了然。格子数目就是这一帧图像的byte数组的大小,其数组元素排放顺序就是后面那一长条的样子。
可以发现与 YV12 相比它们只是UV的排放位置不同而已。
YV12 与 NV12 都属于 YUV 420,一个像素点由 Y、U、V三个通道组成,YUV420 对每个像素的 Y都会扫描采样。之所以叫YUV4:2:0,不是因为没有V,它其实是在纵向上UV交换扫描的,比如第一行扫描U第二行就扫描V,第三行再扫描U。在横向上是隔一个扫描,比如第一列扫描了,第二列就不扫描,然后扫描第三列。YV12 与 NV12 只是存放的数据结构不同而已。
大小是 3/2*W*H
,width*height
个Y加上 (1/4)*width*height
个 V 加上(1/4)*width*height
个 U。
用YV12于NV12都是可以的,笔者在配置相机参数的时候选择了YV12,下面将展示视频剪切与旋转的处理方法,非常简单,笔者也是想象着写出来的。
这个手机框的目的只是为了展示摄像头采集的视频方向,不管你手机如何拿,采集的原始视频都是横的。这里假设我们采集的视频宽是 640,高是 480,我们要剪切成宽是 400,高是 300 的视频。根据上面的知识我们能知道 640*480
的一帧 byte 数组里面将会有 640*480
个Y,且排在最前面,然后有 (1/4)*640*480
个V,然后有(1/4)*640*480
个U,我们要剪切成400*300
,自然是保留一部分数据即可。我们先对Y建立一个模型,既然是 640*480
,我们可以把它当成一行有 640 个Y,一共有 480 行,如下图所示红色标注内表示 640*480
个Y,而黄色区域内则是我们剪切完成的Y的所有值。
需要注意图像方向哈。有了这个模型我们就可以写代码操作数组了。下面搞段代码:
剪切Y:
unsigned char *in_buf;
unsigned char *out_buf_y;
for(int i=480-300;i<480;i++){//遍历高
for(int j=0;j<400;j++){//遍历宽
int index=640*i+j;//当前遍历到的角标
unsigned char value=*(in_buf+index);//当前角标下的Y值
// 开始赋值给我们的目标数组
*(out_buf_y+(i-(480-300))*400+j)=value;//目标数组是400*300的,这里是从0角标开始依次全部遍历且赋值
}
}
假设in_buf是一帧YV12视频数据的话,执行完这个循环我们就得到剪切好的Y值了,接下来我们解析剪切UV数据,UV的模型和Y有点不同。之所以叫YUV4:2:0,不是因为没有V,它其实是在纵向上UV交换扫描的,比如第一行扫描U第二行就扫描V,第三行再扫描U。在横向上是隔一个扫描,比如第一列扫描了,第二列就不扫描,然后扫描第三列。所以U在横向和纵向上的数据都是其Y的1/2,总数量是其1/4,V也是一样的。知道了这些我们就可以轻易的建立模型。
320*240的区域就是我们就是我们U值或者V值的区域,200*150的区域就是我们剪切后的U值或者V值的目标区域。代码如下:
剪切UV:
unsigned char *in_buf;
unsigned char *out_buf_u;
unsigned char *out_buf_v;
for(int i=(480-300)/2;i<480/2;i++){//遍历高
for(int j=0;j<400/2;j++){//遍历宽
int index=(640/2)*i+j;//当前遍历到的角标
unsigned char v=*(in_buf+(640*480)+index);//当前角标下的V值(指针位置得先向后移640*480个单位,因为前面放的是Y)
unsigned char u=*(in_buf+(640*480*5/4)+index);//当前角标下的U值(指针位置得先向后移640*480*5/4个单位,因为前面放的是Y和V)
// 从0角标开始赋值给我们的目标数组out_buf_u
*(out_buf_u+(i-(480-300)/2)*400/2+j)=u;
*(out_buf_v+(i-(480-300)/2)*400/2+j)=v;
}
}
经过上面的操作我们已经完成了最基本的剪切,摄像头采集的数据是横屏的,如果我们竖屏录制且我们不做任何操作的话这时候我们录制的视频是逆时针旋转了90°的,你逆时针那哥就顺时针给你转90°,这样应该就正了。
思路有了,就是如上图所示,我们for循环不变,因为需要剪切的位置不变,我们只改变输出数组的排放位置,原来第一排的放到最后一列,第二排放到倒数第二列,以此内推。下面也用代码演示下:
Y剪切并顺时针旋转90°:
unsigned char *in_buf;
unsigned char *out_buf_y;
for(int i=(480-300);i<480;i++){//遍历高
for(int j=0;j<400;j++){//遍历宽
int index=(640)*i+j;//当前遍历到的角标
unsigned char value=*(in_buf+index);//当前角标下的Y值
*(out_buf_y+j*300+(300-(i-(480-300)-1)))=value;//结合输出数组的图像即可明白
}
}
Y弄好了UV就特别简单,因为我们已经掌握了规律,UV在横向和纵向上的值都是Y的一半。
剪切UV:
unsigned char *in_buf;
unsigned char *out_buf_u;
unsigned char *out_buf_v;
for(int i=(480-300)/2;i<480/2;i++){//遍历高
for(int j=0;j<400/2;j++){//遍历宽
int index=(640/2)*i+j;//当前遍历到的角标
unsigned char value_v=*(in_buf+(640*480)+index);//当前角标下的V值
unsigned char value_u=*(in_buf+(640*480*5/4)+index);//当前角标下的U值
*(out_buf_u+j*300/2+(300/2-(i-(480-300)/2-1)))=value_u;//结合输出数组的图像即可明白
*(out_buf_v+j*300/2+(300/2-(i-(480-300)/2-1)))=value_v;//结合输出数组的图像即可明白
}
}
因为前置摄像头的原因,会导致镜像,所以在用前置摄像头录制的时候还需要处理镜像,原理都差不多的,除了这些我们可以做好多有趣的操作,比如当UV值都赋予128的时候就成了黑白影像,你还可以调节亮度色调,也能做些美颜效果等等。
处理完数据后调用FFmpeg编码的API即可。
如果开启了 c++ 支持(前面有说)且启用了 CMake 插件(开启了 c++ 支持后默认启用),那在编写 JNI 的时候基本就是秒秒钟的事:
当你在 Java 层编写了个 native 接口肯定是红的,没关系你只要按住 Alt + Enter
即可出现如图提示,然后再重击一下 Enter
键,就可在底层生成对应的 JNI 函数了:
这些细节可以减少我们一定的开发时间,而且感觉心里比较爽。
编写好了
JNI 调用 Java,网上的列子非常多,一般情况下普通调用没什么问题。但是有些情况下就会失败,比如你开启了一个 native 线程,在线程里面用原始的方式去调用就会出错,可能会报地址找不到等错误:
可以看到上面笔者是通过 native Debug 捕获到的,调试的时候非常有用。回归刚才的问题,我们看看官方的解释:
没错笔者就是通过 pthread_create
函数创建了一个线程,所以在这线程里面直接 FindClass 或者其他试图调用 Java 方法的操作都不行,都无法与你当前应用程序相关联, 官方给出了三种解决办法,基本都是在 Java 线程中去缓存一些东西,然后在 Native 线程取用,笔者总结了一个方案:
env->NewGlobalRef
函数转变为一个全局引用存起来。env->GetJavaVM
获取当前 javaVM 指针,并且存起来。javaVM->AttachCurrentThread
把存储的虚拟机与当前线程绑定。javaVM->DetachCurrentThread
。下面通过简单代码直观的展示下:
typedef struct Arguments {
JavaVM *javaVM; //jvm指针
jclass java_class; //java接口类的calss对象
} ;
JNIEXPORT jint JNICALL
Java_com_mabeijianxi_smallvideorecord2_jniinterface_FFmpegBridge_test(JNIEnv *env, jclass type) {
Arguments *arguments = (Arguments *) malloc(sizeof(Arguments));
// step 1
arguments->java_class = (jclass) env->NewGlobalRef(type);
// step 2
env->GetJavaVM(&arguments->javaVM);
pthread_t thread;
pthread_create(&thread, NULL,start_native_thread , arguments);
return 0;
}
void * start_native_thread(Arguments * arguments){
JNIEnv *env;
// step 3
arguments->javaVM->AttachCurrentThread(&env, NULL);
// step 4
jmethodID pID = env->GetStaticMethodID(arguments->java_class, "notifyState", "(IF)V");
env->CallStaticVoidMethod(arguments->java_class, pID, END_STATE, 0);
// step 5
arguments->javaVM->DetachCurrentThread();
}
为了简介,上述代码简化了异常处理的部分。
在 NDK 开发之前,你可以选择使用的标准库,默认是 Toolchain Default ,也就是 Toolchain 中默认所包含的库,也可以选择 C++ 11:
笔者在开发过程中一开始使用的是 Toolchain 中默认的库,后来发现很多实用的库都找不到,于是后来改用了 C++ 11,修改方法也很简单,只需要在 gradle.build 脚本中添加 cppFlags 参数即可:
在 cppFlags 里面除了还可以加非常多的配置,比如我们经常定义一个宏,用来控制日志的开关:
在代码里面我们可以通过 #ifdef Debug TODO.. # endif
语句来做些事情。
在开发过程中需要注意内存的回收,开发完成后可以用 Android Monitor 检查下 :
如果开发的时候又依赖与其他预构建动态链接库,在 Java 中 loadLibrary 时需要注意顺序,一般手机都是没问题的,但是 Android 百家争鸣,部分手机如果你不顺序加载将会 crash,顺序加载的意思其实就是比如 a.so 里面用 b.so, b.so 里面又用了 c.so ,那么加载顺序为 c->b->a。
和 Java 层一样,要么断点,要么日志,日志方法历史悠久,现在来看看 Debug 断点。只需要开启一个工具 LLDB:
在需要调式的时候点击 Run->Aattach debugger Android to process->Auto/Native 即可:
需要注意,不需 Native 调试的时候选择 Java ,不然非常慢...。
上面介绍了案例中的部分实现与原理进行了阐述,下面介绍一个完整的案例,先看 2.x demo GIF 图:
这个工程包含两个项目,一个是 1.x 版本,一个是 2.x 版本,1.x 版本代码包括 SO 库在内,大部分来自 Vitamio 的一个免费项目,其 Java 层是开源的,但历史悠久笔者也只能维护 Java 层代码,或者利用 FFmpeg 接口实现更多功能,于是后来笔者便重写了一套底层代码,2.x 就产生了,下面对比下目前两版本的功能:
1.x | 2.x | |
---|---|---|
执行 FFmpeg 命令 | √ | √ |
自定义 FFmpeg | × | √ |
自定义各种录制尺寸 | √ | √ |
录制帧率控制 | √ | √ |
录制比特率控制 | √ | √ |
本地视频压缩 | √ | √ |
本地压缩码率模式选择 | √ | √ |
录制码率模式选择 | √ | × |
本地视频缩放压缩 | √ | √ |
录制暂停 | √ | √ |
暂停录制时摄像头切换 | √ | × |
SO 开源性 | 不开源 | 开源 |
targetAPI 要求 | <=22 | 无限制 |
ABI 支持 | arm系 | 全部 |
全屏录制 | × | √ |
https://github.com/mabeijianxi/small-video-record