1.背景及目标
1.1.背景
项目中紧急bug修复或紧急需求时,传统方案需要给用户提供一个升级apk新包,随着项目的复杂度变高,apk包体积会增大,伴随着用户量增大,会占用额外的带宽;同时用户体验也不好,需要用户手动下载并覆盖安装。
热修复功能可直接通过后端Http请求获取增量包给用户,用户联网后静默下载安装增量包、实现用户无感静默升级。
1.2.目标
实现一个热修复框架,在技术上使其能适配android所有api,验证功能的可行性。
2.现状
主流的热修复方案主要有native hook 方案和 Java层类加载器整改Dex方案。
-
- 通过hook native实现:如阿里系,淘宝的andfix 以及在此基础上进化的HtFix方案。
- 通过merge、dex方案、由PathClassLoader实现:如腾讯的手QZone及在此基础上进化的微信Tinker方案。
- 通过Instant Run方案、由DexClassLoader实现:如美团Robust方案。
常规思路“拿来主义”比较香,但是授之以鱼不如授之以渔。我们还是有必要掌握核心实现思路的,方便项目定制。
3.类加载器merge dex方案
3.1.核心思路浅析
核心流程说明:
1.宿主变动代码,修改bug
2.通过比对新旧代码由diff算法产生增量包
3.宿主通过http请求将增量包加载至宿主私有目录
4.类加载器将私有目录的增量包合并到运行包
5.重启应用,完成热修复
3.2.实现细节技术方案
下边围绕3.1的流程图展开step2、step4技术实现细节。
ps:
step1:具体项目里的业务代码逻辑实现
step3:通过服务器推送或App主动拉取服务器Http请求的方式获取diff dex包,实现比较简单,不再赘述
step5:重启应用
3.2.1.android打包流程
核心流程说明:
1.1AAPT(Android Asset Packaging Tool)打包所有资源文件生成R.java和编译后的资源文件
1.2.AIDL(Android Interface Definition Language)将.aidl接口文件转化为对应的.java接口
2.将1.1、1.2生成的Java代码和项目里的Java代码通过Java编译工具生成.class文件
3.将2生成的class文件和项目里引用的三方库里的class文件通过dx打包成.dex文件
4.将3生成的dex文件和步骤1.1生成的编译后的资源文件和其它文件通过apkbuilder打包至apk
5.通过Jarsigner根据签名证书信息为apk签名
6.通过zipalign对签名后的apk做时间复杂度和空间复杂度的对齐优化,生成最终的apk
3.2.2.通过Gradle打包Task查找hook线索
我们通过AndroidStudio开发项目实际上主要干两件事:写代码、编译代码。3.2.1的整个过程即编译代码是通过Gradle脚本来实现的,而Gradle脚本的执行单元为Task,那么打包过程执行了哪些Task?
执行assembleDebug或assembleRelease任务执行打包任务时实际上是按照如下顺序执行了如下Task:
Line 165: > Task :app:preBuild UP-TO-DATE
Line 166: > Task :app:extractProguardFiles
Line 167: > Task :app:preDebugBuild UP-TO-DATE
Line 168: > Task :app:compileDebugAidl NO-SOURCE
Line 191: > Task :app:checkDebugManifest UP-TO-DATE
Line 192: > Task :app:compileDebugRenderscript UP-TO-DATE
Line 193: > Task :app:generateDebugBuildConfig UP-TO-DATE
Line 194: > Task :app:prepareLintJar UP-TO-DATE
Line 195: > Task :app:generateDebugSources UP-TO-DATE
Line 196: > Task :app:javaPreCompileDebug UP-TO-DATE
Line 216: > Task :app:mainApkListPersistenceDebug UP-TO-DATE
Line 240: > Task :app:generateDebugResValues UP-TO-DATE
Line 244: > Task :app:generateDebugResources UP-TO-DATE
Line 245: > Task :app:mergeDebugResources UP-TO-DATE
Line 246: > Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
Line 247: > Task :app:processDebugManifest UP-TO-DATE
Line 248: > Task :app:processDebugResources
Line 250: > Task :app:compileDebugJavaWithJavac
Line 256: > Task :app:compileDebugNdk NO-SOURCE
Line 257: > Task :app:compileDebugSources
Line 280: > Task :app:mergeDebugShaders UP-TO-DATE
Line 281: > Task :app:compileDebugShaders UP-TO-DATE
Line 282: > Task :app:generateDebugAssets UP-TO-DATE
Line 283: > Task :app:mergeDebugAssets UP-TO-DATE
Line 284: > Task :app:validateSigningDebug UP-TO-DATE
Line 285: > Task :app:signingConfigWriterDebug UP-TO-DATE
Line 359: > Task :app:processDebugJavaRes NO-SOURCE
Line 363: > Task :app:transformResourcesWithMergeJavaResForDebug UP-TO-DATE
Line 404: > Task :app:transformClassesAndResourcesWithProguardForDebug
Line 643: > Task :app:transformClassesWithDexBuilderForDebug
Line 644: > Task :app:transformDexArchiveWithDexMergerForDebug
Line 664: > Task :app:mergeDebugJniLibFolders UP-TO-DATE
Line 668: > Task :app:transformNativeLibsWithMergeJniLibsForDebug UP-TO-DATE
Line 669: > Task :app:packageDebug
Line 670: > Task :app:assembleDebug
看起来好像很复杂,但是task命名见名知意、浅显易懂。我们的重点是要实现Java层代码的热修复,所以需要提炼一下我们需要的task即可,提炼的目的是自定义插件hook打包流程,从而提取出我们想要的不拖泥带水的diff dex包,也就是完成3.1的第2步工作。
我们要提炼的是能让虚拟机识别的dex包,即需要hook3.2.1的第3步,关于hook我们的核心思想是:只做增加逻辑、不做删减,即保留打包整体流程逻辑。
提炼的核心task:
1.transformClassesAndResourcesWithProguardForDebug
2.transformClassesWithDexBuilderForDebug
3.2.3.自定义Plugin
核心hook点已确认,接下来构思实现细节。
PS:自定义Plugin基础不是本文重点,本文不做赘述,主要讲述task hook核心流程。
1.transformClassesAndResourcesWithProguardForDebug task混淆时需要使用之前备份的 mapping 映射文件,并且在任务的 doLast 中备份本次混淆的 mapping 作为下次的参考。
伪代码实现:
// 1.创建补丁文件的输出路径
File outputDir = Utils.getOrCreateOutputDir(project, variantName, patchExtension);
// 2.获取 Android 的混淆任务并配置其使用 mapping 文件
String variantCapName = Utils.capitalize(variantName);
Task proguardTask = project.getTasks().findByName("transformClassesAndResourcesWithProguardFor"
+ variantCapName);
// 3.如果没有开启混淆,则此任务为null,此流程结束
if (proguardTask == null) {
return
}
// 4.如果有已经备份的 mapping 文件,那么本次编译要使用此mapping
File backupMappingFile = new File(project.getBuildDir(), "mapping.txt");
if (backupMappingFile.exists()) {
TransformTask task = (TransformTask) proguardTask;
ProGuardTransform transform = (ProGuardTransform) task.getTransform();
// 相当于在 proguard-rules.pro 中配置了 -applymapping mapping.txt
transform.applyTestedMapping(backupMappingFile);
}
// 5.如果开启了混淆,在混淆任务结束后备份 mapping 映射文件
proguardTask.doLast(new Action<Task>() {
@Override
public void execute(Task task) {
// mapping 文件在 proguardTask 的输出之中
TaskOutputs outputs = proguardTask.getOutputs();
Set<File> files = outputs.getFiles().getFiles();
for (File file : files) {
if (file.getName().endsWith("mapping.txt")) {
try {
FileUtils.copyFile(file, backupMappingFile);
project.getLogger().info("mapping: " + backupMappingFile.getCanonicalPath());
} catch (IOException e) {
e.printStackTrace();
}
break;
}
}
}
});
2.transformClassesWithDexBuilderForDebug 的输入源是混淆后的 class 文件,在此任务doFirst 中,进行hook,通过diff算法与之前备份的class/jar的 md5 值对比筛选出修改过的 class/jar,对它们用 dx 命令行打包成diff dex 补丁包。
伪代码实现:
// 1.获取将 class 打包成 dex 的任务
Task dexTask = project.getTasks().findByName("transformClassesWithDexBuilderFor" + variantCapName);
// 2.在开始打包之前,插桩并记录每个 class 的 md5 哈希值
dexTask.doFirst(new Action<Task>() {
@Override
public void execute(Task task) {
// 记录类本次编译的 md5 值
Map<String, String> newHexes = new HashMap<>();
PatchGenerator patchGenerator = new PatchGenerator(project, patchFile, patchClassFile, hexFile);
Set<File> files = dexTask.getInputs().getFiles().getFiles();
for (File file : files) {
String filePath = file.getAbsolutePath();
// 3.插桩,并做 md5 diff比较,不一致的放入补丁包
if (filePath.endsWith(".class")) {
//3.1处理class文件
processClass(project, applicationName, file, newHexes, patchGenerator);
} else if (filePath.endsWith(".jar")) {
//3.2处理jar包
processJar(project, applicationName, file, newHexes, patchGenerator);
}
}
// 4.保存本次编译的新的md5
Utils.writeHex(newHexes, hexFile);
// 5.将class转化为dex,生成补丁文件
try {
patchGenerator.generate(); // 将patchClass.jar搞到patch.jar的dex里
} catch (Exception e) {
e.printStackTrace();
}
}
});
PS:插桩用到了ASM字节码操作框架,至于为什么要插桩,后面3.5.1会有详细说明。
至此增量包打包功能已实现,接下分析diff包如何并入旧包。
能够直接操作Android虚拟机dex字节码文件的,首先想到的是ClassLoader,那么掌握了类加载机制,理论上就可以进行dex文件合并了。
3.2.4.双亲委派机制
3.2.4.1.Jdk中的双亲委派机制
类加载器将.class类加载到内存中时,为了避免重复加载(确保Class对象的唯一性)以及JVM的安全性,需要使用某一种方式来实现只加载一次,加载过就不能被修改或再次加载,以后就会使用缓存里的类对象。
PS:提供如下线索协助理解
1.采用责任链设计模式,类似Android View体系的事件分发机制
2.类加载器里有缓存策略,缓存有则优先用缓存的
3.双亲委派中的各个类加载器的上级和下级的关系,它不是继承关系,而是关联关系!
3.2.4.2.Android中的双亲委派机制
PS:提供如下线索协助理解
1.我们给的diff增量包里是dex文件
2.与jdk区别,Android虚拟机的类加载器加载的是dex文件,java的虚拟机加载的是class文件
3.PathClassLoader是Android默认的顶层类加载器,加载应用层的类库,我们可以通过context的getClassLoader()获取到PathClassLoader
4.DexClassLoader的源码几乎和PathClassLoader一样的,仅构造函数的差异,前者可指定dex2oat优化后的路径
5.执行流程参考step1...step6
hook点有两个地方:
1.获取到PathClassLoader对象,将diff dex文件拼接整合至旧包
2.创建DexClassLoader对象,并指定上级parent为BootClassLoader(为什么不是PathClassLoader后面会有说明),将diff dex文件拼接整合至旧包
3.2.5.反射机制merge增量包
类加载器自定义完成之后,如何合并增量包?
所有的Dex需要转化为Element,Element存放在ClassLoader -> pathList(DexPathList) -> dexElements(Element[])这个数组中
我们要做的工作是:
1.通过反射获取此ClassLoader -> pathList(DexPathList) -> dexElements(Element[])对象
2.通过反射将增量包转化为Element
3.将增量包Element并入ClassLoader -> pathList(DexPathList) -> dexElements(Element[]),产生新的Element数组,同时注意增量包Element必须在前,保证优先执行增量包的类
4.通过反射将新的Element数组替换至ClassLoader -> pathList -> dexElements(Element[])
至此合并结束。
3.3.详细流程图
PS:
1.注意整个流程图共有5个泳道,代表5个不同的模块,各司其职:
Plugin负责生成增量包
Dalvik Adapter负责提供Dalvik需要的类
Server负责存储增量包
App Client负责获取Server端的增量包并交给ClassLoader
ClassLoader负责合并dex包
2.此流程图只是需要改动的核心模块,省略了大量的默认未变动的流程,实际上的真实流程要比此流程图复杂得多,遵从最小知道原则,已被流程图精简或直接忽略,方便大家理解。
3.5.代码层面适配方案填坑
3.5.1.Art虚拟机适配
Android各个版本的编译技术有所不同:
1. Dalvik虚拟机(DVM):
- 在Android 4.4及以前版本中,Android使用DVM。
- 即时编译(JIT): Dalvik执行应用时,将字节码即时编译为机器码。这种编译发生在应用运行时,即应用的字节码在执行时被即时编译。
2. Android运行时(ART):
- 从Android 5.0(Lollipop)开始,Android引入了ART。
- 预先编译(AOT): 在应用安装时,ART将应用的字节码预先编译成机器码,这一过程称为Ahead-Of-Time编译。这意味着编译只在安装时发生一次,有助于提高应用运行时的性能。
- AndroidN混合编译: ART采用了AOT和JIT的混合模式。在应用安装时进行部分AOT编译,而在应用运行时根据需要进行JIT编译,以优化应用性能和响应速度。ART引入了基于使用情况的Profile-guided compilation。这种方式通过监控应用的运行情况来收集性能数据,并使用这些数据来优化后续的JIT编译。
AndroidN以上,我们的patch.jar是dex diff包,不在apk中,所以没有参与Android系统art虚拟机的优化处理,导致PathClassLoader找不到对应的类。
解决方案:自定义类加载器
自定义类加载器为什么可行?
双亲委派,正常是PathClassLoader加载,对于dex diff包整合我们用自己的类加载器,加载apk初始的资源,而不是优化后的路径。这也解释了为什么DexClassLoader对象,指定上级parent为BootClassLoader,而不是PathClassLoader。
具体方案:
方案1.创建全新的自定义PathClassLoader对象,将构造参数指定为本包下的相关虚拟路径如下:
String dexPath = "/data/user/0/package/cache/patch.jar:/data/app/package/base.apk";
String librarySearchPath = "/data/app/xxx/package/lib/arm64b";
ClassLoader parent = ClassLoader.getSystemClassLoader();
PS:
1.dexPath为dex diff包即patch.jar和旧包apk
方案2.创建DexClassLoader对象,父类指定为BootClassLoader,将构造参数指定为本包下的相关路径。
两种方式亲测都是可以的。
3.5.2.Dalvik虚拟机适配
移除 CLASS_ISPREVERIFIED 字节码插装技术。为每个类添加其依赖。
如果一个类只引用了同一个 dex 文件中的类(排除反射,因为反射不需要一个类的引用就用获取到该类对象),那么在打包 dex 文件时,这个类就会被打上 CLASS_ISPREVERIFIED 标记,被打了此标记的类无法引用其它dex里的类,导致补丁包里的类无法被加载,进而会报错IllegalAccessError,可通过AOP编程思想将所有类都引用一个空的类,这样就不会被打上CLASS_ISPREVERIFIED标记了。
可以通过ASM字节码插装技术解决此问题:
1.新建一个module
2.在新建module里创建一个类DalvikAdapter
3.在插件module里的transformClassesWithDexBuilderForDebug执行前hook:遍历应用类并在其构造函数内部通过ASM字节码插装技术添加上此类的引用
这样就能保证Dalvik虚拟机的任何类都不会打上CLASS_ISPREVERIFIED标记了。
PS:
1.ASM字节码插装技术不是本文重点,实现细节不会赘述,3.6有简单概述方便理解
3.5.3.Dex字节码转Element适配
由于Android不同版本将dex转化为Element的方法不一样,导致3.2.5反射的方法有所差异:
Android 5.1 源码
final class DexPathList {
private Element[] dexElements;
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory) {
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions);
}
private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory,
ArrayList<IOException> suppressedExceptions){
}
}
Android 6.0源码(以上版本均为“makePathElements”)
final class DexPathList {
private Element[] dexElements;
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory) {
// save dexPath for BaseDexClassLoader
this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions);
}
private static Element[] makePathElements(List<File> files, File optimizedDirectory,
List<IOException> suppressedExceptions) {
}
}
结论:
Object[] patchElements = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Method makePathElements = ShareReflectUtil.findMethod(pathList, "makePathElements",
List.class, File.class,
List.class);
ArrayList<IOException> ioExceptions = new ArrayList<>();
patchElements = (Object[])
makePathElements.invoke(pathList, patchs, application.getCacheDir(), ioExceptions);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
Method makePathElements = ShareReflectUtil.findMethod(pathList, "makeDexElements",
ArrayList.class, File.class, ArrayList.class);
ArrayList<IOException> ioExceptions = new ArrayList<>();
patchElements = (Object[])
makePathElements.invoke(pathList, patchs, application.getCacheDir(), ioExceptions);
}
3.6.四句话浓缩ASM
最近一直在学习ASM框架,下边给出自己对ASM字节码框架的理解:
ASM是面向切片编程的一种实现思路,可以hook所有的点,只有你想不到,几乎没有它做不到的。
ASM的操作对象是class字节码文件,源码核心实现思路是访问者模式:数据结构是由ClassFile映射,差异算法由各个访问者提供(理解了此思想对学习ASM非常非常有帮助)。
它提供了core、utils、tree等核心类库,可以帮助开发者generate(增)、transform(删、改)、analysis(查)class字节码文件。
AndroidStudio工程可通过自定义插件的方式引入ASM,hook时机是class文件转dex包之前。
4.效果演示
4.1.模拟生产环境发布app
4.1.1.正常开发app,模拟发版v1.0
这里重点描述下引起bug的代码:
public class MainActivity extends AppCompatActivity {
private Test test = new Test();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
@Override
protected void onResume() {
super.onResume();
TextView tv = findViewById(R.id.tv);
tv.setText(test.makeTest(false));
}
}
public class Test {
public String makeTest(boolean isBug) {
if (isBug) {
return "抛异常了!";
}
return "";
}
}
第一次打包后不会产生增量包,只会有个备份文件的hex.txt
4.1.2.app运行发现界面bug
4.2.模拟热修复过程
4.2.1.解决bug
修改后的代码如下:
public class MainActivity extends AppCompatActivity {
private Test test = new Test();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
@Override
protected void onResume() {
super.onResume();
TextView tv = findViewById(R.id.tv);
tv.setText(test.makeTest(true));
}
}
public class Test {
public String makeTest(boolean isBug) {
if (isBug) {
return "抛异常了!";
}
return "bug已被修改";
}
}
4.2.2.打diff包patch.jar
宿主app执行Gradle任务:patchDebug Task
如何验证patch.jar是增量包呢?通过反编译工具反编译dex文件后如下:
我们只改了两个类,反编译后的增量包与我们修改的代码是吻合的
4.2.3.将patch.jar包上传至服务器
此处省略...
4.2.4宿主下载并合并patch.jar
直接模拟下载完后放到:data/data/pakcage/cache目录下
4.2.5.重启app
界面如下: