Kaede Akatsuki

中二病也要开发 Android

简单的动态加载模式

从这个章节开始,加载SO库的问题算是告一段落,现在开始谈及的动态加载,主要是指基于ClassLoader的加载方式,这也是这个动态加载系列文章的核心。

Java程序中,JVM虚拟机是通过类加载器ClassLoader加载 .jar 文件里面的类的。Android也类似,不过Android用的是Dalvik/ART虚拟机,不是JVM,也不能直接加载 .jar 文件,而是加载 .dex 文件。

通过Android SDK提供的 DX工具.jar 文件优化成 .dex 文件,然后Android的虚拟机才能加载。注意,有的Android应用能直接加载 .jar 文件,那是因为这个 .jar 文件已经经过优化,只不过后缀名没改(其实已经是 .dex 文件)。

如果对ClassLoader的工作机制有兴趣,具体过程请参考 动态加载基础 ClassLoader的工作机制,这里不再赘述。

基本信息

如何获取能够加载的DEX文件

首先我们可以通过JDK的编译命令javac把Java代码编译成 .class 文件,再使用jar命令把 .class 文件封装成 .jar 文件,这与编译普通Java程序的时候完全一样。
之后再用Android SDK的DX工具把 .jar 文件优化成 .dex 文件(在“android-sdk\build-tools\具体版本\”路径下)

dx –dex –output=target.dex origin.jar // target.dex就是我们要的了

此外,我们可以先把代码编译成APK文件,再把APK里面的 .dex 文件解压出来,或者直接把APK文件当成 .dex 使用(只是APK里面的静态资源文件我们暂时还用不到)。至此我们发现,无论加载 .jar,还是 .apk,其实都和加载 .dex 是等价的,Android能加载 .jar.apk,是因为它们都包含有 .dex,直接加载 .apk 文件时,ClassLoader也会自动把 .apk 里的 .dex 解压出来(具体实现代码,有兴趣的话请阅读DexClassLoader和DexFile的源码,兴许以后开个源码分析系列的文章再仔细探讨吧)。

加载并调用DEX文件里面的方法

与JVM不同,Android的虚拟机不能用ClassCload直接加载 .dex,而是要用DexClassLoader或者PathClassLoader,他们都是ClassLoader的子类,这两者的区别是

  1. DexClassLoader:可以加载 .jar/apk/dex 文件,可以从SD卡中加载未安装的APK;
  2. PathClassLoader:要传入系统中已经安装过的 .apk 文件的存放Path,所以只能加载已经安装的APK;

使用前,先看看DexClassLoader的构造方法

1
2
3
4
public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
throw new RuntimeException("Stub!");
}

注意,我们之前提到的,DexClassLoader并不能直接加载外部存储等noexec存储路径中的 .dex 文件,而是要先拷贝到内部存储里。这里的dexPath就是 .dex 的外部存储路径,而optimizedDirectory则是内部路径(exec存储),libraryPath是Native库(其实就是SO库)的所在路径,必须是内部路径,如果不需要用到SO库的话这里直接用null即可,parent则是要传入当前应用的ClassLoader,这与ClassLoader的“双亲代理模式”有关。实际上,DexClassLoader之所以能加载SD卡中的APK文件,就是因为它会先提取并优化dexPath路径上APK文件中的 .dex 文件,并保存到optimizedDirectory路径上,然后再加载优化好的 .dex 文件,所有的动态加载只能发生在exec存储路径上。

注意,如果 .dex 里面有用到SO库相关的代码,我们需要事先把SO库拷贝到内部存储路径,并把路径作为参数传给libraryPath,或者如果你不想在创建DexClassLoader的时候就加载SO库,可以把libraryPath置为null,并确保在调用相关的Native方法前,使用System#loadLibrary加载了相应的SO库。这里我们并不需要用到SO库,所以才使用null。

实例使用DexClassLoader的代码:

