0%

Android-NDK开发之概述

概述

  • Android Studio 用于构建原生库的默认工具是 CMake,需要提供 CmakeList.txt 用于构建原生代码。
  • Android Studio 还支持 ndk-build 构建原生库,需要提供 Android.mk 构建脚本。

ndk-build

ndk-build 脚本可用于编译采用 NDK 基于 Make 的编译系统的项目,包含Application.mkAndroid.mk脚本。

运行 ndk-build 脚本相当于运行以下命令:

1
2
$ export GNUMAKE=/usr/local/bin/gmake
$ $GNUMAKE -f <ndk>/build/core/build-local.mk <parameters>

使用方法:

1
$ ndk-build <option>

可选options:

  • clean:移除之前生成的所有二进制文件。
  • V=1:启动编译,并显示编译命令。
  • -B:强制执行完整的重新编译。
  • -B V=1:强制执行完整的重新编译,并显示编译命令。
  • NDK_LOG=1:显示内部 NDK 日志消息(用于调试 NDK 本身)。
  • NDK_DEBUG=1:强制执行可调试的编译。
  • NDK_DEBUG=0:强制执行发布版编译。
  • NDK_HOST_32BIT=1:始终使用 32 位模式下的工具链。
  • NDK_APPLICATION_MK=<file>:使用 NDK_APPLICATION_MK 变量指向的特定 Application.mk 文件进行编译。
  • -C <project>:编译位于 <project> 的项目路径的原生代码。如果您不想在终端通过 cd 切换到该路径,此选项会非常有用。

Android.mk

Android.mk 文件位于项目 jni/ 目录的子目录中,用于向编译系统描述源文件和共享库。它实际上是编译系统解析一次或多次的微小 GNU makefile 片段。Android.mk 文件用于定义 Application.mk、编译系统和环境变量所未定义的项目范围设置。它还可替换特定模块的项目范围设置。

Android.mk 的语法支持将源文件分组为模块。模块是静态库、共享库或独立的可执行文件。您可在每个 Android.mk 文件中定义一个或多个模块,也可在多个模块中使用同一个源文件。编译系统只将共享库放入您的应用软件包。此外,静态库可生成共享库。

除了封装库之外,编译系统还可为您处理各种其他事项。例如,您无需在 Android.mk 文件中列出头文件或生成的文件之间的显式依赖关系。NDK 编译系统会自动计算这些关系。因此,您应该能够享受到未来 NDK 版本中新工具链/平台支持带来的益处,而无需处理 Android.mk 文件。

详见:Android.mk

简单示例:

1
2
3
4
5
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE:= libtermux
LOCAL_SRC_FILES:= termux.c
include $(BUILD_SHARED_LIBRARY)

Application.mk

Application.mk 指定了 ndk-build 的项目范围设置。默认情况下,它位于应用项目目录中的 jni/Application.mk 下。

注意:其中许多参数也具有模块等效项。例如,APP_CFLAGS 对应于 LOCAL_CFLAGS。无论何种情况下,特定于模块的选项都将优先于应用范围选项。对于标记,两者都使用,但特定于模块的标记将后出现在命令行中,因此它们可能会替换项目范围设置。

详见:Application.mk

简单示例:

1
2
3
APP_ABI := armeabi-v7a arm64-v8a x86
# 或者
APP_ABI := all

CMake

Android Studio 创建 Native 项目完成后,默认使用 Cmake 构建原生库,目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
├─app
│ ├─libs
│ └─src
│ ├─main
│ │ ├─cpp
│ │ │ └─CMakeLists.txt
│ │ │ └─native-lib.cpp
│ │ ├─java
│ │ │ └─com
│ │ │ └─hearing
│ │ │ └─jnidemo
│ │ └─res
│ │ ├─drawable
│ │ ├─drawable-v24
│ │ ├─layout
│ │ ├─mipmap-anydpi-v26
│ │ ├─mipmap-hdpi
│ │ ├─mipmap-mdpi
│ │ ├─mipmap-xhdpi
│ │ ├─mipmap-xxhdpi
│ │ ├─mipmap-xxxhdpi
│ │ └─values

build.gradle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
android {
...
defaultConfig {
...
// This block is different from the one you use to link Gradle
// to your CMake build script.
externalNativeBuild {
cmake {
cppFlags ""
}
}
}
buildTypes {...}

// Use this block to link Gradle to your CMake build script.
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.10.2"
}
}
}

CmakeLists.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
cmake_minimum_required(VERSION 3.10.2)

