上一篇文章實現了 FFmpeg 編譯及 Android 端的簡單調用,成功獲取了 FFmpeg 支持的編解碼信息,而在實際使用時,需要調用 FFmpeg 內部函數,或通過命令行方式調用,但后者簡單很多。
怎么讓 FFmpeg 運行命令呢?很簡單,調用 FFmpeg 中執行命令的函數即可,這個函數位于源碼的 ffmpeg.c 文件中:
int main(int argc, char **argv)
我們的目的很簡單:將 FFmpeg 命令傳遞給 main 函數并執行。而這個傳遞過程需要編寫底層代碼實現,在這個底層接口代碼中,接收上層傳遞過來的 FFmpeg 命令 ,然后調用 ffmpeg.c 中的 main 函數執行該命令。
開始集成之前,首先回顧一下 JNI 標準接入步驟:
- 編寫帶有 native 方法的 Java 類
- 生成該類擴展名為 .h 的頭文件
- 創建該頭文件的 C/C++ 文件,實現 native 方法
- 將該 C/C++ 文件編譯成動態鏈接庫
- 在Java 程序中加載該動態鏈接庫
接下來按照此步驟開始集成,實現 Android 端以命令方式調用 FFmpeg ,這里假設你已經編譯過 FFmpeg 源碼,具體編譯方法可查看本系列第一篇。如果你是新手或對 Android 端集成底層庫不太熟悉,強烈建議先閱讀本系列第一篇 Android 集成 FFmpeg (一)基礎知識及簡單調用 。
首先新建一個文件夾 ndkBuild 作為工作空間,在 ndkBuild 目錄下新建 jni 文件夾, 作為編譯工作目錄。
1. 編寫帶有 native 方法的 Java 類
package com.jni;
public class FFmpegJni {
public static native int run(String[] commands);
}
2. 生成該類擴展名為 .h 的頭文件
在 Android Studio 的 Terminal 中 切換到 java 目錄下,運行 javah 命令生成頭文件:
可以看到在 java 目錄下生成了頭文件:
然后將此頭文件剪切到 jni 目錄下。
3. 創建該頭文件的 C/C++ 文件,實現 native 方法
在 jni 目錄下創建對應的 C 文件 com_jni_FFmpegJni.c :
#include "android_log.h"
#include "com_jni_FFmpegJni.h"
#include "ffmpeg.h"
JNIEXPORT jint JNICALL Java_com_jni_FFmpegJni_run(JNIEnv *env, jclass obj, jobjectArray commands) {
int argc = (*env)->GetArrayLength(env, commands);
char *argv[argc];
int i;
for (i = 0; i < argc; i++) {
jstring js = (jstring) (*env)->GetObjectArrayElement(env, commands, i);
argv[i] = (char*) (*env)->GetStringUTFChars(env, js, 0);
}
LOGD("----------begin---------");
return main(argc, argv);
}
函數的主要作用就是將 Java 端傳遞過來的 jobjectArray 類型的 FFmpeg 命令,轉換為 main 函數所需要的參數 argc 和 argv ,然后調用之。為了將日志輸出函數簡化為簡潔的 "LOGD"、 "LOGE",需要在同級目錄下新建 android_log.h 文件:
#ifdef ANDROID
#include <android/log.h>
#ifndef LOG_TAG
#define MY_TAG "MYTAG"
#define AV_TAG "AVLOG"
#endif
#define LOGE(format, ...) __android_log_print(ANDROID_LOG_ERROR, MY_TAG, format, ##__VA_ARGS__)
#define LOGD(format, ...) __android_log_print(ANDROID_LOG_DEBUG, MY_TAG, format, ##__VA_ARGS__)
#define XLOGD(...) __android_log_print(ANDROID_LOG_INFO,AV_TAG,__VA_ARGS__)
#define XLOGE(...) __android_log_print(ANDROID_LOG_ERROR,AV_TAG,__VA_ARGS__)
#else
#define LOGE(format, ...) printf(MY_TAG format "\n", ##__VA_ARGS__)
#define LOGD(format, ...) printf(MY_TAG format "\n", ##__VA_ARGS__)
#define XLOGE(format, ...) fprintf(stdout, AV_TAG ": " format "\n", ##__VA_ARGS__)
#define XLOGI(format, ...) fprintf(stderr, AV_TAG ": " format "\n", ##__VA_ARGS__)
#endif
其中 XLOGD 和 XLOGE 方法是為了將 FFmpeg 內部日志信息自動輸出到 logcat,后面會用到。除 android_log.h 之外,很顯然,還需要添加 ffmpeg.c 、ffmpeg.h 文件,實際上 ffmpeg.c 的 main 函數中還會調用到其他文件,所以需要從源碼中拷貝 ffmpeg.h、ffmpeg.c、ffmpeg_opt.c、ffmpeg_filter.c、cmdutils.c、cmdutils.h 以及 cmdutils_common_opts.h 共 7 個文件到 jni 目錄下。
此時 jni 目錄下應該有以下 10 個文件:
接下來還要修改 ffmpeg.c 、cmdutils.c 以及 cmdutils.h 三個文件使其適用于 Android 端調用,按功能分為以下三點:
1.日志輸出到 logcat (修改 ffmpeg.c)
在執行命令過程中,FFmpeg 內部的日志系統會輸出很多有用的信息,但是在 Android 的 logcat 中是看不到的,所以需要修改源碼將 FFmpeg 內部日志輸出 logcat 中,方便調試,其實這是十分必要的。修改方法很簡單,只需修改 ffmpeg.c 文件三處:
引入 android_log.h 頭文件:
#include "android_log.h"
修改 log_callback_null 方法為下:(原方法為空)
static void log_callback_null(void *ptr, int level, const char *fmt, va_list vl)
{
static int print_prefix = 1;
static int count;
static char prev[1024];
char line[1024];
static int is_atty;
av_log_format_line(ptr, level, fmt, vl, line, sizeof(line), &print_prefix);
strcpy(prev, line);
if (level <= AV_LOG_WARNING){
XLOGE("%s", line);
}else{
XLOGD("%s", line);
}
}
設置日志回調方法為 log_callback_null:(main 函數開始處)
int main(int argc, char **argv)
{
av_log_set_callback(log_callback_null);
int i, ret;
......
2.執行命令后清除數據(修改 ffmpeg.c)
由于 Android 端執行一條 FFmpeg 命令后并不需要結束進程,所以需要初始化相關變量,否則執行下一條命令時就會崩潰。首先找到 ffmpeg.c 的 ffmpeg_cleanup 方法,在該方法的末尾添加以下代碼:
nb_filtergraphs = 0;
nb_output_files = 0;
nb_output_streams = 0;
nb_input_files = 0;
nb_input_streams = 0;
然后在 main 函數的最后調用 ffmpeg_cleanup 方法,如下:
......
ffmpeg_cleanup(0);
return main_return_code;
}
3.執行結束后不結束進程(修改 cmdutils.c、cmdutils.h)
FFmpeg 在執行過程中出現異常或執行結束后會自動銷毀進程,而我們在 Android 中調用時,只想讓它作為一個普通的方法,不需要銷毀進程,只需要正常返回就可以了,這就需要修改 cmdutils.c 中的 exit_program 方法,源碼中為:
void exit_program(int ret)
{
if (program_exit)
program_exit(ret);
exit(ret);
}
修改為:
int exit_program(int ret)
{
return ret;
}
此處修改了方法的返回值類型,所以還需要修改對應頭文件中的方法聲明,即將 cmdutils.h 中的:
void exit_program(int ret) av_noreturn;
修改為:
int exit_program(int ret);
到這里需要修改項都已修改完畢,網上教程實現 FFmpeg 內部日志輸出到 logcat 的并不多,但這一步是十分有必要的。很多教程中需要將 ffmpeg 中的 main 方法名字修改為 "run" 、"exec" 等等,其實完全沒必要,為什么要對方法名這么在意,乃至不惜徒增新手學習的復雜度呢? 我不知道修改的原因和意義所在。 有些教程中需要把 config.h 文件也拷貝到 jni 目錄下,而我并沒有拷貝,那么到底需不需要呢?FFmpeg 的命令數不勝數,我只能說我執行過的命令都不需要拷貝 config.h ,盡管源碼 ffmpeg.c 中就聲明了引入 config.h 文件。
4. 將該 C/C++ 文件編譯成動態鏈接庫
在 jni 目錄下創建 Android.mk 文件 :
LOCAL_PATH:= $(call my-dir)
#編譯好的 FFmpeg 頭文件目錄
INCLUDE_PATH:=/home/yhao/sf/ffmpeg-3.3.3/Android/arm/include
#編譯好的 FFmpeg 動態庫目錄
FFMPEG_LIB_PATH:=/home/yhao/sf/ffmpeg-3.3.3/Android/arm/lib
include $(CLEAR_VARS)
LOCAL_MODULE:= libavcodec
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavcodec-57.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE:= libavformat
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavformat-57.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE:= libswscale
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libswscale-4.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE:= libavutil
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavutil-55.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE:= libavfilter
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavfilter-6.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE:= libswresample
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libswresample-2.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE:= libpostproc
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libpostproc-54.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE:= libavdevice
LOCAL_SRC_FILES:= $(FFMPEG_LIB_PATH)/libavdevice-57.so
LOCAL_EXPORT_C_INCLUDES := $(INCLUDE_PATH)
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := ffmpeg
LOCAL_SRC_FILES := com_jni_FFmpegJni.c \
cmdutils.c \
ffmpeg.c \
ffmpeg_opt.c \
ffmpeg_filter.c
LOCAL_C_INCLUDES := /home/yhao/sf/ffmpeg-3.3.3
LOCAL_LDLIBS := -lm -llog
LOCAL_SHARED_LIBRARIES := libavcodec libavfilter libavformat libavutil libswresample libswscale libavdevice
include $(BUILD_SHARED_LIBRARY)
此處使用的 FFmpeg 為本系列上篇文章中的編譯配置,其中引入了 libmp3lame 庫以支持 mp3 格式編碼,與上篇文章不同的是最后對 ffmpeg 動態庫的編譯,加入了 ffmpeg.c 、cmdutils.c 等文件。
然后在 jni 目錄下創建 Application.mk 文件:
APP_ABI := armeabi-v7a
APP_PLATFORM=android-14
NDK_TOOLCHAIN_VERSION=4.9
這時 jni 目錄應該有以下 12 個文件:
一切準備就緒,在 jni 目錄下運行 ndk 編譯命令:
ndk-build
然后就可以在 ndkBuild 目錄下看到生成的 libs 和 obj 文件夾了。
5.在Java 程序中加載該動態鏈接庫
將 libs 目錄下生成的 armeabi-v7a 動態庫拷貝到 Android 工程中,此時工程應該是這樣的:
在 FFmpegJni.java 中加載動態庫:
package com.jni;
public class FFmpegJni {
static {
System.loadLibrary("avutil-55");
System.loadLibrary("avcodec-57");
System.loadLibrary("avformat-57");
System.loadLibrary("avdevice-57");
System.loadLibrary("swresample-2");
System.loadLibrary("swscale-4");
System.loadLibrary("postproc-54");
System.loadLibrary("avfilter-6");
System.loadLibrary("ffmpeg");
}
public static native int run(String[] commands);
}
記得在應用的 build.gradle 文件中 android 節點下添加動態庫加載路徑:
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}
OK,至此集成工作就全部完成了,在程序中調用 run 方法,就能以命令方式調用 FFmpeg 。
接下來以剪切 mp3 文件為例,驗證是否集成成功,首先需要準備一個 mp3 文件,這里我提供兩首歌:泡沫、童話鎮,下載解壓后直接將其放到手機根目錄下即可。
直接給出 MainActivity 代碼:
import com.jni.FFmpegJni;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
if ( ActivityCompat.checkSelfPermission(this,
Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ) {
ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE }, 1);
}
}
public void run(View view) {
String dir = Environment.getExternalStorageDirectory().getPath() + "/ffmpegTest/";
//ffmpeg -i source_mp3.mp3 -ss 00:01:12 -t 00:01:42 -acodec copy output_mp3.mp3
String[] commands = new String[10];
commands[0] = "ffmpeg";
commands[1] = "-i";
commands[2] = dir+"paomo.mp3";
commands[3] = "-ss";
commands[4] = "00:01:00";
commands[5] = "-t";
commands[6] = "00:01:00";
commands[7] = "-acodec";
commands[8] = "copy";
commands[9] = dir+"paomo_cut_mp3.mp3";
int result = FFmpegJni.run(commands);
Toast.makeText(MainActivity.this, "命令行執行完成 result="+result, Toast.LENGTH_SHORT).show();
}
}
記得在 AndroidManifest.xml 中聲明權限:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
運行之后,播放生成的 paomo_cut_mp3 試試,剪切成功~ 但是一個例子不夠過癮,再來一個延時播放的命令:
String[] commands = new String[12];
commands[0] = "ffmpeg";
commands[1] = "-i";
commands[2] = dir+"paomo.mp3";
commands[3] = "-filter_complex";
commands[4] = "adelay=5000|5000";
commands[5] = "-ac"; //聲道數
commands[6] = "1";
commands[7] = "-ar"; //采樣率
commands[8] = "24k";
commands[9] = "-ab"; //比特率
commands[10] = "32k";
commands[11] = dir+"adelay_output.mp3";
更簡單的方法實現
本文的核心工作是 ffmpeg.c 等文件的修改及編譯,其實這些文件的修改是一勞永逸的。無論你的 FFmpeg 如何配置編譯選項,不管是支持 mp3 編碼還是支持 h264 編碼,對 ffmpeg.c 等文件的修改內容都是固定的,所以我把這個工作目錄上傳到了 github ,方便大家直接拿來使用。
github 地址 : https://github.com/yhaolpz/ffmpeg-command-ndkBuild
接下來演示一下如何使用,首先要將 ffmpeg-command-ndkBuild 克隆到你的電腦上,注意這里默認你已經編譯過 FFmpeg,編譯方法見本系列第一章。
- 編寫帶有 native 方法的 Java 類
package com.jni;
public class FFmpegJni {
public static native int run(String[] commands);
}
如果你編寫的 Java 類跟上面這個類的包名、類名和方法都相同,那就直接跳到第 4 步,因為你可以直接使用 jni 目錄中的 com_jni_FFmpegJni.c 和 com_jni_FFmpegJni.h 。
- 生成該類擴展名為 .h 的頭文件
在 Android Studio 的 Terminal 中 切換到 Java 目錄下,運行 javah 命令生成頭文件:
javah -classpath . com.包名.類名
將該頭文件拷貝到 jni 目錄下,并且刪除 com_jni_FFmpegJni.h 文件。
- 創建該頭文件的 C/C++ 文件,實現 native 方法
這里不需要重新創建 C 文件,直接在 com_jni_FFmpegJni.c 基礎上修改即可。
- 修改第2行 com_jni_FFmpegJni.h 為你自己的頭文件名
- 修改 Java_com_jni_FFmpegJni_run 方法名為你自己的頭文件中的方法名
- 修改 com_jni_FFmpegJni.c 文件名為你自己的頭文件名
- 修改 Android.mk 中文件最后的 com_jni_FFmpegJni.c 為你自己的 C 文件名
- 將該 C/C++ 文件編譯成動態鏈接庫
- 修改 Android.mk 中的 INCLUDE_PATH 、FFMPEG_LIB_PATH 為你自己編譯好的 FFmpeg 動態庫路徑
- 修改 Android.mk 倒數第四行的 LOCAL_C_INCLUDES 為你自己的 FFmpeg 源碼路徑。
OK~ 直接運行 ndk-build 命令編譯吧,最后一步 "在Java 程序中加載該動態鏈接庫" 以及調用案例在上文中已經描述的很詳細了,就不再贅述了。
總結
本文延續第一篇的規則,將編譯工作置于 Android 工程之外進行,直到生成最后可用的 so 庫再移植到 Android 工程中,我相信這樣對 jni 的理解會更清晰一些。
本文中還有兩個問題待解決,在 Application.mk 文件中通過 NDK_TOOLCHAIN_VERSION=4.9 指定編譯器為 4.9 版本的 gcc,若不指定,將默認使用 clang 編譯器,這時編譯會報錯,提示缺少一些文件。第二個問題就是 Application.mk 中通過 APP_ABI := armeabi-v7a 指定生成 armv7 架構動態庫,若改成 armeabi 架構編譯則會報錯,提示不支持該模式,后續盡快解決這兩個問題。