Kaede Akatsuki

中二病也要开发 Android

如何加载 SD 卡中的 so 库

Android 中 JNI 的使用其实就包含了动态加载,APP 运行时动态加载 .so 库并通过 JNI 调用其封装好的方法。后者一般是使用 NDK 工具从 C/C++ 代码编译而成,运行在 Native 层,效率会比执行在虚拟机的 Java 代码高很多,所以 Android 中经常通过动态加载 .so 库来完成一些对性能比较有需求的工作(比如 T9 搜索、或者 Bitmap 的解码、图片高斯模糊处理等)。此外,由于 .so 库是由 C++ 编译而来的,只能被反编译成汇编代码,相比 Smali 更难被破解,因此 .so 库也可以被用于安全领域。

与我们常说的基于 ClassLoader 的动态加载不同,SO 库的加载是使用 System 类的(由此可见对 SO 库的支持也是 Android 的基础功能),所以这里这是作为补充说明。不过,如果使用 ClassLoader 加载 SD 卡里插件 APK,而插件 APK 里面包含有 SO 库,这就涉及到了对插件 APK 里的 SO 库的加载,所以我们也要知道如何加载 SD 卡里面的 SO 库。

基本信息

一般的 SO 文件的使用姿势

以一个 “图片高斯模糊” 的功能为例,如果使用 Java 代码对图像 Bitmap 的每一个像素点进行计算,那整体耗时将会非常大,所以可以考虑使用 JNI。(详细的 JNI 使用教程网络上有许多,这里不赘述)
这里推荐一个开源的高斯模糊项目 Android StackBlur
在命令行定位到 Android.mk 文件所在目录,运行 NDK 工具的

text
1
ndk-build

命令就能编译出我们需要 SO 库

再把 SO 库复制到 Android Studio 项目的

text
1
jniLibs

目录中

(Android Studio 现在也支持直接编译 SO 库,但是有许多坑,这里我选择手动编译)

接着在 Java 中把 SO 库对应的模块加载进来

1
2
3
4
5
6
7
8
// load so file from internal directory
try {
System.loadLibrary("stackblur");
NativeBlurProcess.isLoadLibraryOk.set(true);
Log.i("MainActivity", "loadLibrary success!");
} catch (Throwable throwable) {
Log.i("MainActivity", "loadLibrary error!" + throwable);
}

加载成功后就可以直接使用 Native 方法了

1
2
3
4
5
6
public class NativeBlurProcess {
public static AtomicBoolean isLoadLibraryOk = new AtomicBoolean(false);
//native method
private static native void functionToBlur(Bitmap bitmapOut, int radius, int threadCount, int threadIndex, int round);

}

由此可见,在 Android 项目中,SO 库的使用也是一种动态加载,在运行时把可执行文件加载进来。一般情况下,SO 库都是打包在 APK 内部的,不允许修改这种 “动态加载” 看起来不是我们熟悉的那种啊,貌似没什么卵用。不过,其实 SO 库也是可以存放在外部存储路径的。

如何把 SO 文件存放在外部存储

注意到上面加载 SO 库的时候我们用到了 System 类的 “loadLibrary” 方法,同时我们也发现 System 类还有一个 “load” 方法,看起来差不多啊,看看他们有什么区别吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* See {@link Runtime#load}.
*/
public static void load(String pathName) {
Runtime.getRuntime().load(pathName, VMStack.getCallingClassLoader());
}

/**
* See {@link Runtime#loadLibrary}.
*/
public static void loadLibrary(String libName) {
Runtime.getRuntime().loadLibrary(libName, VMStack.getCallingClassLoader());
}

先看看 loadLibrary,这里调用了 Runtime 的 loadLibrary,进去一看,又是动态加载熟悉的 ClassLoader 了(这里也佐证了 SO 库的使用就是一种动态加载的说法)

1
2
3
4
5
6
7
8
9
10
11
/*
* Searches for and loads the given shared library using the given ClassLoader.
*/
void loadLibrary(String libraryName, ClassLoader loader) {
if (loader != null) {
String filename = loader.findLibrary(libraryName);
String error = doLoad(filename, loader);
return;
}
……
}

看样子就像是通过库名获取一个文件路径,再调用 “doLoad” 方法加载这个文件,先看看 “loader.findLibrary (libraryName)”

1
2
3
protected String findLibrary(String libName) {
return null;
}

ClassLoader 只是一个抽象类,它的大部分工作都在 BaseDexClassLoader 类中实现,进去看看

1
2
3
4
5
public class BaseDexClassLoader extends ClassLoader {
public String findLibrary(String name) {
throw new RuntimeException("Stub!");
}
}

不对啊,这里只是抛了一个 RuntimeException 异常,什么都没做啊!
其实这里有一个误区 ,也是刚开始开 Android SDK 源码的同学容易搞混的。Android SDK 自带的源码其实只是给我们开发者参考的,基本只是一些常用的类,Google 不会把整个 Android 系统的源码都放到这里来,因为整个项目非常大,ClassLoader 类平时我们接触得少,所以它的具体实现的源码并没有打包进 SDK 里,如果需要,我们要到官方 AOSP 项目里面去看(顺便一提,整个 AOSP5.1 项目大小超过 150GB,真的有需要的话推荐用一个移动硬盘存储)。

这里为了方便,我们可以直接看在线的代码 BaseDexClassLoader.java

1
2
3
4
@Override
public String findLibrary(String name) {
return pathList.findLibrary(name);
}

再看进去 DexPathList 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Finds the named native code library on any of the library
* directories pointed at by this instance. This will find the
* one in the earliest listed directory, ignoring any that are not
* readable regular files.
*
* @return the complete path to the library or {@code null} if no
* library was found
*/
public String findLibrary(String libraryName) {
String fileName = System.mapLibraryName(libraryName);
for (File directory : nativeLibraryDirectories) {
File file = new File(directory, fileName);
if (file.exists() && file.isFile() && file.canRead()) {
return file.getPath();
}
}
return null;
}

到这里已经明朗了,根据传进来的 libName,扫描 APK 内部的 nativeLibrary 目录,获取并返回内部 SO 库文件的完整路径 filename。再回到 Runtime 类,获取 filename 后调用了 “doLoad” 方法,看看

1
2
3
4
5
6
7
8
9
10
11
12
13
private String doLoad(String name, ClassLoader loader) {
String ldLibraryPath = null;
String dexPath = null;
if (loader == null) {
ldLibraryPath = System.getProperty("java.library.path");
} else if (loader instanceof BaseDexClassLoader) {
BaseDexClassLoader dexClassLoader = (BaseDexClassLoader) loader;
ldLibraryPath = dexClassLoader.getLdLibraryPath();
}
synchronized (this) {
return nativeLoad(name, loader, ldLibraryPath);
}
}

到这里就彻底清楚了,调用 Native 方法 “nativeLoad”,通过完整的 SO 库路径 filename,把目标 SO 库加载进来。

说了半天还没有进入正题呢,不过我们可以想到,如果使用 loadLibrary 方法,到最后还是要找到目标 SO 库的完整路径,再把 SO 库加载进来,那我们能不能一开始就给出 SO 库的完整路径,然后直接加载进来?我们猜想 load 方法就是干这个的,看看。

1
2
3
4
5
6
7
8
9
void load(String absolutePath, ClassLoader loader) {
if (absolutePath == null) {
throw new NullPointerException("absolutePath == null");
}
String error = doLoad(absolutePath, loader);
if (error != null) {
throw new UnsatisfiedLinkError(error);
}
}

我勒个去,一上来就直接来到 doLoad 方法了,这证明我们的猜想可能是正确的,那么在实际项目中测试看看吧!

我们先把 SO 放在 Asset 里,然后再复制到内部存储,再使用 load 方法把其加载进来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

File dir = this.getDir("jniLibs", Activity.MODE_PRIVATE);
File distFile = new File(dir.getAbsolutePath() + File.separator + "libstackblur.so");

if (copyFileFromAssets(this, "libstackblur.so", distFile.getAbsolutePath())){
//使用load方法加载内部储存的SO库
System.load(distFile.getAbsolutePath());
NativeBlurProcess.isLoadLibraryOk.set(true);
}
}

