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 开发的方式开发插件项目。

参考日志

Please enable JavaScript to view the comments powered by Disqus.

AltStyle によって変換されたページ (->オリジナル) /