01月25, 2016

Android热补丁的一点总结

由于项目需要,我研究热补丁的实现是从12月上旬开始的,那时候我还是个对编译、ant、hudson只闻其名,对javassist、groovy闻所未闻的孩子;而现在,我已经是一个对ant、hudson一知半解,对javassist、groovy半熟不熟的孩子了,热补丁功能也终于上线了。

原理

首先感谢以下文章给我的帮助,是QQ空间团队的分享

这篇文章可以说是所有热补丁开源项目的鼻祖,是开创热补丁时代篇章的序言。

以及以下开源项目给我的参考:

https://github.com/jasonross/Nuwa

https://github.com/jasonross/Nuwa

https://github.com/jasonross/Nuwa

这三个开源项目基本上差不多,都是根据前面的文章中的内容,实现了在代码中应用补丁的方法,如果感兴趣的童鞋可以自行学习。但是,这些项目对如何生成补丁的方法都语焉不详,也没有提示我们如何避免热补丁中的一些坑。这也就是我这篇文章的主要内容了,本文会按如何踩坑,如何思考,如何解决来进行记录,写得详细一点,如果以后有相关的需要,可以避开这些坑。

还有阿里的开源项目AndFix,其原理是修改dalvik和ART虚拟机中某个函数的指针的入口地址,来改变这个函数的行为的,通过阅读它的源码,发现这种方式在加载补丁的时候会遍历补丁中的所有方法,然后依次替换,如果补丁较大时,对性能可能影响较大;同时对于原包中不存在的类或方法似乎是无法打补丁的(不一定准确);但是也有其优点,就是已经加载了的类也能够进行实时修复,是真正的“热补丁”。而且看到这个项目的时候已经完成了一半了,对C++也已经忘得差不多了,权衡利弊后没有借鉴他的做法。感兴趣的同学开源研究研究: https://github.com/alibaba/AndFix

实现

Tips:继续阅读本文可能需要对QQ空间团队的那篇关于热补丁原理的分享(以下简称原理)有所了解。为了防止给东家丢人,对敏感信息做了点处理。。。

如何处理字节码

原理中说,需要对编译后生成的字节码(.class)文件进行处理——在第一个构造函数中插入一段代码:

System.out.println(AntilazyLoad.class);(后来我改成了Class a=AntilazyLoad.class;)

为什么不在编码阶段进行插入呢,因为如果这样做的话,不但工作量巨大,而且极易出现错误,不可取,因此应该在编译完成以后,再对字节码进行处理,如下图所示,在Java compiler步骤之后。

alt

那么很显然,我需要对编译的脚本进行修改,将插入字节码的工作嵌入到编译的过程中。

项目是使用ant进行编译的,首先简单的介绍一下android使用ant编译的方法。在项目的build.xml(ant编译脚本,下面简称hbuild)中会引用android-sdk的build.xml(下面简称abuild),其中包含的target对应了上图编译过程中的每一个步骤,而且还预留了一些可自定义的target,包括-pre-compile , -post-compile , -pre-build , -post-build等,从名字就可以看出来,我们在hbuild中重写这些target,就可以在编译的过程中插入自己的工作,也就不需要修改abuild文件了,由此可见可拓展性是多么重要。于是我们就在-post-compile里面实现了插入字节码的功能,但是这样做的时候却遇到了一些问题。

  1. 首先,由于依赖工程的编译是调用这些工程中的build.xml来实现的,而这些build中并没有应用统一的post-compile,所以需要对所有的依赖库生成的字节码都进行理。
  2. 其次,在实际验证的过程中,我发现依赖的jar包中的类并没有被修改字节码,原因是在编译的时候,jar是排除在外的,并不在编译生成的路径下,所以需要对这些文件特殊处理;

经过阅读abuild文件,我发现混淆的时候会将所有依赖工程编译的文件和jar包中的文件一起混淆打包成obfuscated.jar。所以我将插入字节码的操作延后到了混淆之后,先将obfuscated.jar文件解压缩,然后对每一个.class文件插入字节码,再记录下这个文件的md5值,所有文件都处理完以后,再重新打jar包,然后覆盖原来的obfuscated.jar文件,之后再继续abuild中的dex等操作,这样就完成了对apk包的处理,程序中已经可以打补丁了。

对hbuild文件的具体修改可以看代码,这里就不一一细说了。

与Hudson相结合

现在已经有了修改字节码的功能,那么整个编译过程是怎么样的呢?如何与hudson结合起来使用呢?

首先是在hudson上新开一个任务,专门用于生成补丁包(因为不同环境下生成的.class文件可能不一样,所以最好保证正式包和补丁包的环境一致)。

