我们之后就以小Demo来学音视频的知识。
因为音视频开发一定会使用JNI,即使用Java调用本地代码/本地代码调用Java。
所以我们也要学会去建立一个 JNI项目。
1. 使用Android Studio 3.4.2搭建一个JNI项目
在Android Studio创建好一个支持C/C++的项目后,我们创建一个 Mp3Encoder 的类,使用它来调用本地方法:
public class Mp3Encoder {
static {
System.loadLibrary("mp3_encode");
}
public native void encode();
}
接下来,通过命令行,进入到该class的目录下, 执行 javac Mp3Encoder.java
:
这个时候文件目录下,就会都多出一个 Mp3Encoder.class
接下来是关键的一步,对这个.class进行导出,声明其为一个 JNI接口。
>javah -jni com.rikkatheworld.mp3encoder.studio.Mp3Encoder
但是却报: 错误: 找不到 ‘com.rikkatheworld.mp3encoder.studio.Mp3Encoder’ 的类文件。
这里必须要先设置好导出头文件的路径:
然后再在java的路径下(注意一定要在编译好的文件的父目录路径下):
导出成功,这个时候文件夹下面多了一个头文件:
我们在java的路径下,通过右键->new->Folder->JniFolder 建立一个JNI文件,然后把这个头文件拖入到jni目录下。而之前那个.class文件就可以删除掉了
接下来我们在jni下新建一个cpp文件 Mp3Encoder,将刚刚生成文件的内容全部复制到这个文件下面,并且实现对应的方法,这里就是做一个日志打印.:
//
// Created by msn on 2019/11/17.
//
#include <jni.h>
#include <android/log.h>
#define LOG_TAG "Mp3Encoder"
#ifndef _Included_com_rikkatheworld_mp3encoder_studio_Mp3Encoder
#define _Included_com_rikkatheworld_mp3encoder_studio_Mp3Encoder
//宏定义 一个日志打印
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#ifdef __cplusplus
extern "C" {
#endif
JNIEXPORT void JNICALL Java_com_rikkatheworld_mp3encoder_studio_Mp3Encoder_encoder
(JNIEnv *, jobject) {
//这里就做一个打印日志
LOGI("encoder encode");
}
#ifdef __cplusplus
}
#endif
#endif
接着在 app build.gradle下加入:
android {
defaultConfig {
...
externalNativeBuild {
cmake {
//设置C++标准为默认
cppFlags ""
}
}
ndk {
//ndk模块名称
moduleName "mp3_encoder"
//声明log
ldLibs "log"
}
}
externalNativeBuild {
cmake {
//指定CMake文件路径
path "CMakeLists.txt"
}
}
}
接着我们在 app下面创建 CMakeLists.txt
,(以前是使用Android.mk,这个相当于代替它的)
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html
# Sets the minimum version of CMake required to build the native library.
cmake_minimum_required(VERSION 3.4.1)
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
add_library( # 输入ndk使用的名称
mp3_encoder
# Sets the library as a shared library.
SHARED
# 输入你的cpp的路径名.
src/main/jni/Mp3Encoder.cpp )
# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.
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)
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
target_link_libraries( # 输入你的ndk模块名
mp3_encoder
# Links the target library to the log library
# included in the NDK.
${log-lib})
最后我们在MianActivity下调用这个jni:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Mp3Encoder mp3Encoder = new Mp3Encoder();
mp3Encoder.encoder();
}
点击运行后输出:
此时,就完成我们第一个jni项目的构建啦~
文件目录如下:
2. 交叉编译的原理和实践
交叉编译是音视频开发中必需的,因为无论在哪个移动平台下开发,第三方库都是需要进行交叉编译的。
本节会从交叉编译的原理开始介绍,然后会在两个移动平台下编译出音视频开发常用的几个库,包括 X264、 FDK_AAC、LAME,最终将以LAME库为例进行实践,完成一个将音视频的PCM裸数据编码成MP3文件的实例,以此来证明交叉编译的重要性。
2.1 交叉编译的原理
所以交叉编译,就是 在一个平台(PC)上生成另外一个平台(Android、IOS)的可执行代码。
Q:Android为什么要进行交叉编译呢?
A:即使是Android设备具有越来越强的计算能力,但是有两个原因不能在 这种嵌入式设备上进行本地编译:
- 还是计算能力的问题,不够全面,不够极致
- ARM平台上没有较好的编译环境,这导致整个编译过程异常繁琐
所以大部分的嵌入式开发平台都是提供了 本身平台交叉编译所需要的交叉工具编译链(Android提供了 Eclipse SDK、Android Studio编译器),这样开发者就能在 PC上编译出可以运行在ARM平台下的程序了。
无论是自行安装PC上的编译器,还是下载其他平台的交叉编译链,它们都会提供下面几个工具:
- CC
编译器,对C源文件进行编译处理,生成汇编文件 - AS
将汇编文件生成目标文件 - AR
打包器,用于库操作 - LD
链接器,为前面生成的目标代码分配地址空间,将多个目标文件链接成一个库或者是可执行文件 - GDB
调试工具 - STRIP
最终生成的可执行文件或者库文件作为输入,然后消除掉其中的源码 - NM
查看静态库文件中的符号表 - Objdump
查看静态库或者动态库中的方法名
2.2 Android Studio平台交叉编译工具
在编译之前,我们先看看LAME、FDK_ACC等这些的概念简介:
- LAME
是目前非常优秀的一种MP3编译引擎,在业界,转码成 MP3格式的音频文件时,最常用的编码器就是LAME库。当达到320Kbits/s以上时,LAME编码出来的音频质量几乎可以CD的音质相媲美。并且保证整个音频文件的体积非常小。
因此若要在移动平台上编码 MP3文件,使用LAME便成为唯一选择。 - FDK_ACC
FDK_ACC 是用来编码和解码的AAC格式音频文件的开源库。 - X264
X264是一个开源的H.264/MPEG-4 AVC视频编码函数库,是最好的有损视频编码器之一。一般的输入的视频帧是YUV,输出是编码之后的 H264的数据包,并且支持 CBR、VBR模式,可以在编码的过程中直接改变码率的设置,这点在直播的场景中是非常实用的(直播场景下利用该特点可以做码率自适应)
了解完这些后,我们在来看看Android NDK下一些经常会用到的组件:
- ARM、x86的交叉编译器
- 构建系统
- Java原生接口文件
- C库
- Math库
- 最小的C++库
- ZLib压缩库
- POSIX线程
- Android日志库
- Android原生应用Api
- OpenGL ES库
- OpenSL ES库
2.3 AS交叉编译LAME
先去 传送门 下载好LAME的源码然后解压缩。
解压完后将 libmp3lame
文件夹下的所有的 带 .h
和带 .c
的 C/C++文件 和 include
下的lame.h
复制到 JNI目录下(最好再统一放到一个新的子目录下,这边就放到了 lame子目录下),因为添加了这么多的文件,那么需要把这些文件写入到CMake的 add_library
:
add_library( # Sets the name of the library.
mp3_encoder
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
src/main/jni/Mp3Encoder.cpp
src/main/jni/lame/bitstream.c src/main/jni/lame/encoder.c
src/main/jni/lame/fft.c src/main/jni/lame/gain_analysis.c
src/main/jni/lame/id3tag.c src/main/jni/lame/lame.c
src/main/jni/lame/mpglib_interface.c src/main/jni/lame/newmdct.c
src/main/jni/lame/presets.c src/main/jni/lame/psymodel.c
src/main/jni/lame/quantize.c src/main/jni/lame/quantize_pvt.c
src/main/jni/lame/reservoir.c src/main/jni/lame/set_get.c
src/main/jni/lame/tables.c src/main/jni/lame/takehiro.c
src/main/jni/lame/util.c src/main/jni/lame/vbrquantize.c
src/main/jni/lame/VbrTag.c src/main/jni/lame/version.c)
ok,lame的源码就已经添加到我们的项目中了。但是因为文件里面一些引入的路径已经变了,所以我们要对这些引入的路径进行更改:
- 删除
fft.c
文件的 47 行的 include“vector/lame_intrin.h” - 删除掉
set_get.h
的第24行 - 修改
util.h
文件的 570 行的extern ieee754_float32_t fast_log2(ieee754_float32_t x)
为extern float fast_log2(float x)
- 此时还有很多文件报错,因为没有定义宏 STDC_HEADERS ,在
build.gradle
中添加宏定义:cFlags “-DSTDC_HEADERS”:
点个锤子后,我们打开之前 写过的Mp3Encoder.java下,进行如下修改
public class Mp3Encoder {
static {
System.loadLibrary("mp3_encoder");
}
public native int init(String pcmFile,int audioChannels,int bitRate,int sampleRate, String mp3Path);
public native void encoder();
public native void destroy();
}
然后给其编译,然后javah(重复上一节的操作)
产生的新的 Mp3Encoder.h替换旧的,接着在 Mp3Encoder.cpp中重写方法,它作为JNI层,是被Java层调用的:
#include "mp3_encoder.h"
#include "com_rikkatheworld_mp3encoder_studio_Mp3Encoder.h"
Mp3Encoder *encoder = NULL;
extern "C" {
#define LOG_TAG "Mp3Encoder"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO,LOG_TAG,__VA_ARGS__)
//实例化Mp3Encoder,然后调用初始方法
JNIEXPORT jint JNICALL Java_com_rikkatheworld_mp3encoder_studio_Mp3Encoder_init
(JNIEnv *env, jobject, jstring pcmPathParam, jint channels, jint bitRate, jint sampleRate,
jstring mp3PathParam) {
const char *pcmPath = env->GetStringUTFChars(pcmPathParam, NULL);
const char *mp3Path = env->GetStringUTFChars(mp3PathParam, NULL);
encoder = new Mp3Encoder();
int ret = encoder->Init(pcmPath, mp3Path, sampleRate, channels, bitRate);
env->ReleaseStringUTFChars(mp3PathParam, mp3Path);
env->ReleaseStringUTFChars(pcmPathParam, pcmPath);
return ret;
}
JNIEXPORT void JNICALL Java_com_rikkatheworld_mp3encoder_studio_Mp3Encoder_encoder
(JNIEnv *, jobject) {
encoder->Encode();
}
JNIEXPORT void JNICALL Java_com_rikkatheworld_mp3encoder_studio_Mp3Encoder_destroy
(JNIEnv *, jobject) {
encoder->Destory();
}
}
我们在JNI层中调用了 native层的代码,我们要去 jni下创建两个文件 mp3_encoder.h
和 mp3_encoder.cpp
我们先来编写 mp3_encoder.h,定义变量和方法:
#ifndef MP3ENCODER_MP3_ENCODER_H
#define MP3ENCODER_MP3_ENCODER_H
#include "lame/lame.h"
extern "C" {
class Mp3Encoder {
private:
FILE *pcmFile;
FILE *mp3File;
lame_t lameClient;
public:
Mp3Encoder();
~Mp3Encoder();
int Init(const char *pcmFilePath, const char *mp3FilePath, int sampleRate, int channels,
int bitRat);
void Encode();
void Destory();
};
#endif //MP3ENCODER_MP3_ENCODER_H
}
接着我们编写 mp3_encoder.cpp
, 它会使用到 lame库 里的一些方法:
#include "mp3_encoder.h"
#include <jni.h>
extern "C"
/**
* 以二进制文件的方式打开PCM文件,以写入二进制文件的方式打开MP3文件,然后初始化LAME
*/
int
Mp3Encoder::Init(const char *pcmFilePath, const char *mp3FilePath, int sampleRate, int channels,
int bitRate) {
int ret = -1;
pcmFile = fopen(pcmFilePath, "rb");
if (pcmFile) {
mp3File = fopen(mp3FilePath, "wb");
if (mp3File) {
lameClient = lame_init();
lame_set_in_samplerate(lameClient, sampleRate);
lame_set_out_samplerate(lameClient, sampleRate);
lame_set_num_channels(lameClient, channels);
lame_set_brate(lameClient, bitRate);
lame_init_params(lameClient);
ret = 0;
}
}
return ret;
}
/**
* 函数主体是一个循环,每次都会读取一段bufferSize大小的PCM数据buffer,然后再编码该buffer
* 但是在编码buffer之前得把该buffer的左右声道拆分开,再送入到 lame编码器
* 最后将编码的数据写入到mp3文件中
*/
void Mp3Encoder::Encode() {
int bufferSize = 1024 * 256;
short *buffer = new short[bufferSize / 2];
short *leftBuffer = new short[bufferSize / 4];
short *rightBuffer = new short[bufferSize / 4];
unsigned char *mp3_buffer = new unsigned char[bufferSize];
size_t readBufferSize = 0;
while ((readBufferSize = fread(buffer, 2, bufferSize / 2, pcmFile)) > 0) {
for (int i = 0; i < readBufferSize; i++) {
if (i % 2 == 0) {
leftBuffer[i / 2] = buffer[i];
} else {
rightBuffer[i / 2] = buffer[i];
}
}
size_t wroteSize = lame_encode_buffer(lameClient, leftBuffer, rightBuffer,
(int) (readBufferSize / 2), mp3_buffer, bufferSize);
fwrite(mp3_buffer, 1, wroteSize, mp3File);
}
delete[] buffer;
delete[] leftBuffer;
delete[] rightBuffer;
delete[] mp3_buffer;
}
void Mp3Encoder::Destory() {
if (pcmFile) {
fclose(pcmFile);
}
if (mp3File) {
fclose(mp3File);
}
}
Mp3Encoder::Mp3Encoder() {
}
Mp3Encoder::~Mp3Encoder() {
}
这些代码其实我们不用特别的了解,只需了解并且跑通就可以了。
到这里,我们的 JNI层、Native层都编译完了。
接下来就是Java层的调用了,我们先下载一个 PCM文件到手机下,然后在MainActivity下这样写:
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
private String[] permissions = new String[]{
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
};
private List<String> mPermissionList = new ArrayList<>();
private static final int MY_PERMISSIONS_REQUEST = 1001;
//采样率,现在能够保证在所有设备上使用的采样率是44100Hz, 但是其他的采样率(22050, 16000, 11025)在一些设备上也可以使用。
public static final int SAMPLE_RATE_INHZ = 44100;
//声道数。CHANNEL_IN_MONO and CHANNEL_IN_STEREO. 其中CHANNEL_IN_MONO是可以保证在所有设备能够使用的。
public static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO;
//返回的音频数据的格式。 ENCODING_PCM_8BIT, ENCODING_PCM_16BIT, and ENCODING_PCM_FLOAT.
public static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
checkPermissions();
String pcmPath, mp3Path;
pcmPath = "/storage/emulated/0/16k.pcm";//pcm文件路径,文件要存在!
mp3Path = "/storage/emulated/0/16k1.mp3";//转换后mp3文件的保存路径
Mp3Encoder mp3Encoder = new Mp3Encoder();
if (mp3Encoder.init(pcmPath, CHANNEL_CONFIG, 128, SAMPLE_RATE_INHZ, mp3Path) == 0) {
Log.d(TAG, "onCreate: encoder-init:success");
mp3Encoder.encoder();
mp3Encoder.destroy();
Log.d(TAG, "onCreate:encode finish");
} else {
Log.d(TAG, "onCreate: encoder-init:failed");
}
}
private void checkPermissions() {
// Marshmallow开始才用申请运行时权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
for (int i = 0; i < permissions.length; i++) {
if (ContextCompat.checkSelfPermission(this, permissions[i]) !=
PackageManager.PERMISSION_GRANTED) {
mPermissionList.add(permissions[i]);
}
}
if (!mPermissionList.isEmpty()) {
String[] permissions = mPermissionList.toArray(new String[mPermissionList.size()]);
ActivityCompat.requestPermissions(this, permissions, MY_PERMISSIONS_REQUEST);
}
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
if (requestCode == MY_PERMISSIONS_REQUEST) {
for (int i = 0; i < grantResults.length; i++) {
if (grantResults[i] != PackageManager.PERMISSION_GRANTED) {
Log.i(TAG, permissions[i] + " 权限被用户禁止!");
}
}
}
}
}
如果顺畅跑完,则会输出结果:
这样就可以去听一下解析出来的MP3文件啦。
3. 总结
从这么一个小小的demo中,我们就已经明确看出了 NDK开发中 三层。
- Java层
Java层的代码就只有native
的声明,以及loadLibrary()
加载so库。
然后在使用的时候调用声明出来的函数就可以了,不涉及实现。 - JNI层
JNI有两个重要的文件:
一个是由Java 有声明native
的类 通过javac
编译、javah
导出而生成的 头文件
另外一个是 根据该头文件而编写的 同名.cpp文件,它就是一个中间态,它被Java调用后,就去调用 native层的代码。
它几乎也没有什么实现。 - Native层
native层充满着 .h头文件,和 .cpp文件,他们都时都是实现特定的功能和写出来的。
Jni层调用的就是这一些文件里面的方法。