NDK

Posted on By ᵇᵒ

Welcome

NDK全称Native Development Kit.
是一个允许在Android应用里引用本地代码的工具集.
多用于以下两种情况:

  • 压榨设备性能以达到低延迟或者运行密集计算,比如游戏或者物理仿真.
  • 复用C或者C++库(好像还可以使用汇编,不过我没试过).

Android Studio
Android Studio 2.2及以上版本,可以直接NDK编译C或者C++代码成本地库,并打包进apk(使用Gradle,IDE集成了NDK编译系统).

CMake
CMake是一个开源、跨平台的脚本工具,可用于构建、测试以及打包软件.
Android Studio编译本地库时默认使用CMake构建.
Android Studio也同样支持ndk-build,Android开发早期在使用,其配置文件是Android.mk.
当然,如果是新建一个本地库,建议大家用CMake.

Requirement

  • Android Studio 前面已经说了,2.2及以上版本.
  • NDK
  • CMake
  • LLDB全称Low Level Debugger.用于调试本地代码.

在安装好了Android Studio之后可以用SDK Manager直接下载后面的组件.
/styles/images/ndk/sdkManager.png

Create a Native Project

创建一个native项目和创建一个其他Android项目有点类似,除了一些额外步骤.
依次点击File -> New -> New Project,在Create New Project界面上勾选上Include C++ Support,填好其他选项后一路Next,直到出现Customize C++ Support界面.

  • C++ Standard:默认选项Toolchain Default,使用默认的CMake设置;如果想使用标准C++就下拉选择C++11.
  • Exceptions Support:启用支持C++异常处理.如果启用,Android Studio会在module的build.gradle文件里添加 -fexceptions 标志给cppFlags.
    -f 指force的意思,强行启用,默认不启用.
  • Runtime Type Information Support启用支持RTTI.如果启用,Android Studio会在module的build.gradle文件里添加 -frtti 标志给cppFlags.
    android {
      ···
      defaultConfig {
          ···
          externalNativeBuild {
              cmake {
                  path "CMakeLists.txt"
                  cppFlags "-std=c++11 -frtti -fexceptions"
              }
          }
      }
    

    :secret:当然,你也可以不勾选,待项目创建好后直接在module的build.gradle里添加需要的标志.Gradle把这些标志作为参数传递给CMake.
    externalNativeBuild
    CMake Variables

选好后点Finish,项目就创建好了.

Directory structure

.
├── app
│   ├── build
│   │   ├── intermediates
│   │   │   ├── cmake
│   │   │   │   └── debug
│   │   │   │       └── obj
│   │   │   │           ├── arm64-v8a
│   │   │   │           │   └── libnative-lib.so
│   │   │   │           ├── armeabi
│   │   │   │           │   └── libnative-lib.so
│   │   │   │           ├── armeabi-v7a
│   │   │   │           │   └── libnative-lib.so
│   │   │   │           ├── mips
│   │   │   │           │   └── libnative-lib.so
│   │   │   │           ├── mips64
│   │   │   │           │   └── libnative-lib.so
│   │   │   │           ├── x86
│   │   │   │           │   └── libnative-lib.so
│   │   │   │           └── x86_64
│   │   │   │               └── libnative-lib.so
│   │   │   ├── jniLibs
│   │   │   └── ···
│   │   └── ···
│   ├── CMakeLists.txt
│   └── src
│   │   └── main
│   │       ├── AndroidManifest.xml
│   │       ├── cpp
│   │       │   └── native-lib.cpp
│   │       ├── java
│   │       │   └── com
│   │       │       └── example
│   │       │           └── bob
│   │       │               └── ndkdemo
│   │       │                   └── MainActivity.java
│   │       └── res
│   └── ···
└── ···

一个NDK项目的结构大致如上(便于观察,这里不太关注的结构都被我省略掉了).
在/src/main目录下多了cpp文件夹,这里用来存放native code.注意,native代码不支持instant run,以后不清楚,至少目前是这样.
在module根目录下多了CMakeLists.txt,该文件类似ndk-build的Android.mk. 在编译后的中间文件里主要多了cmake和jniLibs文件夹.

CMake build script

我们通过修改CMakeLists.txt文件来控制Cmake编译.
还是以默认生成的NDK项目demo为例:

cmake_minimum_required(VERSION 3.4.1)

add_library( native-lib
             SHARED
             src/main/cpp/native-lib.cpp )

find_library( log-lib
              log )

target_link_libraries( native-lib
                       library
                       ${log-lib} )

cmake_minimum_required,设置最低要求的CMake版本号.

  • cmake_minimum_required()应该尽可能的放在顶级CMakeLists.txt文件的开头,甚至比project()还早.
    之所以说顶级CMakeLists.txt文件,是因为CMakeLists.txt还可以include其他文件或者模块,以及add_subdirectory.
  • cmake_minimum_required()如果只是声明在局部函数里边function(),则只对该函数生效,不会影响全局.

add_library,包含三个参数,分别指定库名、库的创建类型、库对应的源码路劲.

  • 这里的库名只是编译和运行期间逻辑上的库名(必须是该项目里全局唯一的),而生成的库文件命名:lib库名.so
    当然,在代码里加载库的时候依然是使用库名.

find_library,搜索给定名字的库.

  • 如果找到了就把路劲赋值给缓存的变量,除非该变量被清除,否则不会重新搜索这个库. 没找到库,则变量值为NOTFOUND,且下一次调用的时候还会再次搜索.
    上面这个例子就是去搜索名为log的库,并把结果赋给变量log-lib.
  • 由于CMake默认包含了系统库的搜索路劲,所以只需要指定NDK库的名字就行了.

target_link_libraries,为每个目标分别指定需要链接的库文件(指定部分目标专用的库文件).
区别于link_libraries,后者为所有目标统一指定需要的库文件(指定所有目标都用的库文件),老命令,若无特殊理由不建议使用.

include_directories,指定native code的头文件路劲.

更多命令参考CMake commands.

注意,和修改了Gradle脚本需要Sync Project /styles/images/ndk/toolbar-sync-gradle.png 一样,修改了CMake脚本需要点击菜单栏 Build > Refresh Linked C++ Projects 更新.

Reduce size of APK

Specify ABIs
Configure multiple APKs for ABIs

JNI

JNI全称Java Native Interface.

JNI与NDK的区别:
NDK是为便于开发基于JNI的应用而提供的一套开发和编译工具集;而JNI则是一套编程接口,可以运用在应用层,也可以运用在应用框架层,以实现Java代码与本地代码的互操作。

native方法的声明

在java方法的基础上去掉方法体,并加上native关键字:

  public native String hello();

loadLibrary 两种方式

  Runtime.getRuntime().loadLibrary("native-lib");
  System.loadLibrary("native-lib");

其实System.loadLibrary()最终也是调用的Runtime.getRuntime().loadLibrary()。

javah 生成头文件

进入\src\main\java目录下,在命令行输入:

  javah -jni com.step2hell.jnisample.MainActivity

后面一定要输入类的全名,包括包名。然后就会生成.h头文件了,生成的.h头文件在当前目录下,默认命名的文件名很长(可以修改)。
.h头文件的内容主要是根据JNI方法规则生成对应的native层方法,为了保证每个函数的唯一性,所以JNI层的方法命名比较长,规则如下:

  Java_包名_类名_函数名
  Java_包名_类名_函数名__函数签名   // 函数签名仅出现在函数重载情况下,函数签名与函数名之间是 2 个下划线

在main/下创建cpp文件夹,将该.h文件加入,并创建新的cpp文件或者c文件,include .h头文件,实现.h头文件的方法即可。
Android Studio创建一个支持c++的项目,会自动生成一个JNI的模板,并且模版只有一个cpp文件,并不需要什么.h头文件,这也是可以的,只要保证cpp的方法命名满足JNI方法规则。当然还是推荐使用javah,可以防止方法名太长而导致的手动输入错误。

  • 使用头文件
    main.h头文件:
    /* DO NOT EDIT THIS FILE - it is machine generated */
    #include <jni.h>
    /* Header for class com_step2hell_jnisample_MainActivity */
    
    #ifndef _Included_com_step2hell_jnisample_MainActivity
    #define _Included_com_step2hell_jnisample_MainActivity
    #ifdef __cplusplus
    extern "C" {
    #endif
    /*
     * Class:     com_step2hell_jnisample_MainActivity
     * Method:    hello
     * Signature: ()Ljava/lang/String;
     */
    JNIEXPORT jstring JNICALL Java_com_step2hell_jnisample_MainActivity_hello
        (JNIEnv *, jobject);
    
    /*
     * Class:     com_example_step2hell_jnisample_MainActivity
     * Method:    helloWorld
     * Signature: (Ljava/lang/String;)Ljava/lang/String;
     */
    JNIEXPORT jstring JNICALL Java_com_step2hell_jnisample_MainActivity_helloWithArgs
          (JNIEnv *, jobject, jstring);
    
    #ifdef __cplusplus
    }
    #endif
    #endif
    

    main.cpp文件:

    #include <string>
    #include "main.h"
    
    jstring Java_com_step2hell_jnisample_MainActivity_hello(
            JNIEnv *env,
            jobject /* this */) {
        std::string hello = "Hello from JNI";
        return env->NewStringUTF(hello.c_str());
    }
    
    jstring Java_com_step2hell_jnisample_MainActivity_helloWithArgs(
            JNIEnv *env,
            jobject /* this */,
            jstring) {
        std::string hello = "Hello from JNI";
        return env->NewStringUTF(hello.c_str());
    }
    
  • 不使用头文件
    main.cpp文件:
    #include <jni.h>
    #include <string>
    
    extern "C" JNIEXPORT jstring JNICALL Java_com_step2hell_jnisample_MainActivity_hello(
            JNIEnv *env,
            jobject /* this */) {
        std::string hello = "Hello from C++";
        return env->NewStringUTF(hello.c_str());
    }
    
    extern "C" JNIEXPORT jstring JNICALL Java_com_step2hell_jnisample_helloWithArgs(
            JNIEnv *env,
            jobject /* this */,
            jstring) {
        std::string hello = "Hello from C++2";
        return env->NewStringUTF(hello.c_str());
    }
    

javap 生成函数签名

java支持多态,通过函数签名可以确定函数唯一性,在native调用java方法的时候用以区分重载函数。
javap是Java class文件分解器,可以反编译(即对javac编译的文件进行反编译),也可以查看java编译器生成的字节码。用于分解class文件。

  javap -s -p -classpath . xxx.class    // 更多参数及用法可以查询 javap -help

在Android Studio中我们需要先build得到对应的class文件,再进入class文件所在目录使用javap指令:

  $ gradle clean assembleDebug
  $ cd build/intermediates/classes/debug/com/step2hell/jnisample
  $ pwd
  /Users/bob/Projects/JNIsample/app/build/intermediates/classes/debug/com/step2hell/jnisample

  $ ls
  BuildConfig.class       MainActivity.class      R.class                 

  $ javap -s -p -classpath . MainActivity.class
  Compiled from "MainActivity.java"
  public class com.step2hell.jnisample.MainActivity extends android.support.v7.app.AppCompatActivity {
  public com.example.step2hell.jnisample.MainActivity();
    descriptor: ()V

  protected void onCreate(android.os.Bundle);
    descriptor: (Landroid/os/Bundle;)V

  public native java.lang.String hello();
    descriptor: ()Ljava/lang/String;

  static {};
    descriptor: ()V
  }

对应的MainActivity源码:

  package com.step2hell.jnisample;

  import android.content.Intent;
  import android.support.v7.app.AppCompatActivity;
  import android.os.Bundle;
  import android.view.View;
  import android.widget.TextView;

  public class MainActivity extends AppCompatActivity {

    // Used to load the 'native-lib' library on application startup.
    static {
        System.loadLibrary("native-lib");
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Example of a call to a native method
        TextView tv = (TextView) findViewById(R.id.sample_text);
        tv.setText(hello());
    }

    /**
     * A native method that is implemented by the 'native-lib' native library,
     * which is packaged with this application.
     */
    public native String hello();
  }

可以看到descriptor对应的括号内为函数参数签名,括号外为函数返回值签名。

JNI Types

  • 基本类型
Java Type Native Type Description
boolean jboolean unsigned 8 bits
byte jbyte signed 8 bits
char jchar unsigned 16 bits
short jshort signed 16 bits
int jint signed 32 bits
long jlong signed 64 bits
float jfloat 32 bits
double jdouble 64 bits
void void N/A
  • 引用类型
    /styles/images/ndk/jniReferenceTypes.gif

  • 类型签名

Type Signature Java Type
Z boolean
B byte
C char
S short
I int
J long
F float
D double
L fully-qualified-class; fully-qualified-class
[ type type[]
( arg-types ) ret-type method type

extern “C”

extern代表声明的方法和变量为全局变量,和java的static一样。
”C”则代表{}内的内容以C语言方式编译和连接。
如果是cpp文件则需要加上该修饰符以兼容C调用:

  #ifdef __cplusplus
  extern "C" {
  #endif

  ··· // 需要采用C编译器编译的C语言代码段

  #ifdef __cplusplus
  }
  #endif

JNIEXPORT & JNICALL

JNIEXPORT & JNICALL 都是定义在jni.h中的宏:

  #define JNIEXPORT  __attribute__ ((visibility ("default")))
  #define JNICALL
  • JNIEXPORT
    JNIEXPORT 用于控制对应的native方法在so库是否导出可见。
    如果native方法前不加关键字JNIEXPORT,该方法默认依然是可见,如果在编译时设置参数-fvisibility=hidden,则不加JNIEXPORT关键字的方法才不可见。
  • JNICALL
    由上述定义可知JNICALL是一个空的宏,猜测作用有如下几个:
    1. 注释说明这是一个JNI调用
    2. 开关作用
    3. 方便移植

虽然 JNIEXPORT & JNICALL 修饰符基本上都可以去掉而不影响程序运行,但是建议保留 遵循规范。

JNIEnv在c和c++中的区别

.cpp文件是c++的语法,.c是c的语法,文件的类型决定了JNIEnv的语法。

  • cpp用法:
    env->FindClass("com/step2hell/jnisample/MainActivity");
    
  • c用法:
    (*env)->FindClass(env,"com/step2hell/jnisample/MainActivity");
    

两者区别,在jni.h中查看JNIEnv定义:

  #if defined(__cplusplus)
  typedef _JNIEnv JNIEnv;
  typedef _JavaVM JavaVM;
  #else
  typedef const struct JNINativeInterface* JNIEnv;
  typedef const struct JNIInvokeInterface* JavaVM;
  #endif

这段话的意思是在c++中,定义_JNIEnv是JNIEnv,其他情况(c)下,定义const struct JNINativeInterface*是JNIEnv。
继续追踪_JNIEnv和JNINativeInterface:

  struct JNINativeInterface {
    ... // 省略代码
    jclass      (*DefineClass)(JNIEnv*, const char*, jobject, const jbyte*,
  };

  struct _JNIEnv {
    /* do not rename this; it does not seem to be entirely opaque */
    const struct JNINativeInterface* functions;

  #if defined(__cplusplus)

    jint GetVersion()
    { return functions->GetVersion(this); }
    ... // 省略代码
  };

可以看到JNINativeInterface 其实定义了很多方法,都是对Java的数据进行操作,而_JNIEnv则封装了一个JNINativeInterface的指针,并且声明与JNINativeInterface中一模一样的方法,并且都是通过JNINativeInterface的指针来调方法,其实就是对JNINativeInterface做了一层封装.c++是面向对象的语言,不需要再用指针方式来调用,并且_JNIEnv中的每个方法都比JNINativeInterface少一个参数,就是JNIEnv。

JNI native调用Java

Native调用java方法,使用的依然是反射原理。

  1. 通过FindClass方法获取到对应的class
  2. 再通过class获取对应的方法id,这里需要根据是否是静态类型分为GetMethodIDGetStaticMethodID
  3. 通过method id调用java方法
    • 返回值类型不同的java方法,native调用的方式也不一样:
      CallVoidMethodCallIntMethodCallFloatMethod等等;
    • 又根据是否是静态java方法,同一类型返回值的方法 获取的方式分:
      CallStaticIntMethodCallIntMethod(这里以Int返回值为例)。

Native还可以调用java类的属性,操作与方法基本一致,只是获取method id更换为获取feild id,CallMethoID变为GetFieldID,获取的方式依然区分是否是静态,依然区分类型(变成了field的类型)。

e.g. 假设有POJO Book:

  package com.step2hell.jnisample;

  import java.io.Serializable;

  public class Book implements Serializable {
    private String name;
    private float price;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public float getPrice() {
        return price;
    }

    public void setPrice(float price) {
        this.price = price;
    }
  }

在MainActivity里定义需要调用的java方法:

  public native float getPriceOfBook(Book book);

native层调用Book的方法setPrice()以及getPrice()

  #include <jni.h>
  #include <string>

  extern "C" JNIEXPORT jfloat JNICALL Java_com_example_step2hell_jnisample_SubActivity_getPriceOfBook
        (JNIEnv *env, jobject, jobject bookObj) {
    // 通过类的路径获取对应的jclass
    jclass bookClass = env->FindClass("com/step2hell/jnisample/Book");

    // 通过获取的jclass获取对应的methodID,第一个参数是jclass类,第二个参数是需要获取的java函数名,第三个参数是需要获取的java函数签名
    jmethodID setPriceMethodID = env->GetMethodID(bookClass, "setPrice", "(F)V");

    // 通过methodID调用方法,返回值为Void的使用CallVoidMethod()
    env->CallVoidMethod(bookObj, setPriceMethodID, 23.0);   // 这里必须是float型,如果是整型则会赋值失败,最后结果为0.0

    jmethodID getPriceMethodID = env->GetMethodID(bookClass, "getPrice", "()F");

    // 对于返回值为float类型的使用CallFloatMethod()
    jfloat price = env->CallFloatMethod(bookObj, getPriceMethodID);

    return price;
  }

通过在native调用java方法setPrice(),将默认为0.0的book价格设置成了23.0,并调用getPrice()方法将价格返回;只需要在java层调用native方法getPriceOfBook(),便可以得到23.0的价格结果。

References

https://developer.android.google.cn/studio/projects/add-native-code https://developer.android.google.cn/training/articles/perf-jni#java