基于ClassLoader、自定义Plugin、ASM字节码插桩的热修复方案详解

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

界面如下:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值