public void onDoBlur(View view){
ImageView imageView = (ImageView) findViewById(R.id.iv_app);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), android.R.drawable.sym_def_app_icon);
Bitmap blur = NativeBlurProcess.blur(bitmap,20,false);
imageView.setImageBitmap(blur);
}


public static boolean copyFileFromAssets(Context context, String fileName, String path) {
boolean copyIsFinish = false;
try {
InputStream is = context.getAssets().open(fileName);
File file = new File(path);
file.createNewFile();
FileOutputStream fos = new FileOutputStream(file);
byte[] temp = new byte[1024];
int i = 0;
while ((i = is.read(temp)) > 0) {
fos.write(temp, 0, i);
}
fos.close();
is.close();
copyIsFinish = true;
} catch (IOException e) {
e.printStackTrace();
Log.e("MainActivity", "[copyFileFromAssets] IOException "+e.toString());
}
return copyIsFinish;
}
}

点击 onDoBlur 按钮,果然加载成功了!

那能不能直接加载外部存储上面的 SO 库呢,把 SO 库拷贝到 SD 卡上面试试。

看起来是不可以的样子,Permission denied!

java.lang.UnsatisfiedLinkError: dlopen failed: couldn’t map “/storage/emulated/0/libstackblur.so” segment 1: Permission denied

看起来像是没有权限的样子,看看源码哪里抛出的异常吧

1
2
3
4
5
6
7
8
9
10
11
/*
* Loads the given shared library using the given ClassLoader.
*/void load(String absolutePath, ClassLoader loader) {
if (absolutePath == null) {
throw new NullPointerException("absolutePath == null");
}
String error = doLoad(absolutePath, loader);
if (error != null) {
// 这里抛出的异常throw new UnsatisfiedLinkError(error);
}
}

应该是执行 doLoad 方法时出现了错误,但是上面也看过了,doLoad 方法里调用了 Native 方法 “nativeLoad”,那应该就是 Native 代码里出现的错误。平时我很少看到 Native 里面,上一次看的时候,是因为需要看看点九图 NinePathDrawable 的缩放控制信息 chunk 数组的具体作用是怎么样,费了好久才找到我想要的一小段代码。所以这里就暂时不跟进去了,有兴趣的同学可以告诉我关键代码的位置。

我在一个 Google 的开发者论坛上找到了一些答案:

The SD Card is mounted noexec, so I’m not sure this will work. Moreover, using the SD Card as a storage location is a really bad idea, since any other application can modify/delete/corrupt it easily. Try downloading the library to your application’s data directory instead, and load it from here.

这也容易理解,SD 卡等外部存储路径是一种可拆卸的(mounted)不可执行(noexec)的储存媒介,不能直接用来作为可执行文件的运行目录,使用前应该把可执行文件复制到 APP 内部存储再运行。

最后,我们也可以看看官方的 API 文档

看来 load 方法的用途和我们理解的一致,文档里说的 shared library 就是指 SO 库(shared object),至此,我们就可以把 SO 文件移动到外部存储了,或者从网络下载都行。