# Declares and names the project.

project("nativedemo")

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

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).
native-lib.cpp )

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

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 )

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
native-lib

# Links the target library to the log library
# included in the NDK.
${log-lib} )

native-lib.cpp:

1
2
3
4
5
6
7
8
9
10
#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring JNICALL
Java_com_hearing_jnidemo_MainActivity_stringFromJNI(
JNIEnv *env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}

ABI

概述

不同的 Android 手机使用不同的 CPU,而不同的 CPU 支持不同的指令集。CPU 与指令集的每种组合都有专属的应用二进制接口,即 ABI。ABI 可以非常精确地定义应用的机器代码在运行时如何与系统交互。您必须为应用要使用的每个 CPU 架构指定 ABI。

CPU(纵向)/ABI(横向) armeabi armeabi-v7a arm64-v8a x86 x86_64 mips mips64
ARMv5 支持
ARMv7 支持 支持
ARMv8 支持 支持 支持
x86 支持 支持 支持
x86_64 支持 支持 支持 支持
MIPS 支持
MIPS64 支持 支持

Android 系统在运行时知道它支持哪些 ABI,因为 build 特定的系统属性会指示:

  • 设备的主要 ABI,与系统映像本身使用的机器代码对应。
  • (可选)与系统映像也支持的其他 ABI 对应的辅助 ABI。

此机制确保系统在安装时从软件包提取最佳机器代码。

为实现最佳性能,应直接针对主要 ABI 进行编译。例如,基于 ARMv5TE 的典型设备只会定义主 ABI:armeabi。相反,基于 ARMv7 的典型设备将主 ABI 定义为 armeabi-v7a,并将辅助 ABI 定义为 armeabi,因为它可以运行为每个 ABI 生成的应用原生二进制文件。

64 位设备也支持其 32 位变体。以 arm64-v8a 设备为例,该设备也可以运行 armeabi 和 armeabi-v7a 代码。但请注意,如果应用以 arm64-v8a 为目标,而非依赖于运行 armeabi-v7a 版应用的设备,则应用在 64 位设备上的性能要好得多。

许多基于 x86 的设备也可运行 armeabi-v7a 和 armeabi NDK 二进制文件。对于这些设备,主 ABI 将是 x86,辅助 ABI 是 armeabi-v7a。

您可以为特定 ABI 强制安装 apk。这在测试时非常有用。请使用以下命令:

1
adb install --abi abi-identifier path_to_apk

安装应用时,软件包管理器服务将扫描 APK,并查找以下形式的任何共享库:

1
lib/<primary-abi>/lib<name>.so

如果未找到,并且您已定义辅助 ABI,该服务将扫描以下形式的共享库:

1
lib/<secondary-abi>/lib<name>.so

找到所需的库时,软件包管理器会将它们复制到应用的原生库目录 (<nativeLibraryDir>/) 下的 /lib/lib<name>.so。以下代码段会收到 nativeLibraryDir(/data/app/com.hearing.test-b4Q3jAne7emB6ypXB6AHtQ==/lib/arm64):

1
2
3
4
5
val ainfo = this.applicationContext.packageManager.getApplicationInfo(
"com.hearing.test",
PackageManager.GET_SHARED_LIBRARY_FILES
)
Log.v(TAG, "native library dir ${ainfo.nativeLibraryDir}")

如果根本没有共享对象文件,应用也会构建并安装,但在运行时会崩溃。

Android中的so库

  • so库的名称可任意,如hearing。
  • so库的文件名必须以lib开头。如libhearing.so,其中lib是必要前缀,hearing才是这个库的名称。
  • System.loadLibrary(“so库的名称”):如System.loadLibrary(“hearing”),会加载该应用下的libhearing.so文件。
  • System.load(“so库文件的全路径”):如System.load(“/data/data/com.hearing.app.demo/libhearing.so”),会加载包名为com.hearing.app.demo的应用的libhearing.so文件。
  • so库在Android Studio中的路径:默认要放在模块/src/<SourceSet>/jniLibs/<ABI>/下。如:将so库放在app/src/main/jniLibs/armeabi。可在模块的build.gradle中修改配置指定so库的路径。如,将so库放在app/libs/armeabi下,并修改配置如下:
    1
    2
    3
    4
    5
    6
    7
    8
    android {
    ...
    sourceSets {
    main {
    jniLibs.srcDirs = ['libs']
    }
    }
    }
  • so库在Android系统中的路径:/data/data/<应用包名>/lib/下。
  • so库在aar包中的路径:jni/<ABI>下。

