Android NDK开发——基本概念_android ndk视频教程

在Android开发中,有时候出于安全,性能,代码共用的考虑,需要使用C/C++编写的库。虽然在现代化工具链的支持下,这个工作的难度已经大大降低,但是毕竟万事开头难,初学者往往还是会遇到很多不可预测的问题。本篇就是基于此背景下写的一份简陋指南,希望能对刚开始编写C/C++库的读者有所帮助。同时为了尽可能减少认知断层,本篇将试着从一个最简单的功能开始,逐步添加工具链,直到实现最终功能,真正做到知其然且之所以然。

目标

本篇的目标很简单,就是能在Android应用中调用到C/C++的函数——接收两个整型值,返回两者相加后的值,暂定这个函数为plus。

从C++源文件开始

为了从我们最熟悉的地方开始,我们先不用复杂工具,先从最原始的C++源文件开始. 打开你喜欢的任何一个文本编辑器,VS Code,Notpad++,记事本都行,新建一个文本文件,并另存为math.cpp。接下来,就可以在这个文件中编写代码了. 前面我们的目标已经说得很清楚,实现个plus函数,接收两个整型值,返回两者之和,所以它可能是下面这样

int plus(int left,int right)
{
    return left + right;
}

我们的源文件就这样完成了,是不是很简单。 但是仅仅有源文件是不够的,因为这个只是给人看的,机器看不懂。所以我们就需要第一个工具——编译器。编译器能帮我们把人看得懂的转化成机器也能看得懂的东西。

编译器

编译器是个复杂工程,但是都是服务于两个基本功能

  1. 理解源文件的内容(人能看懂的)——检查出源文件中的语法错误
  2. 理解二进制的内容(机器能看懂的)——生成二进制的机器码。 基于这两个朴素的功能,编译器却是挠断了头。难点在于功能2。基于这个难点编译器分成了很多种,常见的像Windows平台的VS,Linux平台的G++,Apple的Clang。而对于Android来说,情况略有不同,前面这些编译器都是运行在特定系统上的,编译出来的程序通常也只能运行在对应的系统上。以我现在的机器为例,我现在是在Deepin上写的C++代码,但是我们的目标是让代码跑在Android手机上,是两个不同的平台。更悲观的是,目前为止,还没有一款可以在手机上运行的编译器。那我们是不是就不能在手机上运行C++代码了?当然不是,因为有交叉编译。 交叉编译就是在一个平台上将代码生成另一个平台可执行对象的技术。它和普通编译最大的不同是在链接上。因为一般的链接直接可以去系统库找到合适的库文件,而交叉编译不行,因为当前的平台不是最终运行代码的平台。所以交叉编译还需要有目标平台的常用库。当然,这些Google都替我们准备好了,称为NDK。

NDK

NDK全称是Native Development Kit,里面有很多工具,编译器,链接器,标准库,共享库。这些都是交叉编译必不可少的部分。为了理解方便,我们首先来看看它的文件结构。以我这台机器上的版本为例——
/home/Andy/Android/Sdk/ndk/21.4.7075529(Windows上默认位置则是c:\Users\xxx\AppData\Local\Android\Sdk\)。 NDK就保存在Sdk目录下,以ndk命名,并且使用版本号作为该版本的根目录,如示例中,我安装的NDK版本就是21.4.7075529。同时该示例还是ANDROID_NDK这个环境变量的值。也就是说,在确定环境变量前,我们需要先确定选用的NDK版本,并且路径的值取到版本号目录。

了解了它的存储位置,接下来我们需要认识两个重要的目录

  • build/cmake/,这个文件夹,稍后我们再展开。
  • toolchains/llvm/prebuild/linux-x86_64,最后的linux-x86_64根据平台不同,名称也不同,如Windows平台上就是以Windows开头,但是一般不会找错,因为这个路径下就一个文件夹,并且前面都是一样的。这里有我们心心念念的编译器,链接器,库,文件头等。如编译器就存在这个路径下的bin目录里,它们都是以clang和clang++结尾的,如aarch64-linux-android21-clang++
  1. aarch64代表着这个编译器能生成用在arm64架构机器上的二进制文件,其他对应的还有armv7a,x86_64等。不同的平台要使用相匹配的编译器。它就是交叉编译中所说的目标平台。
  2. linux代表我们执行编译这个操作发生在linux机器上,它就是交叉编译中所说的主机平台。
  3. android21这个显然就是目标系统版本了
  4. clang++代表它是个C++编译器,对应的C编译器是clang。

可以看到,对于Android来说,不同的主机,不同的指令集,不同的Android版本,都对应着一个编译器。
了解了这么多,终于到激动人性的时刻啦,接下来,我们来编译一下前面的C++文件看看。

编译

通过
aarch64-linux-android21-clang++ --help查看参数,会发现它有很多参数和选项,现在我们只想验证下我们的C++源文件有没有语法错误,所以就不管那些复杂的东西,直接一个
aarch64-linux-android21-clang++ -c math.cpp执行编译。

命令执行完后,假如一切顺利,就会在math.cpp相同目录下生成math.o对象文件,说明我们的源码没有语法错误,可进行到下一步的链接。

不过,在此之前,先打断一下。通常我们的项目会包含很多源文件,引用一些第三方库,每次都用手工的形式编译,链接显然是低效且容易出错的。在工具已经很成熟的现在,我们应该尽量使用成熟的工具,将重心放在我们的业务逻辑上来,CMake就是这样的一个工具。

CMake

CMake是个跨平台的项目构建工具。怎么理解呢?编写C++代码时,有时候需要引用其他目录的文件头,但是在编译阶段,编译器是不知道该去哪里查找文件头的,所以需要一种配置告诉编译器文件头的查找位置。再者,分布在不同目录的源码,需要根据一定的需求打包成不同的库。又或者,项目中引用了第三方库,需要在链接阶段告诉链接器从哪个位置查找库,种种这些都是需要配置的东西。

