Android的NDK开发相信各位已经精通各种姿势了。不过基本上都是那种native代码和java代码都在同一个工程中,因为应用从头到脚都是我们自己的,也不需要分离。但有时候可能需要我们自己把某些库打包起来供别人使用,或者使用别人提供给我们的库。本篇文章及下篇文章就讲讲so库如何打包。
一、目标及方式
这篇文章会讲打包so库,这种方式是基于jni层的,需要我们同时提供接口的jar包来配合使用,适用于对方从java层调用我们的库。因为jni中的函数名是有特殊要求的,它会指定jni的java接口的路径,如果不提供jar包,那么使用者就要按照我们在jni头文件中声明的函数名来建立java文件,这将是非常痛苦的。
二、新建调用方工程
按照正常流程新建工程即可,众所周知,新建一个工程,它会自己建一个名为app的module。它是作为我们的调用方,因此不需要什么特殊的操作。
为了避免剩下的操作报错,调用方工程先到此为止,在准备好了被调用方(也就是jar包和so库)后,再继续完善。
三、新建库module
我们仍然在这个工程中,新建一个module(AndroidStudio的组织结构是一个project可以包含多个module,每个module都可以独立编译为一个apk)。这个module就是我们的库模块。
File > New > New module > Android Library
填好相关信息等就可以finish了。目前我在用的是AndroidStudio3.4.1,在新建工程的时候已经找不到添加c++ support的选项了,因此一会儿也免不了要自己去改build.gradle文件来添加c++支持。
新建完后的工程结构如图
可以看到我们sdk模块和app是同级的。
四、为库模块添加c++支持
首先要做一些准备工作。为了顺应时代潮流,此处使用cmake构建。
4.1 准备CMakeLists文件
如果你会自己写的话最好,不会的话,可以新建一个支持c++的工程,在我现在的AndroidStudio3.4.1版本上,在Chose Project这一步时,选择Native C++就会有了,更早的版本更加简单,会有很显眼的选项供你选择。然后复制这个工程的CMakeLists文件过来就好了。或者你可以参考以前有NDK的工程,我这里选择最后一种。
OK无论哪种方式,得到了CMakeLists文件。
cmake_minimum_required(VERSION 3.4.1)
# 这一句用来导出编译生成的so库
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/jniLibs/${ANDROID_ABI})
include_directories(src/main/cpp)
file(GLOB CPP_FILES "src/main/cpp/*.cpp")
add_library(
native-lib
SHARED
${CPP_FILES}
)
通常操作,我就不解释CMakeLists里都是啥意思了。注意我注释的那条语句,它会负责把编译出的so库导出到我们指定的位置。这里的位置就是sdk/jniLibs/{ANDROID_ABI}。
然后将这个文件放在库模块的根目录下(虽然我这库模块的名字是sdk,但是并不是AndroidSDK,切勿搞混,需要AndroidSDK的时候我会特别指明的)。放在别的地方也行,因为在gradle文件里这个位置是自己定的。文件结构如下:
这个文件结构和你在文件管理器中看到的是一样的。如果你看不到这个界面,点击图中绿色线的下拉菜单选择Project Files即可(一般都是在Android结构下)。
4.2 修改sdk的gradle脚本
此处修改的是sdk的build.gradle文件(不是AndroidSDK!!!)。在defaultConfig节点下指定要编译的cpu架构,在android节点下指定CMakeLists的位置。最后文件如下:
apply plugin: 'com.android.library'
android {
compileSdkVersion 28
defaultConfig {
minSdkVersion 23
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
//这里指定cpu架构
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
//这里指定CMakeLists的位置,默认根目录是从sdk目录开始的。
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.android.support:appcompat-v7:28.0.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}
4.3 编写c++及java代码
我们从库里返回一个字符串"Hello from NDK"。
注意看我们的CMakeLists文件中指定的src位置,是src/main/cpp
,那么去检查一下有无这个文件。没有的话就新建一个。然后在里面新建一个cpp文件(如果你是c的话,注意修改一下CMakeLists里相关的地方,因为这个目前仅包含了cpp文件)。点击Build > Make module "sdk"
,等模块编译完成后,我们就可以看到项目中出现了cpp文件夹和里面的源代码,并且被AndroidStudio认定为源代码文件夹。如果看不到,再点击一下Build > Refresh linked C++ project
,就可以看到了。
编写一个jni函数。假设我在我的包里要建立一个MyStringUtil.java文件,那么这个文件的完整路径就是com.zu.sdk.MyStringUtil
。
那么在这个c++文件中,我们这样写:
#include
#include
#include
using namespace std;
extern "C"
JNIEXPORT jstring
JNICALL
Java_com_zu_sdk_MyStringUtil_getStringNDK(JNIEnv *env, jobject instance)
{
string result = "Hello from NDK";
return env->NewStringUTF(result.c_str());
}
extern "C"
是个比较迷惑的点,因为我们现在使用的是cpp文件,使用c++编译器编译后,函数名会改变,但是c的不会,因此我们要在作为jni接口的函数上加上这句。否则jni会找不到接口。
然后按照我们之前说的,在包下建立名为MyStringUtil.java的文件。之后的文件结构如图:
在java文件中加载ndk库,然后声明函数,要和c++文件中的对应起来。
package com.zu.sdk;
public class MyStringUtil {
public static String ndkString()
{
return getStringNDK();
}
private static native String getStringNDK();
static{
System.loadLibrary("native-lib");
}
}
注意如果你的java的函数和jni中的函数能对应起来的话,AndroidStudio会有相应提示:
标红的不要管它,因为现在我还没有build。而且现在AndroidStudio越来越难用,很多bug出现。
4.4 测试库
至此我们已经把库的部分完成了,但是还是要测试一下能不能正常运行。这里我们选择直接把库的module作为app module的项目依赖,这样一来,每次我们运行app module时,都是以sdk module作为依赖实时编译的,不必我们每次都要导出so库和jar包。方便修改。
在app module下已经有一个初始化的MainActivity。直接用它显示NDK过来的字符串。
接下来要将sdk作为app的依赖。在app上点击右键,点击Open module settings
,点开Dependencies,选中app。
点击红圈里的加号,选择Module Dependency,会出来目前这个工程里的所有module,选择sdk这个module。
点击OK之后,再点击OK,这个时候依赖就添加好了。那么到底什么地方发生了改变呢?其实就是在app的build.gradle文件中的dependencies节点下加了一句:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
implementation project(path: ':sdk')//就是这句
}
所以不想这么麻烦你也可以直接把这句加进去在sync一下gradle就可以了。
然后在app的MainActivity里就可以调用sdk里MyStringUtils里的ndkString
方法获取字符串。
package com.zu.ndktest
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
//现在可以导入sdk里的内容了
import com.zu.sdk.MyStringUtil.ndkString
class MainActivity : AppCompatActivity() {
private lateinit var tvNDK: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
tvNDK = findViewById(R.id.tv_ndk)
//这句调用方法
tvNDK.text = ndkString()
}
}
app里的代码都是Kotlin。
然后运行。
nice,字符串没有错误,说明成功了。
五、打包so库
so库其实不用打包,因为我们已经在CMakeLists文件中指定了so库的输出路径。点击Build > Make module "sdk"
,然后去文件管理器里看一下,sdk目录下会生成一个jniLibs文件夹,里面包含了指定cpu架构的so文件。
六、打包jar包
通过上一步我们已经打包出了so,但是只有so是很难使用的,因为jni接口是指定包名的,使用者的包名基本不可能和我们的一样,因此提供一个jar包来调用so库。
打包jar包实际上是一个不怎么常见的操作,这里我们要在sdk的build.gradle中添加一个task,通过这个task来打包。
为sdk的build.gradle文件添加如下节点
task makeJar(type: Copy) {
delete 'libs/sdk.jar' //删除已经存在的jar包
from('build/intermediates/packaged-classes/release/')//从该目录下加载要打包的文件,注意这个目录,不同版本的AndroidStudio是不一样的,比如在3.0版本是build/intermediates/bundles/release/,要自己去查一下。
into('libs/')//jar包的保存目录
include('classes.jar')//设置过滤,只打包classes文件
rename('classes.jar', 'sdk.jar')//重命名,mylibrary.jar 根据自己的需求设置
}
makeJar.dependsOn(build)
注意这个节点是一个顶级节点,它不属于其他任何节点。放一个完整的sdk的build.gradle,如果有c++配置或者其他的一些问题可以参考这个gradle。
apply plugin: 'com.android.library'
android {
compileSdkVersion 28
defaultConfig {
minSdkVersion 23
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
//该task用来打包jar包
task makeJar(type: Copy) {
delete 'libs/sdk.jar' //删除已经存在的jar包
from('build/intermediates/packaged-classes/release/')//从该目录下加载要打包的文件,注意这个目录,不同版本的AndroidStudio是不一样的,比如在3.0版本是build/intermediates/bundles/release/,要自己去查一下。
into('libs/')//jar包的保存目录
include('classes.jar')//设置过滤,只打包classes文件
rename('classes.jar', 'sdk.jar')//重命名,mylibrary.jar 根据自己的需求设置
}
makeJar.dependsOn(build)
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.android.support:appcompat-v7:28.0.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}
添加完之后,sync gradle一下。然后,打开Android Studio的Terminal,输入gradlew makeJar
,然后你就会看到一堆输出。
最后看到build success,然后去sdk/libs
目录下查看是否生成了名为sdk.jar的文件。如果没有,应该就是你的classes.jar路径写错了。
七、如何使用
如今我们已经打包好了so和jar,接下来就试一下能否正常使用。回到app,这次我们要修改一下app的依赖和其他一些东西。
首先是修改依赖,原来是直接依赖了sdk这个module的,现在要依赖生成的jar包。
修改之前,要把生成的jar包和so库复制到app目录下,app/libs/sdk.jar
和app/jniLibs/{abi}/native-lib.so
是最后应该存放的路径。当然路径是随便定的,只要你能找得到并且能在gradle文件里正确的指出。
然后修改app的build.gradle文件。
在依赖里,注释掉依赖sdk的那行,添加对jar包的依赖:
//implementation project(path: ':sdk')
implementation files('libs/sdk.jar')
这种写法是依赖一个具体包的写法,如果某些情况下你必须得使用已经存在的jar包,就按照这种方式即可。
然后在android
节点下添加如下节点:
sourceSets {
main {
jniLibs.srcDirs = ['jniLibs']
}
}
这个则是指定了so库所在路径。
需要注意的是,jar包依赖和项目依赖不可共存,否则会发生包冲突,如果你要依赖module,那你就把jar包依赖和sourceSets节点注释掉。相反亦是。
到这就可以运行了。
这种方式开发起来极为方便,但是局限性比较大,因为终究还是要使用android环境,并且对方只能从java调用(当然你要是愿意写java>jni的调用那我也没意见),调试起来也很麻烦,因为最终你还是要放到Android里去验证结果。下一篇会讲一种更类似于桌面端的开发方式,除了仅在打包时需要使用到NDK,其他时候都和普通开发c/c++程序没有区别,而且可以在电脑环境下充分验证程序之后再去打包,也更好分工。