一、回顾框架原理
本篇继续来看热修复框架Robust原理,在之前的一篇文章中已经详细讲解了:Robust框架原理,因为这个框架不是开源的,所以通过官方给出的原理介绍,咋们自己模拟了案例和框架逻辑的简单实践。最后在通过反编译美团app进行验证咋们的逻辑实现是否大致不差。最终确定实践的逻辑大同小异。但是在上一篇文章末尾多次强调了,这个框架吸引我研究的不是他热修复技术,而是他有一个技术点,就是如何在编译期给每个类每个方法都加上修复功能代码,对于上层开发代码是透明的。因为从之前案例可以看到,如果方法没有修复功能代码,那么此方法就丧失了修复功能,再来看一下这个框架的原理图,包括编译期动态插入代码和加载修复包逻辑:
![](images/201701050501.jpg)
二、自动插入原理分析
那么下面就来详细介绍编译期这个框架是如何将项目中每个类每个方法都插入一段修复代码。在介绍这个知识点,可以先去了解一下,Java中如何利用asm包操作字节码逻辑。或者可以看一下这篇文章:Android中动态插入代码工具icodetools
这篇文章中已经详细介绍了如何在每个类的每个方法中插入一段代码。其实本文就是基于这个技术来进行操作的。不过这里插入的代码比那个要复杂。不多解释了,直接来看怎么操作。
为了演示和填坑方便,咋们最好开始使用一个简单的案例来,因为第一次谁都保证不了能一帆风顺插入成功。所以这里就用一个简单的类文件进行即可。这里定义一个简单的类Person,内部定义多个不同类型的方法,包括方法的返回值,参数,类型等。这也是为了后续检测我们插入代码的各种情形是否都能成功。我们的目的也只有一个,就是如何动态给Person这个类中每个方法插入之前提到的动态代码:
if(changeQuickRedirect != null){ if(PatchProxy.isSupport(new Object[]{xxx,xxx,...}, this, changeQuickRedirect, false)){ return ((XXX) PatchProxy.accessDispatch(new Object[0], this, changeQuickRedirect, false)); } } |
在类中插入一个静态变量:
public static ChangeQuickRedirect changeQuickRedirect; |
咋们定义的Person类如下:
![](images/201701050502.jpg)
这个类非常简单,定义了很多不同类型格式的方法,下面我们就要来编写代码,自动给每个方法注入那段修复代码以及给这个类添加一个静态变量。有了之前的那篇文章:Android中动态插入代码工具icodetools,我们操作就很简单了,这里依然需要借助asm包和Eclipse的插件Bytecode,咋们直接利用Bytecode插件查看那段代码的asm对应的代码,不过这里需要注意,每个方法插入的代码不同,先来看修复代码的两个重要方法:isSupport和accessDispatch,这两个方法都有四个参数:
第一个参数:Object数组,存放的是这个方法的所有参数值,看到如果是基本类型需要做封箱转化。
第二个参数:当前方法所属的对象,如果方法是static类型就是null,如果方法是非static的就是this。
第三个参数:修复接口类型,也就是我们需要插入的静态变量changeQuickRedirect。
第四个参数:方法是否为static类型。
所以从上面这四个参数,就知道我们在插入代码时需要做如下处理,主要包括以下几点:
1、每个方法的参数不同,因为我们看到插入的修复代码的isSupport和accessDispatch方法的第一个参数都是一个Object数组,也就是这个方法的所有参数。
2、方法类型声明不同,如果一个方法是static类型的,isSupport和accessDispatch方法的第二个参数是null,以及最后一个参数是true,否则就是this和false值。
3、方法的返回值不同,对于方法是否有返回值需要做特殊处理,以及方法返回值类型不同也要做处理。
主要是这三点,但是实际操作还有很多小的细节问题,比如参数如果是基本类型,咋们还得做封箱操作,将其变成对象类型。返回值如果是基本类型,还得做拆箱操作,把对象类型变成基本类型。
三、自动插入案例
上面分析完了基本原理,下面直接来操作,开始我们用一个简单的方法做案例,然后手动的先插入一段修复代码,在借助Bytecode插件查看这段代码对应的asm代码:
![](images/201701050503.jpg)
通过asm代码,我们需要注意的就是参数数组构建,和返回值转化:
![](images/201701050504.jpg)
下面我们可以把这段asm代码直接拷贝到Java代码中,在这个过程中,我们需要对那个参数数组构建做处理了,因为现在方法的参数个数是不确定的,所以咋们得编写动态构建代码:
![](images/201701050505.jpg)
这段代码就是完成了修复代码的动态插入,逻辑和顺序很清晰,首先得构造出方法的四个参数,其中最重要的就是第一个参数Object数组了。
第一个参数:构建方法参数数组
![](images/201701050506.jpg)
在这里还得区分,一个方法是否为有参数和无参数的情况。做特殊处理,然后最核心的地方就是创建多个参数数组类型的代码了:
![](images/201701050507.jpg)
上面代码,就开始创建一个方法的所有参数类型数组,需要做以下几个特殊处理:
1、因为字节码指令中常量值指令是Opcode.ICONST_0到Opcode.ICONST_5的,所以如果一个数组大小超过这个指令范围了,就得借助Opcode.BIPUSH进行操作了。
2、判断当前方法是否为static类型的,因为这个类型关系到后面取方法局部参数的索引值,我们知道非static类型的方法有一个隐含的参数this,所以这里要做一次局部参数索引值判断。static类型从0开始,非static类型从1开始。
3、在进行数组数据填充的时候,因为需要通过索引值访问,这里依然要做特殊处理,超过5通过Opcode.BIPUSH指令进行操作了。
4、对于参数处理需要区分基本类型和对象类型,因为他们采用的LOAD指令不同,一般基本类型中long是LLOAD,double是DLOAD,float是FLOAD,其他基本类型都是ILOAD;对于对象类型都是ALOAD。
5、对于参数中,如果一个参数的前面一个参数是long,double类型,要对参数索引做特殊处理,这里猜想可能和这两种类型占用的字节数有关,毕竟他们都是占用8个字节。而其他类型都是在4个字节以内的。当遇到是这种两种类型,参数索引值就得加一。
看到这里有这么多个坑,可以想到我在填坑的时候多么痛苦,但是填坑方法也是很简单的,可以先模拟定义这样的方法,然后查看他对应的asm代码即可:
![](images/201701050508.jpg)
这个方法就包含多个参数,而且所有特殊情况都包含了,查看asm代码即可:
![](images/201701050509.jpg)
这样咋们就把坑给填完了。继续看上面的代码,在处理特殊的基本类型,因为上面提到基本类型除了LOAD指令不一样,还有就是需要进行对象封箱操作,从asm代码中也可以看到,看看具体方法:
![](images/201701050510.jpg)
对于不同基本类型做了特殊处理,下面看一下boolean类型的处理:
![](images/201701050511.jpg)
其他基本类型都大致相同了,这里不再解释了。
第二个参数:当前方法所属的类对象
到这里就看完了,修复方法的第一个参数:对象数组构建,也是整个过程中的核心,也是最复杂的。咋们在回过头继续看,第二个参数:方法当前所属的对象
![](images/201701050528.jpg)
这里需要做判断就是方法是否为static类型,如果是static类型直接传入null即可,如果是非static类型就要直接传入隐含的第一个参数this了。
第三个参数:静态变量changeQuickRedirect
这个参数就简单了,直接用类的静态变量changeQuickRedirect即可:
![](images/201701050512.jpg)
第四个参数:方法是否为static类型
![](images/201701050513.jpg)
有了上面四个参数之后,下面就可以开始调用了修复的两个方法了,一个是isSupport:
![](images/201701050514.jpg)
这个方法返回值是boolean类型,也就是在if语句中执行,可以用IFEQ指令即可。不过这里还有一个坑,就是如果是Bytecode插件直接得到的asm代码,方法的参数签名第一个是Ljava/lang/Object;,这个明显不对的,因为我们知道第一个参数是数组类型,所以需要手动改成[Ljava/lang/Object;,这个坑找了好久才填成功了。
然后就是accessDispatch方法调用,在调用这个方法之前,我们依然需要构造四个参数,不过这个构造过程和之前是一模一样的。直接抄过来就可以了,主要是执行完这个方法之后的事,又有好多坑:
![](images/201701050515.jpg)
这里看到,我们又得像上面构造那个复杂的方法参数数组一样填坑了。这里需要做这几个特殊处理:
1、方法是否有返回值,如果没有返回值,直接调用Opcode.RETURN指令即可。
2、方法返回值类型如果是基本类型需要特殊处理。
3、方法返回值类型是对象类型,需要做类型签名处理,如果是数组类型不做处理,如果是非数组类型需要去除前面的L字符,以及后面的分号字符,不然后面在使用dx命令转化jar的时候报错。
下面来看看如果返回值是基本类型,我们需要进行拆箱操作,即把对象类型变成基本类型:
![](images/201701050516.jpg)
代码也很简单,直接拷贝asm代码即可,对每个基本类型做判断即可。最后就是返回指令,因为不同基本类型和对象类型采用的不一样,基本类型中float类型是FRETURN,long类型是LRETURN,double类型是DRETURN,其他类型都是IRETURN,如果是对象类型直接是ARETURN即可:
![](images/201701050517.jpg)
四、遇到的问题
到这里我们就完成了动态代码注入的编写,整个过程可以看到有很多地方需要处理,也就是填坑,在无数次实验中遇到问题解决问题,因为如果开始把asm对应的代码拷贝过来会遇到一些问题的。不过每次遇到问题的时候解决办法也很简单,借助jd-gui工具,查看我们每次处理之后的class文件,比如这里:
![](images/201701050518.jpg)
这里看到,这个方法处理就报错了,其实这个就是之前遇到的坑,如果一个参数前面一个参数是long,double类型没有做特殊处理的结果。这时候发现有问题,我们可以先手动编写修复代码,然后借助Eclipse的Bytecode插件查看其对应的asm代码,和我们生成代码逻辑作比较即可。
还要一种方法,可以使用javap命令生成两个class的字节码,然后对比也可以:
![](images/201701050519.jpg)
然后对比这两个class文件的字节码:
![](images/201701050520.jpg)
不一样的地方,再继续修改指令即可。
五、踩过的坑
到这里我们就把动态插入代码的逻辑编写完毕了,总结一下我们遇到的坑:
第一、处理构造方法参数数组
1、参数个数,字节码指令常数值是ICONST_0到ICONST_5,过了这个范围,就得用BIPUSH指令。
2、基本类型需要进行封箱操作。
3、参数前面一个参数是long和double类型,需要做特殊处理。
4、基本类型和对象类型在存放值的时候用的LOAD指令不同。
第二、方法返回值处理
1、方法有无值返回。
2、返回值是基本类型需要做拆箱处理。
3、对于返回值是数组和非数组类型处理。
4、基本类型和对象类型返回指令不同。
六、包装成小工具
下面还没完,因为上面我们看到只是编写完了插入代码的工具类方法,回头可以看到,这个方法需要传入几个参数:
![](images/201701050521.jpg)
下面来说明这几个参数的含义:
第一个参数:操作方法的类MethodVisitor
第二个参数:方法所属类的全称名称
第三个参数:方法参数签名字符串列表
第四个参数:方法返回值类型签名
第五个参数:方法是否为static类型
下面咋们需要借助asm包中的api来处理class文件了,在之前介绍Android中动态插入代码工具icodetools
的时候,说过一句,操作类使用ClassVisitor,操作方法使用MethodVisitor即可,直接看代码:
![](images/201701050522.jpg)
这里可以通过方法的描述字段desc,通过Type类得到方法的参数类型和返回值类型
![](images/201701050523.jpg)
在这里,可以通过access字段获取方法是否为static类型,而且需要给每个类添加一个静态变量changeQuickRedirect
![](images/201701050524.jpg)
然后就需要借助ClassReader类,这里传入的是需要处理的类的字节数组,然后可以获取到类名。处理之后在返回类的字节数组即可。
外部在封装一个方法,读写文件,所这里为了后面方便使用,编写了两个简单小工具,一个是用于单独class文件处理,一个是为了jar文件处理,只要输入源文件,输出就是处理之后的结果了:
![](images/201701050525.jpg)
这个项目中具体代码就不多解释了,后面会给出项目的下载地址,可以自己弄下来慢慢解读。但是这里需要注意一点,就是这里的ChangeQuickRedirect和PatchProxy这两个类必须和应用工程中的名称包名保持一致,不然插入是失败的。下面就简单用处理单个的class文件工具处理一下Person类:
![](images/201701050526.jpg)
好了,到这里,咋们就处理完了Robust框架动态插入代码的逻辑了。提供了两个工具,一个是处理jar文件,一个是处理单独class文件。那么有同学可能会困惑?美团项目应该还有其他操作吧。的确如此。
七、项目中如何使用工具
有了这两个工具,我们可以将其导出成jar文件,在项目编译期间开始操作,先不管项目用ant脚本,还是gradle脚本了,不了解用脚本编译Android应用的同学,可以查看这里:Android中使用脚本编译应用;用脚本编译项目都是需要经历这么几个阶段的:
1、使用Android SDK提供的aapt.exe生成R.java类文件
2、使用Android SDK提供的aidl.exe把.aidl转成.java文件(如果没有aidl,则跳过这一步)
3、使用JDK提供的javac.exe编译.java类文件生成class文件
4、使用Android SDK提供的dx.bat命令行脚本生成classes.dex文件
5、使用Android SDK提供的aapt.exe生成资源包文件(包括res、assets、androidmanifest.xml等)
6、使用Android SDK提供的apkbuilder.bat生成未签名的apk安装文件
7、使用jdk的jarsigner.exe对未签名的包进行apk签名
![](images/201701050527.jpg)
那么脚本是我们自己控制的,所以可以在两个阶段选择处理,也就有两个方案:
第一个方案:只需要在将java文件用javac命令编译成class文件之后,利用上面的那个可以处理单个class文件工具进行处理即可。这样对于开发人员其实是无感知的。在编译阶段自动完成了。
第二个方案:在编译所有文件得到class文件之后,将其打包成jar文件,然后在借助上面提到的处理jar文件工具进行处理即可。然后在使用dx命令将处理之后的jar文件变成dex文件即可。
其实不管是哪种方案,只要在编译期找对时机,利用上面给出的两个工具都可以完成的。其实还有一种思路,就是需要借助之前提到的icodetools工具,需要把这个工具进行改一下,把本文中的动态插入代码逻辑移植到icodetools工具中,然后咋们可以输入一个apk,输出的apk就是已经添加成功的结果了。不过这种方式不可取,我相信美团不会用这种思路去处理的。
八、优化工作
到这里我们就算把美团的Robust框架中动态插入修复代码的逻辑讲解完了,但是这里还有一些细节问题需要处理:
1、添加黑名单规则,我们可以看到,这个动态插入代码段是为了修复作用,那么一个apk中所有类是否都有必要插入呢?明显不需要,比如我们用到了v4包中的类,那么这里的类肯定不需要插入的。当然还有一些我们自己定义的类的一些方法也不想插入的。所以这里就要有一个插入时的黑名单,这个需要在上面插入工具里做处理,比较简单,因为我们知道处理的方法名和类名了,只是做一个简单过滤即可。
2、从上面看到每个类需要有一个changeQuickRedirect变量,这个变量名是唯一的,但是又不能保证在开发过程中,每个开发人员都会使用这个名字,如果有人使用了,而我们又自动插入了,那么编译肯定会报错的。所以我们在插入代码之前需要做一些判断逻辑。如果有这个变量就不插入了。并且给与一些信息提示。
九、框架优缺点
结合之前的框架原理实践案例以及本文的知识,下面来看一下美团这个热修复框架的优缺点:
优点:在之前一篇文章中已经知道他的加载逻辑非常简单,直接使用DexClassLoader类加载修复包即可。所以可以看到这个修复框架的兼容性非常好。因为直接使用系统提供的api,不会有很高的崩溃率,不像AndFix框架借助底层,会有系统限制需要做兼容操作的。
缺点:从本文就可以知道了,一个企业级应用代码本身就很庞大,在这样给每个类每个方法都插入了这段代码那么,可想而知,插入代码之后的apk包得多大。而且还有一些混淆问题,和AndFix框架一样,不支持资源修复。
所以如果把这个框架真正集成到项目中还有很多坑需要我们去填,当然这个不是本文介绍的范围了。感兴趣的同学可以去网上搜一下关于Robust框架的问题,有详细说明。热修复路慢慢其修远兮,吾将上下而填坑!
项目下载地址:https://github.com/fourbrother/RobustInsertCodeTools
十、总结
本文主要继续前面一篇文章介绍Robust框架的原理和实践案例之后,看一下这个框架的核心技术点就是如何在编译期间自动给每个类每个方法中插入代码,借助asm包和Bytecode插件完成了。而这个意义不仅仅是局限于研究了Robust框架,而是为了后续操作都有用,也就是说以后如果有自动插入代码逻辑,本文也是一个非常不错的案例。后面还会继续分析市面上的最后一个热修复框架Tinker了。最后小编周末写文章真的好累,记得看完之后多多扩散分享,要是有打赏就更好了。
|