為 Android 編譯並整合 FFmpeg 的嘗試與踩坑

drunkfood發表於2021-11-09

前言與環境說明

隨著 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 版本後提供對蘋果晶片的支援
  • Gradle Version:7.0.3

前置知識準備

在實際上手前,閱讀了 Android 與 Java 的官方開發文件與幾篇優秀的相關文章,按照自己的理解和知識水平,整理了一些概念的基本且淺顯的解釋,方便理解下一步要進行的操作。

  1. Native 層

  2. JNI

  3. NDK

  4. 交叉編譯、建構系統與 CMake

  5. ABI 與動態連結庫

  6. FFmpeg

Android 系統的 Native 層

Android Stack

雖然 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:

  • [x] 動態連結庫 (.so) 與靜態庫 (.a) 的區別
    • 靜態庫中的程式碼在編譯後直接進入可執行檔案中,而動態連結庫則是將程式碼包含在程式外的庫檔案中,在執行時被程式所呼叫,不能單獨執行
  • [x] 什麼是工具鏈 (Toolchain) ?
    • NDK 中提供的用於交叉編譯 C/C++ 程式碼的一系列工具
  • [x] Cmake 在這裡到底用來幹什麼?
    • CMake 根據 CMakeLists.txt 配置檔案來生成一個指導工具鏈進行編譯的標準建構檔案,隨後工具鏈便可根據建構檔案將原始碼編譯成動態連結庫
    • 當我們編譯一個 .c 檔案的時候,我們可以直接將其丟進 gcc 中編譯;但當我們需要編譯一個專案的一系列 .c 檔案時,一股腦丟進去編譯顯然就會大亂套了,於是我們需要一個建構系統來管理這個專案的編譯。在 Windows 下我們使用 Visual Studio 的 .sln 檔案,macOS 下我們使用 Xcode 的 .xcodeproj 檔案,Linux 下我們可以使用 Make 的 Makefile 檔案。這些建構系統的建構檔案可以指導編譯器或編譯工具鏈來編譯 .c 檔案。
  • [x] What about Ninja ?
    • 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 庫的的基本邏輯與行為有了一定的理解,我們再進行梳理:

  1. 編寫 CMakeList.txt ,將 C/C++ 程式碼與引入的 FFmpeg 庫加入到專案中,並連結到一起。
  2. 在 Java 類中編寫並呼叫 Native 方法
  3. 在 C/C++ 程式碼中實現 Native 方法,Native 方法呼叫 FFmpeg 庫
  4. 使用 CMake + Ninja 與 NDK 工具鏈將 C/C++ 程式碼以及引入的 FFmpeg 庫編譯成動態連結庫
  5. 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-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 編譯設定,可以發現許多問題:

  1. 這裡的檔名全部設定成以我們輸入的 cross_prefix 為字首,但經過我們的檢視,我們的檔名字首實際上是形如 aarch64-linux-android21 這樣 ${cross_prefix}-android+版本號 的格式。

  2. 這裡將 cc_default 重寫為了 clang,但沒有重寫 cxx_default

  3. 這裡的 striparpkg-confignm 工具也設定成了以 cross_prefix 為字首,但實際上,可以看到我們的幾個檔名字首實際上是 llvm- ,在 Android 官方文件中也可以得知 binutils 工具(例如 arstrip)不需要字首,因為它們不受 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

……

strip_default="llvm-${strip_default}"
windres_default="${cross_prefix}${windres_default}"

執行指令碼,如果編譯成功可以看到我們設定的輸出目錄下已經出現了includebinsharelib 這幾個資料夾,lib 資料夾內就是我們需要的編譯好的 FFmpeg 動態連結庫。

將 FFmpeg 整合在 Android 中

得到了 FFmpeg 的動態連結庫之後,我們還不能直接在 Android 應用中使用。因為我們還沒有實現 Java 程式碼與 C 程式碼的互相通訊:JNI。不少教程使用的是 NDK 提供的 ndk-build,但 Android 官方現在更加推薦使用 CMake,我們可以在 Gradle 外掛的幫助下直接呼叫 CMake 而免去命令列操作之勞,請先檢查是否安裝 Ninja。

可以簡單地按以下步驟操作:

  1. 新建一個專案,在 app 目錄下右鍵,選擇 Add C++ to Module ,Android Studio 會在 main 目錄下自動生成 ProjectName.cppCMakeList.txt,開啟 CMakeList.txt 觀察格式,生成的註釋已經很好讀了,這裡不再贅述。

    ……
    add_library(
            ffmpegtest				#庫名
            SHARED	
            ffmpegtest.cpp)		#實現JNI方法的cpp程式碼 自動生成檔名為專案名
    ……
    
  2. cpp 目錄下新建 lib/arm64-v8a 資料夾,將我們上一步驟編譯好的 FFmpeg 的 lib 目錄下 .so 格式的動態連結庫貼上進去,並將 include 資料夾複製貼上到 cpp 目錄下。(如果編譯了其他 ABI 的庫,在 lib 目錄下新建以 ABI 為名的子目錄存放)

  3. 開啟 CMakeList.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是CMakeList.txt所在目錄 請勿省略
            													#CMAKE_ANDROID_ARCH_ABI是下文在Gradle中設定的abifilter引數 
            													#此處是arm64-v8a	請勿省略
            													
    ……																#如上格式新增所有FFmpeg動態連結庫
    
    include_directories(${CMAKE_SOURCE_DIR}/include)
    																	#新增FFmpeg標頭檔案
    																	
    target_link_libraries(
            ffmpegtest
            avfilter									#將所有動態連結庫與ffmpegtest庫(實現了JNI)連結
            avformat									#至此 可以在java程式碼內通過JNI呼叫ffmpegtest庫中函式
            avdevice									#同時在ffmpegtest.cpp中的JNI方法中呼叫FFmpeg庫中的函式 
            avcodec										#從而實現在Android中使用FFmpeg庫
            avutil
            swresample
            swscale
            
            ${log-lib})
    
  4. 在類或應用中初始化庫,在類中編寫一個沒有函式體的 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
    }
    }
    
  5. 開啟 module 等級的 build.gradle

    • 檢查 ndkVersioncmake.version
    • defaultConfig.externalNativeBuild 中新增 ndk{abiFilters "arm64-v8a"} ,如果不指定這個引數,Gradle 會建構所有 ABI 的應用,如果你還編譯了其他 ABI 的 FFmpeg 並且想要為其他 ABI 構建應用,在這裡新增。
    • 不要新增 sourceSets.main.jniLibs.srcDirs,這個引數已經過時而且會導致建構失敗
  6. build 並執行,可以在 logcat 中看到 cpp 程式碼中從 FFmpeg 函式中發出的資訊。至此,我們初步實現了編譯並在 Android 中整合了 FFmpeg。

References

【聯創の鍊金工坊】Android NDK 之 Hello World

GCC/Make/CMake 之 GCC - 知乎

Android 整合 FFmpeg (一) 基礎知識及簡單呼叫_yhao的部落格-CSDN部落格

將 NDK 與其他構建系統配合使用 | Android NDK | Android Developers

Android ABI | Android NDK | Android Developers

自動打包 CMake 使用的預構建依賴項 | Android 開發者 | Android Developers

相關文章