Android NDK入门避坑指南

前言

本文基于自己所学到的NDk的知识和一些在网上查到的资料

因为学习NDK的时间不是很长,学到的内容难免有所错漏,希望有问题的可以明确的指出来,我会积极采纳

本文会竭尽可能的将我现在所学到的关于NDK的知识清晰的表达出来,本文主要以NDK的基本概念,NDK的组成部分,NDK的注意事项三个部分组成

实践部分因为本篇文章已经6000多字了,为了不让文章过长,将会在后面的博客中

一. NDK的基本概念

在学习NDK之前,我首先得知道什么是NDK,NDK可以干些什么,使用NDK的好处有哪些,知道了这些,我们才可以更好的学习NDK

1. 什么是NDK

NDK即Native Development Kit,是Android中的一个开发工具包,为我们提供native开发的环境

NDK是我们实现Java与Native进行交互的一个手段

2. NDK可以干什么

可以快速开发CC++的动态库,并自动将so和应用一起打包成 APK

即可通过 NDK使 Java与native代码(如 C、C++)交互

NDK在现在很多热门的技术中都有使用,如:Android 音视频开发,热更新,OpenCV,等等

3. 我们为什么要使用NDK

  1. 允许程序开发人员直接使用 C/C++ 源代码,极大的提高了 Android 应用程序开发的灵活性

  2. 跨平台应用移植、使用第三方库。如:许多第三方库只有 C/C++ 语言的版本,而 Android 应用程序需要使用现有的第三方库,如 FFmpeg、OpenCV等,则必须使用NDK

  3. 采用C++代码来处理性能要求高的操作,提高了Android APP的性能

  4. 安全性高

4. NDK与SDK的关系

在Android开发中,最常用的是SDK,那么SDK与NDK的关系是什么呢?

在SDK中,我们使用Java来进行开发,而在NDK中,我们使用C++来进行开发

SDK支持了Android开发中的大部分操作,如UI展示,用户与手机的交互等,主要是支持了Android APP开发的基础功能

NDK支持了一些复杂的,比较高级的操作,如音视频的解析,大量数据的运算,提高Android游戏的运行速度等,主要是Android APP的一些高级功能

所以NDK与SDK是并列关系,NDK是SDK的有效补充

二. NDK的组成部分

现在我们知道了NDK是什么,NDK的作业,优点了,那我们该开始正式学习NDK了,但是工欲善其事,必先利其器,在使用NDK这个工具以前,我们必须先好好地了解NDK

所以本部分将分析NDK中的一些组成及他们的作用

本部分将讲述NDK中的JNI ,二进制文件(.so 和 .a), ABI ,本机编译工具,交叉编译工具等等

1. JNI

  • 定义

    JNI 即Java Native Interface,即Javanative接口

    JNI是一种编程框架,使得 Java 虚拟机中的 Java 程序可以调用本地应用 / 或库,也可以被其他程序调用。 本地程序一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序

    上文中,NDK也是支持Java代码与Native代码的交互,那他们之间有什么区别呢?

    实际上,JNI是一个编程框架,是一个抽象的东西,NDK是一个工具包,是一个

    所以:NDK是实现JNI的一个手段

  • 作用 支持Java代码与native代码进行交互,即Java代码调用native代码 或者 native代码调用Java代码 native代码主要指CC++

  • 使用JNI的原因

    有些事情 Java 无法处理时,JNI 允许程序员用其他编程语言来解决,例如,Java 标准库不支持的平台相关功能或者程序库。也用于改造已存在的用其它语言写的程序,供 Java 程序调用

  • 使用JNI的步骤

    1. 使用Native关键字定义Java方法(即需要调用的native方法)

    2. 使用javac编译上述 Java源文件 (即 .java文件)最终得到 .class文件

    3. 通过 javah 命令编译.class文件,最终导出JNI的头文件(.h文件)

    4. 使用 Java需要交互的native代码,以及实现在 Java中声明的Native方法

    5. 编译.so库文件

    6. 通过Java命令执行 Java程序,最终实现Java调用native代码编译生成的.so文件

    实际上2345步骤的目的就是生成.so 文件

    所以上面的步骤可以归纳为三步:

    1. 声明Native方法

    2. 实现Native方法,经过一系列操作,最终生成.so 文件

    3. 加载.so文件,调用Native方法

      这里只简单介绍JNI的步骤,具体实现的例子在后面的博客中

2 .so 和 .a 文件

上面我们已经说到,JNI 支持了Java代码和native代码的互相调用

但是JNI是直接调用Java代码和native代码吗?

实际上,JNI是调用java代码和native代码编译后的.so.a文件来实现了Java代码和native代码的交互

那么.so.a文件是什么呢?下面我列出了.so和.a文件的一些定义

  • 动态链接库 (.so 后缀):

    运行时才动态加载这个库;

    动态链接库,也叫共享库,因为在 NDK 中用 shared 来表示是动态库

    现在热门的插件化,热更新以及缩小APK大小等技术都使用了 .so 文件

  • 静态链接库 (.a 后缀)

    编译的时候, 就把静态库打包进 APK 中

    • 缺点 : 使用静态库编译, 编译的时间比较长,同时也使得APK比较大

    • 优点 : 只导出一个库, 可以隐藏自己调用的库;

  • .so.a本质上都是二进制文件,下文我将用二进制文件统称这两个文件

  • 每个CPU系统只能使用相对应的二进制文件,即他们不像jar包一样,所有的CPU系统都可以使用一个jar包,.so 和 .a 每个系统必须使用自己的,不能使用别的,如 armeabi.so文件,不能被应用到x86

3. CPU架构

Android 平台,其支持的设备型号繁多,单单就设备的核心 CPU 而言,都有三大类:ARM、x86 和 MIPS

**ARM主要应用于手机中,x86主要应用于PC中

Android中使用x86主要是因为:因为PC是x86架构,所以PC上的手机模拟器需要x86的二进制文件

而在NDK r17中,有了大的变化:

在NDK r17 以后,NDK 不在支持32位和64位 MIPSARM v5(armeabi)

所以现在NDK只支持ARMx86,而ARMx86又各自分为两种:

简单的来说:ARM 和 x86 各分为 32位和64位两种,所以现在NDK一共支持4种CPU架构

即:ARM 32位 ,ARM 64位 , x86 32位 ,x86 64位

4. ABI

ABI : Application Binary Interface

我们上面说了,每个系统只能使用相对应的二进制文件,

不同的 Android 设备使用不同的 CPU,而不同的 CPU 支持不同的指令集。CPU 与指令集的每种组合都有专属的应用二进制接口 (ABI)

简而言之:而ABI定义了二进制文件是怎么运行在对应的CPU中的

上文中我们大致了解了Android中常用的CPU架构,而且我们知道,ABI 定义了二进制文件时怎么在CPU中运行的,那么我们可以知道,每一个CPU架构必定有一个相对应的ABI

上面我们已经知道了有四种,那么ABI也有四种,他们分别是:armeabi-v7a,armeabi-v8a,x86,x86_64

ABI对应的CPU架构应用
armeabi-v7aARM 32位手机
armeabi-v8aARM 64位手机
X86X86 32位PC
X86_64X86 64位PC

CPU架构中64位的CPU架构兼容32位的ABI和64位的ABI,32位的CPU架构只支持32位的ABI

armeabi-v7a设备只兼容armeabi-v7a

armeabi-v8a设备兼容armeabi-v7a,armeabi-v7a

X86设备只兼容X86_64

X86_64兼容X86,X86_64

NDK编译实际上默认编译出所有系统的文件

但是有时我们只需要使用指定的系统,我们就可以指定编译什么系统,减少二进制文件,避免我们不会使用到的二进制文件被打包到apk中,如我们在PC上使用模拟器来执行APP时,我们需要x86,但是我们APP最终是要在手机上运行的,这样我们只需要ARM的就行了,我们就可以在最终打包APK时,去掉x86的

我们可以使用下面的代码来指定我们要编译什么CPU架构的二进制文件

//在project的build.gradle中
android {
    defaultConfig {
        ndk {
            abiFilters 'arm64-v8a', 'x86_64'
        }
    }

5. 编译工具

5.1 本机编译工具

我们已经知道,每个系统只能使用自己系统的二进制文件,本机编译工具正是编译出本机系统可以使用的二进制文件

在Android中可以使用的本机编译工具有两种:ndk-buildcmake

  1. cmake

    我们已经知道,每个系统只能使用自己的二进制文件

    那么我们在开发中,就需要编译出对应的二进制文件,而且如果我们的软件想要跨平台,那么我们就得保证在每一个平台上都可以编译

    即:如果我们的软件想在Mac OS 和 Windows上运行,那么我们的软件在Mac OS上运行时,要生成可以在Mac OS上运行的二进制文件,在Windows上运行,要生成Windows上可以运行的二进制文件

    这样的话,那我们得为每一个平台都要编写一次MakeFile文件,这将是一个很无趣,很令人抓狂的操作

而CMake就是针对上述问题的一个工具

允许开发者编写一种与平台无关的CMakeList.txt文件来定制整个编译流程,然后再根据目标用户的平台逐步生成所需的本地化Makefile和工程文件,如Unix的Makefile或Windows的Visual Studio工程

从而达到“只写一次,到处运行

即:我们只要写一个 cmakeLists.txt文件,那么就可以在所有平台中编译出对应的二进制文件

这样的话,当运行在Windows上时,自动生成Windows的,在Mac OS行运行,自动生成MacOS的,但是,我们只需要写一个cmakeList.txt文件即可!!!

使用 CMake 生成 Makefile 并编译的流程如下:

  1. 编写 CMake 配置文件 CMakeLists.txt 。
  2. 执行命令 cmake PATH 或者 ccmake PATH 生成 Makefile(ccmakecmake 的区别在于前者提供了一个交互式的界面)。其中, PATH 是 CMakeLists.txt 所在的目录。
  3. 使用 make 命令进行编译。

当然,在Android开发过程中,我们不需要执行上面的流程,因为在 Android studio 当中已经为我们集成了,我们只需要编写cmakeLists.txt文件,剩下的就交给 Android studio 就行了

在Android开发过程中,使用cmake只需要两个文件,xxx.cppCMakeLists.txt

  • cmakeLists.txt 就如上面所述,定义整个的编译流程
  • xxx.cpp 则是要被编译的 C++ 文件

具体的实现方式我会在下面列出

  1. ndk-build

ndk-build是一个和cmake功能差不多的工具,他们都减少了我们定制编译流程的操作

但是ndk-build是以前常使用的工具,我们现在常用cmake,ndk-build的操作要比cmake复杂一些

ndk-build本质上是一个脚本,它的位置就在NDK目录的最上层,即在< NDK >/ndk-build路径下

因为是一个脚本,所以下面的命令等同于ndk-build

# $GNUMAKE 指 GNU Make 3.81 或更高版本
# <ndk> 则指向 NDK 安装目录
$GNUMAKE -f <ndk>/build/core/build-local.mk <parameters>

#等同于
ndk-build

使用ndk-build我们需要两个文件: Android.mk 和 Application.mk

  1. Android.mk

    Google的官方文档对Android.mk的定义如下

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

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

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

    此文件的语法与随整个 Android 开源项目分发的 Android.mk 文件中使用的语法非常接近。虽然使用这些语法的构建系统实现并不相同,但通过有意将语法设计得相似,可使应用开发者更轻松地将源代码重复用于外部库。

这个的定义很长,简单的来说,Android.mk的作用如下:

  1. 描述了源文件的位置和名字
  2. 描述了生成什么文件,如共享库,静态库等
  3. 自动处理构建中的一些事项,如文件之间的依赖关系
  4. 语法设置得与Android开源项目相似,使得我们可以轻易的将源代码重复使用

Android.mk的一些常用的变量如下:

  # 这个是源文件的路径,call my-dir表示了返回当前Android.mk所在的目录
  LOCAL_PATH := $(call my-dir)
  
  # 清除许多 LOCAL_XXX 变量
  # 注意:不会清除 LOCAL_PATH 
  include $(CLEAR_VARS)
  
  # LOCAL_MODULE 变量存储要构建的模块的名称
  # 这里最终会生成叫 libhello-jni.so 的文件
  LOCAL_MODULE := hello-jni
  
  # 源文件名字
  # 可以有多个源文件,使用空格隔开
  LOCAL_SRC_FILES := hello-jni.c
  
  # 指定编译出什么二进制文件
  # 这里编译出共享库,即:.so文件
  # 编译出静态库可以使用: BUILD_STATIC_LIBRARY
  include $(BUILD_SHARED_LIBRARY)
  1. Application.mk

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

    简单的来说,Application.mk的功能主要是:主要是描述了Android Native开发需要的模组(module)

    一些常用的变量如下:

# 定义生成的二进制文件要生成的CPU架构
# 这里指定生成 arm64-v8a 可以用的二进制文件
APP_ABI := arm64-v8a

# 定义可以使用该二进制文件的Android版本
APP_PLATFORM := android-21

# 默认情况下,ndk-build 假定 Android.mk 文件位于项目根目录的相对路径 jni/Android.mk 中。
# 要从其他位置加载 Android.mk 文件,将 APP_BUILD_SCRIPT 设置为 Android.mk 文件的绝对路径。
APP_BUILD_SCRIPT

  1. cmake 和 ndk-build 都可以在Android开发中使用,但是现在默认使用的是cmake,以前常用的是ndk-build

    ndk-build 的实现过程比cmake复杂的多,所以现在推荐使用cmake

    但是因为以前的项目常用 ndk-build ,如果我们要参与以前 NDK项目 的开发,那么 ndk-build也是需要了解的

    所以,如果要新建一个项目,那么推荐使用cmake,要参与以前老项目的开发,ndk-build也不可落下

5.2交叉编译工具

与本机编译对应的,有时我们需要编译出其他系统的二进制文件,如我们在PC上写Android文件,那么我们PC中就需要编译出Android中可以运行的二进制文件

交叉编译工具,又叫交叉编译链( toolchain)

交叉编译链(编译器、链接器等)来生成可以在其他系统中运行的二进制文件

在NDK中,交叉编译工具主要有两种:clanggcc

三. NDK中一些值得注意的事情

1. NDK版本变化的问题

1.1 NDK中编译工具的变化

cmake 和 ndk-build 都可以在Android开发中使用,但是现在默认使用的是cmake,以前常用的是ndk-build

ndk-build 的实现过程比cmake复杂的多,所以现在推荐使用cmake

但是因为以前的项目常用 ndk-build ,如果我们要参与以前 NDK项目 的开发,那么 ndk-build也是需要了解的

所以,如果要新建一个项目,那么推荐使用cmake,要参与以前老项目的开发,ndk-build也不可落下

1.2 NDK中交叉编译的工具的变化

在ndk r17c 以后默认使用的变成了clang,而不是gcc

库文件和头文件的变化

在r17c以前,头文件,库文件以及gcc的路径如下:

# 库文件路径
android-ndk-r17c/platforms/android-21/arch-arm/usr/lib

# 头文件路径
android-ndk-r17c/sysroot/usr/include

#gcc的路径
android-ndk-r17c/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin

而在r17以后,如 r20 中,头文件和库文件统一放到了sysroot中:

 #头文件
 toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/include
 
 #库文件
 toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib

我们可以发现 r20 把头文件和库文件弄到了一起

但是,r20 中也没有把 r17 中的库文件删除,即我们也可以在下面的路径中找到头文件

android-ndk-r17c/platforms/android-21/arch-arm/usr/lib
交叉编译工具位置的变化

NDK r19以前ndk 内置了一个可以自动生成交叉编译工具(toolchain).py文件,放在

ndk路径下面的build/tool/make_standalone_toolchain.py

要生成toolchain,使用下面的命令

$NDK_HOME/build/tools/make_standalone_toolchain.py --arch arm --api 21 --install-dir /Users/fczhao/Desktop

后面的几个都是必要的

--arch 指定了生成的toolchain要在哪个CPU框架中使用

--api 指定了生成的toolchain要在哪个Android API 中使用

--install-dir 生成的toolchain的路径

如果使用的是NDK r19以前的,可以参考下面的这个文章

https://developer.android.com/ndk/guides/standalone_toolchain?hl=zh-cn

但是NDK r19以后的NDK已经内置了这些文件,如果运行上面的命令,会出现这样的日志

WARNING:__main__:make_standalone_toolchain.py is no longer necessary. The

#$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin 这个路径已经有了我们要生成的文件
$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin directory contains target-specific scripts that perform
the same task. For example, instead of:

    $ python $NDK/build/tools/make_standalone_toolchain.py \
        --arch arm --api 21 --install-dir toolchain
    $ toolchain/bin/clang++ src.cpp

Instead use:

    $ $NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi21-clang++ src.cpp

Installation directory already exists. Use --force.

然后让我们去上面输出的路径中看看,就可以看到NDK的确已经提供了很多的clang,这里我只截取了一部分

1.3 NDK支持的CPU架构的变化

上文中我们在ABI已经提到了CPU架构相关的东西,但是为了增加印象,这里稍微重新提一下

Android 平台核心 CPU有三大类:ARM、x86 和 MIPS

而在NDK r17中,有了大的变化:

在NDK r17 以后,NDK 不在支持32位和64位 MIPSARM v5(armeabi)

所以现在NDK中只支持armeabi-v7a,armeabi-v8a,x86,x86_64四类

2. NDK 的系统是否适用的问题

值得注意的是:
在下载NDK时,不像我们下载JDK一样

当下载同一版本的JDK时,所有的电脑(不论是macOS,Windows还是Linus)下载的都是一样

而当我们下载相同版本的NDK时,会发现Google提供了适合于不同系统的NDK,如下图

之所以提供针对每个系统的NDK的原因是

因为Java良好的跨平台性,所以所有的系统都可以使用同一个JDK

而NDK调用的是C++代码,C++没有这么好的跨平台性,从而我们必须为每一个平台配置适合于它的NDK

所以在我们看别人的NDK博客时,一定要注意是否与自己使用的NDK是一个系统的,一个版本的,否则一定会出现问题

3. NDK 中Java与native代码的交互

Java 和 c/c++ 有两种交互方式:

  • Java调用 native 代码

  • native 代码调用 Java 代码

也就是说,交互是Java与C++之间的互相调用

但是在NDK中他们实际上的顺序是:

Java调用C++代码,然后C++调用Java代码返回C++执行后的数据,如图所示

从图中可以看到,我们使用NDK的目的实际上是:

Java调用C++,让C++处理一些复杂的操作,然后把处理后的数据返回到Java中

​ 而我们为什么不直接使用Java来进行处理,而是绕了一圈,通过 Java -> C++ -> Java的方式来实现,其中的原因是:

​ C++速度比Java快

​ 举个简单的例子,在ACM或者LeetCode中,C++代码运行的超时时间是 1s,而Java的是 2s

​ 所以对于一些计算量很大的操作,如音视频的渲染等我们采用C++可以有效的减少计算时间和内存占用,减少手机发热和耗电量等


现在我们已经大致了解了一些NDK的东西了,后面的博客我将分别分析cmake,ndk-build,clang,gcc的具体实现过程



作者:TorMooa
链接:https://www.jianshu.com/p/fe54d70a260e
 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值