Android插件化和热更新分享
一、插件化方案
Android的插件化和热更新技术,目前已经比较成熟,微信、淘宝、QQ、360手机助手中都应用到了插件化。插件化技术的特点是无需单独安装apk,即可运行,即插即用,无需升级宿主应用,减少app的更新频率,除此之外他还可以降低模块耦合,按需加载,节省流量等特点。
1、主要插件化方案对比
| 支撑\方案 | VirtualApk | Replugin | Atlas | Shadow |
|---|---|---|---|---|
| 支持四大组件 | ✓ | ✓ | ✓ | ✓ |
| 组件无需在宿主 manifest 注册 | ✓ 占坑 | ✓ 占坑 | x | ✓ 代理 |
| 插件可依赖宿主 | ✓ | ✓ | x | ✓ |
| 支持 PendingIntent | ✓ | ✓ | ✓ | ✓ |
| Android 特性支持 | 几乎全部 | 几乎全部 | 未明确 | 几乎全部 |
| 插件构建 | Gradle 插件 | Gradle 插件 | 部署 aapt | Gradle插件 |
| 框架轻重 | 轻量 | 轻量 | 重量 | 轻量 |
| 支持安卓版本 | all | all | all | all |
| 接入难度 | 中 | 易 | 复杂 | 中 |
| 侧重阶段 | 运行期 | 运行期 | 编译期 | 运行期 |
| 热修复能力 | 无 | 无 | 有 | 无 |
| 插件更新方式 | 插件独立更新 | 插件独立更新 | 插件及宿主同时更新 | 独立更新 |
| 热度 | 低 | 高 | 低 | 高 |
| 插件安装后可否删除 | 不能 | 不能 | 可以 | 不能 |
| 技术流派 | Hook 流 | Hook 流 | Hook流 | 零反射,无 hack |
| 优点 | 插件和宿主独立,插件进程 | 插件和宿主独立,插件进程 | 组件化、容器化、热更新 | 无 hack,兼容性好 |
| 缺点 | Hack 私有 api | hack classLoader,已经解决了兼容性问题 | Hack私有api | 零反射,无入侵性且零增量 |
2、插件化git、wiki和参考资料
1、https://github.com/didi/VirtualAPK/wiki 滴滴 VirtualApk wiki
2、https://blog.csdn.net/TyearLin/article/details/120180936 VirtualApk原理与对比
3、https://github.com/Qihoo360/RePlugin/wiki 360 Replugin wiki
4、https://github.com/Qihoo360/RePlugin/blob/master/README.md Replugin
5、https://cloud.tencent.com/developer/article/1192720 RePlugin流程与源码解析
6、https://github.com/canyanzhang/Shadow/blob/master/README.md Shadow
7、https://www.jianshu.com/p/f00dc837227f Shadow插件接入指南
8、https://blog.csdn.net/weixin_42150080/article/details/117474116 Shadow插件接入指南
9、https://blog.csdn.net/rzleilei/article/details/121103090 Shadow原理分析
11、https://juejin.cn/post/6844903975381270536 Shadow 原理解析
3、插件化核心点介绍
-
Shadow原理
Shadow是一个腾讯自主研发的Android插件框架,经过线上亿级用户量检验。 Shadow不仅开源分享了插件技术的关键代码,还完整的分享了上线部署所需要的所有设计。
与市面上其他插件框架相比,Shadow主要具有以下特点:
(1)复用独立安装App的源码:插件App的源码原本就是可以正常安装运行的。
(2)零反射无Hack实现插件技术:从理论上就已经确定无需对任何系统做兼容开发,更无任何隐藏API调用,和Google限制非公开SDK接口访问的策略完全不冲突。
(3)全动态插件框架:一次性实现完美的插件框架很难,但Shadow将这些实现全部动态化起来,使插件框架的代码成为了插件的一部分。插件的迭代不再受宿主打包了旧版本插件框架所限制。
(4)宿主增量极小:得益于全动态实现,真正合入宿主程序的代码量极小(15KB,160方法数左右)。
(5)Kotlin实现:core.loader,core.transform核心代码完全用Kotlin实现,代码简洁易维护。
1.1 Shadow号称无Hook点
核心原理是运用代理的方式, 以 Activity 加载为例:把原本的Acitivty编译期间改成一个代理类,去代理宿主Activity的所有生命周期。

整理出来的完整的启动流程图,基本上覆盖了所有的流程,下面就是对整个流程做一下解释。
1.首先click作为起点,我们点击按钮启动应用,这时候调用的是startPluginActivity的方法,启动就是启动我们在宿主中埋桩好的PluginDefaultProxyActivity,同时会带入一些必要信息,比如目标类的类名等等。
2.由于在mainfest中我们注册了PluginDefaultProxyActivity,所以AMS的检查会通过,然后通知Instrumetation进行对应的Activity的创建。
3.PluginDefaultProxyActivity创建时,会调用父类的构造函数。其父类是PluginContainerActivity。在PluginContainerActivity的构造方法中,会生成代理类ShadowActivityDelegate对象delegate,由这个代理类维护宿主和实现类的关系。
4.系统的Instrumetation创建后,会调用Activity的onCreate方法,由于PluginDefaultProxyActivity中没有任何实现,所以系统会调用其父类PluginContainerActivity的onCreate方法当中。
5.PluginContainerActivity的onCreate方法中,会通过之前创建的delegate对象,去创建targetActivity(根据传过来的类名等信息生成的),targetActivity就是我们的目标Activity,这里面包含了我们正常的业务逻辑。
6.创建好了对象之后,会调用targetActivity的onCreate方法。我们的目标Activity中,自然会有一些正常的使用逻辑。比如setContentView()等等。
7.这里就以setContentView为例。TargetActivity中调用setContentView方法,其实会调用到其父类ShadowActivity中的setContentView方法。
8.这里自然会有人会问了,我们正常的TargetActivity不是继承自Activity嘛,如果都把父类改成ShadowActivity岂不是很麻烦?这里Shadow用了字节码插桩的技术,就是在APK打包的时候,自动会帮我们做替换的。
9.在ShadowActivity的onCreate方法中,会通过代理类通知真正的宿主Activity的setContentView方法的。
1.2 Shadow是如何加载插件中的dex的
就是常规的使用DexClassLoader进行加载。
new DexClassLoader(apkFile.getAbsolutePath(), oDexDir.getAbsolutePath(), null, ODexBloc.class.getClassLoader());
1.3 Shadow是如何加载资源包的
val packageManager = hostAppContext.packageManager
packageArchiveInfo.applicationInfo.publicSourceDir = archiveFilePath
packageArchiveInfo.applicationInfo.sourceDir = archiveFilePath
packageArchiveInfo.applicationInfo.sharedLibraryFiles = hostAppContext.applicationInfo.sharedLibraryFiles
try {
return packageManager.getResourcesForApplication(packageArchiveInfo.applicationInfo)
} catch (e: PackageManager.NameNotFoundException) {
throw RuntimeException(e)
}
-
Replugin
-
RePlugin流程与源码解析 https://cloud.tencent.com/developer/article/1192720
-
Hook 了一个点 ClassLoader
宿主和插件使用的ClassLoader,以及它们的创建和Hook住时机。这是RePlugin唯一的Hook点,而其中插件ClassLoader和宿主ClassLoader是相互关系的,
如图:

-
VirtualApk
1、Hook 和 AMS,Instrumentation,ContentProvider
2、插件Dex插入到宿主的 Dex Elements 集合中
3、在 AndroidManifest 中预注册四大组件(插桩),供插件使用
4、需要注意的是,插件中的类不可以和宿主重复
5、将插件Activity改名为StubActivity,在绕过Android对启动插件Activity的合法校验后,再将StubActivity改回原来的Activity名称。
该过程有两个关键点:
\1) 将插件Activity转换为StubActivity;
该阶段的作用是绕过Android对启动Activity的限制:Activity必须先在AndroidManifest注册,否则不能被startActivity。具体实现为利用hook的VAInstrumentation拦截Instrumentation的execStartActivity方法,并在该方法内将待启动的插件Activity名称改 为StubActivity。
\2) 将StubActivity还原为插件Activity;
此阶段的作用是继1) 阶段绕过系统对插件Activity的合法校验后,将StubActivity名称改回原来的名称,以使得在Instrumentation.newActivity时实例并启动正确的目标Activity。
4、插件化三个流派总结
从上表可以看出:
1、Hook流派,因为使用了反射代理/替换系统私有Api,随着Android对反射越收越紧,Android P以后存在无法解决的兼容性问题,市面上基于Hook原理的框架基本上都停止维护了。
Hook。可以在不同层次进行 Hook,从而动态替换也细分为若干小流派。可以直接在 Activity 里做 Hook,重写 getAsset 的几个方法,从而使用自己的 ResourceManager 和 AssetPath;也可以在更抽象的层面,也就是在 startActivity 方法的位置做 Hook,涉及的类包括 ActivityThread、Instrumentation 等;最高层次则是在 AMS 上做修改,也就是张勇的解决方案,这里需要修改的类非常多,AMS、PMS 等都需要改动。总之,在越抽象的层次上做 Hook,需要做的改动就越大,但好处就是更加灵活了。没有哪一个方法更好,一切看你自己的选择。
2、组件替换类,改变了插件的运行形态,并且插件方需要接入SDK,修改系统组件的基类,对插件的侵入性较强。
写一个 PluginActivity 继承自 Activity 基类,把 Activity 基类里面涉及生命周期的方法全都重写一遍,插件中的 Activity 是没有生命周期的,所以要让插件中的 Activity 都继承自 PluginActivity,这样就有生命周期了。
3、AAB是Android官方方案,因此此方案兼容性问题较少,同时官方配套的周边工具也比较丰富。但AAB方案插件下载安装等核心环节严重依赖GMS环境,因此国内环境无法使用 。
Qigsaw方案因为爱奇艺业务调整,已停止维护
综上:
推荐 Shadow,目前 Shadow 在手机QQ中有应用。其次推荐Replugin,都是新起插件进程来运行插件,对内存有一定的损耗。
二、热更新方案对比
| 支撑\方案 | Tinker | QZone | AndFix-ali | Robust-meituan | Amigo-elme | nuwa | Sophix |
|---|---|---|---|---|---|---|---|
| 类替换 | ✓ | ✓ | x | x | ✓ | ✓ | ✓ |
| So 替换 | ✓ | x | x | x | ✓ | x | ✓ |
| 资源替换 | ✓ | ✓ | x | x | ✓ | ✓ | ✓ |
| 全平台支持 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| 即时生效 | x | x | ✓ | ✓ | x | x | |
| 性能损耗 | 较小 | 较大 | 较小 | 较小 | 较小 | 较大 | 较小 |
| 补丁包大小 | 较小 | 较大 | 一般 | 一般 | 较大 | 较大 | 较小 |
| 开发透明 | ✓ | ✓ | x | x | ✓ | ✓ | 不开源 |
| 复杂度 | 较低 | 较低 | 复杂 | 复杂 | 较低 | 较低 | 较低 |
| gradle 支持 | ✓ | x | x | x | ✓ | ✓ | ✓ |
| Rom 体积 | 较大 | 较小 | 较小 | 较小 | 较大 | 较小 | 较小 |
| 成功率 | 较高 | 较高 | 一般 | 最高 | 较高 | 较高 | 高 |
| 热度 | 高 | 高 | 低 | 高 | 低 | 低 | 高 |
| 监控 | 提供分发控制及监控 | x | x | x | x | x | 不开源,收费 |
超级补丁技术基于DEX分包方案,使用了多DEX加载的原理,大致的过程就是:把BUG方法修复以后,放到一个单独的DEX里,插入到dexElements数组的最前面,让虚拟机去加载修复完后的方法。
1、QZone的热更新方案
QZone方案推出比较早,对热修复技术的推进很有启发意义。它是基于Android dex分包方案,最关键的技术点在于利用字节码插桩的方式绕开了预校验问题。这种方案只支持App重启之后才能修复,也就是App在运行的时候加载到了补丁包也不能及时修复,需要App重新启动的时候才会修复,这是因为QZone方案是基于类加载区需要重新加载补丁类才能实现的,所以必须进行重启才能修复。此外,QZone方案只支持到类结构本身代码层面的修复,不支持资源的修复。
原理:
1、class加载原理:dex文件转换成dexFile对象,存入Element[]数组,findclass顺序遍历Element数组获取DexFile,然后执行DexFile的findclass。
2、Hook了ClassLoader.pathList.dexElements[],将补丁的dex插入到数组的最前端,所以,会优先查找到修复的类,从而,达到修复的效果。
修复的步骤为:
1、可以看出是通过获取到当前应用的Classloader,即为BaseDexClassloader
2、通过反射获取到他的DexPathList属性对象pathList
3、通过反射调用pathList的dexElements方法把patch.dex转化为Element[]
4、两个Element[]进行合并,把patch.dex放到最前面去
5、加载Element[],达到修复目的
优点:
1、代码是非侵入式的,对apk体积影响不大。
2、没有合成整包(和微信Tinker比起来),产物比较小,比较灵活
3、可以实现类替换,兼容性高。(某些三星手机不起作用)
缺点:
1、需要下次启动才修复。
2、性能损耗大,为了避免类被加上CLASS_ISPREVERIFIED,使用插桩,单独放一个帮助类在独立的dex中让其他类调用。
2、阿里AndFix
AndFix采用native hook的方式,这套方案直接使用dalvik_replaceMethod替换class中方法的实现。由于它并没有整体替换class, 而field在class中的相对地址在class加载时已确定,所以AndFix无法支持新增或者删除filed的情况(通过替换init与clinit只可以修改field的数值)。
原理:
直接在native层进行方法的结构体信息对换,从而实现方法的新旧替换。
优点:
补丁实时生效,不需要重新启动。
缺点:
1、存在稳定及兼容性问题。ArtMethod的结构基本参考Google开源的代码,各大厂商的ROM都可能有所改动,可能导致结构不一致,修复失败。
2、无法增加变量及类,只能修复方法级别的Bug,无法做到新功能的发布。
3、微信Tinker
微信tinker项目之初最大难点在于如何突破Qzone方案的性能问题,通过研究Instant Run的冷插拔与buck的exopackage找到灵感。它们的思想都是全量替换新的Dex因为使用全新的dex,所以自然绕开了Art地址可能错乱的问题,在Dalvik模式下也不需要插桩,加载全新的合成dex即可。
原理:
1、采用dex替换的方式,避免了dex插桩带来的性能损耗。原理是提供dex差量包,整体替换dex的方案。差量的方式给出patch.dex,然后将patch.dex与应用的classes.dex合并成一个完整的dex,完整dex加载得到dexFile对象作为参数构建一个Element对象,然后整体替换掉旧的dex-Elements数组。
2、Tinker自研了DexDiff/DexMerge算法,对于dex文件的处理经验老道。Tinker还支持资源和So包的更新,So补丁包使用BsDiff来生成,资源补丁包直接使用文件md5对比来生成,针对资源比较大的(默认大于100KB属于大文件)会使用BsDiff来对文件生成差量补丁。
优点:
1、兼容性高、补丁小。
2、开发透明,代码非侵入式。
3、支持so文件、资源文件、类的增加和删除。
缺点:
需要下次启动才修复。
结论:热更新推荐用 Tinker,QQ 中自研了 QFix,也可以自研HotFix,需要自己建布丁平台,自己下发补丁。
三、拓展方案
1、Hybrid 混合开发框架
2、Hippy、RN、Weex 跨端开发,支持热更新
3、小程序容器框架,开发小程序应用来下发升级 Bundle 来实现热更新
四、双亲委托机制与反射机制
双亲委托机制与反射是插件化和热更新的基础
1、类加载之双亲委托机制
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// First, check if the class has already been loaded
//获取已经加载过的Class
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//父亲加载过的Class
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order to find the class.
//自己加载Class
c = findClass(name);
}
}
return c;
}
从上述源码我们可以看出,类加载的大致流程如下:
1、获取已经加载过的Class。
2、从父类加载器中获取Class。
3、如果parent为null,则调用BootstrapClassLoader进行加载。
4、如果class依旧没有找到,则调用当前类加载器的findClass方法进行加载。
2、双亲委托机制的优点
1、避免重复的类加载,可以节省资源(这也是热修复的前提)
2、安全性较高,防止核心API被随意篡改
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException(
"Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
}
final class DexPathList{
//dex文件数组
private Element[] dexElements;
private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
List<IOException> suppressedExceptions, ClassLoader loader) {
Element[] elements = new Element[files.size()];
int elementsPos = 0;
/*
\* Open all files and load the (direct or contained) dex files up front.
*/
for (File file : files) {
if (file.isDirectory()) {
// We support directories for looking up resources. Looking up resources in
// directories is useful for running libcore tests.
elements[elementsPos++] = new Element(file);
} else if (file.isFile()) {
String name = file.getName();
if (name.endsWith(DEX_SUFFIX)) {
// Raw dex file (not inside a zip/jar).
try {
DexFile dex = loadDexFile(file, optimizedDirectory, loader, elements);
if (dex != null) {
elements[elementsPos++] = new Element(dex, null);
}
} catch (IOException suppressed) {
System.logE("Unable to load dex file: " + file, suppressed);
suppressedExceptions.add(suppressed);
}
} else {
DexFile dex = null;
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
/*
\* IOException might get thrown "legitimately" by the DexFile constructor if
\* the zip file turns out to be resource-only (that is, no classes.dex file
\* in it).
\* Let dex == null and hang on to the exception to add to the tea-leaves for
\* when findClass returns null.
*/
suppressedExceptions.add(suppressed);
}
if (dex == null) {
elements[elementsPos++] = new Element(file);
} else {
elements[elementsPos++] = new Element(dex, file);
}
}
} else {
System.logW("ClassLoader referenced unknown path: " + file);
}
}
if (elementsPos != elements.length) {
elements = Arrays.copyOf(elements, elementsPos);
}
return elements;
}
} 以上技术,都是我以前在项目开发过程中,涉及到或使用到的技术,以前在手机 QQ 团队进行过 Shadow插件化开发 和 负责小程序 Native 容器的开发与维护;在 alibaba 期间负责过 Hybrid容器框架的开发,应用的商业化开发。对插件化和热更新,客户端开发的拓展技术方案,都进行过实际的操作。
本文对这些技术进行了梗概的记述,并附上了可信的参考文档,希望能对大家理解以上技术有所帮助。