so库的使用建议

  • 理论上应该为每个ABI目录提供对应的so库。但是Android支持7种ABI,若全部支持,必然导致APK包过大。
  • 一般只保留arm64-v8a、armeabi-v7a这两个ABI的so库。
  • mips/mips64:极少用于手机,可忽略。
  • x86/x86_64:x86架构的手机的市场占有率很低,约为1%左右。而且x86架构都包含ARM模拟层,兼容ARM类型的ABI。注意,模拟器大为x86架构,平板很多也是x86。
  • arm64-v8a:64位ARM架构。可用32位模式运行armeabi-v7a和armeabi。
  • armeabi-v7a:32位。
  • armeabi:老版本ARMv5。

不难发现,只要提供armeabi便可兼容新/旧设备。但armeabi缺少对浮点数机器的支持,存在性能瓶颈。应该将armeabi目录中的so库拷贝一份到armeabi-v7a目录中。

默认情况下,Gradle 会针对 NDK 支持的 ABI 将您的原生库构建到单独的 .so 文件中,并将其全部打包到您的 APK 中。如果您希望 Gradle 仅构建和打包原生库的特定 ABI 配置,您可以在模块级 build.gradle 文件中使用 ndk.abiFilters 标志指定这些配置,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
android {
...
defaultConfig {
...
externalNativeBuild {
cmake {...}
// or ndkBuild {...}
}

ndk {
// Specifies the ABI configurations of your native
// libraries Gradle should build and package with your APK.
abiFilters 'x86', 'x86_64', 'armeabi', 'armeabi-v7a',
'arm64-v8a'
}
}
buildTypes {...}
externalNativeBuild {...}
}

在大多数情况下,您只需要在 ndk {} 块中指定 abiFilters(如上所示),因为它会指示 Gradle 构建和打包原生库的这些版本。不过,如果您希望控制 Gradle 应当构建的配置,并独立于您希望其打包到 APK 中的配置,请在 defaultConfig.externalNativeBuild.cmake {} 块(或 defaultConfig.externalNativeBuild.ndkBuild {} 块中)配置另一个 abiFilters 标志。Gradle 会构建这些 ABI 配置,不过仅会打包您在 defaultConfig.ndk{} 块中指定的配置。

为了进一步降低 APK 的大小,请考虑配置 ABI APK 拆分,而不是创建一个包含原生库所有版本的大型 APK,Gradle 会为您想要支持的每个 ABI 创建单独的 APK,并且仅打包每个 ABI 需要的文件。如果您配置 ABI 拆分,但没有像上面的代码示例一样指定 abiFilters 标志,Gradle 会构建原生库的所有受支持 ABI 版本,不过仅会打包您在 ABI 拆分配置中指定的版本。为了避免构建您不想要的原生库版本,请为 abiFilters 标志和 ABI 拆分配置提供相同的 ABI 列表。

静态库和动态库

本质上来说库是可执行代码的二进制形式,可以被操作系统载入内存执行。库可以分为两种:静态库(.a/.lib)和动态库(.so/.dll)。

我们知道将一个程序编译成可执行程序的步骤是: cpp, h... -> 预编译 -> 编译 -> 汇编 -> 链接 -> 可执行文件,动态库和静态库的区别在于链接阶段如何处理库。

静态库

链接阶段会将汇编生成的目标文件 .o 与引用的静态库一起链接打包到可执行文件中,其特点:

  • 对静态库的链接是在编译时期完成的。
  • 程序在运行时与函数库没有关系,移植方便。
  • 浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件。
  • 静态库更新时,使用它的程序需要重新编译。

动态库

动态库在程序编译时不会被链接到目标代码中,直到程序运行到时才会被载入,其特点:

  • 动态库在程序运行时才链接载入。
  • 可以实现进程之间的资源共享(因此动态库也称为共享库)。
  • 简化某些程序升级过程,增量更新。

打印Android日志

1
2
3
4
5
6
7
8
9
10
#include "android/log.h"

// Log.i
#define LOG_I(tag, ...) __android_log_print(ANDROID_LOG_INFO, tag, __VA_ARGS__)

// Log.d
#define LOG_D(tag, ...) __android_log_print(ANDROID_LOG_DEBUG, tag, __VA_ARGS__)

// Log.e
#define LOG_E(tag, ...) __android_log_print(ANDROID_LOG_ERROR, tag, __VA_ARGS__)