前言與環境說明
隨着 FFmpeg、NDK 與 Android Studio 的不斷迭代,本文可能也會像我參考過的過期文章一樣失效(很遺憾),但希望本文中提到的問題排查以及步驟說明能夠幫到你,如果發現了文章中的謬誤以及不足之處也歡迎你提供建議與指正,十分感謝🙏。
初步目標是使用 FFmpeg 實現 Android 內簡單的視頻剪輯、添加背景音樂、添加字幕等功能,由於本人初學 Android 開發,能力有限,基礎薄弱,無法較為全面地深入學習過程中遇到的問題,文章中可能摻雜有一些知其然而不知其所以然的部分或一些不恰當不精確的個人理解,還請見諒🙇。
設備:macOS Big Sur 11.6 (Apple Silicon M1)
FFmpeg 版本:4.4
開發環境:
- Android Studio Arctic Fox | 2020.3.1 Patch 3 arm64 preview
- JavaVersion =
1.8
- minSdk :
21
- NDK Version :
23.1.7779620
- 目前 NDK 在蘋果芯片下仍只能使用 Rosetta 2 轉譯后進行使用
- CMake Version :
3.22.0-rc2
- 目前從 Android Studio 內 SDK Manager 中所能取得的最新版為
3.18.1
- CMake 已在
3.19.3
版本后提供對蘋果芯片的支持
- 目前從 Android Studio 內 SDK Manager 中所能取得的最新版為
- Gradle Version:
7.0.3
前置知識准備
在實際上手前,閱讀了 Android 與 Java 的官方開發文檔與幾篇優秀的相關文章,按照自己的理解和知識水平,整理了一些概念的基本且淺顯的解釋,方便理解下一步要進行的操作。
-
Native 層
-
JNI
-
NDK
-
交叉編譯、構建系統與 CMake
-
ABI 與動態鏈接庫
-
FFmpeg
Android 系統的 Native 層
雖然 Android 系統的許多 API 使用 Java 開發,但許多核心 Android 系統組件和服務(如 ART 和 HAL 等)由 C/C++ 寫成,需要以 C/C++ 編寫的 Native 庫。因此 Android 除了提供開發 Java 代碼所需的 JDK (Java Development Kit) 之外,還提供了供開發者進行 Native 層開發的 NDK (Native Development Kit)。
Java 運行於 Java 虛擬機之上,因而實現了易移植、可跨平台運行等特性,但這也使得 Android 需要依賴一些「Native」的代碼來訪問系統底層,去完成一些 Java 實現不了的任務。也正因如此,C/C++ 這類「原生」的語言也使 Android 程序喪失了跨平台這一特性,在為 Android 編譯 C/C++ 程序時需考慮目標機器所使用的 CPU 架構、操作系統版本等。
JNI
JNI 即 Java Native Interface,是 Java 提供用來與其他語言編寫的程序通信的接口,之中定義了 Java 字節碼與 Native 代碼的交互方式。這里我們通過 NDK 來使用 JNI,從而實現 Android 程序中 Java 代碼與 C/C++ 代碼的相互調用。
這里記錄一些遇到的問題和自己認為可以暫時過掉的一些 quick answer:
-
- 靜態庫中的代碼在編譯后直接進入可執行文件中,而動態鏈接庫則是將代碼包含在程序外的庫文件中,在運行時被程序所調用,不能單獨執行
-
- NDK 中提供的用於交叉編譯 C/C++ 代碼的一系列工具
-
- CMake 根據 CMakeLists.txt 配置文件來生成一個指導工具鏈進行編譯的標准構建文件,隨后工具鏈便可根據構建文件將源代碼編譯成動態鏈接庫
- 當我們編譯一個 .c 文件的時候,我們可以直接將其丟進 gcc 中編譯;但當我們需要編譯一個項目的一系列 .c 文件時,一股腦丟進去編譯顯然就會大亂套了,於是我們需要一個構建系統來管理這個項目的編譯。在 Windows 下我們使用 Visual Studio 的 .sln 文件,macOS 下我們使用 Xcode 的 .xcodeproj 文件,Linux 下我們可以使用 Make 的 Makefile 文件。這些構建系統的構建文件可以指導編譯器或編譯工具鏈來編譯 .c 文件。
-
- Ninja 是一個專注於編譯速度的構建系統,用了大家都說好
NDK
NDK 即 Native Development Kit,在這里可以讓我們在 Android 開發中使用 C/C++ 語言編寫而成的庫。
在 Android 開發中,我們應當先在 Java 文件中編寫 Native 方法,然后在 C/C++ 文件中實現 Native 方法,接着使用 NDK 的工具鏈將 C/C++ 代碼編譯成動態鏈接庫,然后使用 Android Studio 的 Gradle 將我們編譯好的庫打包到 APK 中。隨后在運行程序時,Java 代碼就可以通過 Java 原生接口 (JNI) 框架調用庫中的 Native 方法。
交叉編譯、構建系統與 CMake
交叉編譯 (Cross Compile),指在與目標機器不同處理器架構的編譯機器上,編譯出適合目標機器架構運行的程序,我們如果要在 x86_64 平台的 PC 中編譯出運行於 arm 架構的 Android 設備中的 C/C++ 程序,就需要用到交叉編譯工具鏈 (Toolchain),即用於交叉編譯的一系列工具。這里我們使用 NDK 提供的默認工具鏈(從 r19 版本之后開始,NDK 不再支持獨立工具鏈)。
當我們編譯一個 .c 文件的時候,我們可以直接將其丟進 gcc 中編譯;但當我們需要編譯一個項目的一系列 .c 文件或整合已有的庫時,一股腦丟進去編譯顯然就會大亂套了,於是我們需要一個構建系統來管理這個項目的編譯。例如在 Windows 下有 Visual Studio 的 .sln 文件,macOS 下有 Xcode 的 .xcodeproj 文件,Unix 下可以使用 Make 的 Makefile 文件或 Ninja 的 .ninja 文件等等。
這些構建系統的構建文件可以指導編譯器或編譯工具鏈來編譯整個項目。像 Makefile 或者 .ninja 這樣的較為簡單的構建系統文件,我們可以嘗試手寫一份進行構建,但當我們的構建以及編譯要涉及跨平台交叉編譯時,我們便要針對不同的目標平台編寫不同的文件,因此目前更通用的做法是使用像 CMake 這樣更高等級的構建系統來生成這些構建文件。
CMake 是 Cross platform Make 的簡寫。CMake 是一個開源的跨平台編譯工具(又被稱為「元構建系統」),其可以根據 CMakeLists.txt 配置文件來生成一個指導工具鏈進行編譯的標准構建文件(不同平台下可選擇生成不同構建系統的構建文件),隨后工具鏈便可根據該構建文件將源代碼編譯成動態鏈接庫。
Android Studio 推薦使用 CMake + Ninja + NDK 內置工具鏈來進行 Native 庫開發。
ABI
ABI 即應用二進制接口 (Application Binary Interface)。ABI 中包含以下信息
-
可使用的 CPU 指令集(和擴展指令集)。
-
運行時內存存儲和加載的字節順序。Android 始終是 little-endian(小端法)。
-
在應用和系統之間傳遞數據的規范(包括對齊限制),以及系統調用函數時如何使用堆棧和寄存器。
-
可執行二進制文件(例如程序和共享庫)的格式,以及它們支持的內容類型。
-
如何重整 C++ 名稱。
當我們編寫 Java 代碼時,由於 Java 運行在 Java 虛擬機上,我們無需關心設備具體的硬件條件、架構或 CPU,但當我們需要在 Android 程序中使用 Native 代碼時,由於不同的 Android 設備使用不同的 CPU,而不同的 CPU 支持不同的指令集,CPU 與指令集的每種組合都有專屬的 ABI。因此我們需要針對不同的 Android ABI,構建並編譯出適應於不同 ABI 的 .so 動態鏈接庫。
當我們將這些為不同 ABI 所編譯的庫打包成 APK 時,這些 APK 自然也是只有特定 ABI 的 Android 設備才能安裝使用的。例如:蘋果芯片支持的 arm64-v8a 鏡像無法安裝專門為 armeabi-v7a 編譯的 APK 包,我們在編譯的時候可以在 Gradle 的 ndk.abiFilters
參數中控制要編譯打包何種 ABI 的庫。
FFmpeg
FFmpeg 是一套 C 語言下開發的開源、跨平台的音視頻錄制、轉碼及流處理的完整解決方案,被不少開源項目所使用。
經過前述文字的梳理,想必已經對 Android 下使用 Native 庫的的基本邏輯與行為有了一定的理解,我們再進行梳理:
- 編寫 CMakeLists.txt ,將 C/C++ 代碼與引入的 FFmpeg 庫加入到項目中,並鏈接到一起。
- 在 Java 類中編寫並調用 Native 方法
- 在 C/C++ 代碼中實現 Native 方法,Native 方法調用 FFmpeg 庫
- 使用 CMake + Ninja 與 NDK 工具鏈將 C/C++ 代碼以及引入的 FFmpeg 庫編譯成動態鏈接庫
- Gradle 將動態鏈接庫打包進 APK 中
編譯 FFmpeg
先下載一份 FFmpeg 源碼 進行編譯,你可以選擇別人編譯好的 FFmpeg build 或者使用別人寫好的編譯腳本,省去不少麻煩的同時跳過這一步,這里推薦 FFmpegKit。
Android 工程中只支持導入 .so 結尾的動態庫,形如:libavcodec-57.so
。但是 FFmpeg 編譯生成的動態庫默認格式為 xx.so.版本號 ,形如:libavcodec.so.57
, 所以需要修改 FFmpeg 根目錄下的 configure 文件,使其生成以 .so 結尾格式的動態庫:
# 將 configure 文件中 build settings 下的:
SLIBNAME_WITH_MAJOR='$(SLIBNAME).$(LIBMAJOR)'
LIB_INSTALL_EXTRA_CMD='$$(RANLIB) "$(LIBDIR)/$(LIBNAME)"'
SLIB_INSTALL_NAME='$(SLIBNAME_WITH_VERSION)'
SLIB_INSTALL_LINKS='$(SLIBNAME_WITH_MAJOR) $(SLIBNAME)'
#替換為:
SLIBNAME_WITH_MAJOR='$(SLIBPREF)$(FULLNAME)-$(LIBMAJOR)$(SLIBSUF)'
LIB_INSTALL_EXTRA_CMD='$$(RANLIB)"$(LIBDIR)/$(LIBNAME)"'
SLIB_INSTALL_NAME='$(SLIBNAME_WITH_MAJOR)'
SLIB_INSTALL_LINKS='$(SLIBNAME)'
FFmpeg 已經為我們准備好了 Makefile 可以直接用於構建,還為我們提供了 configure 程序可以調節編譯的設置,configure 提供許多參數可供選擇,如編譯模塊,目標平台、編譯工具鏈等等,通常的做法是編寫一份腳本進行設置與構建,我們在目錄下新建一個 build.sh 腳本文件。
在編譯的過程中,由於自己技術水平過低,照抄別人的攻略腳本的過程中走了不少彎路。
這里是本人用於在 macOS 下編譯 arm64-v8a 的 FFmpeg 使用的腳本。請務必根據說明與自己的工具鏈情況進行修改。如果在編譯過程中遇到問題,一定要先查 log 以及翻閱官方文檔,此處參照的 文檔。
NDK_ROOT= #NDK 根目錄
TOOLCHAIN=$NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64
#工具鏈目錄 目前 NDK 在 M1 還只能在 Rosseta 轉譯下使用 x64 的工具鏈
export PATH=$PATH:$TOOLCHAIN/bin
target_arch=aarch64
target_host=aarch64-linux
#編譯目標平台
toolchain_prefix=$target_host-android21
#在 configure 中定義了新變量
#target_arch=arm
#target_host=armv7a-linux
#toolchain_prefix=$target_host-androideabi21
#這里是編譯armv7的選項
#這里的變量設置以及接下來對 configure 的編輯非常重要
#如果照抄之前的過期博文(或此文)設置腳本會導致編譯失敗
#詳見下面的分析
PREFIX= #編譯輸出路徑
ANDROID_API=21 #最小API
./configure \
--prefix=$PREFIX \
#設定輸出路徑
--enable-shared \
#生成動態鏈接庫
--disable-static \
#不生成靜態庫
--enable-postproc \
--enable-jni \
--enable-cross-compile \
#啟用交叉編譯
--extra-cflags="-D__ANDROID__API__=21 -U_FILE_OFFSET_BITS" \
--cross-prefix=$target_host- \
#設定交叉編譯目標前綴
--cross_prefix_clang=$toolchain_prefix- \
--arch=$target_arch \
#設定目標框架
--target-os=android \
#設定目標平台系統 iOS = darwin
--sysroot=$TOOLCHAIN/sysroot
#設定sysroot目錄
make clean
make -j4
make install
說明:
在編寫腳本前,請先 cd
到工具鏈 bin
目錄下,ls
查看工具鏈程序的文件名格式,在本人使用的 NDK 23.1.7779620 darwin 工具鏈中情況如下:
……
aarch64-linux-android-as
aarch64-linux-android21-clang
aarch64-linux-android21-clang++
aarch64-linux-android22-clang
aarch64-linux-android22-clang++
aarch64-linux-android23-clang
aarch64-linux-android23-clang++
aarch64-linux-android24-clang
aarch64-linux-android24-clang++
aarch64-linux-android26-clang
aarch64-linux-android26-clang++
aarch64-linux-android27-clang
aarch64-linux-android27-clang++
aarch64-linux-android28-clang
………
llvm-ar
llvm-as
llvm-cfi-verify
llvm-config
llvm-cov
llvm-cxxfilt
llvm-dis
llvm-dwarfdump
llvm-dwp
llvm-lib
llvm-link
llvm-lipo
llvm-modextract
llvm-nm
……
可以看到,NDK 提供的 clang 都是帶有 Android 版本號前綴的,此時打開 configure 文件的源碼,搜索到 if test "$target_os" = android
這一行,查看 Android 編譯設置,可以發現許多問題:
-
這里的文件名全部設置成以我們輸入的
cross_prefix
為前綴,但經過我們的查看,我們的文件名前綴實際上是形如aarch64-linux-android21
這樣${cross_prefix}-android+版本號
的格式。 -
這里將
cc_default
重寫為了 clang,但沒有重寫cxx_default
。 -
這里的
strip
、ar
、pkg-config
與nm
工具也設置成了以cross_prefix
為前綴,但實際上,可以看到我們的幾個文件名前綴實際上是llvm-
,在 Android 官方文檔中也可以得知 binutils 工具(例如ar
和strip
)不需要前綴,因為它們不受minSdkVersion
影響。而pkg-config
並沒有內置在工具鏈中,需要我們通過包管理器手動獲取。(本人沒有安裝的情況下編譯也沒有失敗)brew install pkg-config
請務必注意,這里的實際設置情況請以你自己的 NDK 工具鏈為參照。
為了保證正確編譯,configure 的相關代碼修改如下:
set_default target_os
if test "$target_os" = android; then
cc_default="clang"
cxx_default="clang++" #將cxx_default重寫
fi #注:ndk r17版本后已棄用gcc
ar_default="llvm-${ar_default}" #將前綴修改為llvm-
cc_default="${cross_prefix_clang}${cc_default}" #在CMDLINE_SET中定義一個新變量cross_prefix_clang並在腳本中輸入
cxx_default="${cross_prefix_clang}${cxx_default}" #也可以直接修改成${cross_prefix}-android21-${cxx_default}
nm_default="llvm-${nm_default}"
pkg_config_default="${pkg_config_default}" #使用我們安裝的pkg-config
ranlib_default="llvm-${ranlib_default} -D"
strip_default="llvm-${strip_default}"
windres_default="${cross_prefix}${windres_default}"
運行腳本,如果編譯成功可以看到我們設置的輸出目錄下已經出現了include
、bin
、share
和 lib
這幾個文件夾,lib
文件夾內就是我們需要的編譯好的 FFmpeg 動態鏈接庫。
將 FFmpeg 集成在 Android 中
得到了 FFmpeg 的動態鏈接庫之后,我們還不能直接在 Android 應用中使用。因為我們還沒有實現 Java 代碼與 C 代碼的互相通信:JNI。不少教程使用的是 NDK 提供的 ndk-build,但 Android 官方現在更加推薦使用 CMake,我們可以在 Gradle 插件的幫助下直接調用 CMake 而免去命令行操作之勞,請先檢查是否安裝 Ninja。
可以簡單地按以下步驟操作:
-
新建一個項目,在
app
目錄下右鍵,選擇Add C++ to Module
,Android Studio 會在main
目錄下自動生成ProjectName.cpp
與CMakeLists.txt
,打開CMakeLists.txt
觀察格式,生成的注釋已經很好讀了,這里不再贅述。…… add_library( ffmpegtest #庫名 SHARED ffmpegtest.cpp) #實現JNI方法的cpp代碼 自動生成文件名為項目名 ……
-
在
cpp
目錄下新建lib/arm64-v8a
文件夾,將我們上一步驟編譯好的 FFmpeg 的lib
目錄下 .so 格式的動態鏈接庫粘貼進去,並將include
文件夾復制粘貼到cpp
目錄下。(如果編譯了其他 ABI 的庫,在lib
目錄下新建以 ABI 為名的子目錄存放) -
打開
CMakeLists.txt
進行編輯,添加以下內容:
add_library(avcodec
#庫名 注意無lib前綴
SHARED
#SHARED 表示動態鏈接庫
IMPORTED)
#IMPORTED 表示外部導入庫
set_target_properties(avcodec
#設置avcodec庫的導入路徑
PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/lib/${CMAKE_ANDROID_ARCH_ABI}/libavcodec.so)
#CMAKE_SOURCE_DIR是CMakeLists.txt所在目錄 請勿省略
#CMAKE_ANDROID_ARCH_ABI是下文在Gradle中設置的abifilter參數
#此處是arm64-v8a 請勿省略
…… #如上格式添加所有FFmpeg動態鏈接庫
include_directories(${CMAKE_SOURCE_DIR}/include)
#添加FFmpeg頭文件
target_link_libraries(
ffmpegtest
avfilter
avformat
#將所有動態鏈接庫與ffmpegtest庫(實現了JNI)鏈接
#至此 可以在java代碼內通過JNI調用FFmpeg庫中函數
#同時在ffmpegtest.cpp中的JNI函數中調用Java中的方法
#從而實現在Android中實現與FFmpeg庫交互
avdevice
avcodec
avutil
swresample
swscale
postproc
${log-lib})
-
在類或應用中初始化庫,在類中編寫一個沒有函數體的 native 方法並調用,這個時候方法會報錯,⌥(Alt) + Enter 讓 Android Studio 幫我們在
ffmpegtest.cpp
內生成 JNI 函數。private native void run(); static{ System.loadLibrary("ffmpegtest"); } ……
#include <jni.h> #include <android log.h=""> //導入Android log頭文件 extern "C"{ //FFmpeg由C寫成 注意使用C關鍵字括起來 #include "libavcodec/avcodec.h" //導入FFmpeg依賴庫頭文件 JNIEXPORT void JNICALL Java_com_example_ffmpegtest_MainActivity_run(JNIEnv *env, jobject thiz) { __android_log_print(ANDROID_LOG_INFO,"FFmpegTag", "avcodec_configuration():\n%s",avcodec_configuration()); //輸出avcodec配置到logcat } }
-
打開 module 等級的
build.gradle
- 檢查
ndkVersion
與cmake.version
- 在
defaultConfig.externalNativeBuild
中添加ndk{abiFilters "arm64-v8a"}
,如果不指定這個參數,Gradle 會構建所有 ABI 的應用,如果你還編譯了其他 ABI 的 FFmpeg 並且想要為其他 ABI 構建應用,在這里添加。 - 不要添加
sourceSets.main.jniLibs.srcDirs
,這個參數已經過時而且會導致構建失敗
- 檢查
-
build 並運行,可以在 logcat 中看到 cpp 代碼中從 FFmpeg 函數中發出的信息。至此,我們初步實現了編譯並在 Android 中集成了 FFmpeg。
問題排查:
- 絕大部分的問題由 CMakeLists.txt 沒有正確編寫引發,請先檢查 CMake 的錯誤報告以及構建日記,確認 CMakeLists.txt 下庫的頭文件添加路徑、動態庫添加路徑與鏈接庫名有無錯誤。
- 檢查 CMake 與 NDK 的版本是否顯式地在
build.gradle
中定義,是否正確配置了 CMake 和 Ninja - 檢查是否配置了
abiFilters
- APK analyze 檢查動態鏈接庫是否成功被打包入應用
References
【聯創の煉金工坊】Android NDK 之 Hello World
Android 集成 FFmpeg (一) 基礎知識及簡單調用_yhao的博客-CSDN博客
將 NDK 與其他構建系統配合使用 | Android NDK | Android Developers
Android ABI | Android NDK | Android Developers