Android NDK開發掃盲及最新CMake的編譯使用

Tsy遠發表於2017-07-06

本篇文章旨在簡介 Android 中 NDK 是什麼以及重點講解最新 Android Studio 編譯工具 CMake 的使用

1 NDK 簡介

在介紹 NDK 之前還是首推 Android 官方 NDK 文件。傳送門

官方文件分別從以下幾個方面介紹了 NDK

  1. NDK 的基礎概念
  2. 如何編譯 NDK 專案
  3. ABI 是什麼以及不同 CPU 指令集支援哪些 ABI
  4. 如何使用您自己及其他預建的庫

本節將會對文件進行總結和補充。所以建議先瀏覽一遍文件,或者看完本篇文章再回頭看一遍文件。

1.1 NDK 基礎概念

首先先用簡單的話分別解釋下 JNINDK, 以及分別和 Android 開發、c/c++ 開發的配合。在解釋過程中會對 Android.mkApplication.mkndk-buildCMakeCMakeList 這些常見名詞進行掃盲。

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.mkApplication.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)生成 對應 makefileproject 檔案,然後再呼叫底層的編譯。

在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 以及對應的指令集

ABI
ABI

2 CMake 的使用

這一節將重點介紹 CMake 的規則和使用,以及如何使用 CMake 編譯自己及其他預建的庫。

2.1 Hello world

我們通過一個Hello World專案來理解 CMake

首先建立一個新的包含原生程式碼的專案。在 New Project 時,勾選 Include C++ support

New Project
New Project

專案建立好以後我們可以看到和普通Android專案有以下4個不同。

  1. main 下面增加了 cpp 目錄,即放置 c/c++ 程式碼的地方
  2. module-level 的 build.gradle 有修改
  3. 增加了 CMakeLists.txt 檔案
  4. 多了一個 .externalNativeBuild 目錄

Difference
Difference

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 中查到。如下

cmake command
cmake command

更多的可以填寫的命令引數和含義可以參見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 實現瞭如何建立出靜態庫/動態庫,並引用它。現在我們把程式碼拉下來看下具體是如何實現的。

hello-libs
hello-libs

我們先看下Github上的README介紹:

  • app - 從 $project/distribution/ 中使用一個靜態庫和一個動態庫
  • gen-libs - 生成一個動態庫和一個靜態庫並複製到 $project/distribution/ 目錄,你不需要再編譯這個庫,二進位制檔案已經儲存在了專案中。當然,如果有需要你也可以編譯自己的原始碼,只需要去掉 setting.gradleapp/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=clangCMake 一共有2種編譯工具鏈 - clanggccgcc 已經廢棄,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 也會被執行。即會去分別執行 gmathgperf 中的 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 命令的意思是設定目標的一些屬性來改變它們構建的方式。這個命令中設定了 gmathARCHIVE_OUTPUT_DIRECTORY 屬性。也就是改變了輸出路徑。

add_custom_command 命令是自定義命令。命令中把標頭檔案也複製到了 distribution_DIR 中。

以上就是一個靜態庫/動態庫的編譯過程。總結以下3點

  1. 編譯靜態庫/動態庫
  2. 修改輸出路徑
  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個步驟引入:

  1. 分別建立靜態庫/動態庫,直接引用已經有的 .a 檔案 或者 .so 檔案
  2. 建立自己應用的庫 hello-libs
  3. 加入之前暴露標頭檔案
  4. 連結上靜態庫/動態庫

還是很好理解的。編輯好並 Sync 後,你就可以發現 hello-libs 中的c/c++程式碼可以引用暴露的標頭檔案呼叫內部方法了。

3 資料文獻

首推 Android NDK 官方文件,雖然很多都不完整,但是絕對是必須看一遍的東西。

當初次接觸 NDK 開發又覺得新建的 Hello World 專案過於簡單時。建議把 googlesamples - android-ndk 專案拉下來。裡面有多個例項參考,比官方文件完整很多。

Google Samples
Google Samples

當你發現示例裡的一些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

相關文章