Redex初探与Interdex:Andorid冷启动优化
发布于 2 个月前 作者 Bugly_Tony 104944 次浏览 来自 技术

导语

早在去年10月份,facebook就发布了介绍redex的文章,这个据说可以直接对apk做处理,既提高启动性能,又可减少安装包的利器让安卓开发者们都心动不已。直到今年4月,redex终于开源了,我们也第一时间对redex做了研究(有观众可能要说我骗人,这都11月了怎么还第一时间呢?好把这个总结是拖了很久才写),虽然由于坑多,最终没有接入到项目构建中,但受Interdex启发,在应用冷启动速度优化方面有了新的收获。

PS:本篇提到的冷启动速度优化,不包括Android 5.0及以上系统

一、redex的使用与坑

1.安装与使用

使用redex的第一个坑就是环境。很遗憾的是这个工具不支持windows系统(用mac开发的壕请忽略),只好装虚拟机来跑ubuntu。解决了系统,就可以按照github上的官方指引一步步来了,这里需要安装茫茫多的依赖库和解决若干环境问题,幸好各种典型issue已经有了解决方案,这里不再赘述。

2.优化原理与配置

Redex的优化项众多,并且可以很方便的修改配置文件来选择需要执行的优化,默认的配置文件如下

根据官方的介绍文档,redex的优化主要有以下几项:

A.内联。 简单说就是去除一些多级调用的中间层级,举个例子:

func1 -> static func2 -> static func3

优化后就是

func1 -> static func3

这样可以减少函数调用时间和字节码。除了静态方法调用,对象引用也有类似优化。

B.删除无用代码,移除空类。

C.对于只有一个实现类的接口或父类,直接用实现类代替。

D.SynthPass 翻译不能,官方例子,内部类B访问外部类A的private static变量,compile后其实是通过生成额外的acces方法来帮助内部类访问外部类私有成员。这个优化可以去除额外生成的字节码,方法相当于把变量的作用域改成public。

E.字符串缩减,包括提供字节码层面的混淆能力,类似Proguard,以及DEX文件中metadata的优化,可以有效缩减安装包大小。

F.Interdex 需要使用者提供程序启动时加载类序列作为配置文件,按此顺序调整dex中类的顺序,可以有效提升冷启动速度,提升幅度在30%左右。优化的原理facebook推测是优化读取IO和内存(按研究的结果来看其实另有原因,后面再说)

3.实践中遇到的坑

如此多的优化项累加,想来效果应该非常可观。但残酷的现实是,经过对手Q安装包的处理验证,redex中还存在不少bug和坑,接入使用的性价比不高:

A.IlegalAccessError

这是redex的一个bug,原因是在内联优化中,移除中间层的方法时没有考虑作用域,比如:

Func1 -> public static func2 -> private static func3

会被优化成:

Func1 -> private static func3

而调用类又不能访问其他类的私有方法,导致抛异常(这个问题有不少issue,近期redex似乎已经修复了,还未验证)。

B.NoClassDefError

一个比较诡异的问题,运行时报这个错,但反编译Dex文件,这个类是存在的,怀疑是redex的bug,github也有少部分类似的issue,原因未明。

C.NoSuchMethodError

一个坑。因为手Q里很多业务是以插件机制运行的,部分插件是非独立的,也就是和手Q工程一起编译,并且会引用手Q代码,在编译完成后,这些插件也分别打包好存放在手q的apk里。这样会导致的问题是: redex在做优化时可能会把手Q部分方法移除,如果插件刚好引用了这个方法,就出现NoSuchMethodError了。

D.Interdex

这个优化项会完全打乱原有的dex分布,甚至dex的数量也会发生改变,用来校验分dex是否注入成功的Foo类,以及补丁patch也被打乱,对启动时分dex注入,补丁等逻辑都有很大影响。

E.签名

redex执行后需要对apk重新签名,而手Q在签名之后还有一些优化逻辑。

这个时候redex可配置优化项的方便之处就体现出来了。遇到问题时,可以把可疑的优化项屏蔽掉,继续验证。可即使如此,屏蔽到最后悲催的发现可用优化项已经不多,优化的效果也不太明显(安装包可以减少100k左右,启动速度方面因为interdex需要较大改动,未尝试)。仅存的几个优化项没经过更细致的测试也可能存在隐患,而就算只使用这少数优化,在编译脚本修改和rdm构建环境搭建上也会有很大的工作量。

二、Interdex,冷启动速度优化

想直接接入redex成本较大,但要我们直接放弃这些优化空间,内心也是拒绝的。那么我们能否参考facebook的思路,尝试自行实现一些优化项呢?

在redex中,大部分优化原理都需要解析dex格式,从中还原出引用、继承关系,加以分析,工作量巨大。但Interdex比较例外,这个优化不需要去分析类引用,它只需要调整Dex中类的顺序,把启动时需要加载的类按顺序放到主dex里,这个工作我们完全可以在编译过程中实现,而且这个优化可以提升启动速度,优化效果从facebook公布的数据来看也比较可观,性价比高。

1.如何实现Interdex

根据interdex官方介绍的原理,我们可以知道要实现这个优化需要解决三个问题:如何获取启动时加载类的序列?如何把需要的类放到主dex中?如何调整主dex中类的顺序?

A.如何获取启动时加载类的序列?

redex中的方案是dump出程序启动时的hprof文件,再从中分析出加载的类,比较麻烦。这里我们采用的方案是hook住ClassLoader.findClass方法,在系统加载类时日志打印出类名,这样分析日志就可以得到启动时加载的类序列了。

B.如何把需要的类放到主dex中?