而不同的系统,不同的IDE对于上述配置的支持是不尽相同的,如Windows上的Visual Studio就是需要在项目的属性里面配置。在开发者使用同样的工具时,问题还不是很大。但是一旦涉及到多平台,多IDE的情况,协同开发就会花费大把的时间在配置上。CMake就是为了解决这些问题应运而生的。

CMake的配置信息都是写在名为CMakeLists.txt的文件中。如前面提到头文件引用,源码依赖,库依赖等等,只需要在CmakeLists.txt中写一次,就可以在Windows,MacOS,Linux平台上的主流IDE上无缝使用。如我在Windows的Visual Studio上创建了一个CMake的项目,配置好了依赖信息,传给同事。同事用MacOS开发,他可以在一点不修改的情况下,马上完成编译,打包,测试等工作。这就是CMake跨平台的威力——简洁,高效,灵活。

使用CMake管理项目

建CMake项目

我们前面已经有了math.cpp,又有了CMake,现在就把他们结合一下。 怎样建立一个CMake项目呢?一共分三步:

  1. 建一个文件夹。示例中我们就建一个math的文件夹吧。
  2. 在新建的文件夹里新建CMakeLists.txt文本文件。注意,这里的文件名不能变。
  3. 在新建的CMakeLists.txt文件里配置项目信息。

最简单的CMake项目信息需要包括至少三个东西

  • 支持的最低CMake版本
cmake_minimum_required(VERSION 3.18.1)
  • 项目名称
project(math)
  • 生成物——生成物可能是可执行文件,也可能是库。因为我们要生成Android上的库,所以这里是的生成物是库。
add_library(${PROJECT_NAME} SHARED math.cpp)

经过这三步,CMake项目就建成了。下一步我们来试试用CMake来编译项目。

编译CMake项目

在执行真正的编译前,CMake有个准备阶段,这个阶段CMake会收集必要的信息,然后生成满足条件的工程项目,然后才能执行编译。 那么什么是必要的信息呢?CMake为了尽可能降低复杂性,会自己猜测收集一些信息。

如我们在Windows上执行生成操作,CMake会默认目标平台就是Windows,默认生成VS的工程,所以在Windows上编译Windows上的库就几乎是零配置的。

  1. 在math目录下新建一个build的目录,然后把工作目录切换到build目录。
cd build
cmake ..

在命令执行之后,就能在build目录下找到VS的工程,可以直接使用VS打开,无错误地完成编译。当然,更快的方法还是直接使用CMake编译.

  1. 使用CMake编译
cmake --build .

注意前面的..代表父目录,也就是CMakeLists.txt文件存在的math目录,而.则代表当前目录,即build这个目录。假如这两步都顺利执行了,我们就能在build目录下收获一个库文件。Windows平台上可能叫math.dll,而Linux平台上可能叫math.so,但是都是动态库,因为我们在CMakelists.txt文件里配置的就是动态库。

从上面的流程来看,CMake的工作流程不复杂。但是我们使用的是默认配置,也就是最终生成的库只能用在编译的平台上。要使用CMake编译Android库,我们就需要在生成工程时,手动告诉CMake一些配置,而不是让CMake去猜。

CMake的交叉编译

配置参数从哪来

虽然我们不知道完成交叉编译的最少配置是什么,但是我们可以猜一下。

首先要完成源码的编译,编译器和链接器少不了,前面也知道了,Android平台上有专门的编译器和链接器,所以至少有个配置应该是告诉CMake用哪一个编译器和链接器。

其次Android的系统版本和架构也是必不可少的,毕竟对于Android开发来说,这个对于Android应用都很重要。

还能想到其他参数吗,好像想不到了。不过,好消息是,Google替我们想好了,那就是直接使用CMAKE——TOOLCHAIIIN_FILE。这个选项是CMake 提供的,使用的时候把配置文件路径设置为它的值就可以了,CMake会通过这个路径查找到目标文件,使用目标文件里面的配置代替它的自己靠猜的参数。而这个配置文件,就是刚才提到过的两个重要文件夹之一的build/camke,我们的配置文件就是该文件夹下面的android.toolchain.cmake。

Google的CMake配置文件

android.toolchain.cmake扮演了一个包装器的作用,它会利用提供给它的参数,和默认的配置,共同完成CMake的配置工作。其实这个文件还是个很好的CMake学习资料,可以学到很多CMake的技巧。现在,我们先不学CMake相关的,先来看看我们可用的参数有哪些。在文件的开头,Google就把可配置的参数都列举出来了

ANDROID_TOOLCHAIN
ANDROID_ABI
ANDROID_PLATFORM
ANDROID_STL
ANDROID_PIE
ANDROID_CPP_FEATURES
ANDROID_ALLOW_UNDEFINED_SYMBOLS
ANDROID_ARM_MODE
ANDROID_ARM_NEON
ANDROID_DISABLE_FORMAT_STRING_CHECKS
ANDROID_CCACHE

这些参数其实不是CMake的参数,在配置文件被执行的过程中,这些参数会被转换成真正的CMake参数。我们可以通过指定这些参数的值,让CMake完成不同的构建需求。假如都不指定,则会使用默认值,不同的NDK版本,默认值可能会不一样。

我们来着重看看最关键的ANDROID_ABI和ANDROID_PLATFORM。前面这个是指当前构建的包运行的CPU指令集是哪一个,可选的值有arneabi-v7a,arn64-v8a,x86,x86_64,mips,mips64。后一个则是指构建包的Android版本。它的值有两种形式,一种就是直接android-[version]的形式[version]在使用时替换成具体的系统版本,如android-23,代表最低支持的系统版本是Android 23。另一种形式是字符串latest。这个值就如这个单词的意思一样,用最新的。

