NDK 开发入门:C++ 基础与 JNI 调用详解
一、C++ 基础知识
1.1 函数
- 函数是一组一起执行一个任务的语句。每个 C 程序都至少有一个函数,即主函数
main(),所有简单的程序都可以定义其他额外的函数。 .h头文件。- 指针函数:指带指针的函数,即本质是一个函数。函数返回类型是某一类型的指针。
int *func(int x, int y)。 - 函数指针:指向函数的指针变量,即本质是一个指针变量。
int (*funcp)(int x)。
int i;
int *a = &i; // a 是一个指针,它指向变量 i
int &b = i; // b 是一个引用,它是变量 i 的引用 (别名)
int * &c = a; // c 是一个引用,它是指针 a 的引用
int & *d; // d 是一个指针,它指向引用,但引用不是实体,所以这是错误的
在分析上面代码时,可以从变量标识符开始从右往左看,最靠近标识符的是变量的本质类型,而再往左即为对变量类型的进一步修饰。
例如:int * & a 标识符 a 的左边紧邻的是 &,证明 a 是一个引用变量,而再往左是 *,可见 a 是一个指针的引用,再往左是 int,可见 a 是一个指向 int 类型的指针的引用。
.和->
struct Data {
int a, b, c;
}; /*定义结构体类型*/
struct Data * p; /* 定义结构体指针 */
struct Data A = {1, 2, 3}; /* 声明结构体变量 A,A 即结构体名 */
int x; /* 声明一个变量 x */
p = &A ; /* 地址赋值,让 p 指向 A */
x = p->a; /* 取出 p 所指向的结构体中包含的数据项 a 赋值给 x */
/* 此时由于 p 指向 A,因而 p->a == A.a,也就是 1 */
因为此处
p是一个指针,所以不能使用 . 号访问内部成员 (即不能p.a),而要使用->。但是A.a是可以的,因为A不是指针,是结构体名。
一般情况下用 "." 只需要声明一个结构体。格式是:结构体类型名 + 结构体名。然后用 结构体名加'.'加成员名 就可以引用成员了。因为自动分配了结构体的内存。如同 int a; 一样。用 "->",则要声明一个结构体指针,还要手动开辟一个该结构体的内存,然后把返回的地址赋给声明的结构体指针,才能用 "->" 正确引用。否则内存中只分配了指针的内存,没有分配结构体的内存,导致想要的结构体实际上是不存在。这时候用 "->" 引用自然出错了,因为没有结构体,自然没有结构体的域了。此外,(*p).a 等价于 p->a。
::
::是作用域符,是运算符中等级最高的,它分为三种:
- 全局作用域符,用法
::name - 类作用域符,用法
class::name - 命名空间作用域符,用法
namespace::name
他们都是左关联,他们的作用都是为了更明确的调用你想要的变量:
-
如在程序中的某一处你想调用全局变量
a,那么就写成::a;(也可以是全局函数) -
如果想调用
class A中的成员变量a,那么就写成A::a; -
另外一个如果想调用
namespace std中的cout成员,你就写成std::cout(相当于using namespace std;cout)意思是在这里我想用cout对象是命名空间std中的cout(即就是标准库里边的cout); -
表示'域操作符':声明了一个类
A,类A里声明了一个成员函数void f(),但没有在类的声明里给出f的定义,那么在类外定义 f 时,就要写成void A::f(),表示这个f()函数是类A的成员函数。 -
直接用在全局函数前,表示是全局函数:在 VC 里,你可以在调用 API 函数里,在 API 函数名前加
:: -
表示引用成员函数及变量,作用域成员运算符:
System::Math::Sqrt()相当于System.Math.Sqrt();
1.2 Linux 内存布局
Linux 进程内存通常分为代码段、数据段、BSS 段、堆区和栈区。理解内存布局对于调试 NDK 崩溃和优化性能至关重要。
1.3 指针数组
- 数组:
int arr[] = {1, 2, 3}; - 指针:
int* p = arr,指针 p 指向数组 arr 的首地址;*p = 6;将 arr 数组的第一个元素赋值为 6;*(p+1) = 10;将 arr 数组第二个元素赋值为 10; - 指针数组:数组里面每一个元素都是指针
int* p[3];
for(int i = 0; i < 3; i++){
p[i] = &arr[i];
}
- 数组指针:也称为行指针;
int (*p)[n]
优先级高,首先说明 p 是一个指针,指向一个整型的一维数组,这个一维数组的长度是 n,也可以说是 p 的步长。执行 p+1 时,p 要跨过 n 个整型数据的长度。
int a[3][4]; int (*p)[4]; //该语句是定义一个数组指针,指向含 4 个元素的一维数组 p = a; //将该二维数组的首地址赋给 p,也就是 a[0] 或 &a[0][0] p++; //该语句执行后,也就是 p = p+1; p 跨过行 a[0][] 指向了行 a[1][]
1.4 结构体
struct Person {
char c;
int i;
char ch;
};
int main() {
struct Person person;
person.c = 8;
person.i = 9;
}
存储变量时地址要求对齐,编译器在编译程序时会遵循两个原则:
(1)结构体变量中成员的偏移量必须是成员大小的整数倍 (2)结构体大小必须是所有成员大小的整数倍,也即所有成员大小的公倍数
1.5 共用体
- 共用体是一种特殊的数据类型,允许你在相同的内存位置存储不同的数据类型。
- 你可以定义一个带有多成员的共用体,但是任何时候只能有一个成员带有值。
- 共用体提供了一种使用相同的内存位置的有效方式。
- 共用体占用的内存应足够存储共用体中最大的成员。
union Data {
int i;
float f;
char str[20];
}data;
int main() {
union Data data;
data.i = 11;
}
1.6 typedef
- 定义一种类型的别名,而不只是简单的宏替换。可以用作同时声明指针型的多个对象:
char *pa, *pb; //传统写法
typedef char* PCHAR; // 使用 typedef 写法 一般用大写
PCHAR pa, pb; // 可行,同时声明了两个指向字符变量的指针
- 用在旧的 C 的代码中,帮助
struct。以前的代码中,声明struct新对象时,必须要带上struct,即形式为:struct 结构名 对象名:
struct tagPOINT1 {
int x;
int y;
};
struct tagPOINT1 p1;
//使用 typedef
typedef struct tagPOINT {
int x;
int y;
} POINT;
POINT p1; // 这样就比原来的方式少写了一个 struct,比较省事,尤其在大量使用的时候
- 用
typedef来定义与平台无关的类型:
#if __ANDROID__
typedef double SUM;
#else
typedef float SUM ;
#endif
int test() {
SUM a;
return 0;
}
- 为复杂的声明定义一个新的简单的别名:
//原声明:
int *(*a[5])(int, char*);
//变量名为 a,直接用一个新别名 pFun 替换 a 就可以了:
typedef int *(*pFun)(int, char*);
//原声明的最简化版:
pFun a[5];
1.7 类的构造和解析、友元函数
1.7.1 C++ 中头文件(.h)和源文件(.cpp)
.h这里一般写类的声明(包括类里面的成员和方法的声明)、函数原型、#define 常数等,但一般来说不写出具体的实现。写头文件时,为了防止重复编译,我们在开头和结尾处必须按照如下样式加上预编译语句:
#ifndef CIRCLE_H
#define CIRCLE_H
class Circle {
private:
double r;
public:
Circle(); //构造函数
Circle(double R); //构造函数
double Area();
};
#endif
至于
CIRCLE_H这个名字实际上是无所谓的,你叫什么都行,只要符合规范都行。原则上来说,非常建议把它写成这种形式,因为比较容易和头文件的名字对应。
.cpp源文件主要写实现头文件中已经声明的那些函数的具体代码。需要注意的是,开头必须#include一下实现的头文件,以及要用到的头文件。
#include "Circle.h"
Circle::Circle() {
this->r = 5.0;
}
Circle::Circle(double R) {
this->r = R;
}
double Circle::Area() {
return 3.14 * r * r;
}
- 最后,我们建一个
main.cpp来测试我们写的 Circle 类
#include <iostream>
#include "Circle.h"
using namespace std;
int main() {
Circle c(3);
cout << "Area=" << c.Area() << endl;
return 1;
}
1.7.2 构造函数和析构函数
- 类的构造函数是类的一种特殊的成员函数,它会在每次创建类的新对象时执行。构造函数的名称与类的名称是完全相同的,并且不会返回任何类型,也不会返回 void。构造函数可用于为某些成员变量设置初始值。
- 类的析构函数是类的一种特殊的成员函数,它会在每次删除所创建的对象时执行。析构函数的名称与类的名称是完全相同的,只是在前面加了个波浪号(~)作为前缀,它不会返回任何值,也不能带有任何参数。析构函数有助于在跳出程序(比如关闭文件、释放内存等)前释放资源。
1.7.3 友元函数、友元类
- 友元函数是一种定义在类外部的普通函数,它不属于任何类,但它需要在类体内进行说明,为了与该类的成员函数加以区别,在说明时前面加以关键字
friend。 - 友元函数不是成员函数,但是它可以访问类中的私有成员。
- 一个函数可以是多个类的友元函数,只需要在各个类中分别声明。
- 友元的作用在于提高程序的运行效率(即减少了类型检查和安全性检查等都需要的时间开销),但是,它破坏了类的封装性和隐藏性,使得非成员函数可以访问类的私有成员。
- 友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。
- 当希望一个类可以存取另一个类的私有成员时,可以将该类声明为另一类的友元类。定义友元类的语句格式如下:
friend class 类名(friend 和 class 是关键字,类名必须是程序中的一个已定义过的类)。
class INTEGER {
private:
int num;
public:
friend void Print(const INTEGER& obj); //声明友元函数
};
void Print(const INTEGER& obj) //不使用 friend 和类::
{
//函数体
}
void main() {
INTEGER obj;
Print(obj); //直接调用
}
#include <iostream>
using namespace std;
class girl {
private:
char *name;
int age;
friend class boy; //声明类 boy 是类 girl 的友元
public:
girl(char *n, int age):name(n),age(age){};
};
class boy {
private:
char *name;
int age;
public:
boy(char *n, int age):name(n),age(age){};
void disp(girl &);
};
void boy::disp(girl &x) // 该函数必须在 girl 类定义的后面定义,否则 girl 类中的私有变量还是未知的
{
cout << "boy's name is:" << name << ",age:" << age << endl;
cout << "girl's name is:" << x.name << ",age:" << x.age << endl;
//借助友元,在 boy 的成员函数 disp 中,借助 girl 的对象,直接访问 girl 的私有变量
//正常情况下,只允许在 girl 的成员函数中访问 girl 的私有变量
}
void main() {
boy b("aaa", 8);
girl g("bbb", 99);
b.disp(g);
}
1.8 单例对象、操作符重载
- 我们可以重定义或重载大部分 C++ 内置的运算符。这样就能使用自定义类型的运算符。重载的运算符是带有特殊名称的函数,函数名是由关键字
operator和其后要重载的运算符符号构成的。与其他函数一样,重载运算符有一个返回类型和一个参数列表。
#include <iostream>
using namespace std;
class Box {
public:
double getVolume(void) {
return length * breadth * height;
}
void setLength( double len ) {
length = len;
}
void setBreadth( double bre ) {
breadth = bre;
}
void setHeight( double hei ) {
height = hei;
}
// 重载 + 运算符,用于把两个 Box 对象相加
Box operator+(const Box& b) {
Box box;
box.length = this->length + b.length;
box.breadth = this->breadth + b.breadth;
box.height = this->height + b.height;
return box;
}
private:
double length; // 长度
double breadth; // 宽度
double height; // 高度
};
// 程序的主函数
int main( ) {
Box Box1; // 声明 Box1,类型为 Box
Box Box2; // 声明 Box2,类型为 Box
Box Box3; // 声明 Box3,类型为 Box
double volume = 0.0; // 把体积存储在该变量中
// Box1 详述
Box1.setLength(6.0);
Box1.setBreadth(7.0);
Box1.setHeight(5.0);
// Box2 详述
Box2.setLength(12.0);
Box2.setBreadth(13.0);
Box2.setHeight(10.0);
// Box1 的体积
volume = Box1.getVolume();
cout << "Volume of Box1 : " << volume << endl;
// Box2 的体积
volume = Box2.getVolume();
cout << "Volume of Box2 : " << volume << endl;
// 把两个对象相加,得到 Box3
Box3 = Box1 + Box2;
// Box3 的体积
volume = Box3.getVolume();
cout << "Volume of Box3 : " << volume << endl;
return 0;
}
打印结果:
Volume of Box1 : 210 Volume of Box2 : 1560 Volume of Box3 : 5400
1.9 继承多态、虚函数
1.9.1 继承
- 一个类可以派生自多个类,这意味着,它可以从多个基类继承数据和函数。定义一个派生类,我们使用一个类派生列表来指定基类。类派生列表以一个或多个基类命名:
class derived-class: access-specifier base-class; - 其中,访问修饰符
access-specifier是public、protected或private其中的一个,base-class是之前定义过的某个类的名称。如果未使用访问修饰符access-specifier,则默认为private。 - 派生类可以访问基类中所有的非私有成员。因此基类成员如果不想被派生类的成员函数访问,则应在基类中声明为
private。 - 一个派生类继承了所有的基类方法,但下列情况除外:
基类的构造函数、析构函数和拷贝构造函数。
基类的重载运算符。基类的友元函数。
当一个类派生自基类,该基类可以被继承为 public、protected 或 private 几种类型。继承类型是通过上面讲解的访问修饰符 access-specifier 来指定的。
我们几乎不使用 protected 或 private 继承,通常使用 public 继承。当使用不同类型的继承时,遵循以下几个规则:
公有继承(public):当一个类派生自公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类的私有成员不能直接被派生类访问,但是可以通过调用基类的公有和保护成员来访问。
保护继承(protected):当一个类派生自保护基类时,基类的公有和保护成员将成为派生类的保护成员。私有继承(private):当一个类派生自私有基类时,基类的公有和保护成员将成为派生类的私有成员。
#include <iostream>
using namespace std;
// 基类
class Shape {
public:
void setWidth(int w) {
width = w;
}
void setHeight(int h) {
height = h;
}
protected:
int width;
int height;
};
// 派生类
class Rectangle: public Shape {
public:
int getArea() {
return (width * height);
}
};
int main(void) {
Rectangle Rect;
Rect.setWidth(5);
Rect.setHeight(7);
// 输出对象的面积
cout << "Total area: " << Rect.getArea() << endl;
return 0;
}
打印结果:
Total area: 35
1.9.2 虚函数
定义一个函数为虚函数,不代表函数为不被实现的函数。
定义他为虚函数是为了允许用基类的指针来调用子类的这个函数。定义一个函数为纯虚函数,才代表函数没有被实现。定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。
class A {
public:
virtual void foo() {
cout << "A::foo() is called" << endl;
}
};
class B:public A {
public:
void foo() {
cout << "B::foo() is called" << endl;
}
};
int main(void) {
A *a = new B();
a->foo(); // 在这里,a 虽然是指向 A 的指针,但是被调用的函数 (foo) 却是 B 的!
return 0;
}
- 一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被称为'虚'函数。
- 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加 '=0':
virtual void funtion()=0 - 将函数定义为纯虚函数,则编译器要求在派生类中必须予以重写以实现多态性。声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。
1.10 类模板、函数模板
- 模板是泛型编程的基础,泛型编程即以一种独立于任何特定类型的方式编写代码。
- 模板是创建泛型类或函数的蓝图或公式。
模板函数定义的一般形式如下所示:
template <typename type> ret-type func-name(parameter list) {
// 函数的主体
}
#include <iostream>
#include <string>
using namespace std;
template <typename T>
inline T const& Max (T const& a, T const& b) {
return a < b ? b:a;
}
int main () {
int i = 39;
int j = 20;
cout << "Max(i, j): " << Max(i, j) << endl;
double f1 = 13.5;
double f2 = 20.7;
cout << "Max(f1, f2): " << Max(f1, f2) << endl;
string s1 = "Hello";
string s2 = "World";
cout << "Max(s1, s2): " << Max(s1, s2) << endl;
return 0;
}
打印结果:
Max(i, j): 39 Max(f1, f2): 20.7 Max(s1, s2): World
类模板,泛型类声明的一般形式如下所示:
template <class type> class class-name {
.
.
.
}
#include <iostream>
#include <vector>
#include <cstdlib>
#include <string>
#include <stdexcept>
using namespace std;
template <class T>
class Stack {
private:
vector<T> elems; // 元素
public:
void push(T const&); // 入栈
void pop(); // 出栈
T top() const; // 返回栈顶元素
bool empty() const{ // 如果为空则返回真。
return elems.empty();
}
};
template <class T>
void Stack<T>::push (T const& elem) {
// 追加传入元素的副本
elems.push_back(elem);
}
template <class T>
void Stack<T>::pop () {
if (elems.empty()) {
throw out_of_range("Stack<>::pop(): empty stack");
}
// 删除最后一个元素
elems.pop_back();
}
template <class T>
T Stack<T>::top () const {
if (elems.empty()) {
throw out_of_range("Stack<>::top(): empty stack");
}
// 返回最后一个元素的副本
return elems.back();
}
int main() {
try {
Stack<int> intStack; // int 类型的栈
Stack<string> stringStack; // string 类型的栈
// 操作 int 类型的栈
intStack.push(7);
cout << intStack.top() << endl;
// 操作 string 类型的栈
stringStack.push("hello");
cout << stringStack.top() << std::endl;
stringStack.pop();
stringStack.pop();
}
catch (exception const& ex) {
cerr << "Exception: " << ex.what() << endl;
return -1;
}
}
打印结果:
7 hello Exception: Stack<>::pop(): empty stack
1.11 容器
- 序列式容器(Sequence containers),此为可序群集,其中每个元素均有固定位置—取决于插入时机和地点,和元素值无关。如果你以追加方式对一个群集插入六个元素,它们的排列次序将和插入次序一致。STL 提供了三个序列式容器:向量(vector)、双端队列(deque)、列表(list),此外你也可以把 string 和 array 当做一种序列式容器。
- 关联式容器(Associative containers),此为已序群集,元素位置取决于特定的排序准则以及元素值,和插入次序无关。如果你将六个元素置入这样的群集中,它们的位置取决于元素值,和插入次序无关。STL 提供了四个关联式容器:集合(set)、多重集合(multiset)、映射(map)和多重映射(multimap)。
- 容器配接器:根据上面七种基本容器类别实现而成。stack,元素采取 LIFO(后进先出)的管理策略、queue,元素采取 FIFO(先进先出)的管理策略。也就是说,它是个普通的缓冲区(buffer)、priority_queue,元素可以拥有不同的优先权。所谓优先权,乃是基于程序员提供的排序准则(缺省使用 operators)而定义。Priority queue 的效果相当于这样一个 buffer:'下一元素永远是 queue 中优先级最高的元素'。如果同时有多个元素具备最髙优先权,则其次序无明确定义。
特点:
vector头部与中间插入和删除效率较低,在尾部插入和删除效率高,支持随机访问。deque是在头部和尾部插入和删除效率较高,支持随机访问,但效率没有vector高。list在任意位置的插入和删除效率都较高,但不支持随机访问。set由红黑树实现,其内部元素依据其值自动排序,每个元素值只能出现一次,不允许重复,且插入和删除效率比用其他序列容器高。map可以自动建立 Key-value 的对应,key 和 value 可以是任意你需要的类型,根据 key 快速查找记录。
选择:
- 如果需要高效的随机存取,不在乎插入和删除的效率,使用
vector。 - 如果需要大量的插入和删除元素,不关心随机存取的效率,使用
list。 - 如果需要随机存取,并且关心两端数据的插入和删除效率,使用
deque。 - 如果打算存储数据字典,并且要求方便地根据 key 找到 value,一对一的情况使用
map,一对多的情况使用multimap。 - 如果打算查找一个元素是否存在于某集合中,唯一存在的情况使用
set,不唯一存在的情况使用multiset。
时间复杂度:
vector在头部和中间位置插入和删除的时间复杂度为 O(N),在尾部插入和删除的时间复杂度为 O(1),查找的时间复杂度为 O(1);deque在中间位置插入和删除的时间复杂度为 O(N),在头部和尾部插入和删除的时间复杂度为 O(1),查找的时间复杂度为 O(1);list在任意位置插入和删除的时间复杂度都为 O(1),查找的时间复杂度为 O(N);set和map都是通过红黑树实现,因此插入、删除和查找操作的时间复杂度都是 O(log N)。
1.12 命名空间
1.12.1 namespace
- 命名空间是一种描述逻辑分组的机制,可以将按某些标准在逻辑上属于同一个集团的声明放在同一个命名空间中。用于区分不同库中相同名称的函数、类、变量。
namespace namespace_name {
// 代码声明
}
- 无名命名空间。你可以在当前编译单元中(无名命名空间之外),直接使用无名命名空间中的成员名称,但是在当前编译单元之外,它又是不可见的。它可以使代码保持局部性,从而保护代码不被他人非法使用。
namespace {
// 代码声明
}
- 不能在命名空间的定义中声明(另一个嵌套的)子命名空间,只能在命名空间的定义中定义子命名空间。
- 不能直接使用
命名空间名::成员名……定义方式,为命名空间添加新成员,而必须先在命名空间的定义中添加新成员的声明。 - 命名空间是开放的,即可以随时把新的成员名称加入到已有的命名空间之中去。方法是,多次声明和定义同一命名空间,每次添加自己的新成员和名称。
1.12.2 using
- 可以使用
using namespace指令,这样在使用命名空间时就可以不用在前面加上命名空间的名称。这个指令会告诉编译器,后续的代码将使用指定的命名空间中的名称。
#include <iostream>
using namespace std;
// 第一个命名空间
namespace first_space{
void func(){
cout << "Inside first_space" << endl;
}
}
// 第二个命名空间
namespace second_space{
void func(){
cout << "Inside second_space" << endl;
}
}
using namespace first_space;
int main () {
// 调用第一个命名空间中的函数
func();
return 0;
}
- 除了可以使用 using 编译指令(组合关键字
using namespace)外,还可以使用using 声明来简化对命名空间中的名称的使用:using 命名空间名::[命名空间名::……] 成员名;。注意,关键字using后面并没有跟关键字namespace,而且最后必须为命名空间的成员名(而在using编译指令的最后,必须为命名空间名)。
using 指令使用后,可以一劳永逸,对整个命名空间的所有成员都有效,非常方便。而using 声明,则必须对命名空间的不同成员名称,一个一个地去声明。但是,一般来说,使用using 声明会更安全。因为,using 声明只导入指定的名称,如果该名称与局部名称发生冲突,编译器会报错。而using 指令导入整个命名空间中的所有成员的名称,包括那些可能根本用不到的名称,如果其中有名称与局部名称发生冲突,则编译器并不会发出任何警告信息,而只是用局部名去自动覆盖命名空间中的同名成员。特别是命名空间的开放性,使得一个命名空间的成员,可能分散在多个地方,程序员难以准确知道,别人到底为该命名空间添加了哪些名称。
二、Java 调用 C/C++
- 加载
.so库;
//MainActivity.java
static {
System.loadLibrary("native-lib");
}
- 编写 java 函数;
//MainActivity.java
public native String stringFromJNI();
- 编写 C/C++ 函数;
//native-lib.cpp
#include <jni.h>
#include <string>
//函数名的构成:Java 加上包名、方法名并用下划线连接(Java_packageName_methodName)
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_cppdemo_MainActivity_stringFromJNI(
JNIEnv *env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
- ndk/cmake 配置(下面只列出 cmake 配置);
# CMakeLists.txt
# 设置构建本地库所需的最小版本的 cbuild。
cmake_minimum_required(VERSION 3.4.1)
# 创建并命名一个库,将其设置为静态
# 或者共享,并提供其源代码的相对路径。
# 您可以定义多个库,而 cbuild 为您构建它们。
# Gradle 自动将共享库与你的 APK 打包。
add_library( native-lib #设置库的名称。即 SO 文件的名称,生产的 so 文件为'libnative-lib.so', 在加载的时候'System.loadLibrary("native-lib");'
SHARED # 将库设置为共享库。
native-lib.cpp # 提供一个源文件的相对路径
helloJni.cpp # 提供同一个 SO 文件中的另一个源文件的相对路径
)
# 搜索指定的预构建库,并将该路径存储为一个变量。因为 cbuild 默认包含了搜索路径中的系统库,所以您只需要指定您想要添加的公共 NDK 库的名称。cbuild 在完成构建之前验证这个库是否存在。
find_library(log-lib # 设置 path 变量的名称。
log # 指定 NDK 库的名称 你想让 CMake 来定位。
)
#指定库的库应该链接到你的目标库。您可以链接多个库,比如在这个构建脚本中定义的库、预构建的第三方库或系统库。
target_link_libraries( native-lib # 指定目标库中。与 add_library 的库名称一定要相同
${log-lib} # 将目标库链接到日志库包含在 NDK。
)
#如果需要生产多个 SO 文件的话,写法如下
add_library( natave-lib # 设置库的名称。另一个 so 文件的名称
SHARED # 将库设置为共享库。
nataveJni.cpp # 提供一个源文件的相对路径
)
target_link_libraries( natave-lib #指定目标库中。与 add_library 的库名称一定要相同
${log-lib} # 将目标库链接到日志库包含在 NDK。
)
// build.gradle(:app)
android {
compileSdkVersion 29
buildToolsVersion "30.0.2"
defaultConfig {
applicationId "com.example.cppdemo"
minSdkVersion 16
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
cppFlags ""
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.10.2"
}
}
}
三、JNI 基础
3.1 JNIEnv、JavaVM
JavaVM是 Java 虚拟机在 JNI 层的代表,JNI 全局只有一个;- 从上面的代码中我们可以发现,虽然 Java 函数不带参数,但是 native 函数却带了两个参数,第一个参数
JNIEnv是指向可用 JNI 函数表的接口指针,第二个参数jobject是 Java 函数所在类的实例的 Java 对象引用; JNIEnv是JavaVM在线程中的代表,每个线程都有一个,JNI 中可能有很多个JNIEnv,同时JNIEnv具有线程相关性,也就是 B 线程无法使用 A 线程的JNIEnv;JNIEnv类型实际上代表了 Java 环境,通过这个JNIEnv*指针,就可以对 Java 端的代码进行操作:
调用 Java 函数;操作 Java 对象;
JNIEnv的本质是一个与线程相关的代表 JNI 环境的结构体,里面存放了大量的 JNI 函数指针;JNIEnv内部结构如下:
(注:此处原图链接已移除,实际开发中请参考官方文档查看 JNIEnv 结构)
JavaVM的结构如下:
(注:此处原图链接已移除,实际开发中请参考官方文档查看 JavaVM 结构)
3.2 数据类型
3.2.1 基础数据类型
| Signature 格式 | Java | Native | Description |
|---|---|---|---|
| B | byte | jbyte | signed 8 bits |
| C | char | jchar | unsigned 16 bits |
| D | double | jdouble | 64 bits |
| F | float | jfloat | 32 bits |
| I | int | jint | signed 32 bits |
| S | short | jshort | signed 16 bits |
| J | long | jlong | signed 64 bits |
| Z | boolean | jboolean | unsigned 8 bits |
| V | void | void | N/A |
3.2.2 数组数据类型
数组简称:在前面添加 [
| Signature 格式 | Java | Native |
|---|---|---|
| [B | byte[] | jbyteArray |
| [C | char[] | jcharArray |
| [D | double[] | jdoubleArray |
| [F | float[] | jfloatArray |
| [I | int[] | jintArray |
| [S | short[] | jshortArray |
| [J | long[] | jlongArray |
| [Z | boolean[] | jbooleanArray |
3.2.3 复杂数据类型
对象类型简称:L+classname +;
| Signature 格式 | Java | Native |
|---|---|---|
| Ljava/lang/String; | String | jstring |
| L+classname +; | 所有对象 | jobject |
| [L+classname +; | Object[] | jobjectArray |
| Ljava.lang.Class; | Class | jclass |
| Ljava.lang.Throwable; | Throwable | jthrowable |
3.2.4 函数签名
(输入参数...) 返回值参数
| Signature 格式 | Java 函数 |
|---|---|
| ()V | void func() |
| (I)F | float func(int i) |
| ([I)J | long func(int[] i) |
| (Ljava/lang/Class;)D | double func(Class c) |
| ([ILjava/lang/String;)Z | boolean func(int[] i,String s) |
| (I)Ljava/lang/String; | String func(int i) |
3.3 JNI 操作 JAVA 对象、类
- 获取你需要访问的 Java 对象的类:
jclass thisclazz = env->GetObjectClass(thiz);//使用 GetObjectClass 方法获取 thiz 对应的 jclass。
jclass thisclazz = env->FindClass("com/xxx/xxx/abc");//直接搜索类名
- 获取 MethodID:
/**
* thisclazz -->上一步获取的 jclass
* "onCallback"-->要调用的方法名
* "(I)Ljava/lang/String;"-->方法的 Signature,签名参照前面的第 3.2 小节表格。
*/
jmethodID mid_callback = env->GetMethodID(thisclazz , "onCallback", "(Ljava/lang/String;)I");
jmethodID mid_callback = env->GetStaticMethodID(thisclazz , "onCallback", "(Ljava/lang/String;)I");//获取静态方法的 ID
- 调用方法:
jint result = env->CallIntMethod(thisclazz , mid_callback , jstrParams);
jint result = env->CallStaticIntMethod(thisclazz , mid_callback , jstrParams);//调用静态方法
贴一下,有需要可以在这里查询。
3.4 JNI 引用
3.4.1 局部引用
- 通过
NewLocalRef和各种 JNI 接口创建(FindClass、NewObject、GetObjectClass和NewCharArray等)。 - 会阻止 GC 回收所引用的对象,不在本地函数中跨函数使用,不能跨线前使用。
- 函数返回后局部引用所引用的对象会被 JVM 自动释放,或手动释放。
- 手动释放的方式:GetXXX 就必须调用 ReleaseXXX,调用完
GetStringUTFChars之后,调用ReleaseStringUTFChars释放;对于手动创建的jclass,jobject等对象使用DeleteLocalRef方法进行释放。
3.4.2 全局引用
- 调用
NewGlobalRef基于局部引用创建。 - 会阻 GC 回收所引用的对象。可以跨方法、跨线程使用。
- JVM 不会自动释放,必须手动释放。
- 全局引用在显式释放之前保持有效,必须通过
DeleteGlobalRef来手动删除全局引用调用。
3.4.3 弱全局引用
- 调用
NewWeakGlobalRef基于局部引用或全局引用创建。 - 不会阻止 GC 回收所引用的对象,可以跨方法、跨线程使用。
- 引用不会自动释放,在 JVM 认为应该回收它的时候(比如内存紧张的时候)进行回收而被释放。或调用
DeleteWeakGlobalRef手动释放。
四、总结与进阶建议
本文系统地介绍了 Android NDK 开发的核心知识体系。从 C++ 语言基础如指针管理、内存布局、面向对象特性入手,深入讲解了 Java 与 C++ 交互的机制,包括 JNI 环境、数据类型转换、对象引用生命周期管理等关键概念。
在实际开发中,建议开发者注意以下几点:
- 内存安全:在 NDK 开发中,务必小心处理内存泄漏和野指针问题,特别是在 JNI 调用频繁的场景下。
- 异常处理:确保在 C++ 侧捕获并妥善处理 Java 抛出的异常,避免导致应用崩溃。
- 性能优化:合理使用 JNI 调用,避免在循环中频繁进行 Java 与 Native 之间的切换,以减少性能损耗。
- 工具链掌握:熟练掌握 CMake、Gradle 以及 Android Studio 的调试工具,能够高效地排查 Native 层问题。
通过持续实践与深入学习,开发者可以更好地利用 NDK 提升 Android 应用的性能与功能边界。


