本篇文章旨在簡介 Android 中
NDK
是什麼以及重點講解最新 Android Studio 編譯工具CMake
的使用
1 NDK 簡介
在介紹 NDK
之前還是首推 Android 官方 NDK
文件。傳送門
官方文件分別從以下幾個方面介紹了 NDK
NDK
的基礎概念- 如何編譯
NDK
專案 ABI
是什麼以及不同 CPU 指令集支援哪些ABI
- 如何使用您自己及其他預建的庫
本節將會對文件進行總結和補充。所以建議先瀏覽一遍文件,或者看完本篇文章再回頭看一遍文件。
1.1 NDK 基礎概念
首先先用簡單的話分別解釋下 JNI
、NDK
, 以及分別和 Android 開發、c/c++ 開發的配合。在解釋過程中會對 Android.mk
、Application.mk
、ndk-build
、CMake
、CMakeList
這些常見名詞進行掃盲。
JNI(Java Native Interface):Java本地介面。是為了方便Java呼叫c、c++等原生程式碼所封裝的一層介面(也是一個標準)。大家都知道,Java的優點是跨平臺,但是作為優點的同時,其在本地互動的時候就程式設計了缺點。Java的跨平臺特性導致其本地互動的能力不夠強大,一些和作業系統相關的特性Java無法完成,於是Java提供了jni專門用於和原生程式碼互動,這樣就增強了Java語言的本地互動能力。上述部分文字摘自任玉剛的 Java JNI 介紹
NDK(Native Development Kit) : 原生開發工具包,即幫助開發原生程式碼的一系列工具,包括但不限於編譯工具、一些公共庫、開發IDE等。
NDK
工具包中提供了完整的一套將 c/c++ 程式碼編譯成靜態/動態庫的工具,而 Android.mk
和 Application.mk
你可以認為是描述編譯引數和一些配置的檔案。比如指定使用c++11還是c++14編譯,會引用哪些共享庫,並描述關係等,還會指定編譯的 abi
。只有有了這些 NDK
中的編譯工具才能準確的編譯 c/c++ 程式碼。
ndk-build
檔案是 Android NDK r4 中引入的一個 shell 指令碼。其用途是呼叫正確的 NDK
構建指令碼。其實最終還是會去呼叫 NDK
自己的編譯工具。
那 CMake
又是什麼呢。脫離 Android 開發來看,c/c++ 的編譯檔案在不同平臺是不一樣的。Unix 下會使用 makefile
檔案編譯,Windows 下會使用 project
檔案編譯。而 CMake
則是一個跨平臺的編譯工具,它並不會直接編譯出物件,而是根據自定義的語言規則(CMakeLists.txt
)生成 對應 makefile
或 project
檔案,然後再呼叫底層的編譯。
在Android Studio 2.2 之後,工具中增加了 CMake
的支援,你可以這麼認為,在 Android Studio 2.2 之後你有2種選擇來編譯你寫的 c/c++ 程式碼。一個是 ndk-build
+ Android.mk
+ Application.mk
組合,另一個是 CMake
+ CMakeLists.txt
組合。這2個組合與Android程式碼和c/c++程式碼無關,只是不同的構建指令碼和構建命令。本篇文章主要會描述後者的組合。(也是Android現在主推的)
1.2 ABI 是什麼
ABI
(Application binary interface)應用程式二進位制介面。不同的CPU 與指令集的每種組合都有定義的 ABI
(應用程式二進位制介面),一段程式只有遵循這個介面規範才能在該 CPU 上執行,所以同樣的程式程式碼為了相容多個不同的CPU,需要為不同的 ABI
構建不同的庫檔案。當然對於CPU來說,不同的架構並不意味著一定互不相容。
- armeabi裝置只相容armeabi;
- armeabi-v7a裝置相容armeabi-v7a、armeabi;
- arm64-v8a裝置相容arm64-v8a、armeabi-v7a、armeabi;
- X86裝置相容X86、armeabi;
- X86_64裝置相容X86_64、X86、armeabi;
- mips64裝置相容mips64、mips;
- mips只相容mips;
具體的相容問題可以參見這篇文章。Android SO檔案的相容和適配
當我們開發 Android 應用的時候,由於 Java 程式碼執行在虛擬機器上,所以我們從來沒有關心過這方面的問題。但是當我們開發或者使用原生程式碼時就需要了解不同 ABI
以及為自己的程式選擇接入不同 ABI
的庫。(庫越多,包越大,所以要有選擇)
下面我們來看下一共有哪些 ABI
以及對應的指令集
2 CMake 的使用
這一節將重點介紹 CMake
的規則和使用,以及如何使用 CMake
編譯自己及其他預建的庫。
2.1 Hello world
我們通過一個Hello World專案來理解 CMake
首先建立一個新的包含原生程式碼的專案。在 New Project 時,勾選 Include C++ support
專案建立好以後我們可以看到和普通Android專案有以下4個不同。
main
下面增加了cpp
目錄,即放置 c/c++ 程式碼的地方- module-level 的
build.gradle
有修改 - 增加了
CMakeLists.txt
檔案 - 多了一個
.externalNativeBuild
目錄
build.gradle
android {
...
defaultConfig {
...
externalNativeBuild {
cmake {
cppFlags "-frtti -fexceptions"
arguments "-DANDROID_ARM_NEON=TRUE"
}
}
}
buildTypes {
...
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
...複製程式碼
由於 CMake
的命令整合在了 gradle
- externalNativeBuild
中,所以在 gradle
中有2個地方配置 CMake
。
defaultConfig
外面的 externalNativeBuild - cmake
,指明瞭 CMakeList.txt
的路徑;defaultConfig
裡面的 externalNativeBuild - cmake
,主要填寫 CMake
的命令引數。即由 arguments
中的引數最後轉化成一個可執行的 CMake
的命令,可以在 .externalNativeBuild/cmake/debug/{abi}/cmake_build_command.txt
中查到。如下
更多的可以填寫的命令引數和含義可以參見Android NDK-CMake文件
CMakeLists.txt
CMakeLists.txt
中主要定義了哪些檔案需要編譯,以及和其他庫的關係等。
看下新專案中的 CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
# 編譯出一個動態庫 native-lib,原始檔只有 src/main/cpp/native-lib.cpp
add_library( # Sets the name of the library.
native-lib
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
src/main/cpp/native-lib.cpp )
# 找到預編譯庫 log_lib 並link到我們的動態庫 native-lib中
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.
native-lib
# Links the target library to the log library
# included in the NDK.
${log-lib} )複製程式碼
這其實是一個最基本的 CMakeLists.txt
,其實 CMakeLists.txt
裡面可以非常強大,比如自定義命令、查詢檔案、標頭檔案包含、設定變數等等。建議結合 CMake
的官方文件使用。同時在這推薦一箇中文翻譯的簡易的CMake手冊
2.2 CMake 使用自己及其他預建的庫
當你需要引入已有的靜態庫/動態庫(FFMpeg)或者自己編譯核心部分並提供出去時就需要考慮如何在 CMake
中使用自己及其他預建的庫。
Android NDK 官網的使用現有庫的文件中還是使用 ndk-build
+ Android.mk
+ Application.mk
組合的說明文件。(其實官方文件中大部分都是的,並沒有使用 CMake
)
幸運的是, Github上的官方示例 裡面有個專案 hello-libs 實現瞭如何建立出靜態庫/動態庫,並引用它。現在我們把程式碼拉下來看下具體是如何實現的。
我們先看下Github上的README介紹:
- app - 從
$project/distribution/
中使用一個靜態庫和一個動態庫 - gen-libs - 生成一個動態庫和一個靜態庫並複製到
$project/distribution/
目錄,你不需要再編譯這個庫,二進位制檔案已經儲存在了專案中。當然,如果有需要你也可以編譯自己的原始碼,只需要去掉setting.gradle
和app/build.gradle
中的註釋,然後執行一次,接著註釋回去,防止在 build 的過程中不受影響。
我們採用自底向上的方式分析模組,先看下 gen-libs
模組。
gen-libs/build.gradle
android {
...
defaultConfig {
...
externalNativeBuild {
cmake {
arguments '-DANDROID_PLATFORM=android-9',
'-DANDROID_TOOLCHAIN=clang'
// explicitly build libs
targets 'gmath', 'gperf'
}
}
}
...
}
...複製程式碼
查詢文件可以知道 arguments
中 -DANDROID_PLATFORM
代表編譯的 android 平臺,文件建議直接設定 minSdkVersion
就行了,所以這個引數可忽略。另一個引數 -DANDROID_TOOLCHAIN=clang
,CMake
一共有2種編譯工具鏈 - clang
和 gcc
,gcc
已經廢棄,clang
是預設的。
targets 'gmath', 'gperf'
代表編譯哪些專案。(不填就是都編譯)
cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
set(CMAKE_VERBOSE_MAKEFILE on)
set(lib_src_DIR ${CMAKE_CURRENT_SOURCE_DIR})
set(lib_build_DIR $ENV{HOME}/tmp)
file(MAKE_DIRECTORY ${lib_build_DIR})
add_subdirectory(${lib_src_DIR}/gmath ${lib_build_DIR}/gmath)
add_subdirectory(${lib_src_DIR}/gperf ${lib_build_DIR}/gperf)複製程式碼
外層的 CMakeLists
裡面核心就是 add_subdirectory
,查詢CMake 官方文件 可以知道這條命令的作用是為構建新增一個子路徑。子路徑中的 CMakeLists.txt
也會被執行。即會去分別執行 gmath
和 gperf
中的 CMakeLists.txt
cpp/gmath/CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
set(CMAKE_VERBOSE_MAKEFILE on)
add_library(gmath STATIC src/gmath.c)
# copy out the lib binary... need to leave the static lib around to pass gradle check
set(distribution_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../distribution)
set_target_properties(gmath
PROPERTIES
ARCHIVE_OUTPUT_DIRECTORY
"${distribution_DIR}/gmath/lib/${ANDROID_ABI}")
# copy out lib header file...
add_custom_command(TARGET gmath POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E
copy "${CMAKE_CURRENT_SOURCE_DIR}/src/gmath.h"
"${distribution_DIR}/gmath/include/gmath.h"
# **** the following 2 lines are for potential future debug purpose ****
# COMMAND "${CMAKE_COMMAND}" -E
# remove_directory "${CMAKE_CURRENT_BINARY_DIR}"
COMMENT "Copying gmath to output directory")複製程式碼
這個是其中一個靜態庫的 CMakeLists.txt
,另一個跟他很像。只是把 STATIC
改成了 SHARED
(動態庫)。
add_library(gmath STATIC src/gmath.c)
之前用到過,編譯出一個靜態庫,原始檔是 src/gmath.c
set_target_properties
命令的意思是設定目標的一些屬性來改變它們構建的方式。這個命令中設定了 gmath
的 ARCHIVE_OUTPUT_DIRECTORY
屬性。也就是改變了輸出路徑。
add_custom_command
命令是自定義命令。命令中把標頭檔案也複製到了 distribution_DIR
中。
以上就是一個靜態庫/動態庫的編譯過程。總結以下3點
- 編譯靜態庫/動態庫
- 修改輸出路徑
- 複製暴露的標頭檔案
接著,我們看下 app
模組是如何使用預建好的靜態庫/動態庫的。
app/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
# configure import libs
set(distribution_DIR ${CMAKE_SOURCE_DIR}/../../../../distribution)
# 建立一個靜態庫 lib_gmath 直接引用libgmath.a
add_library(lib_gmath STATIC IMPORTED)
set_target_properties(lib_gmath PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/gmath/lib/${ANDROID_ABI}/libgmath.a)
# 建立一個動態庫 lib_gperf 直接引用libgperf.so
add_library(lib_gperf SHARED IMPORTED)
set_target_properties(lib_gperf PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/gperf/lib/${ANDROID_ABI}/libgperf.so)
# build application's shared lib
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++11")
# 建立庫 hello-libs
add_library(hello-libs SHARED
hello-libs.cpp)
# 加入標頭檔案
target_include_directories(hello-libs PRIVATE
${distribution_DIR}/gmath/include
${distribution_DIR}/gperf/include)
# hello-libs庫連結上 lib_gmath 和 lib_gperf
target_link_libraries(hello-libs
android
lib_gmath
lib_gperf
log)複製程式碼
我將解釋放在了註釋中。可以看下基本上分成了4個步驟引入:
- 分別建立靜態庫/動態庫,直接引用已經有的 .a 檔案 或者 .so 檔案
- 建立自己應用的庫
hello-libs
- 加入之前暴露標頭檔案
- 連結上靜態庫/動態庫
還是很好理解的。編輯好並 Sync
後,你就可以發現 hello-libs
中的c/c++程式碼可以引用暴露的標頭檔案呼叫內部方法了。
3 資料文獻
首推 Android NDK 官方文件,雖然很多都不完整,但是絕對是必須看一遍的東西。
當初次接觸 NDK
開發又覺得新建的 Hello World 專案過於簡單時。建議把 googlesamples - android-ndk 專案拉下來。裡面有多個例項參考,比官方文件完整很多。
當你發現示例裡的一些NDK配置滿足不了你的需求後,你就需要到 CMake 官方文件 去查詢完整的支援的函式,同時這裡也提供一箇中文翻譯的簡易的CMake手冊。
以上文件資料僅為了解決 NDK 開發過程中編譯配置問題,具體 c/c++ 的邏輯編寫、jni等不在此範疇。
彩蛋
文末獻上一組彩蛋,將 CMake
或者 NDK
開發過程中遇到的坑和小技巧以 Q&A 的方式列出。持續更新
Q1:怎麼指定 C++標準?
A:在 build_gradle
中,配置 cppFlags -std
externalNativeBuild {
cmake {
cppFlags "-frtti -fexceptions -std=c++14"
arguments '-DANDROID_STL=c++_shared'
}
}複製程式碼
Q2:add_library 如何編譯一個目錄中所有原始檔?
A: 使用 aux_source_directory
方法將路徑列表全部放到一個變數中。
# 查詢所有原始碼 並拼接到路徑列表
aux_source_directory(${CMAKE_HOME_DIRECTORY}/src/api SRC_LIST)
aux_source_directory(${CMAKE_HOME_DIRECTORY}/src/core CORE_SRC_LIST)
list(APPEND SRC_LIST ${CORE_SRC_LIST})
add_library(native-lib SHARED ${SRC_LIST})複製程式碼
Q3:怎麼除錯 CMakeLists.txt 中的程式碼?
A:使用 message
方法
cmake_minimum_required(VERSION 3.4.1)
message(STATUS "execute CMakeLists")
...複製程式碼
然後執行後在 .externalNativeBuild/cmake/debug/{abi}/cmake_build_output.txt
中檢視 log。
Q4:什麼時候 CMakeLists.txt 裡面會執行?
A:測試了下,好像在 sync 的時候會執行。執行一次後會生成 makefile
的檔案快取之類的東西放在 externalNativeBuild
中。所以如果 CMakeLists.txt
中沒有修改的話再次同步好像是不會重新執行的。(或者刪除 .externalNativeBuild
目錄)
真正編譯的時候好像只是讀取.externalNativeBuild
目錄中已經解析好的 makefile
去編譯。不會再去執行 CMakeLists.txt