Android插件化和热更新分享

一、插件化方案

​ Android的插件化和热更新技术,目前已经比较成熟,微信、淘宝、QQ、360手机助手中都应用到了插件化。插件化技术的特点是无需单独安装apk,即可运行,即插即用,无需升级宿主应用,减少app的更新频率,除此之外他还可以降低模块耦合,按需加载,节省流量等特点。

1、主要插件化方案对比

支撑\方案VirtualApkRepluginAtlasShadow
支持四大组件
组件无需在宿主 manifest 注册✓ 占坑✓ 占坑x✓ 代理
插件可依赖宿主x
支持 PendingIntent
Android 特性支持几乎全部几乎全部未明确几乎全部
插件构建Gradle 插件Gradle 插件部署 aaptGradle插件
框架轻重轻量轻量重量轻量
支持安卓版本allallallall
接入难度复杂
侧重阶段运行期运行期编译期运行期
热修复能力
插件更新方式插件独立更新插件独立更新插件及宿主同时更新独立更新
热度
插件安装后可否删除不能不能可以不能
技术流派Hook 流Hook 流Hook流零反射,无 hack
优点插件和宿主独立,插件进程插件和宿主独立,插件进程组件化、容器化、热更新无 hack,兼容性好
缺点Hack 私有 apihack 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原理分析

10、https://github.com/ChenSiLiang/android-toy/blob/master/Shadow%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90%E7%AC%94%E8%AE%B0/Shadow%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.md Shadow 源码解析

11、https://juejin.cn/post/6844903975381270536 Shadow 原理解析

3、插件化核心点介绍

  1. 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)

​ }

  1. Replugin

  2. RePlugin流程与源码解析 https://cloud.tencent.com/developer/article/1192720

  3. Hook 了一个点 ClassLoader

​ 宿主和插件使用的ClassLoader,以及它们的创建和Hook住时机。这是RePlugin唯一的Hook点,而其中插件ClassLoader和宿主ClassLoader是相互关系的,

如图:

  1. 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,都是新起插件进程来运行插件,对内存有一定的损耗。

二、热更新方案对比

支撑\方案TinkerQZoneAndFix-aliRobust-meituanAmigo-elmenuwaSophix
类替换xx
So 替换xxxx
资源替换xx
全平台支持
即时生效xxxx
性能损耗较小较大较小较小较小较大较小
补丁包大小较小较大一般一般较大较大较小
开发透明xx不开源
复杂度较低较低复杂复杂较低较低较低
gradle 支持xxx
Rom 体积较大较小较小较小较大较小较小
成功率较高较高一般最高较高较高
热度
监控提供分发控制及监控xxxxx不开源,收费

​ 超级补丁技术基于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容器框架的开发,应用的商业化开发。对插件化和热更新,客户端开发的拓展技术方案,都进行过实际的操作。

​ 本文对这些技术进行了梗概的记述,并附上了可信的参考文档,希望能对大家理解以上技术有所帮助。