Android程序中,内嵌ELF可执行文件
Android开发C语言混合编程总结
前言
都知道的,Android基于Linux系统,然后覆盖了一层由Java虚拟机为核心的壳系统。跟一般常见的Linux+Java系统不同的,是其中有对硬件驱动进行支持,以避开GPL开源协议限制的HAL硬件抽象层。
大多数时候,我们使用JVM语言进行编程,比如传统的Java或者新贵Kotlin。碰到对速度比较敏感的项目,比如游戏,比如视频播放。我们就会用到Android的JNI技术,使用NDK的支持,利用C++开发高计算量的模块,供给上层的Java程序调用。
本文先从一个最简单的JNI例子来开始介绍Android中Java和C++的混合编程,随后再介绍Android直接调用ELF命令行程序的规范方法,以及调用混合了第三方库略微复杂的命令行程序。
Android Studio配置
第一个配置是安装Android的SDK,这是开发Android程序必须的。
进入Android Studio的设置界面,Mac的快捷键是Command
+,
,Windows和Linux版本请自行从菜单中选择。
在设置界面中,从左侧顺序选择:Appearance&Behavior -> System Settings -> Android SDK,可以进入到SDK的设置。
右侧的SDK版本列表中,最前面显示了✔️或者后面显示了Installed,表示该版本的SDK已经安装。通常如果没有特殊需要,只安装1个最新版本的SDK即可。图中我是因为某些项目特殊的要求,安装了两个特定不同版本的SDK。
希望安装某版本的SDK,只要点选相应行最前面的多选框,然后单击右下角确认按钮即可安装。
如果不是自己从头开始,而是接手了其他开发人员的源码,源码中可能指定了特定版本的SDK。这时候可以修改其项目配置文件中版本的设置,到你安装的SDK版本。更简单的方法是直接在这里安装对应的SDK,防止因为版本依赖出现的很多繁琐问题。
第二个配置的是NDK,还在刚才SDK设置的界面中,点击界面上侧中间的“SDK Tools”标签,可以进入到NDK设置的界面。
NDK的设置没有那么多的选择,只要安装就好,已经安装碰到有新版本,也可以随性选择更新或者使用老版本继续。NDK不同版本间的兼容性都还不错,大多都不用担心。
NDK的设置是Android开发中,Java/C混合编程需要的。
第三个配置是增加一个外部工具javah,这个工具是将Java编写的“包装”文件,转换一个C/C++的.h文件。虽然Java/C++都是面向对象语言,但两者的面向对象实现是不同的。所以在Java中某个类的方法,转换到C++的世界中,是使用很长的函数名来做区分。这种情况使用手工编写虽然效果一样,但很容易出错,使用javah工具则能自动完成。
在Android Studio设置界面左侧的列表中,顺序选择Tools -> External Tools,单击右侧界面左下角的“+”,新建一个工具,比如就叫”javah”。
其中三个需要设置的内容分别是:
- javah程序路径:
$JDKPath$/bin/javah
,这个跟jdk安装的路径有关。 - 命令行参数:
-classpath . -jni -d $ModuleFileDir$/src/main/jni $FileClass$
,主要指定输出路径。 - 工作目录:
$ModuleFileDir$/src/main/Java
,当前项目路径。
至此Android Studio的主要设置就完成了,当然只是最基本必须的设置,如果自己还有其它需求,类似git仓库地址等,可以再自行设置。
下面就可以开始进行项目的开发。
先准备一个基本的Android程序
在Android Studio界面选择New Project,如果是在开始界面,直接点击主界面上的按钮;也可以在文件菜单中选择。
选择基本的Empty Activity就好。
接着是项目的设置,项目名称、存储位置这些都不用说了,最低的API版本决定了你的程序可以在最低什么版本的Android手机上执行,如果没有特殊需要,尽量可以低一点,毕竟Android手机的升级比例,比iOS是低了好多倍的。
这样,项目就建立完成,Android Studio使用标准模板,对项目做了初始化。我们可以在这个基础上再添加自己的内容。
从屏幕左侧项目文件的列表中,选择app -> res -> layout -> acitvity_main.xml文件,文件会在右侧打开,模式是交互式的界面设计器。在其中,按照下图的样子,我们增加一个TextView控件和一个按钮。文本框是为了将来显示输出的结果,按钮当然就是开始执行的触发器。
TextView控件我们修改一下名字,叫textView1。按钮的名字改为button1,另外为按钮的onClick属性增添一个调用:bt1_click。
界面部分就完成了,记着存盘,然后可以关掉这个文件。
这时候,Android Studio界面会显示在MainActivity.java文件的位置。这是新建项目之后自动打开的文件,也是这个项目的主窗口程序文件。我们首先编辑窗口布局文件的时候,这个文件被隐藏在了后面。
我们在文件的库引用部分,增加如下两行:
import android.widget.TextView;
import android.view.View;
这两行是我们接下来的程序会使用到的库引用。
在类的变量声明部分,增加这样两行:
TextView textview1;
int c=0;
第一行是声明一个文本框,用于关联到刚才界面编辑器中加入的文本框。
c变量就是一个简单的计数器,我们希望每点击一次按钮,这个计数器累加1,从而确认我们每次点击都被响应了,而不是程序没有任何反馈给用户。
在onCreate
函数的最后,增加关联文本框的代码:
textview1=(TextView)findViewById(R.id.textView1);
R.id.后面的textView1就是我们在界面编辑的时候,为文本框起的名字。
接着,在类的最后,增加按钮点击响应的处理函数:
public void bt1_click(View view){
c = c+1;
textview1.setText("click:"+c);
}
清晰起见,我们把这部分完成的代码再抄过来一遍:
package com.test.calljni;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.TextView;
import android.view.View;
public class MainActivity extends AppCompatActivity {
TextView textview1;
int c=0;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
textview1=(TextView)findViewById(R.id.textView1);
}
public void bt1_click(View view){
c = c+1;
textview1.setText("click:"+c);
}
}
程序完成,可以从Build菜单选择Make Project编译项目。然后在Run菜单选择Run ‘app’。
如果是第一次使用Android Studio,你还可能会被提醒需要你新建一个Android模拟器来执行程序。当然也可以把打开了调试功能的Android手机插在电脑上进行真机调试。
执行的结果如图:
点击两次按钮后,画面变为:
好了,我们的基本实验平台准备完成,下面才是进入正题。
调用JNI库
每个JNI库都分为两部分,一个是C++编写的.so动态链接库,另一部分则是Java对这个动态链接库的封装。我们先从Java部分看起。
编写JNI库的Java封装类
开始写这个JNI库之前,我们首先要对这个库的总体功能、结构划分、接口类型充分做好规划,这样才能保证两种语言之间的顺畅调用。因为尚没有一种工具可以同时有效的对两种语言进行跟踪调试,所以在接口部分如果碰到问题,往往只能在大量的日志输出中去查找线索,费时费力。
作为一个简单的演示,我们的JNI库功能很简单,从Java封装的角度看,我们有一个名为JniLib的Java类,其中包含一个方法,叫callToCpp,这个方法,将会在C++中来实现。
在文件列表中,选择MainActivity.java所在的包名,点击右键,选择New->Java Class。
一切选用默认设置,类名为JniLib。
Android Studio会自动生成并打开一个JniLib.java文件。其中只有一个而空白的类定义。我们在其中继续编写自己的内容。
这个封装类的代码非常简单,我们直接列出全部:
package com.test.calljni;
public class JniLib {
static {
System.loadLibrary("JniLib");
}
public static native String callToCpp();
}
其中的静态部分,相当于构造函数了,直接载入一个动态链接库,名称为“JniLib”。这个是对于Java来说的库名,实际对应的文件名将是libJniLib.so。就是说,Android在载入动态链接库的时候,自动在给定的链接库名称前面添加“lib”,后面添加“.so”后缀。这个我们在后面还会更直观的展示。
接着是声明一个native类型的函数,callToCpp(),native表示这个函数将在刚刚载入的libJniLib.so中实现,也就是将由C++来实现。
由封装类生成C++头文件
下面是利用这个JniLib类,生成C++使用的.h头文件。
在Android Studio界面的左侧列表中,用鼠标右键点击JniLib文件,弹出菜单中选择External Tools -> javah,这个javah就是我们前面建立的附加工具。
此时最好将Android Studio左侧的视图从默认的“Android”方式修改到“Project”方式,这样能更清晰的看到目录层次关系。
随后左侧列表中,跟Java文件夹同级,会出现一个jni文件夹,其中有一个文件:com_test_calljni_JniLib.h,这就是刚才由javah自动生成的。
头文件生成到src/main/jni目录,这是我们在javah扩展工具设定的时候所确定下来的。
在列表中双击com_test_calljni_JniLib.h文件打开,其内容为:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_test_calljni_JniLib */
#ifndef _Included_com_test_calljni_JniLib
#define _Included_com_test_calljni_JniLib
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: com_test_calljni_JniLib
* Method: callToCpp
* Signature: ()Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_com_test_calljni_JniLib_callToCpp
(JNIEnv *, jclass);
#ifdef __cplusplus
}
#endif
#endif
Java_com_test_calljni_JniLib_callToCpp函数定义这一行,对应就是我们在Java JniLib类中所声明的callToCpp方法。整个函数名中包含了封装语言Java/Java包名com.test.calljni/类名JniLib/方法名callToCpp几个部分。
请注意文件第一行的提醒信息,这个头文件的内容不要自行修改,如果修改Java封装文件JniLib.java导致了类名、函数名的变化,应当重复上一步,使用javah工具重新完整生成头文件。
C++实现JNI库
继续用C++编写我们的函数实现。用鼠标右键点击列表中的jni文件夹,新建一个c++源文件,名称定为JniLib.cpp。
内容如下:
#include "com_test_calljni_JniLib.h"
JNIEXPORT jstring JNICALL Java_com_test_calljni_JniLib_callToCpp
(JNIEnv *env, jclass){
return (*env).NewStringUTF("从cpp返回的文本。");
};
c++代码中,首先是引用刚才由javah生成的头文件,这是为了保证c++中定义的函数,严格吻合Java封装类中所指定的类型。
函数的定义比较长,可以从.h文件中直接拷贝进来。因为JNIEnv参数我们会用到,所以我们在后面添加一个具体的变量名,这里用“env”。
函数中只有一条语句,就是返回一个文本字符串,使用JNI中提供的NewStringUTF函数把这个C++的字符串转换为一个Java的String对象。
NDK编译脚本
使用NDK系统编译JNI库,还需要有两个文件,都将位于src/main/jni文件夹中,一个是Application.mk文件,内容只有一行:
APP_ABI := all
ABI是应用程序二进制接口的缩写,指的是Android主机的CPU类型,不同CPU需要有不同的二进制接口类型。
Java是一种跨CPU的语言,并不要求指定特定的CPU。而C/C++语言,在不同的CPU上,都需要进行特定的编译。
这里设定APP_ABI为all,指的是我们写的这个JniLib库,将接受所有NDK支持的CPU类型。NDK在编译的时候,会自动编译多个不同CPU需要的动态链接库。并都打包在最终的APK文件中。
在不同的Android系统安装的时候,会自动选择正确的CPU类型安装其中一种。
接着看第二个NDK编译所需文件,Android.mk:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := JniLib
LOCAL_SRC_FILES := JniLib.cpp
include $(BUILD_SHARED_LIBRARY)
用过Makefile的人应当看上去感觉很熟悉。这个就相当于Makefile的主文件,用于描述如何编译我们的JNI库。当然因为我们其中大量的使用了NDK已有的环境变量和脚本,所以Applcation.mk/Android.mk实际都将被NDK的主体Makefile调用,最终完成完整的编译。
其中LOCAL_MODULE变量所指定的名称,就是我们编译之后的模块名称,这个跟JniLib.java中加载的类名,必须是一致的。
Gradle自动编译NDK项目
有了这些,如果用过命令行的话,我们可以直接在命令行对JNI部分进行编译了。
但作为一个完整的程序,我们更希望JNI部分,也能在整体Android Studio项目编译的时候编译,并一起打包进APK。
所以我们修改一下本项目的Gradle脚本,增加NDK编译的配置。Gradle是Android Studio中所采用的开源工具,用于项目的管理和自动构建。
在Android Studio左侧列表中找到app/build.gradle文件,双击打开。在项目的主目录下还有一个build.gradle文件,不要误选到那一个。
在android一节中,defaultConfig之下、buildTypes之上增加如下代码:
externalNativeBuild {
ndkBuild {
path "src/main/jni/Android.mk"
}
}
表示本项目使用ndk编译JNI库,本项目JNI库的编译脚本为src/main/jni/Android.mk文件。还可以选择使用CMAKE系统来编译JNI项目,不过为了不扩展太大的话题,这里就不讲了。对CMAKE情有独钟的开发者可以搜索相关资料。
为了能看的清楚,贴一次完整的app/build.gradle文件:
apply plugin: 'com.android.application'
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.test.calljni"
minSdkVersion 19
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
externalNativeBuild {
ndkBuild {
path "src/main/jni/Android.mk"
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
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'
}
至此,JNI部分的完整定义就完成了。
在Java中调用JNI库
JNI库的效果,还要修改一下我们程序的MainActivity类,才能体现出来。不然JNI库会被编译,会被打包,但并没有什么用。
首先修改项目的布局文件activity_main.xml文件,在当前按钮的右边,再增加一个按钮,名称为button2,onClick设置为bt2_click,顺便也为按钮设置一个新的显示字符串“CALLJNI”。修改完成存盘,关闭文件。
这个小例子重点是说明同C/C++语言的混合编程,所以很多细节都从简了,比如刚才按钮的显示信息,都应当是定义在资源文件中的,而不是在这里直接使用常量字符串。常量字符串虽然简便,但无法完成多国语言自动切换等基本功能,在正式的项目中应当避免这样使用。
接着在MainActivity.java文件中,增加点击事件处理程序,添加在bt1_click定义的下面就成:
public void bt2_click(View view){
c = c+1;
textview1.setText("click:"+c+"\n"+JniLib.callToCpp());
}
现在可以完整的编译一遍了,如果没有错误发生,就在模拟器中执行来测试。
点击CALLJNI按钮后,文本框显示的信息表示JNI正常执行了。
解析包含JNI库的APK安装文件
先上一张apk包的文件结构图片吧:
包含JNI库的安装包,比平常的安装包多一个lib文件夹。其中按照支持的CPU类型,再细致分类。最终里面是JNI库的二进制文件。
在我们这个例子中,就是libJniLib.so,如同前面说过的。
APK包安装的时候,根据确定的硬件平台,实际只有一个对应的.so文件会被安装的设备上。
调用一个完整的命令行可执行文件
调用完整的可执行文件,这在Android中并不是官方推荐的。但通常基于Linux系统的编程,这又是不可避免的。很多必要操作,如果开发系统的SDK支持不足,或者用起来不方便。都可以通过直接访问系统层参数文件或者系统层可执行文件来完成。
不同的操作系统,有不同的可执行文件格式。比如Windows的EXE/PE格式,macOS的Mach-O。在Linux上,就是ELF格式。
作为C语言为主要编程工具的Linux系统,拥有庞大的ELF可执行资源,几乎所有的程序都是直接、或者间接由ELF可执行程序完成的,甚至包括JVM本身。
一些新兴语言,比如golang,也提供了直接生成Android二进制文件的交叉编译功能。
所以让Android程序直接可以同ELF可执行程序互动,不仅仅是同C语言混合编程的问题,而是这样可以获得大量社区资源的支持。很多开源项目拿来,很少的修改,就可以在Android程序的背后发挥作用。
早期的Android系统调用可执行程序非常容易,把编译好的程序拷贝到Android中,设置为可执行属性,就可以执行了。
随着Android系统的升级,安全性越来越好,除非root,上面这种方式已经不灵了。越来越多的限制让直接执行内嵌的可执行文件变得不再可行。
在当前的Android版本中,在APK程序中内嵌可执行文件,需要通过以下几个步骤:
- 在NDK中编译对应的源代码。或者在其它语言环境中,使用对应工具,生成在Android环境可以执行的二进制代码。
- 除了.so之外的编译结果,并不会自动打包到APK中。所以编译出的二进制代码,需要作为数据文件,放入APK的资源区。
- 在Java代码中,根据检测到的CPU类型,把对应的可执行文件,从数据区拷贝到Android设备上,并设置为可执行。
- 在Java代码中调用可执行程序,并获取结果。
编译可执行文件
首先当然是准备一个C/C++代码,比如我们用一个最经典的Hello World。这么多年以来,这居然是兼容性最好的代码了:)
#include<stdio.h>
int main(int argc, char **argv){
printf("你好世界, I'm hello.c\n");
return 0;
}
文件名叫hello.c,放到jni文件夹下面。
然后配置Android.mk文件,以编译这个代码。
把下面的代码放置到Android.mk的最后:
include $(CLEAR_VARS)
LOCAL_MODULE := hello
LOCAL_SRC_FILES := hello.c
include $(BUILD_EXECUTABLE)
仔细看,其实只有最后一行有区别,根据英文应当能理解含义,就是编译为可执行文件的意思。
编译结果打包进入APK
因为内置可执行文件并不是官方推荐的方式,所以编译的结果,并不会被自动打包到安装包APK。
经由Gradle调用ndk-build编译的结果保存在如下的路径:
# Debug版本
app/build/intermediates/ndkBuild/debug/obj/local/
# Release版本
app/build/intermediates/ndkBuild/release/obj/local/
同样在Gradle的设置中,可以指定把具体的内容打包到Android的assets文件夹中。assets文件夹中包含的是程序运行所需的资源文件,所以这里,也是把可执行文件,当做资源、数据文件,嵌入在APK中。
请把下面代码,放置到app/build.gradle文件,android.defaultConfig一节的最后:
sourceSets{
main{
assets{
srcDirs = ['build/intermediates/ndkBuild/debug/obj/local']
}
}
}
sourceSets.main.assets.srcDirs的设置实际是一个数组,可以包含多个路径。如果开发的项目还有别的数据文件需要打包,可以在这里增添自己的内容。
注意上面示例中设置中的路径,是个不完美的地方。当前指向了debug调试编译输出的结果。在开发完成,正式投产的时候,应当换到release输出结果,也即:build/intermediates/ndkBuild/release/obj/local
。不然包含的二进制文件中间会有调试信息,除了文件尺寸会大,也造成不安全因素。
其实我个人常用的方式,是直接用Release方式编译一遍整个项目,然后release文件夹中就会有二进制编译结果。随后Gradle的设置,就一直保持在release版本的打包。反正你也不可能用Android Studio对C/C++代码进行调试,那个工作你肯定是使用另外的开发工具完成的。
然后事情并没有结束,我们打开编译结果的文件夹看一看,是类似下面的样子:
其中同样会根据CPU类型不同,分为几个文件夹,这是预料之中的。但中间除了有我们需要的hello可执行文件,还会有本已打包的JNI库.so文件,以及一些编译输出信息和中间文件。而这些,就成为了我们的垃圾文件,需要排除在外。
可以把下面代码,添加在app/build.gradle中,externalNativeBuild上面的位置,跟externalNativeBuild处在同一级:
aaptOptions {
ignoreAssetsPattern '!*.txt:!*.so:!*debug:!*release:!*.a'
}
这里要吐槽一下Android Studio Gradle脚本的设计。通常讲,ignoreAssetsPattern关键词已经有了“忽略、排除”的含义,是个否定词。而在其中的设置中,又对每个需要排除的内容,前面增加“!”否定,实在是反人类啊……
现在如果编译一遍,看看打包的结果,当然也只是完成了打包,我们还没有执行这个程序。
APK中多了一个assets文件夹,其中根据CPU类型分类,hello已经在里面了。
把可执行程序拷贝到Android系统
这个工作是最复杂的部分,至少比我们演示中显示一个字符串复杂多了。
好在这个程序非常通用,把这个类留着,以后所有同类程序都可以直接拿来使用。
在java文件夹自己的包名上右键点击鼠标,增加一个Java类,命名为CopyElfs。在生成的java文件中,把下面的代码帖进去:
package com.test.calljni;
import android.content.Context;
import android.content.res.AssetManager;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import android.os.Build;
public class CopyElfs {
String TAG="Ce_Debug:";
Context ct;
String appFileDirectory,executableFilePath;
AssetManager assetManager;
List resList;
String cpuType;
String[] assetsFiles={
"hello"
};
CopyElfs(Context c){
ct=c;
appFileDirectory = ct.getFilesDir().getPath();
executableFilePath = appFileDirectory + "/executable";
// cpuType = Build.SUPPORTED_ABIS[0];
cpuType = Build.CPU_ABI;
assetManager = ct.getAssets();
try {
resList = Arrays.asList(ct.getAssets().list(cpuType+"/"));
Log.d(TAG,"get assets list:"+resList.toString());
} catch (IOException e){
Log.e(TAG, "Error list assets folder:", e);
}
}
boolean resFileExist(String filename){
File f=new File(executableFilePath+"/"+filename);
if (f.exists())
return true;
return false;
}
void copyFile(InputStream in, OutputStream out){
try {
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
} catch (IOException e){
Log.e(TAG, "Failed to read/write asset file: ", e);
}
};
private void copyAssets(String filename) {
InputStream in = null;
OutputStream out = null;
Log.d(TAG, "Attempting to copy this file: " + filename);
try {
in = assetManager.open(cpuType+"/"+filename);
File outFile = new File(executableFilePath, filename);
out = new FileOutputStream(outFile);
copyFile(in, out);
in.close();
in = null;
out.flush();
out.close();
out = null;
} catch(IOException e) {
Log.e(TAG, "Failed to copy asset file: " + filename, e);
}
Log.d(TAG, "Copy success: " + filename);
}
void copyAll2Data(){
int i;
File folder=new File(executableFilePath);
if (!folder.exists()){
folder.mkdir();
}
for(i=0;i<assetsFiles.length;i++){
if (!resFileExist(assetsFiles[i])){
copyAssets(assetsFiles[i]);
File execFile = new File(executableFilePath+"/"+assetsFiles[i]);
execFile.setExecutable(true);
}
}
}
String getExecutableFilePath(){
return executableFilePath;
}
}
类成员assetsFiles数组中,可以包含多个可执行文件,把文件名放在这里,就会被拷贝到Android设备的/data/data/包名/files/excutable/文件夹,并设置为可以执行。
接着在MainActivity类的onCreate成员中,增加对拷贝可执行文件功能的调用:
CopyElfs ce;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
textview1=(TextView)findViewById(R.id.textView1);
ce = new CopyElfs(getBaseContext());
ce.copyAll2Data();
}
执行对Elf执行文件的调用
做了这么多准备性工作,开始真正对程序的调用。
首先还是修改布局文件,再增加一个按钮,名称叫button3,显示字符串是“CALLELF”,onClick的事件处理函数是bt3_click。
这次要添加的代码不仅仅是bt3_click方法,还要对调用命令行程序以及获取其结果单独抽象为一个方法。
考虑到还要增加一些对应的类成员变量,和库文件的引用。我们把完整的MainActivity.java代码列出来:
package com.test.calljni;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.TextView;
import android.view.View;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import android.util.Log;
public class MainActivity extends AppCompatActivity {
String TAG="Main_Debug:";
TextView textview1;
int c=0;
CopyElfs ce;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
textview1=(TextView)findViewById(R.id.textView1);
ce = new CopyElfs(getBaseContext());
ce.copyAll2Data();
}
public void bt1_click(View view){
c = c+1;
textview1.setText("click:"+c);
}
public void bt2_click(View view){
c = c+1;
textview1.setText("click:"+c+"\n"+JniLib.callToCpp());
}
public String callElf(String cmd){
Process p;
String tmpText;
String execResult = "";
try {
p = Runtime.getRuntime().exec(ce.getExecutableFilePath() + "/"+cmd);
BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()));
while ((tmpText = br.readLine()) != null) {
execResult += tmpText+"\n";
}
}catch (IOException e){
Log.i(TAG,e.toString());
}
return execResult;
}
public void bt3_click(View view){
c = c+1;
textview1.setText("click:"+c+"\n"+callElf("hello"));
}
}
现在已经完整了,可以编译然后在模拟器执行来尝试一下。
还可以详细探究可执行文件,拷贝到Android设备之后的细节。这个使用adb工具连接到设备上就能看出来,请看下面执行的截图:
编译带有扩展库的可执行文件
前面的例子,我们已经认识到了NDK的强大。而ndk-build编译工具,基本属于一个Makefile的工作方式。
然而在Linux庞大的开源社区中,多种编译管理工具都同时存在。其实不仅仅Android,即便在桌面版的Linux版本中,编译不同的软件包,也是一件费时费力的事情。
因此想继承开源社区的庞大优势,除了上面讲到的这些必要工作,把软件包编译到Android的环境中,是最主要需要完成的工作。
这个话题太大,内容太多也太分散,我们的文章是远远无法涵盖的。以最常用的OpenSSL开源库为例,GitHub上有一个编译脚本,值得参考:
https://github.com/lllkey/android-openssl-build
我们下面只演示一下,在自己的程序中,调用openssl库的方式。实际在Android SDK以及Java标准库中,都已经有很多编、解码功能足以满足应用。所以这里只是用于演示操作的方法,正式开发中,要根据实际需要选择开源库来使用。
首先我们把上面编译好的openssl库下载到本地,放到跟当前的Android项目平级就好,其实路径随意自己定,只要在接下来的设置中,指到正确的路径就没有问题。
$ git clone https://github.com/lllkey/android-openssl-build.git
因为这个开源库并非我们项目的一部分,我们只把它的编译结果,链接到我们的项目中:
$ cd calljni/app/src/main/jni
$ ln -s /home/andrew/dev/android/android-openssl-build/result/ openssl
#注意上面的路径,应当是你clone下来的真实路径
$ ls -lh openssl/
total 0
drwxr-xr-x 4 andrew staff 136B Jun 4 08:48 arm64-v8a
drwxr-xr-x 4 andrew staff 136B Jun 4 08:48 armeabi-v7a
drwxr-xr-x 4 andrew staff 136B Jun 4 08:48 x86
drwxr-xr-x 4 andrew staff 136B Jun 4 08:48 x86_64
下面我们写一个小程序,用于调用openssl库中的md5编码功能,程序名为md5.c,放置在jni路径下面:
#include <stdio.h>
#include <string.h>
#include <openssl/md5.h>
void openssl_md5(const char *data, int size, char *rs){
unsigned char buf[16];
memset(buf,0,16);
MD5_CTX c;
MD5_Init(&c);
MD5_Update(&c,data,size);
MD5_Final(buf,&c);
char tmp[3];
strcpy(rs,"");
int i;
for (i = 0; i < 16; i++){
sprintf(tmp,"%02x",buf[i]);
strcat(rs,tmp);
}
}
int main(int argc, char **argv){
if (argc != 2){
printf("Wrong argument.\n");
return 1;
}
char md5str[33];
openssl_md5(argv[1],strlen(argv[1]),md5str);
printf("%s\n",md5str);
return 0;
}
然后是修改Android.mk编译脚本,这次增加的是三部分。两个是已经编译完成的openssl Android版本库;一个是我们新增的md5.c编译。编译时还要满足,根据不同的CPU类型,选择不同的openssl库,并且编译对应的CPU版本md5可执行文件。这个过程中,需要使用不同的预定义环境参量来完成这个工作:
include $(CLEAR_VARS)
LOCAL_MODULE := ssl
LOCAL_SRC_FILES := $(LOCAL_PATH)/openssl/$(TARGET_ARCH_ABI)/lib/libssl.a
include $(PREBUILT_STATIC_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := crypto
LOCAL_SRC_FILES := $(LOCAL_PATH)/openssl/$(TARGET_ARCH_ABI)/lib/libcrypto.a
include $(PREBUILT_STATIC_LIBRARY)
include $(CLEAR_VARS)
LOCAL_SHARED_LIBRARIES := \
ssl \
crypto
LOCAL_C_INCLUDES += $(LOCAL_PATH)/openssl/$(TARGET_ARCH_ABI)/include
LOCAL_MODULE := md5
LOCAL_SRC_FILES := md5.c
include $(BUILD_EXECUTABLE)
上面的代码中:
- $(PREBUILT_STATIC_LIBRARY)指定了预定义的静态库文件
- $(LOCAL_PATH)就是指jni文件夹路径
- $(TARGET_ARCH_ABI)是根据目标CPU的ABI不同,选择不同的库文件和C语言头文件。
想必你也想到了,还要在MainActivity.java中,增加调用md5的代码,当然还有layout文件:
按键响应代码:
public void bt4_click(View view){
c = c+1;
textview1.setText("click:"+c+"\n"+callElf("md5 testString"));
}
作为md5参数的字符串,在正式的程序中,肯定应当是从某些计算中获取,或者从屏幕的输入框读取。这里直接使用一个常量“testString”。
最后还有特别容易忘的一个地方,就是CopyElfs中可执行文件的列表:
String[] assetsFiles={
"hello","md5"
};
不得不承认,有了上一小节的基础,增加个可执行程序或者第三方库,都不算什么工作量。
程序的执行结果如下:
还可以在台式电脑中验证一下计算的结果:
$ echo -n "testString" | md5
536788f4dbdffeecfbb8f350a941eea3
使用第三方库的其它注意事项
md5程序,使用了openssl的静态链接库.a文件。在Android4之后的版本中,如果不做root,似乎暂时没有好办法使用.so动态链接库。
JNI则可以使用.so文件,这时候在Android.mk中,应当使用$(PREBUILT_SHARED_LIBRARY)参量,来说明一个.so的预定义动态链接库。
使用了第三方的动态链接库,在调用JNI的时候也有额外一点需要注意,就是在载入自己的JNI库之前,必须把用到的依赖库,首先载入进来,否则直接载入JNI库会报错:
public class JniLib {
static {
System.loadLibrary("crypto");
System.loadLibrary("ssl");
System.loadLibrary("JniLib");
}
.......