此时生成正式包的过程是:在git上提交后,先经过正常的编译、混淆,然后将混淆后的.class文件进行字节码处理,再重新打包,覆盖混淆的结果,接下来继续执行正常的打包流程。最后的结果中,会保留mapping文件和md5_class文件,用以生成补丁包,在git中记录tag=tag_version。

生成补丁包的过程是:先checkout到需要打补丁的tag_version上,修复bug、下载对应的mapping和md5_class文件,然后push到branch_hotfix分支上,之后先按照打正式包的过程打出正式包,其中混淆的过程是使用了mapping文件进行的。然后再依据md5_class文件中记录的每个.class文件的md5和当前生成的.class文件的md5进行比对,将有变化的文件提取出来,打成dex包,再生成jar包并签名,这样针对tag_version的补丁包就生成了。

这个过程中我也遇到了一点问题,就是在hbuild脚本中如何调用dx来生成dex包。通过阅读abuild,知道使用“”能够得到dx的环境(这样得到的环境是与sdk版本相关联的,也就是说如果修改了sdk版本,dx的环境也会自动发生变化),需要注意的是是hudson上使用的sdk版本与我本地的版本不一样,需要通过,然后在用dx_loc来调用。再后来由于要将编译环境迁移到linux服务器上,编译脚本应该要自动判断当前环境来使用相应的命令:

alt

其他注意事项

  1. 在app中加载补丁的时机越早越好,因为加载补丁以前使用到的类是无法打补丁的,所以最好在程序调用的第一个方法中打补丁,原理中是在继承的application中的oncreate函数中调用的,但是经过研究,我发现可以在attachBaseContext中加载,但是需要注意的是,在attachBaseContext中调用getApplicationContext方法得到的结果是null。

  2. 在验证的过程中,我发现有些使用单例模式的类,将构造函数设置成了私有的空函数,理论上编译完以后应该会保留这些构造函数才对,但是生成的字节码中却没有构造函数,我猜测是编译器或者混淆的时候进行了优化,但是这样的话应该将AntilazyLoad.class的调用放到哪里呢?我最后的选择是再插入一个无参构造函数,在其中调用AntilazyLoad.class,因为这样对程序的影响最小。

  3. 自动生成的R文件不需要进行处理,也不应该进行处理,否则甚至可能出错。

实践中遇到的问题

Application中直接调用的类无法打补丁

在原理中,以及所有开源代码中,都是在直接继承application的类中加载补丁,相当于在MyApplication中加载补丁,但是由于项目的application并不是直接继承自Application的,而且中间有多层继承关系,所以在实际操作的过程中给我带来了不少麻烦。

alt

为了使代码只进行最小限度的修改就能实现功能,我在原有Application的继承链中加入了一层,在HotFixApplication的attachBaseContext()函数中加载补丁,这样也可能引发问题,也就是PluginApplication和WrapApplication的attachBaseContext()函数中使用到的类无法打补丁,不过现在这两个类已经删除了,所以不存在问题了。

先介绍一下背景:我模拟的补丁环境是:带有bug的apk包是将BrowserActivity的oncreate()中的代码全部注释掉,则启动程序后是黑屏界面;补丁包则在bug包的基础上,还原oncreate()中的注释而已,因此打了补丁以后就应该可以正常运行了;而在MyApplication中调用的函数如下图所示。