那么我们怎么知道哪个参数可以取哪些值呢,有个简单方法:先在文件头确定要查看的参数,然后全局搜索,看set和if相关的语句就能确定它支持的参数形式了。

使用配置文件完成交叉编译

说了那么一大堆,回到最开始的例子上来。现在我们有了CMakelists.txt,还有了math.cpp,又找到了针对Android的配置文件android.toolchin.cmake。那么怎样才能把三者结合起来呢,这就不得不提到CMake的参数配置了。

在前面,我们直接使用

cmake ..

就完成了工程文件的生成配置,但是其实它是可以传递参数的。_**CMake的参数都是以-D开头,用空白符分割的键值对。**而CMake缺省的参数都是以CMAKE为开头的,所以大部分情况下参数的形式都是-DCMAKE_XXX这种。如给CMake传递toolchain文件的形式就是

cmake -DCMAKE_TOOLCHAIN_FILE=/home/Andy/Android/Sdk/ndk/21.4.7075529/build/cmake/android.toolchain.cmake

这个参数的意思就是告诉CMake,使用=后面指定的文件来配置CMake的参数。 然而,完成交叉编译,我们还少一个选项——-G。这个选项是交叉编译必需的。因为交叉编译CMake不知道该生成什么形式的工程,所以需要使用这个选项指定生成工程的类型。一种是传统形式的Make工程,指定形式是

cmake -G "Unix Makefiles"

可以看出,这种形式是基于Unix平台下的Make工程的,它使用make作为构建工具,所以指定这种形式以后,还需要指定make的路径,工程才能顺利完成编译。而另一种Google推荐的方式是Ninja,这种方式更简单,因为不需要单独指定Ninja的路径,它默认就随CMake安装在同一个目录下,所以可以减少一个传参。Ninja也是一种构建工具,但是专注速度,所以我们这一次就使用Ninja。它的指定方式是这样的

cmake -G Ninja

结合以上两个参数,就可以得到最终的编译命令

cmake -GNinja -DCMAKE_TOOLCHAIN_FILE=/home/Andy/Android/Sdk/ndk/21.4.7075529/build/cmake/android.toolchain.cmake ..

生成工程后再执行编译

cmake --build .

我们就得到了最终能运行在Android上的动态库了。用我这个NDK版本编译出来的动态库支持的Android版本是21,指令集是armeabi-v7a。当然根据前面的描述我们可以像前面传递toolchain文件一下传递期望的参数,如以最新版的Android版本构建x86的库,就可以这样写

cmake -GNinja -DCMAKE_TOOLCHAIN_FILE=/home/Andy/Android/Sdk/ndk/21.4.7075529/build/cmake/android.toolchain.cmake -DANDROID_PLATFORM=latest -DANDROID_ABI=x86 ..

这就给我们个思路,假如有些第三方库没有提供编译指南,但是是用CMake管理的,我们就可以直接套用上面的公式来编译这个第三方库。

JNI

前面在CMake的帮助下,我们已经得到了libmath.so动态库,但是这个库还是不能被Android应用直接使用,因为Android应用是用Java(Kotlin)语言开发的,而它们都是JVM语言,代码都是跑在JVM上的。要想使用这个库,还需要想办法让库加载到JVM中,然后才有可能访问得到。它碰巧的是,JVM还真有这个能力,它就是JNI。

JNI基本思想

JNI能提供Java到C/C++的双向访问,也就是可以在Java代码里访问C/C++的方法或者数据,反过来也一样支持,这过程中JVM功不可没。所以要理解JNI技术,需要我们以JVM的角度思考问题。 JVM好比一个货物集散中心,无论是去哪个地方的货物都需要先来到这个集散中心,再通过它把货物分发到目的地。这里的货物就可以是Java方法或者C/C++函数。但是和普通的快递不一样的是,这里的货物不知道自己的目的地是哪里,需要集散中心自己去找。那么找的依据从哪里来呢,也就是怎样保证集散中心查找结果的唯一性呢,最简单的方法当然就是货物自己标识自己,并且保证它的唯一性。 显然对于Java来说,这个问题很好解决。Java有着层层保证唯一性的机制。

  1. 包名可以保证类名的唯一性;
  2. 类名可以保证同一包名下类的唯一性;
  3. 同一个类下可以用方法名保证唯一性;
  4. 方法发生重载的时候可以用参数类型和个数确定类的唯一性。

而对于C/C++来说,没有包名和类名,那么用方法名和方法参数可以确定唯一性吗?答案是可以,只要我们把包名和类名作为一种限定条件。

而添加限定条件的方式有两种,一种就是简单粗暴,直接把包名类名作为函数名的一部分,这样JVM也不用看其他的东西,直接粗暴地将包名,类名,函数名和参数这些对应起来就能确定对端对应的方法了。这种方法叫做静态注册。其实这和Android里面的广播特别像:广播的静态注册就是直接粗暴地在AndroidManifest文件中写死了,不用在代码里配置,一写了就生效。对应于静态注册,肯定还有个动态注册的方法。动态注册就是用写代码的方式告诉JVM函数间的对应关系,而不是让它在函数调用时再去查找。显然这种方式的优势就是调用速度更快一点,毕竟我们只需要一次注册,就可以在后续调用中直接访问到对端,不再需要查找操作。但是同样和Android中广播的动态注册一样,动态注册要繁琐得多,而且动态注册还要注意把握好注册时机,不然容易造成调用失败。我们继续以前面的libmath.so为例讲解。

Java使用本地库

Java端访问C/C++函数很简单,一共分三步:

  1. Java调用System.loadLibrary()方法载入库
System.loadlibrary("math.so");

这里有个值得注意的地方,CMake生成的动态库是libmath.so,但是这里只写了math.so,也就是说不需要传递lib这个前缀。这一步执行完后,JVM就知道有个plus函数了。

  1. Java声明一个和C++函数对应的native方法。这里对应指的是参数列表和返回值要保持一致,方法名则可以不一致。
public native int nativePlus(int left,int right);

通常,习惯将native方法添加native的前缀。

  1. 在需要的地方直接调用这个native方法。调用方法和普通的Java方法是一致的,传递匹配的参数,用匹配的类型接收返回值。

把这几布融合到一个类里面就是这样

package hongui.me;

import android.os.Bundle;

import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;

import hongui.me.databinding.ActivityMainBinding;

public class MainActivity extends AppCompatActivity {

    static {
        System.loadLibrary("me");
    }

    ActivityMainBinding binding;

    private native int nativePlus(int left,int right);

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        // Example of a call to a native method
        binding.sampleText.setText("1 + 1 = "+nativePlus(1,1));
    }
}

C/C++端引入JNI

JNI其实对于C/C++来说是一层适配层,在这一层主要做函数转换的工作,不做具体的功能实现,所以,通常来说我们会新建一个源文件,用来专门处理JNI层的问题,而JNI层最主要的问题当然就是前面提到的方法注册问题了。

静态注册

静态注册的基本思路就是根据现有的Java native方法写一个与之对应的C/C++函数签名,具体来说分四步。

  1. 先写出和Java native函数一模一样的函数签名
int nativePlus(int left,int right)
  1. 在函数名前面添加包名和类名。因为包名在Java中是用.分割的,而C/C++中点通常是用作函数调用,为了避免编译错误,需要把.替换成_。
hongui_me_MainActivity_nativePlus(int left,int right)
  1. 转换函数参数。前面提到过所有的操作都是基于JVM的,在Java中,这些是自然而然的,但是在C/C++中就没有JVM环境,提供JVM环境的形式就只能是添加参数。

为了达到这个目的,任何JNI的函数都要在参数列表开头添加两个参数。而Java里面的最小环境是线程,所以第一个参数就是代表调用这个函数时,调用方的线程环境对象JNIEnv,这个对象是C/C++访问Java的唯一通道。第二个则是调用对象。因为Java中不能直接调用方法,需要通过类名或者某个类来调用方法,第二个参数就代表那个对象或者那个类,它的类型是jobjet。从第三个参数开始,参数列表就和Java端一一对应了,但是也只是对应,毕竟有些类型在C/C++端是没有的,这就是JNI中的类型系统了,对于我们当前的例子来说Java里面的int值对应着JNI里面的jint,所以后两个参数都是jint类型。这一步至关重要,任何一个参数转换失败都可能造成程序崩溃。

hongui_me_MainActivity_nativePlus(
        JNIEnv* env,
        jobject /* this */,
        jint left,
        jint right)
  1. 添加必要前缀。这一步会很容易被忽略,因为这一部分不是那么自然而然。首先我们的函数名还得加一个前缀Java,现在的函数名变成了这样Java_hongui_me_MainActivity_nativePlus。其次在返回值两头需要添加JNIEXPORT和JNICALL,这里返回值是jint,所以添加完这两个宏之后是这样JNIEXPORT jint JNICALL。最后还要在最开头添加extern "C"的兼容指令。至于为啥要添加这一步,感兴趣的读者可以去详细了解,简单概括就是这是JNI的规范。

经过这四步,最终静态方法找函数的C/C++函数签名变成了这样

#include "math.h"

extern "C" JNIEXPORT jint JNICALL
Java_hongui_me_MainActivity_nativePlus(
        JNIEnv* env,
        jobject /* this */,
        jint left,
        jint right){
           return plus(left,right);
        }

注意到,这里我把前面的math.cpp改成了math.h,并在JNI适配文件(文件名是native_jni.cpp)中调用了这个函数。所以现在有两个源文件了,需要更新一下CMakeList.txt。

cmake_minimum_required(VERSION 3.18。1)

project(math)

add_library(${PROJECT_NAME} SHARED native_jni.cpp)

可以看到这里我们只把最后一行的文件名改了,因为CMakeLists.txt当前所在的目录也是include的查找目录,所以不需要给它单独设置值,假如需要添加其他位置的头文件则可以使用include_directories(dir)添加。 现在使用CMake重新编译,生成动态库,这次Java就能直接不报错运行了。

动态注册

前面提到过动态注册需要注意注册时机,那么什么算是好时机呢?在前面Java使用本地库这一节,我们知道,要想使用库,必须先载入,载入成功后就可以调用JNI方法了。那么动态注册必然要发生在载入之后,使用之前。JNI很人性化的想到了这一点,在库载入完成以后会马上调用jint JNI_OnLoad(JavaVM *vm, void *reserved)这个函数,这个方法还提供了一个关键的JavaVM对象,简直就是动态注册的最佳入口了。确定了注册时机,现在我们来实操一下。_注意:动态注册和静态注册都是C/C++端实现JNI函数的一种方式,同一个函数一般只采用一种注册方式。_所以,接下来的步骤是和静态注册平行的,并不是先后关系。

动态注册分六步

  1. 新建native_jni.cpp文件,添加JNI_OnLoad()函数的实现。
extern "C" JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void *reserved) {

   return JNI_VERSION_1_6;
}

这就是这个函数的标准形式和实现,前面那一串都是JNI函数的标准形式,关键点在于函数名和参数以及返回值。要想这个函数在库载入后自动调用,函数名必须是这个,而且参数形式也不能变,并且用最后的返回值告诉JVM当前JNI的版本。也就是说,这些都是模板,直接搬就行。

  1. 得到JNIEnv对象

前面提到过,所有的JNI相关的操作都是通过JNIEnv对象完成的,但是现在我们只有个JavaVM对象,显然秘诀就在JavaVM身上。
通过它的GetEnv方法就可以得到JNIEnv对象

JNIEnv *env = nullptr;
vm->GetEnv(env, JNI_VERSION_1_6);
  1. 找到目标类

