浅析 Android 组件化、插件化、热更新

Catalogue
  1. 1 区别
  2. 2 实现原理
    1. 2.1 插件化
    2. 2.2 热更新

本文主要目的是理解组件化、插件化、热更新的实现原理,并且知道他们之前的区别。

1 区别

组件化:“组件化”又名“模块化”,其实就是把每个功能独立的模块分离成独立的 module ,如:网络操作、基础库、独立业务功能等都可以抽离成单个 module ,由此可见日常开发中我们已经在使用组件化开发了。主 APP 工程可以引用这些 module ,并使用它们各自所实现的功能。

备注
其实上面对于组件化的说法是很一种很粗旷的理解,不适合复杂项目。组件化完整理解建议参考这篇文章,这里需要说下的是,笔者在此之前只是粗旷的运用过组件化开发,对于链文中提到的用法没有证实过,后续有这方面经验再来补充。

插件化:“插件化”是一种能让 APP 动态增加新功能的技术,它使得动态添加新功能的过程能在不进行重新打包安装的情况下进行。插件化在原来的代码中有固定的执行入口,比如:点击一个按钮的事件,加载的一个页面。

热更新:“热更新”又名“热修复”,它可以对 APP 中已有的内容做改动,而不需要预先留下固定的执行入口,通常用来修复 BUG 。

2 实现原理

2.1 插件化

第一步:实现插件 App 项目。插件 app 包含了之后将在主 App 中被使用的 Util 类。

1
2
3
4
5
6
7
8
//com.cxp.plugin.Util

public class Util {
public void print() {
System.out.println("这是插件中的方法!");
}
}

第二步:Build APk(s),生成一个 apk 包。
第三步:实现主 App 项目,并将插件 apk 包放入 assets 文件夹中,命名为:”plugin.apk”。

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
//com.cxp.pluginableapp. MainActivity

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

//复制 assets 中的文件到 externalCacheDir 目录。
val apk = File(externalCacheDir.absolutePath + "/plugin.apk")
var source: Source? = null
var sink: BufferedSink? = null
try {
source = assets.open("plugin.apk").source()
sink = apk.sink().buffer()
sink.writeAll(source)
} finally {
source?.close()
sink?.close()
}
//新建 ClassLoader 加载 dex(.class) 文件。
val classLoader = DexClassLoader(apk.absolutePath, cacheDir.absolutePath, null, null)
val utilClass = classLoader.loadClass("com.cxp.plugin.Util")
//反射调用类中 print 方法。
val o: Any = utilClass.newInstance()
val method=utilClass.getMethod("print")
method.invoke(o)
}
}

运行程序,成功输出:“这是插件中的方法!”。

问题1:为什么要新建一个 DexClassLoader 来加载类?
应用在启动后都会生成一个加载自身 dex 文件的 classLoader,这个classLoader 能够加载的类在应用被启动的同时就已经确定下来了,所以对于插件 app 提供的 class ,不能再用这个 classLoader 加载,必须新建一个用来加载插件 app 的 classLoader。

问题2:一个应用启动后会有几个 ClassLoader ?
至少 2 个。请参看知乎文章中“有几个ClassLoader实例?”部分的讲解。

问题3:什么是双亲委托 ?
当需要加载一个类时,自下而上查看是否已经加载过该类了,如找到了,则直接返回缓存中的 Class 对象,当到达最顶层的加载器仍然找不到该 Class 对象时,则自上而下开始调用各自的 findClass 方法生成Class 对象,直到成功为止。

问题4: DexClassLoader 的创建和 loadClass 方法分别做什么事 ?
DexClassLoader 的创建过程是依据外部提供的 dexs 创建一个或多个 DexFile 对象并最终被 pathList 持有,完成了 dex 文件载入过程。这一步完成了将 .class 文件加载到方法区的工作。

loadClass 方法经过双亲委托机制后,首次加载类会调用到pathList.findClass(name)方法,该方法最终通过遍历 DexFile 的 defineClass native 方法创建 class 对象。这一步完成了在堆区创建 class 对象的工作。

如果要自己实现一套完整的插件化方案,可以参考这篇文章的思路。