1
2
3
File optimizedDexOutputPath = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "test_dexloader.jar");// 外部路径
File dexOutputDir = this.getDir("dex", 0);// 无法直接从外部路径加载.dex文件,需要指定APP内部路径作为缓存目录(.dex文件会被解压到此目录)
DexClassLoader dexClassLoader = new DexClassLoader(optimizedDexOutputPath.getAbsolutePath(),dexOutputDir.getAbsolutePath(), null, getClassLoader());

到这里,我们已经成功把 .dex 文件给加载进来了,接下来就是如何调用 .dex 里面的代码,主要有两种方式。

使用反射的方式

使用DexClassLoader加载进来的类,我们本地并没有这些类的源码,所以无法直接调用新加载进来的类,不过可以通过反射的方法调用,简单粗暴。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
DexClassLoader dexClassLoader = new DexClassLoader(optimizedDexOutputPath.getAbsolutePath(), dexOutputDir.getAbsolutePath(), null, getClassLoader());
Class libProviderClazz = null;
try {
libProviderClazz = dexClassLoader.loadClass("me.kaede.dexclassloader.MyLoader");
// 遍历类里所有方法
Method[] methods = libProviderClazz.getDeclaredMethods();
for (int i = 0; i < methods.length; i++) {
Log.e(TAG, methods[i].toString());
}
Method start = libProviderClazz.getDeclaredMethod("func");// 获取方法
start.setAccessible(true);// 把方法设为public,让外部可以调用
String string = (String) start.invoke(libProviderClazz.newInstance());// 调用方法并获取返回值
Toast.makeText(this, string, Toast.LENGTH_LONG).show();
} catch (Exception exception) {
// Handle exception gracefully here.
exception.printStackTrace();
}

使用接口的方式

使用反射的方式不利于代码维护,如果动态加载的业务是多变的话,就不适合了。毕竟 .dex 里面的类也是我们自己维护的,所以可以设计一个宿主和插件共用的基础类,把方法抽象成公共接口,再把这些接口复制到公共库里面去,宿主项目和插件项目都依赖公共库,这样就可以通过这些接口调用动态加载插件得到的类的方法了。

1
2
3
4
5
6
7
8
pulic interface IFunc {
public String func();
}

// 调用
IFunc ifunc = (IFunc)libProviderClazz;
String string = ifunc.func();// 这样看上去是不是比较好维护
Toast.makeText(this, string, Toast.LENGTH_LONG).show();

到这里,我们已经成功从外部路径动态加载一个 .dex 文件,并执行里面的代码逻辑了。通过从服务器下载最新的 .dex 文件并替换本地的旧文件,就能初步实现“APP的动态升级了”。但是这只是非常基础的功能,所以我称之为“简单加载”。从一般的Android开发需要来看,“简单动态加载”虽然能动态更换类了,但还是有不少问题需要解决。

如何动态更改XML布局

虽然已经能动态更改代码逻辑了,但是UI界面要怎么更改啊?Android开发中大部分的情况下,UI界面都是通过XML布局实现的,放在res目录下,可是 .dex 库里面并没有这些静态资源啊,所以无法改变XML布局。(这里即使直接动态加载APK文件,但是通过DexClassLoader只能加载新的APK其中的 .dex 文件,并无法加载其中的res资源文件,所以如果在动态加载的 .dex 的类中直接使用新的APK的res资源的话会抛出异常。)
大家都知道,所有的XML布局在运行的时候都要通过LayoutInflator渲染成View的实例,这个实例与我们使用纯Java代码创建的View实例几乎是等价的,而且后者可能效率还更高,所有的XML布局实现的UI界面都有等价的纯代码的创建方案。由此伸展开来,res目录下所有XML资源都有等价的纯代码的实现方式,比如XML动画、XML Drawable等。
所以,如果想要动态更改应用的UI界面的话,可以通过用纯代码创建布局的形式来解决。此外,还可以模仿LayoutInflator的工作方式,自己写一套布局渲染机制来代替系统的LayoutInflator方案(类似于许多跨平台游戏引擎的方案),这样就能在完全不依赖res资源的情况下创建UI界面了,当然这样的工作量不少,而且,完全避开res资源的话,所有的分辨率、国际化等自适应问题都要自己在应用层写代码维护了,显然脱离res资源框架不是一个很明智的做法,但是这种做法确实可行,在我们之前的实际生产中的项目中也稳定使用着,这里出于责任问题就不方便公开细节了。
(早期还没有解决res资源的方案,现在有了,宝宝心里苦 🌚,说实在,这种方案非常繁琐,不好维护,一方面,这是产品一句“技术可行就做呗”而产生的解决方案;另一方面,当时动态加载技术还很不成熟,也没有什么实际投入到生产的项目,所以采取了非常保守的开发方式)。