redex的做法应该是解析出所有dex中的类,再按配置的加载类序列,从主dex开始重新生成各个dex,所以会打乱原有的dex分布。而在手q中,分dex规则是编译脚本中维护的,因此我们可以修改分包逻辑,将需要的类放到主dex。

C.如何调整主dex中类的顺序?

开源就是好。Android编译时把.class转换成.dex是依靠dx.bat,这个工具实际执行的是sdk中的dx.jar。我们可以修改dx的源码,替换这个jar包,就可以执行自定义的dx逻辑了。简单说下具体修改方法:

这里需要对dex的文件格式做一定了解,不再细说,网上有一篇很好的文章,有兴趣可以了解下 Android逆向之旅—解析编译之后的Dex文件格式

借网上的一张图,dex文件的基本结构如下:

从dex的文件格式我们可以知道,dex被据划分为多个section,一个类的完整信息也被分散到各个section里。想从dex中解析一个类必须要先从classDef段找到类定义,从中找到类包含的各种信息的偏移地址,再从对应地址去读取数据,所以要调整dex的类排列顺序,理论上只需要对classDef段修改即可。

(从这里看其实类的排列顺序对读取时的内存影响应该不大,因为在dex中类的数据并不是连续存储的)

在dx执行时,最终将dex数据写入到文件也是以section为单位逐个写入,并且每个section写入前都会执行orderItems做排序,修改这个方法即可实现我们的目的。

2.优化效果

一番折腾后,终于实现将启动时加载的类按顺序放到主dex中了,赶快用专项测试跑下数据,启动过程actLoginA的耗时减少了30%左右,提升效果还是比较明显的,数值上与facebook的结论也比较接近。

可惜没能高兴太久,当我把改动上传到rdm,用rdm构建的release包做专项测试时,发现并没有什么效果。此时内心是有点懵x的,难道是专项测试时偶现了误差?还是测试时用的参照包和我本地包不是一个version?

还是我眼花看错了,实际没效果?

怎么办,前一天写日报好像已经把优化30%的结果同步出去了,过了一天还能撤回邮件吗?

冷静,这个时候不能着急,总之先冷静下来找找哪里有时光机。

经过反复、仔细的验证,可以确认的事实是,rdm构建的release包无明显优化,本地debug包和rdm构建的debug包,都有明显优化。

3.为啥release不生效?

手q最终发布的包必然是release包,只对debug包生效的优化并没有什么作用。并且这个优化的原理我们也没有弄清楚,facebook的理论主要是优化IO和内存带来的速度提升,但前面也提过,从dex文件的结构来看,这个解释并不能让人信服。所以还要继续分析,如果弄明白了为什么release包不生效,也许就可以推测出优化原理。

首先怀疑的是混淆。Release构建中会做混淆,很多类名都会变化,而我们优化时用的类加载序列是原始类名,所以在release构建时不能正确的调整顺序。嘿嘿,应该是原因了把,这个好修复,混淆是在dx之前执行的,只要混淆后拿到混淆表,把类加载序列里的类名替换成混淆后的即可。修改后再次测试,结果仍没什么变化。

再找原因,release构建有做ZipAlign优化而debug没有,是不是这个影响?验证后排除。

继续怀疑,是不是release包类加载顺序变了?这个按说是不太可能,但抱着死马当活马医的心态试了下,果不其然是匹死马,排除。

finally,在和hyim、大龙两位老司机讨论时发现了新的嫌疑人,插桩。当时手q使用的热补丁是classloader方案:反射修改classloader的DexPathList。这个方案为了解决加载补丁类时verify出错的问题,需要对所有的类进行插桩,而插桩逻辑只有在release构建才会执行。在relesse构建中去掉插桩逻辑,再次测试,actLoginA终于有了提升。

4.优化原理

插桩的目的是避免安装时虚拟机做pre-verify,让类打上CLASS_ISPREVERIFIED标识。这会导致Interdex优化失效,而系统做pre-verify是为了提升性能,再结合Interdex的实现,综合来看interdex真正的优化原理就比较明显了:

将启动时加载的类放到主dex,提升了这些类的内聚,让更多的类满足pre-verify的条件,在安装时就做了校验和优化,以减少首次加载的耗时,从而优化冷启动耗时。

(这个结论也再次证明dex中类排列顺序应该不影响性能,因为打不打pre-verify只看类引用关系。去掉启动类排序逻辑后再次验证,确实仍有明显优化效果)

而插桩会导致所有类必然不能打上pre-verify,所以不管怎么调整类分布,都没用。

一个小疑问:手Q刚开始用热补丁时,为啥没有发现明显的actLoginA下降?

原因:手q有多个分dex,并且之前主要是按包名来做分dex,所以主dex中除了主依赖集外,剩余的很多类可能都已经不满足pre-verify条件了,所以插不插桩区别不大。

三、总结

  1. Interdex优化确实可以明显提升应用冷启动速度,原理也比较简单:把互相引用的类尽量放在同个dex,增加类的pre-verify。这个思路其实不仅仅可以用在启动上,一些其他的关键场景也可能用类似方法提升性能。不过这个优化与修改classloader.DexPathList的热补丁方案有冲突,想要二者兼得需要选择其他补丁方案。

比如zhekai的新方案详见 QFix探索之路——手Q热补丁轻量级方案

  1. redex还是一个很好的工具,有很多优化项可以挖掘,小型app相对来说应该更容易接入,大型项目会遇到更多的坑,直接接入不易,但也可以从中了解到新的思路。赞开源精神。

  2. 保持怀疑和好奇。再牛x的项目,也不能所有理论都是对的,还是要多实践。比如Interdex中调整类顺序,在这个优化项本身是没什么用,而整个研究中这部分是最花费时间的。 (当然长远来看,了解dx执行和自定义dx实现,了解dex文件结构都是挺有用的,这波不亏)