前面说过,动态注册和静态注册都是要有包名和类名最限定的,只是使用方式不一样而已。所以动态注册我们也还是要使用到包名和类名,不过这次的形式又不一样了。静态注册包名类名用_代替.,这一次要用/代替.。所以我们最终的类形式是hongui/me/MainActivity。这是一个字符串形式,怎样将它转换成JNI中的jclass类型呢,这就该第二步的JNIEnv出场了。

jclass cls=env->FindClass("hongui/me/MainActivity");

这个cls对象就和Java里面那个MainActivity是一一对应的了。有了类对象下一步当然就是方法了。

  1. 生成JNI函数对象数组。

因为动态注册可以同时注册一个类的多个方法,所以注册参数是数组形式的,而数组的类型是JNINativeMethod。这个类型的作用就是把Java端的native方法和JNI方法联系在一起,怎么做的呢,看它结构。

typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;
  • name对应Java端那个native的方法名,所以这个值应该是nativePlus。
  • signature对应着这个native方法的参数列表外加函数类型的签名。

什么是签名呢,就是类型简写。在Java中有八大基本类型,还有方法,对象,类。数组等,这些东西都有一套对应的字符串形式,好比是一张哈希表,键是类型的字符串表示,值是对应的Java类型。如jint是真正的JNI类型,它的类型签名是I,也就是int的首字母大写。

函数也有自己的类型签名(paramType)returnType这里的paramType和returnType都需要是JNI类型签名,类型间不需要任何分隔符。

综上,nativePlus的类型签名是(II)I。两个整型参数,返回另一个整型。

  • fnPtr正如它名字一样,它是一个函数指针,值就是我们真正的nativePlus实现了(这里我们还没有实现,所以先假定是jni_plus)。

综上,最终函数对象数组应该是下面这样

 JNINativeMethod methods[] = {
    {"nativePlus","(II)I",reinterpret_cast(jni_plus)}
 };
  1. 注册

现在有了代表类的jclass对象,还有了代表方法的JNINativeMethod数组,还有JNIEnv对象,把它们结合起来就可以完成注册了

env->RegisterNatives(cls,methods,sizeof(methods)/sizeof(methods[0]));

这里第三个参数是代表方法的个数,我们使用了sizeof操作法得出了所有的methods的大小,再用sizeof得出第一个元素的大小,就可以得到methods的个数。当然,这里直接手动填入1也是可以的。

  1. 实现JNI函数 在第4步,我们用了个jni_plus来代表nativePlus的本地实现,但是这个函数实际上还没有创建,我们需要在源文件中定义。现在这个函数名就可以随便起了,不用像静态注册那样那么长还不能随便命名,只要保持最终的函数名和注册时用的那个名字一致就可以了。但是这里还是要加上extern "C"的前缀,避免编译器对函数名进行特殊处理。参数列表和静态注册完全一致。所以,我们最终的函数实现如下。
#include "math.h"

extern "C" jint jni_plus(
        JNIEnv* env,
        jobject /* this */,
        jint left,
        jint right){
           return plus(left,right);
        }

好了,动态注册的实现形式也完成了,CMake编译后你会发现结果和静态注册完全一致。所以这两种注册方式完全取决于个人喜好和需求,当需要频繁调用native方法时,我觉得动态注册是有优势的,但是假如调用次数很少,完全可以直接用静态注册,查找消耗完全可以忽略不记。

One more thing

前面我提到CMake是管理C/C++项目的高手,但是对于Android开发来说,Gradle才是YYDS。这一点Google也意识到了,所以gradle的插件上直接提供了CMake和Gradle无缝衔接的丝滑配置。在android这个构建块下,可以直接配置CMakeLists.txt的路径和版本信息。

externalNativeBuild {
        cmake {
            path file('src/main/cpp/CMakeLists.txt')
            version '3.20.5'
        }
    }

这样,后面无论是修改了C/C++代码,还是修改了Java代码,都可以直接点击运行,gradle会帮助我们编译好相应的库并拷贝到最终目录里,完全不再需要我们手动编译和拷贝库文件了。当然假如你对它的默认行为还不满意,还可以通过defaultConfig配置默认行为,它的大概配置可以是这样

android {
    compileSdkVersion 29

    defaultConfig {
        minSdkVersion 21
        targetSdkVersion 29

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles 'consumer-rules.pro'

        externalNativeBuild {
            cmake {
                cppFlags += "-std=c++1z"
                arguments '-DANDROID_STL=c++_shared'
                abiFilters 'armeabi-v7a', 'arm64-v8a'
            }
        }
    }
}

这里cppFlags是指定C++相关参数的,对应的还有个cFlags用来指定C相关参数。arguments则是指定CMake的编译参数,最后一个就是我们熟悉的库最终要编译生成几个架构包了,我们这里只是生成两个。

有了这些配置,Android Studio开发NDK完全就像开发Java一样,都有智能提示,都可以即时编译,即时运行,纵享丝滑。

总结

NDK开发其实应该分为两部分,C++开发和JNI开发。

C++开发和PC上的C++开发完全一致,可以使用标准库,可以引用第三方库,随着项目规模的扩大,引入了CMake来管理项目,这对于跨平台项目来说优势明显,还可以无缝衔接到Gradle中。

而JNI开发则更多的是关注C/C++端和Java端的对应关系,每一个Java端的native方法都要有一个对应的C/C++函数与之对应,JNI提供静态注册和动态注册两种方式来完成这一工作,但其核心都是利用包名,类名,函数名,参数列表来确定唯一性。静态注册将包名,类名体现在函数名上,动态注册则是使用类对象,本地方法对象,JNIENV的注册方法来实现唯一性。
NDK则是后面的大BOSS,它提供编译器,链接器等工具完成交叉编译,还有一些系统自带的库,如log,z,opengl等等供我们直接使用。--- title: "现代C++学习指南 模板" description: "现代C++学习指南 模板"