具体的实现流程也可参考如下四篇文章:
1、Android插件化探索(一)类加载器DexClassLoader。
2、Android插件化探索(二)资源加载。
3、Android插件化探索(三)免安装运行Activity(上)。
4、Android插件化探索(四)免安装运行Activity(下)。

2.2 热更新

dex 文件可通过 d8 工具生成,也可以换成 apk 文件,它存放在 assets 目录下。以下是 dex 文件所含代码。

1
2
3
4
5
6
7
package com.cxp.hotfix;

public class Util {
public void print() {
System.out.println("这是 hotfix 后的方法!");
}
}

主项目中对应的代码如下。

1
2
3
4
5
6
7
package com.cxp.hotfix;

public class Util {
public void print() {
System.out.println("这是原始方法!");
}
}

启动主项目后应立即将加载修复包所得的 dexElements 添加到当前主项目类加载器的 dexElements 首部。

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
47
48
49
public class HotfixApplication extends Application {
File apk;

@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
//将修复包放入 ExternalCacheDir 目录下。
apk = new File(getExternalCacheDir() + "/hotfix.dex");
try (Source source = Okio.source(getAssets().open("hotfix.dex"));
BufferedSink sink = Okio.buffer(Okio.sink(apk))) {
sink.writeAll(source);
} catch (IOException e) {
e.printStackTrace();
}
//通过反射方式将修复包的 dexs 加入到现有 dexElements 数组的头部。
if (apk.exists()) {
try {
ClassLoader classLoader = getClassLoader();
Class loaderClass = BaseDexClassLoader.class;
Field pathListField = loaderClass.getDeclaredField("pathList");
pathListField.setAccessible(true);
Object pathListObject = pathListField.get(classLoader); // getClassLoader().pathList

Class pathListClass = pathListObject.getClass();
Field dexElementsField = pathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
PathClassLoader newClassLoader = new PathClassLoader(apk.getPath(), null);
Object newPathListObject = pathListField.get(newClassLoader); // newClassLoader.pathList
Object newDexElementsObject = dexElementsField.get(newPathListObject); // newClassLoader.pathList.dexElements

Object dexElementsObject = dexElementsField.get(pathListObject); // getClassLoader().pathList.dexElements

int oldLength = Array.getLength(dexElementsObject);
int newLength = Array.getLength(newDexElementsObject);
Object concatDexElementsObject = Array.newInstance(dexElementsObject.getClass().getComponentType(), oldLength + newLength);
for (int i = 0; i < newLength; i++) {
Array.set(concatDexElementsObject, i, Array.get(newDexElementsObject, i));
}
for (int i = 0; i < oldLength; i++) {
Array.set(concatDexElementsObject, newLength + i, Array.get(dexElementsObject, i));
}

dexElementsField.set(pathListObject, concatDexElementsObject);
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
}
}
}

主项目调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.cxp.hotfix
...

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Util().print()
}
}
//打印结果是:“这是 hotfix 后的方法!”。

为什么要在 dexElements 头部加入元素呢?
类加载过程最终会调用 DexPathList 的 findClass 方法,该方法查找生成 class 对象是按照从前往后遍历 dexElements 数组的方式进行的,找到了就直接返回,源码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

public Class<?> findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
Class<?> clazz = element.findClass(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}

if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}

知识拓展 如果你想全量更新的话,把原来的 dexElements 全部替换就可以了,这要求所提供的 dex 文件内容要与原 APP 中已有内容完全一致。

热更新增量修复的方式和官方的 MultiDex 很像,只不过 MultiDex 是将其它 Dex 添加到 Emlement 数组后面而已。不清楚 MultiDex 原理的话,请通读这篇文章

注意 以上内容理解完全后,建议再阅读下知乎的这篇文章,同时,针对链文要额外说明的是,从 API 26 开始的 optimizedDirectory 参数已被弃用了,所以链文中 “DexClassLoader 和 PathClassLoader” 的表述已错误,阅读时记得忽略该部分。

最后要说明的是,这篇文章只是分析了个原理大概,离生产目标还很远,要想达到可生产的水平还有很多工作要做。目前很多大厂已经开源了自己的方案,按照自身需求挑选他们中的一个直接使用也未尝不可。

写作最后
本文是笔者学习了扔物线老师的 Hencoder plus 课程后,结合网上的优秀文章而做的总结。