使用Fragment代替Activity

Activity需要在Manifest里注册,然后以标准的Intent启动才会具有生命周期(详情参考AMS的工作机制),很明显,如果想要动态加载的 .dex 里的Activity没有注册的话,是无法启动的。
有一种简单粗暴的做法就是可以把 .dex 里所有需要用到的Activity都事先注册到原项目里,不过这样只适用于Activity数量不经常改变的业务,如果 .dex 里的Activity有变化,原项目就必须跟着升级。另外一种方案是使用Fragment,Fragment只是普通的Java类,不是组件类,但是自带生命周期(同步FragmentActivity的),不需要在Manifest里注册,所以可以在 .dex 里使用Fragment来代替Activity,代价就是Fragment之间的切换会繁琐许多。

ART模式的兼容性问题

当初我们开始设计动态加载方案的时候,还没有ART模式。随着Kitkat的发布以及ART模式的出现,我们开始担心“用DexClassLoader加载 .dex 文件”的方案会不会在ART模式上面存在兼容性问题。
其实,ART模式相比原来的Dalvik,会在安装APK的时候,使用Android系统自带的 dex2oat 工具把APK里面的. .dex 文件转化成 OAT 文件, OAT 文件是一种Android私有ELF文件格式,它不仅包含有从DEX文件翻译而来的本地机器指令,还包含有原来的DEX文件内容。这使得我们无需重新编译原有的APK就可以让它正常地在ART里面运行,也就是我们不需要改变原来的APK编程接口。ART模式的系统里,同样存在DexClassLoader类,包名路径也没变,只不过它的具体实现与原来的有所不同,但是接口是一致的。(如果你熟悉设计模式的话,应该会知道有种原则叫做“针对接口编程,而不针对实现编程”,就是因为接口没有变化,才能保证ART模式的向下兼容。)
在Kitkat项目的源码中,我们依然能找到同样API的DexClassLoader:

1
2
3
4
5
6
7
8
9
10
11
package dalvik.system;

import dalvik.system.BaseDexClassLoader;
import java.io.File;

public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
throw new RuntimeException("Stub!");
}
}

也就是说,ART模式在加载 .dex 文件的方法上,对Dalvik做了向下兼容,所以使用DexClassLoader加载进来的 .dex 文件同样也会被转化成OAT文件再被执行,“以DexClassLoader为核心的动态加载方案”在ART模式上可以稳定运行。
关于ART模式以及OAT文件的详细分析,请参考官方的ART and Dalvik,以及老罗的Android ART运行时无缝替换Dalvik虚拟机的过程分析

存在的问题与改进方案

以上大致就是“Android动态性加载初级阶段”的解决方案,虽然现在已经能投入到具体的生产中去,但是还有一些问题无法忽略。

  1. 无法使用res目录下的资源,比如layout、values等;
  2. 无法动态加载新的Activity等组件,因为这些组件需要在Manifest中注册,动态加载无法更改当前APK的Manifest;

在这些问题没有解决的情况下,虽然可以以比较“绕”的方式开发插件项目,但是还是过于繁琐(方正我是受不了)。以上问题可以通过 使用反射调用Framework层的隐藏API接口加载res资源 以及 代理Activity 的方式解决,可以把这种的动态加载框架成为“代理模式”。在代理模式下,我们能以接近常规Android开发的方式开发插件项目。

参考日志