date: 2022-10-31T21:33:38+08:00

author: "hongui"

categories:

  • C++ tags:
  • 学习指南
  • C++

draft: false

模板作为C++重要的特性,一直有着举足轻重的地位,是编写高度抽象代码的利器。

什么是模板

模板在现实生活中就是范例:把都一样的部分固定起来,把变动的部分空出来,使用时将两部分合起来组成有效的东西。如申请书,Word模板都是这种形式。C++中的模板也是如此,不过更明确的是C++中的模板,变动的部分是一个代指类型的东西,称之为泛型参数。

我们先从一个例子来看一看模板是怎样发展而来的。如我们需要计算两个对象相加的结果,该如何写代码呢?在写代码前,我们有几个问题需要讨论清楚: 首先我们需要确定的是这两个对象是什么类型,毕竟C++是强类型的编程语言,变量,函数,类都是要明确指定类型是什么的,不确定的类型编译就不能通过。我们先假设这两个类型是整型。确定了类型之后,还需要确定这两个对象需要怎样加起来,根据我们假设的整型,我们知道可以直接调用运算符+。最后我们需要确定,两个对象相加后的结果类型是什么,整型相加的结果也是整型。综上,这个例子的代码看起来可能是这样的

int sum(int left,int right){
    return left + right;
}

这个例子很简单,简单到甚至都不需要单独写成一个函数。如果我们需要计算的数据不是两个数,而是一个数组的和呢?基于前面的分析和假设,我们也能很快实现相应的代码

int sum(const int data[], const std::size_t length) {
    int result{};
    for (int i = 0; i < length;++i) {
        result += *(data + i);
    }
    return result;
}

同样很简单。但是遗憾的是,这个函数通用性不强,它只能计算整型的数组和,假如我们需要计算带有小数点的数组和,它就不灵了,因为第一个参数类型不匹配,尽管我们知道sum的代码几乎都能复用,除了第一行的int需要替换成double。但是不能!我们只能复制一份,然后把int的地方改成double。

double sum(const double data[], const std::size_t length) {
    double result{};
    for (int i = 0; i < length;++i) {
        result += *(data + i);
    }
    return result;
}

这时你就会发现问题了,这个过程,我们仅仅改变了类型信息。这样的问题还会继续增加,我们可能又需要求float的数组和,上面那个double的数组和同样匹配不了,因为float,double是两个类型。正是因为数据类型不一样,所以很多时候我们需要为不同的数据提供相似的代码,这在数据类型膨胀的情况下是很痛苦的,当对算法进行修改的时候我们需要保证所有的数据类型都被修改到,并且要逐个进行测试,这无疑会增加工作量,并放大错误率。但是实际有效的代码都是要明确类型的,如果类型不明确,编译器就没法确定代码是否合法,不确定的事情编译器就要报错,所以按照普通的思路,这个问题是无解的。 但是其实很多时候,这些相似的代码仅仅是数据类型不一样而已,对付这种重复的工作应该让给计算机来完成,也就是编译器。所以我们需要一种技术,让编译器先不管具体类型是什么,而是用一种特殊的类型来替换,这个类型可以替换成任何类型,用这个特殊的类型完成具体的算法,在使用的时候根据实际的需求,将类型信息提供给算法,让编译器生成满足所提供类型的具体算法,而这就是模板。这和生活中的模板思想上是共通的。算法是固定的部分,数据类型是可变的部分,两个合起来就是合法的C++代码。也就是利用模板,我们可以只写一个算法,借助编译器生成所有类型的算法,这些算法之间唯一不同的就是类型。 当然光有模板还不够,上面只解决了类型的问题,没有解决算法实现的问题。怎么说呢,如我们有一个需求,需要将数据先排序,再查找最大值。这对于数字(int,float,double等)类型是有效的,直接使用比较运算符(<,>)就可以完成了,但是假如想让这个算法适用于自定义类型呢?直接在模板实现中写比较运算符对自定义类型是无效的,因为自定义类型没有实现相对应的比较运算函数。解决方法也很简单,自定义类型实现相对应的比较运算符就行了。诸如此类的问题,在模板中会经常遇到,因为我们对类型的信息一无所知,但是又要确保几乎所有的类型都能正常运作,这就不得不运用各种技术对类型进行限定或者检测,这其实才是模板问题的精髓。所以模板问题不仅仅是类型问题,还是其他C++问题的综合体,需要对C++特性有着较为完整的理解,才能写出有用高效的代码。 C++中通常将模板分为函数模板和模板类,我们先从比较简单的函数模板开始认识。

函数模板

函数模板是一种函数,和普通函数不一样的地方是,它的参数列表中至少有一个是不确定类型的。我们用开头的例子来小试牛刀:

template 
T sum(const T data[], const std::size_t length) {
    T result{};
    for (int i = 0; i < length;++i) {
        result += *(data + i);
    }
    return result;
}