![alt](http://www.siki.space/static/upload/20170308/YBz1Ymhp-hxCGfur841p1ZhI.png)

一开始,我按照原理中的介绍进行处理,则在运行的时候,会在UnknowInitHandler处报如下图的错误,原因是在MyApplication中调用了另一个dex包中的类——这是一个坑。根据原理中所说,是因为MyApplication在安装的过程中被打上了pre-verified的tag。我的处理是在MyApplication中添加一个static方法,在这个方法的调用AntilazyLoad.class,这样就可以防止被打tag了。

alt

但是这样处理以后,在加载了这个补丁包后,还会在ThemeBuilder处报NoSuchMethodError错误——这是另一个坑。原因是因为混淆会将未被调用的方法删除,从而缩小字节码体积,而ThemeBuilder中有些方法只在被我注释掉的代码有调用,所以如果补丁中的类只有一部分加载成功,则会出现调用的方法缺失。

解决的过程就不详细描述了,直接说结果:凡是在application中使用到的类,都会被预加载,从而导致这些类无法打补丁。这让我重新理解的类加载时机,一开始我以为,一个类只有第一次被使用到的时候才会加载,其实不然,第一次被使用的时候是被初始化而不是被加载,加载在初始化以前就已经完成了,解决方案是将在MyApplication中的代码封装起来,使用反射来调用。

其实在我研究的过程中,曾经出现过在HotfixApplication中引用的类可以打补丁使用的情况(在BrowserActivity中使用了这个类原来没有的方法),但是再次实验又无法实现了,由于当时其他的一些变量没有完全记录下来,所以可能还需要进一步实验确认——这是第三个坑。如果这个问题最终解决了,就可以对Application中使用到的类也打补丁了,也就只有Application类本身、其子类、以及打热补丁用到的类无法打补丁,其余类都能打补丁了,热补丁的使用范围会进一步拓展。不过由于后来已经删除了PluginApplication和WrapApplication,所以这个问题在项目中已经不存在了,留到以后研究。

类加载过程参考:http://chenzhou123520.iteye.com/blog/1597597

PullDataManager的继承关系引起的崩溃

在第一次使用热补丁的时候就发生了一个问题,当时是准备测试一下热补丁的功能,所以修复了几处崩溃,结果经过测试,发现在有些机型上使用这个补丁会崩溃,最后总结出来——在使用了ART的机型上会崩溃。

经过我不断排查(因为前期做过兼容性测试,5.0的机型是有覆盖的,而且之前的测试补丁都能生效),发现是在PullDataManager中出现了问题。下面详细描述一下:

Bug以及原因:PullDataManager继承了Handler,但是确没有任何与Handler有关的操作,而且PullDataManager有可能在线程中被实例化,但是Handler对象是不能在线程中实例化的,因此导致程序崩溃。

如何修复:去除PullDataManager的继承关系。

结果是可以执行PullDataManager的构造函数,但是一旦调用PullDataManager中的方法,则会出现以下崩溃。

alt

经过反复尝试我发现,如果程序包中的PullDataManager(下面简称PO)不继承Handler或者PO继承其他自定义类,而在补丁包中的PullDataManager(下面简称PF)修改成继承自任意类,都不会出现这样的崩溃,但是如果PO继承自Handler,Activity,BroadcastReveiver等系统常用组件,而PF继承自自定义类或者去除继承关系,则会出现崩溃。

但是如果PO和PF都继承自Handler,Activity,BroadcastReveiver等系统常用组件,但是确不是继承自同一个,却不会出现崩溃。

而且如果PO和PF是某个类的内部类,则不会受到上述约束。即使这个内部类在其他外部的类中被使用了也不受影响。

由于还没有阅读源码,所以暂时不清楚崩溃的原因,但是我猜测是ART对系统组件进行了特殊优化处理,导致这种热补丁失效。这个问题百度不到,因为其特殊性,毕竟更换父类的需求本身就比较少,而且还有ART的限制,所以应该可以算是首次发现这个问题了吧。总结一点经验就是发补丁还得多测测才能发!

打点的类(UrlCount)和热补丁相关的类需要打补丁如何处理

这是在打点的时候出现的问题,因为打点的类有可能需要打补丁,或者是在APPGlobal初始化之前,有类需要打补丁,则会导致调用打点出错,导致崩溃。

于是我在加载补丁中的类调用的打点函数addLoadCount中加入了try-catch,就不会出现崩溃了。

还有一个问题,就是热补丁相关的类,因为在处理字节码的时候,我是将热补丁相关的类排除在外了,防止出现找不到AntilazyLoad的bug(因为这个时候还没有加载hack_dex.jar),这会引起一个问题——当我将热补丁独立成一个jar包,可能被其他项目使用的时候,他们会继承一些类,而这些类我没法知道其类名,其路径,所以最好是对所有的类都一视同仁进行处理。

所以为了能够对这些类也打补丁,我在对字节码的处理中Class a=AntilazyLoad.class;加了try-catch,这样就可以了,处理字节码的时候也更方便了。但是我有点担心太多的try-catch可能导致性能损失,如果以后发现有问题再 改回来吧。

插件相关的类(com.**.plugin.)无法打补丁

alt

由于插件相关的类中用到了android系统的隐藏api,而javassist修改字节码的时候需要将所有引用的路径都添加进来,所以对其加AntiLazy会失败(如上图),如果对这些类打补丁,则会出现pre_verified错误,导致这些类无法打补丁。

这个问题的解决办法是将pluginloader_sdk_v2.0.0.jar打包的时候用到的隐藏api的jar包导入(插件在编译成jar包的时候,用到了一个修改过的android.jar包,里面包含一次api,对这个jar包是引用但不编译的)即可,但是由于插件打补丁可能比较少,所以暂时没有做处理了。 于是我只是将com.*.plugin从补丁中删除了,但是就会导致下面的bug。

Caused by: java.lang.IncompatibleClassChangeError: com.*.plugin.bean.PluginInfo崩溃

这个问题也是发生在ART虚拟机中,有一次我将大部分类都打成了补丁进行测试(通过修改MD5_class文件进行的),想看体积大补丁对程序运行有没有影响。排除了上面的bug以后,又试出来这个bug,如下图:

alt

由于这个类是插件的类,不能添加到补丁包中,所以我将其手动从补丁包中删除了,但是它的父类,却并没有被我删掉,于是就出现了这个问题,只要把父类也打到补丁包就可以了。 于是我进行了测试,最后发现,如果一个类的基类是abstract的,并且这个类的基类在补丁中,而这个类自身不在补丁中的时候,就会出现这个bug

而如果只是普通的父类,则不会出这个问题,接口的实现类也不会出这个问题。

而且如果一个类的祖父类是abstract的,但是其父类是普通类,此时如果祖父类在补丁中,父类和其自身不在补丁中,也不会崩溃;而只有其父类在补丁中,则会崩溃;父类和祖父类都在补丁中,也会崩溃。(这里有点绕)

暂时不清楚原理是什么,但是我们可以猜测一下:在安装的时候,art虚拟机会对abstract类A做标记,如果用到一个类B,它的祖先类是这个abstract类A,则只会对B的父类进行校验,如果这个B的父类发生了变化,则认为不安全,否则就认为安全。

本来我以为做一个4层继承关系的实验就能验证规律了,结果让我失望了,之前的猜测完全错了,而且由于4层的排列组合太多,完全总结不出规律来!

也就是说,所以如果修改了父类,最好把所有子类也打都进补丁!!!

现实操作中也有可能出现这种问题,因为经过我验证,如果只对一个类的父类进行修改,那么这个子类本身的md5可能不会发生变化,这个发现让我觉得很神奇。。。所以如果修改了父类,最好把所有子类也打都进补丁!!!

ps:大概看了一下源码http://androidxref.com/5.0.0_r2/s?refs=ThrowIncompatibleClassChangeError&project=art,猜测了一下bug原因,不一定正确:

ART模式下,安装apk的时候会将dex编译成oat文件(机器码),运行的时候根据dex到oat的映射关系来直接运行机器码,提高运行速度,而加载oat文件的时候会生成dex_cache文件(每个dex关联一个dex_cache),用以保存dex到oat的映射关系,而一个方法只有在用到的时候才会建立真正的映射关系;

如果一个方法是抽象方法,则会在dex_cache中记录为runtime类型,暂时记录成resolved_method(与dex_cache相关),在运行时再动态的解释运行真正的方法(也就是不会运行oat的机器码),然后再校验resolved_method和真正的方法的类型;

父类A在补丁包中,而子类B不在补丁包中,则两者的dex_cache不同,resolved_method也不同,导致校验失败(但是从源码看好像只校验方法的类型,不应该失败,也有可能是在修改字节码的时候改动了补丁包中的类导致的,待验证)。由于ART牵扯到挺多东西,一时半会也没看明白,如何有新的体会再来修改。

参考: http://blog.csdn.net/luoshengyang/article/details/39307813

http://blog.csdn.net/luoshengyang/article/details/39533503

http://blog.csdn.net/luoshengyang/article/details/40289405

如何处理打点

第一次发版的时候忘记加打点了,所以没法看到热补丁的效果,这是个大大的失策。 经过讨论,需要有一个打点的管理类在内存中,定时将打的点保存起来,防止丢失,具体如何打点目前有两种思路:

修改系统默认的DexClassLoader,当从补丁包中加载一个类的时候打点。

阅读源码可知,DexClassLoader中加载类的核心部分是DexPathList类,在DexPathList中将每个dex或者zip、jar解析成一个DexFile实例,加载类的时候是调用了DexFile的loadClassBinaryName方法,所以我们可以对补丁生成代理的DexFile,进行拦截打点。但是DexPathList和DexFile都是final类,不好实现拦截。

修改补丁包中的字节码,当其被加载的时候打点。

这种方法是在补丁中的.class文件中插入(静态)初始化块,或者在已经存在的初始化块中加入打点的逻辑,同样是使用javassist对字节码进行修改。

可见第二种方法更简单,所以采用之。

最后附上源码: https://github.com/l465659833/AndroidHotfix

PS:由于Android7.0中ART虚拟机的改进,这种热补丁方式已经无法使用了。而也有更新的热补丁方法被研究出来,包括微信的Tinker,美团的Robust等,其中美团的Robust是基于Instant Run原理实现的,可以说是谷歌官方都支持的方法,预计在未来都不再需要考虑兼容性的问题了,感兴趣的朋友可以自行百度。

本文链接:http://www.siki.space/post/a_conclution_about_hotfix_in_android.html

-- EOF --

Comments