背景 && 摘要

Android Studio(2.2 及其以上)配合 Gradle 插件(2.2.0 及其以上), 你可以直接编译工程中的 C/C++ 代码为 native 库,然后 Gradle 会自动将其集成到 APK里面. Android Studio 默认的 native 库构建工具是 CMake,也支持 ndk-build 工具来编译 native 代码. Android 官方指南 [1] 中所建议的是:新创建的 native 库,应该使用 CMake 来编译. 这里将说明如何在 Android Studio 中使用 CMake 来编译一个 native 库.

由于 AS 提供了一个 Action 运行框,其快捷键为 Ctrl+Shift+A ,在运行框中输入大概的内容, 就能模糊匹配到动作选项,很方便,以下,我打开某个选项时,说打开 Action 就是指 Ctrl+Shift+A打开运行框.

环境

Ubuntu 16.04 LTS x64 + Android Studio 2.3.1


1.配置环境并建立工程

你需要确保 NDK,CMake,LLDB 已经安装了,如下所示:

如果没有安装,勾选安装即可,为了加快下载速度,可以使用国内镜像,镜像的具体设置就从略了.

另外一个需要说的是,如果你以前单独安装了 ndk,那么你只需要将整个 ndk 目录拷贝到 android sdk 目录下, 将其命名为 ndk-bundle 即可. 为了更清晰的说明问题,下面是一个结构图:

bugnofree@ishell:sdk$ tree -L 1  bugnofree@ishell:sdk$ tree ndk-bundle/ -L 1
.                                ndk-bundle/
├── add-ons                      ├── build
├── build-tools                  ├── CHANGELOG.md
├── cmake                        ├── ndk-build
├── docs                         ├── ndk-depends
├── emulator                     ├── ndk-gdb
├── extras                       ├── ndk-stack
├── licenses                     ├── ndk-which
├── lldb                         ├── package.xml
├── ndk-bundle                   ├── platforms
├── patcher                      ├── prebuilt
├── platforms                    ├── python-packages
├── platform-tools               ├── shader-tools
├── samples                      ├── simpleperf
├── skins                        ├── source.properties
├── sources                      ├── sources
├── system-images                ├── sysroot
├── temp                         └── toolchains
└── tools

打开 AS 新建一个工程,早起版本的 AS 可以通过命令行建立工程,不过 2.3 以后的版本取消了命令行模式, 让人很不爽,但是没办法,有如下几步 ╮(╯▽╰)╭...全都是图片...而且不是很重要, 因此我将其放到 这个页面了:

  1. New Project
    勾选 Include C++ Support
  2. Target Android Devices
    选择 API 15及其以上
  3. Add An Activity to Mobile
    选择 Empty Activity
  4. Customize the Activity
  5. Customize C++ Support
    选择 C++11 标准,勾选异常处理支持以及运行时类型信息支持.
  6. 询问代理设置
    这里我选择的大连东软镜像.

之后 AS 就默默地吭哧吭哧做些事情,然后就打开了主界面,将工程的视图调整为 Project 类型后, 整个结构如下:

其中 app/src/main/cpp 中存放的就是你的 native 文件,比如 C/C++ 源代码,文件头,预编译库等. AS 会默认创建一个 native-lib.cpp 文件,以帮助开发者使用 native. 另一个目录 app/.externalNativeBuild,包含了 CMake 或 ndk-build 所用到的的构建脚本. 这里使用 CMake,默认会生成的文件为 CMakeLists.txt .

工程建立后,你就可以编译(Build/Make project)并运行(Run/Run app)项目了. 默认的是 debug 级别的应用,我们当然可以设置输出为 release 级别的, 依次 Build/Select Build Variant,然后选择 release 即可.

生成的 so 文件可以在 app/build/intermediates 下看到, 根据编译的类型,具体分为 debug 和 release 版本的 so 库文件.

2.JNI 函数的使用

当我们的 native 方法较多且使用 jni 规范命名法使用它们时, 因为名称比较长且容易出错,这就显得很麻烦.我们可以通过 javah 这个命令来帮助我们生成一个头文件.

首先将要用到的 native 方法声明放到 ndk/sampleso/MainActivity.java 中, 然后切换到包的根目录,执行如下的 javah 命令:

javah -d ../cpp -jni ndk.sampleso.MainActivity

就会自动根据 MainActivity.java 中的 native 方法生成对应的头文件.

很多人这一步做的不正确,提示文件找不到,大多原因就是目录没有找正确. 这里的包名为 ndk.sampleso,所以包的根目录为 ndk, ndk 的父目录为 java 目录, 命令行切换到 java 目录:app/src/java,然后执行上述命令就可以生成头文件了, -d选项用于指定生成头文件的存放目录.

这里生成的 jni 头文件名称为 ndk_sampleso_MainActivity.h ,位于 cpp 目录下面, 然后我们修改 native-lib.cpp 文件以包含该头文件(我也另外添加了一些其他的代码), 同时为了阐述清楚,三个源代码文件 MainActivity.java,native-lib.cpp,ndk_sampleso_MainActivity.h如下:

//MainActivity.java
package ndk.sampleso; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; 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(stringFromJNI()); } /** * A native method that is implemented by the 'native-lib' native library, * which is packaged with this application. */ public native String stringFromJNI(); }

这里你可以看到如何加载一个 so 库,如何声明一个 native 方法,如何去用一个 native 方法. native 的具体实现则是在 native-lib.cpp 中:

// native-lib.cpp
#include "ndk_sampleso_MainActivity.h" #include <string> #include <android/log.h> #define TAG "DEMOSO" #define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)) void __attribute__ ((constructor)) demo_init(void); JNIEXPORT jstring JNICALL Java_ndk_sampleso_MainActivity_stringFromJNI( JNIEnv *env, jobject /* this */) { std::string hello = "Hello from C++"; LOGI("stringFromJNI"); return env->NewStringUTF(hello.c_str()); } extern "C" void _init(void) { LOGI("_init()"); } void demo_init(void) { LOGI("demo_init()"); }

native-lib.cpp 被编译后将会生成一个 libnative-lib.so 文件, 包含的头文件 ndk_sampleso_MainActivity.h 稍后将会提到. 在这里为了打印一些调试信息,不能用 printf 了,为了方便, 定义了一个宏 LOGI,实现了类似 printf 的功能, 在 logcat 中过滤 DEMOSO 即可查看调试信息.

在 android so 安全中,有几个函数应特别注意,这里面我已经添加了几个样例. 普通的 jni 函数 Java_ndk_sampleso_MainActivity_stringFromJNI 就不多说了, 这里面我设置了一个构造函数,它们会在 so 库调用后接手程序流程,在真正的 so 代码执行前执行, 对 so 做一些初始化工作,这样声明的函数将会被放到 .init_array 节区中, 你可以声明多个这样的函数.它们都会被执行,执行的顺序与声明的顺序无关,而与实现的顺序有关. 假如说现在依次声明了三个初始化函数:

void  __attribute__ ((constructor)) b(void);
void  __attribute__ ((constructor)) a(void);
void  __attribute__ ((constructor)) c(void);

void c(void){ LOGI("c()"); }
void b(void){ LOGI("b()"); }
void a(void){ LOGI("a()"); }

那么这三个函数执行顺序是 c(),b(),a(). 你肯定也看到了另外一个函数 extern "C" void _init(void);, 这个函数的执行时机更早,它位于 .text节区中, 其名字必须是 _init, 它的作用是什么呢?用于控制构造函数和析构函数 [2].然而用这些函数必须很小心, 已经不建议使用,使用它们可能导致无法预料的结果. 正如这位前辈遇到的诡异问题Tricky question about '_init' function of a shared lib . 你也看到了这里用到了 extern "C"声明, 由于我们是 C++ 文件,而这一声明表示让编译器以 C 语言规范的编译方式编译该函数, 且该函数是被外部所使用的.

当我们运行上述程序时,通过 logcat 我们看到的打印信息如下:

05-23 14:19:54.831 18978-18978/ndk.sampleso I/DEMOSO: _init()
05-23 14:19:54.831 18978-18978/ndk.sampleso I/DEMOSO: demo_init()
05-23 14:19:55.061 18978-18978/ndk.sampleso I/DEMOSO: stringFromJNI
// ndk_sampleso_MainActivity.h
/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class ndk_sampleso_MainActivity */ #ifndef _Included_ndk_sampleso_MainActivity #define _Included_ndk_sampleso_MainActivity #ifdef __cplusplus extern "C" { #endif /* * Class: ndk_sampleso_MainActivity * Method: stringFromJNI * Signature: ()Ljava/lang/String; */ JNIEXPORT jstring JNICALL Java_ndk_sampleso_MainActivity_stringFromJNI (JNIEnv *, jobject); #ifdef __cplusplus } #endif #endif

这个文件自动生成的,我们不需要对其修改, 可能有些人不大理解这里 #ifdef,extern "C"之类的东西, 接下来两小节,我将简单介绍一下 C/C++ 中的预定义指令和 C/C++ 混合编程.

3.预处理指令

# 开头的指令叫做预处理指令(Preprocessor directives), 每一个预处理指令占一行,行的开头不能有任何空格,行尾也没有分号, 如果太长需要多行书写,可以在行尾用 \来扩展.

3.1 宏定义

定义宏的指令为 #define,这个我们应该很熟悉了,它的语法如下:

#define identifier replacement

编译器将会把所有的 identifier 替换为 replacement . 与其对应的是 #undef指令,它取消对一个宏的定义. 一个例子如下:

#define TABLE_SIZE 100
int table1[TABLE_SIZE];
#undef TABLE_SIZE
#define TABLE_SIZE 200
int table2[TABLE_SIZE];

更高级的用法是宏函数.使用的符号有两个: ###.

第一个通过使用 #给宏传递参数, #之后跟着一个参数名,表示对该参数的引用. 比如说下面这个例子:

#define str(x) #x
cout << str(test);
//等价于下面这句
cout << "test";

##则用于直白的连接宏函数中的参数.比如:

#define glue(a,b) a ## b
glue(c,out) << "test";
//等价于下面这句
cout << "test";

3.2 条件指令

这里面的指令主要包括下面这些:

#ifdef,#ifndef,#if,#endif,#else,#elif

这些指令的用法很广泛,很常见的一种就如 ndk_sampleso_MainActivity.h 里面的用法. 在头文件中,我们经常会定义一些变量,工具函数等等之类的东西, 如果多次包含同一个头文件,将会产生编译错误(重复了变量或者函数声明等), 为了解决这种重复包含问题,就用到了条件指令.

条件指令中,额外提供了一个操作符 defined, 用于判断一个宏是否已经被定义了,它的语法为 defined identifier 或者 defined (identifier). 稍后的例子中将会提到.

条件指令也支持基本的算术运算以及比较操作,比如+,-,*,/,==,>,<等

这些条件指令的用法类似与 C/C++ 中的 if..else.. 语句,故不再详细说明,下面给出一个例子:

#define ABCD 2
#include <iostream>
int main()
{
#ifdef ABCD
    std::cout << "1: yes\n";
#else
    std::cout << "1: no\n";
#endif
 
#ifndef ABCD
    std::cout << "2: no1\n";
#elif ABCD == 2
    std::cout << "2: yes\n";
#else
    std::cout << "2: no2\n";
#endif
 
#if !defined(DCBA) && (ABCD < 2*4-3)
    std::cout << "3: yes\n";
#endif
}

3.3 预定义宏名

下面的宏名总是被定义了的,你可以代码中引用,它们都是以双下划线开头.

4.C/C++ 混合编程

由于 C++ 支持函数重载,C++ 编译器对函数进行编译时,和 C 编译器对函数的编译方法是不一样的, 举个简单的例子,同样的一个函数 void demo(int a,int b);, C++ 为了支持重载,编译后其内部符号为 _demo_int_int, C 则为 _demo,这样的话,C++中可以对demo进行重载, 比如说重载函数为 void demo(int a,double b), 那么C++就可以编译为 _demo_int_double.由于内部符号的不同, C 和 C++ 互相调用函数时就会产生找不到内部符号,从而导致没办法进行函数调用. 为了解决这个问题,因此用到了 extern "C",它就是告诉 C++ 编译器, 喂,伙计,你把我按照 C 的编译方式来搞.这样的话,C/C++ 就能够互相调用函数了. 这种互相调用分为两种,一是 C 调用 C++,一是 C++ 调用 C. Ubuntu 中文 wiki 上有一个非常好的例子 [5],我也就不再啰嗦了:

C++ 调用 C 函数



C 调用 C++ 函数

5.单独编译 so 库

有时候,我们或许只是想单独的编译 so 库文件,然后来研究 so 文件. 目前我们可以借助 ndk-build 来完成.一个 demo 例子工程结构如下:

main/
├── java
│   └── SampleSo.java
└── jni
    ├── Android.mk
    ├── Application.mk
    └── SampleSo.c

各个文件的内容如下所示:

SampleSo.java
public class SampleSo
{
    public native String printHello();
}
/*
 bugnofree@ishell:java$ javah -jni -d ../jni SampleSo
*/

Android.mk LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := sampleso LOCAL_SRC_FILES := SampleSo.c include $(BUILD_SHARED_LIBRARY)
Application.mk APP_ABI := all
SampleSo.c #include "SampleSo.h" JNIEXPORT jstring JNICALL Java_sample_SampleSo_printHello (JNIEnv *env, jobject thiz) { return (*env)->NewStringUTF(env, "Hello from JNI ! Compiled with ABI"); }

先进入 java 目录,执行下 javah 命令生成头文件,然后进入到 jni 目录执行 ndk-build 命令, 就会在 jni 同目录的目录下生成库文件了.结构示意如下:

main
├── java
│   └── SampleSo.java
├── jni
│   ├── Android.mk
│   ├── Application.mk
│   ├── SampleSo.c
│   └── SampleSo.h
├── libs
│   ├── arm64-v8a
│   ├── armeabi
│   ├── armeabi-v7a
│   ├── mips
│   ├── mips64
│   ├── x86
│   └── x86_64
└── obj
    └── local

Reference

  1. Add C and C++ Code to Your Project
  2. Program Library HOWTO 5. Miscellaneous
  3. Preprocessor directives
  4. Conditional inclusion
  5. Mix C Cpp




Find me by dXAyZ2Vla0AxNjMuY29tCg== or Sinablog

Copyright ©2017 by bugnofree All rights reserved.