int main() {
    int intData[] = { 1, 1, 2, 2 };
    float floatData[] = { 1, 1, 2, 2 };
    double doubleData[] = { 1, 1, 2, 2 };
    auto len = sizeof(intData)/sizeof(intData[0]);

    std::cout << "intSum = " << sum(intData,len) << ", floatSum = " << sum(floatData,len) <<", doubleSum = " <(doubleData,len)<

在这里,我们仅仅写了一个函数,就可以同时适用于int,float,double。如果还有其它类型实现了默认初始化和运算符+=就同样可以使用这个函数来求和,不需要改动任何现有代码,这就是模板的魅力。 在继续看新东西前,我们先来认识一下函数模板和普通函数之间有什么不同:

  1. 函数模板需要一个模板头,即template。它的作用是告诉编译器下面的函数中遇到T的地方都不是具体类型,需要在调用函数时再确定。
  2. 函数声明中,类型位置被T替代了,也就是说T是一个占位类型,可以将它当作普通类型来用。在写模板代码时,这是很有用的。

再来看使用函数的地方,也就是类似sum(xxxData,len)的语句,其中的xxx代表数据类型,也就是函数模板中T的实际类型。简单来说就告诉编译器,用类型xxx替换函数模板中的类型T,这个过程有个官方的名字,实例化,这是另一个和普通函数不一样的地方.。用函数模板是需要经过两个步骤的。

  1. 定义模板。这一步没有具体类型,需要使用一个泛型参数来对类型占位,也就是只要是出现实际类型的地方,都要使用泛型参数来占位,并用这个泛型参数来实现完整的算法。这一步编译器由于不知道具体类型,不会对一些类型操作进行禁止,而只是检查标识符是否存在,语法是否合法等。
  2. 实例化。实例化的过程只会发生在开发者调用函数模板的地方,没有实例化的函数模板的代码是不会出现在最执行文件中的。编译器会对每一处发生实例化的地方,用实际参数来替换泛型参数,并检查实际类型是否支持算法中所有的操作,如果不支持,则编译失败,需要开发者实现相关的操作或者修改函数模板。如上例中,假如我们用一个自定义类型来实例化,就会发现编译无法通过,因为自定义类型没有定义操作符+=(除非该操作符已经被定义了),这个过程就发生在实例化。解决方案也很简单,对自定义类型添加操作符+=即可。

类型推导

在上例中,我们发现在实例化的过程中,要同时给函数模板传递类型参数和数据参数,并且类型参数往往和数据的类型是一一对应的,这中冗杂的语法对于现代C++来说是不可接受的,所以现代C++编译器都支持类型推导。类型推导可以让开发者省略类型参数,直接根据数据类型来推导出类型参数,所以上例实例化都可以写成sum(xxxData,len)的形式,编译器能分别推导出xxx的类型是int,float,double。 当然类型推导也不是万能的,我们来看下面这个例子

template 
T max(T a, T b) {
    return a > b ? a : b;
}
int main() {
    int a = 1;
    int b = 2;
    std::cout << "max(" << a << ","<

这个例子很直观,结果当然也毫无意外。现在我们要变形了:我们把变量b的类型改为float,就会发现编译无法通过了。提示我们数据类型不匹配,因为a是int,b是float,所以推导出的结果就是max(),而实际上我们是只有一个类型参数的。 那既然问题很明了,解决方法也似乎很简单,给max再加一个参数不就行了吗?我们来看一看。

template 
A max(A a, B b) {
    return a > b ? a : b;
}
int main() {
    int a = 1;
    float b = 2;
    std::cout << "max(" << a << ","<

经过这样改之后,编译和运行都不报错了,问题似乎解决了,是吗? 并不是,我们把float b = 2;换成float b = 2.5;,

int main() {
    int a = 1;
    float b = 2.5;
    std::cout << "max(" << a << ","<

再次运行程序,就会发现输出是错误的了。因为函数模板中,我们把返回值定义成了A,在实例化的时候A被推导成了int类型,所以实际上max的返回值就成了int类型,最大值B就被从float强制转换成了int类型,丢失了数据精度。那有没有解决方法呢?有的,而且不止一种! 根据上面的分析,其问题的根本是数据被强转了,解决方案当然就是阻止它发生强转,也就是保持两种数据类型是一致的,那怎么保证呢?阻止编译器的类型推导,手动填写类型参数。

int main() {
    int a = 1;
    float b = 2.5;
    std::cout << "max(" << a << ","<(a,b) << std::endl;
    return 0;
}

// 输出
// max(1,2.5) = 2.5

可以看到在此例中,我们只填写了一个类型参数,因为类型B会自动推导成float。没错,类型推导是可以部分禁用的! 另一种解决方案就是完全让编译器计算类型。怎么计算呢,C++11提供了auto和decltype。auto可以计算变量的类型,decltype可以计算表达式的类型,用法如下:

auto a=1; // a被推导成int类型
auto b=1.5; // b被推导成double类型
decltype(a+b) //结果是double类型

也就是可以将返回值置为auto,然后让编译器决定返回类型

template 
auto max(A a, B b) {
    return a > b ? a : b;
}
int main() {
    int a = 1;
    float b = 2.5;
    std::cout << "max(" << a << ","<(a,b) << std::endl;
    return 0;
}

// 输出
// max(1,2.5) = 2.5

假如编译器只支持C++11的话,会麻烦一点,不仅要前置auto,在函数头后还要使用decltype来计算返回类型,这个特性称为尾返回推导。

template 
auto max(A a, B b)->decltype(a + b) {
    return a > b ? a : b;
}

这里decltype里面写的是 函数模板暂时放一放,我们来看一看类模板是怎样的。

类模板

和函数模板一样,类模板也至少包含一个泛型参数,这个泛型参数的作用域是整个类,也就是说可以使用这个泛型参数定义成员变量和成员函数。

template 
class Result {
    T data;
    int code;
    std::string reason;

public:
    Result(T data, int code = 0, std::string reason = "success") :data{ data }, code{ code }, reason{ reason } {

    }

    friend std::ostream& operator<<(std::ostream& os, const Result result) {
        os << "Result(data = " << result.data <<", code = " << result.code <<", reason = " << result.reason <<")" << std::endl;
        return os;
    }
};
int main() {
    Result result{ 9527 };
    std::cout << result << std::endl;
    return 0;
}

// 输出
// Result(data = 9527, code = 0, reason = success)

可以看到,类模板和普通类类似,普通类有的它都有——成员函数,成员变量,构造函数等等,值得一说的依然是这个泛型参数T。上例是SDK中常见的数据类,用于指示操作是否成功并且必要时返回操作结果。对于返回一般数据类型,这个类已经足够了,但是假如我们的某个接口无返回值,按照传统即返回void类型,问题出现了。data的实际类型是void,但是我们找不到任何值来初始化它。更进一步,返回void的时候,我们根本不需要data这个成员变量。为了解决类似这种问题,模板提供了特化。

特化和偏特化

特化就是用特定类型替代泛型参数重新实现类模板或者函数模板,它依赖于原始模板。如上例中,我们已经有了原始模板类Result,为了解决void不能使用的情况,我们需要为void类型重新定义一个Result,即Result,则Result就称为Result的一种特化,原来的Result称为原始模板类。这样的特化版本可以有很多个,一个类型就是一个特化版本,它完美融合了通用性和特殊性两个优势。当实例化过程中,如果实例化类型和特化类型一致,则实例化将使用特化的那个类(函数)来完成,如下面的例子

// Result定义保持不变,新增特化版本
template <>
class Result{
    int code;
    std::string reason;
public:
    Result(int code = 0, std::string reason = "success"): code{ code }, reason{ reason }{}

    friend std::ostream& operator<<(std::ostream& os, const Result result) {
        os << "Result("<<"code = " << result.code << ", reason = " << result.reason << ")" << std::endl;
        return os;
    }
};

int main() {
    Result voidResult;
    Result intResult{9527};
    std::cout << "void = "<< voidResult<

可以看到,当实例化为int类型时,使用的是原始的模板类。而当实例化为void类型时,使用的是特化的版本。 除了特化,还有偏特化。偏特化和特化很像,就是对类型进行一个更窄的限定,使之适用于某一类类型,如const,指针,引用等。或者对有多个泛型参数的类进行部分特化。 特化和偏特化是对模板特殊类型的补充,解决的是模板实现上的一些问题。很多时候如果通用模板不好实现,可以考虑使用特化。当然,特化版本越多,模板的维护成本就越高,这时候就该考虑是否是设计上存在缺陷了。

类型限定

C++模板的强大不仅仅表现在对类型的操作上,有时候为了防止我们的类被滥用,我们还需要对这些能力做一些限定,比如禁止某些特定的类型实例化。 在上面的例子中,假设我们规定Result必须返回实际的数据,禁止void实例化该怎么做呢?容易想到的是,我们首先需要一种方法判断实例化时的类型是否是特定类型,然后需要在实例化类型是禁止类型时告诉编译器编译失败。所有的这些,标准库type_traits都提供了支持。它提供了一系列工具来帮助我们识别类型参数,如数字,字符串,指针等等,也提供了一些其他工具辅助这些类型参数工具完成更复杂的功能。 此例中,我们希望实例化类型不能是void,经过查找type_traits,我们发现有个is_void的类,它有个value常量,这个常量在类型参数为void是为true,否则为false。当然有了判定方法还不够,我们还需要在类型不匹配时让编译器报错的方法,恰好,我们有enable_if_t。它有两个类型参数,第一个是布尔表达式,第二个是类型参数。当表达式为真时,类型参数才有定义,否则编译失败。所以为了完成禁止void实例化的功能,我们需要借助两个工具,is_void判断类型参数是否是void,enable_if_t完成布尔表达式到类型参数的转换。综上,让我们来看看实现:

template 
class Result {
    std::enable_if_t::value,T> data;
    int code;
    std::string reason; 

public:
    Result(std::enable_if_t< !std::is_void::value,T> data, int code = 0, std::string reason = "success") :data{ data }, code{ code }, reason{ reason } {

    }

    friend std::ostream& operator<<(std::ostream& os, const Result< std::enable_if_t< !std::is_void::value, T>> result) {
        os << "Result(data = " << result.data <<", code = " << result.code <<", reason = " << result.reason <<")" << std::endl;
        return os;
    }
};

例中,第3行和第8行都用到了类型限定,其实我们只需要在构造函数是对T限定就可以了。当用void来实例化Result时,将无法通过编译。

其他问题

C++模板有两方面的问题要解决,一方面是本身模板相关的问题,而另一方面就是和其他特性一起工作。如C++11引入了右值引用,但是右值引用通过参数传递以后会造成引用坍缩,丢失其右值引用的性质,表现得像一般引用类型,为了解决这个问题,C++提供了std::move工具。这对于普通函数是没问题的,但是假如这是一个模板函数呢?C++同样提供了完美转发的解决方法。 所谓完美转发,就是让右值引用保持右值引用,左值引用也保持左值引用。它需要配合万能引用一起使用。万能引用和右值引用很相似,只不过万能引用类型是不确定的,在编译期才能确定。看下面的例子

template 
void test(T&& p) {
    std::cout << "p = " << std::forward(p) << std::endl;
}

int main() {
    int a = 1;
    test(a);
    test(std::move(a));

    return 0;
}

// 输出
// p = 1
// p = 1

T&&是万能引用,因为它类型不确定,然后通过std::forward<>转发参数。可以看到在8,9行,我们成功传递给test左值和右值,并且也成功得到了预期结果,不需要为右值单独写函数来处理。模板的这个功能极大简化了函数的设计,对于API的设计来说简直就是救星。 此外,函数模板还有重载的问题。通常来说普通函数的优先级会高于函数模板的优先级,函数模板之间越特殊的会优先匹配等等。这些问题随着对模板了解的深入,会慢慢出现,但是在学习初期没必要花费太多精力来了解这些特性,一切以实用为主。

总结

模板是C++中很大的一个课题,融合了类型系统,标准库,类等一系列的大课题。所以写出完美的模板代码需要首先对这些课题有较为完整的了解。其次由于模板对类型控制较为宽松,还需要开发者对模板的适用范围有全局的把控,禁止什么,对什么类型需要特殊化处理,都要考虑到位,稍不注意就会隐藏一个难以察觉的bug。 总之就是一句话,模板是常学常新,常用常新的,需要在实践中学习,又要在学习中实践的东西,祝大家每次都有新收获!

参考资料

  1. type_traints