查看原文
其他

R8疑难杂症分析实战 - 类反射篇|得物技术

Jordas 得物技术 2024-03-08

目录

一、背景

二、问题分析

    1. 查文档

    2. 尝试调试

    3. 啃源码

        3.1 R8 优化

        3.2 R8 混淆

            3.2.1 混淆当前类自身

            3.2.2 混淆当前类的引用

三、总结

背景

基于 Java 类加载的特性,我们通常会将一些期望只执行一次且不需要上下文的代码(例如 SDK 初始化)放到类的静态代码块中,通过触发类加载来执行这些代码,这样就不需要考虑线程安全问题以及重复执行问题。

在启动优化中就频繁采用了这种方案来将一些主线程耗时逻辑转移至异步线程并提前执行,为了避免不必要的耦合,我们通常是通过 Class.forName("com.aaa.bbb") 的方式来触发类加载,但是这种写法要求对应的类必须 keep 住,避免被混淆导致找不到类。

有一处代码恰恰就是粗心忘了加 keep 注解,但是最终线上并没有抛出 ClassNotFoundException,反编译生产包之后发现字节码中的类名字符串竟然被神奇的替换成了混淆之后对应的类名。

问题分析

Google 虽说会在编译期做很多优化,但为了稳定性应该不会做侵入性这么强的编辑,所以最终呈现的结果应该是异常情况,带着这个疑问我们开始分析问题。

查文档

这个问题显而易见是和混淆有关,但是常规的混淆不会直接修改硬编码的字符串常量,于是翻阅了下 Google 官网关于 R8 的一些介绍,得知在混淆之外 R8 还做了一系列的优化操作来减少包体积&提高指令执行速度,和我们这个问题最接近的就是这篇类反射的优化:

大致总结下,就是针对不存在子类的类,调用它的 getClass() 方法的地方都会被替换成 xx.class,字节码角度来看就是指令从 Invoke-Virtual 替换成了 Const-Class,访问常量池的引用和执行方法无疑性能会略有提升,但是当我们在类中定义了 TAG 成员变量用于打印日志时会频繁访问这个变量,这将使得性能提升更显著。

尝试调试

这个优化似乎和我们的问题关联不是很大,因此想到走捷径直接去 Debug,断点调试一下就能知道这个字符串是如何变更的。但是 R8 早就被 Google 内置到了 AGP 中一并打包,并且 R8 自身的代码就是混淆过的,而且很多关键节点的类都被压缩成了一行,因此 Debug 行不通。

R8 的官方 Git 仓库 Readme 中有提到如何用本地构建的 R8.Jar 去替换 AGP 中的 R8,但是我实测没有生效,感兴趣的可以自行尝试。

啃源码


R8 优化

事已至此,再想分析问题就只能看 R8 源码分析问题,我们先找到反射优化相关的类 ReflectionOptimizer:

参考注释,我们得知 getClass() 和 forName() 两种写法其实都会被替换成对应的 Class 常量,但是它后续提到了这个类必须是 resolvable,accessible and already initialized,因此我们的类没有被优化应该就是因为这里,反射优化的逻辑比较长,在正式开始替换之前有很多的判断,关键的判断就是下图中的 !baseClass.isResolvable(appView) 判断,如果这里返回了 False,则直接 Return,即该类不会参与优化。

这里是用了一个递归的方法来检查当前这个类,以它的父类,它实现的所有接口,是否符合要求,有任何一个不符合就返回 False。具体逻辑看下方代码中注释:

这个集合的定义:

综上,我们得知一个类如果有父类或者实现了接口,那么它们(父类和接口)都必须是这个集合中的一员,否则这个类就不会参与优化,回到我们一开始的问题,这个类确实实现了接口,而且不在这个集合中。

带这个这个结论我们写 demo 验证下,确实只有继承了 Activity 的类成功的被优化成了 Class 常量。

除此之外我还分别在 AGP 4.1.3,AGP 7.1.2, AGP 8.2.0,发现 AGP 4 和 AGP 7 表现一致,但是在 AGP 8 环境下,即使继承的类不在这个集合中也能被替换成.Class。通过查询 Git 记录找到了这个改动对应的 Commit:

可以看到这里用了开关控制,并新增了另一种判断方式,即计算这些类对应的最低安卓版本,当这个版本大于我们工程中配置的 minSdkVersion 时,就不会对这个类进行替换,否则在低版本设备上运行时会因为找不到这个 Class 对象而崩溃。

R8 混淆

至此,我们已经解开了这个问题的前半部分,知道了为什么这个 forName 方法为什么没有被正常替换成 Class 常量,接下来再分析为什么类名字符串会被替换成混淆后的类名。

我们知道混淆会将除了被 keep 的类方法,成员变量给替换成无意义的简短字符,例如a,b,c,d。在实际执行的过程中其实是分成两步:

  • 将要混淆的目标类及其所有的成员变量,方法的名称都替换成混淆字符,这一步其实主要是在修改常量池中的 Class 对象中的内容。

  • 将所有引用了这个类/成员变量/方法的地方,都替换成混淆后的名称。

混淆当前类自身:

这里我们针对类名做分析,第一步对应的代码在 ClassRenamer 中,这个类主要负责前面的第一步工作,因此定义了一系列重命名的方法,这里我们主要关注给 Class 常量修改的方法:

简单来说就是拿到当前类对应的混淆后类名,并用它向常量池插入一个新的字符串常量,并将其常量池索引赋值给当前类的 Class 常量,实现类名修改。

混淆当前类的引用:

第二步的实现主要在 ClassReferenceFixer 中,参考注释可知这个类负责对所有引用了混淆过的常量池常量、成员变量、方法、类的地方进行同步替换。

我们主要关注字符串常量相关的修改,因此分析 visitStringConstant 这个方法即可:

如果一个字符串常量是作为参数存在于 Class.forname(),Class.getDeclaredFied() 这类方法中,那么他对应的 stringConstant 对象则会持有一个对应的类或者成员变量的引用。

这里就是通过对比当前字符串常量的值和持有的引用指向的 Class 常量中的类名字符串,如果不一致则说明类名已经被混淆,此时会将这个字符串常量的值修改成混淆后的类名。


总结

综上,这个问题的根因就是 AGP8 以下版本中的 R8 对类反射优化的判断方式过于严格,导致我们的类没能被优化成 Class 变量,随后又因为混淆流程的设计,导致了该字符串常量被替换成混淆类名。

虽然这些问题叠加在一起,最终运行的结果依旧是符合我们预期的,但也只能说 R8 的开发团队考虑的足够全面,这类取巧利用 Java 或者安卓特性写的代码在实际开发中务必要在最终的 Release 包上充分测试方才稳妥。



往期回顾


1. 解密得物Trace2.0:日PB级数据量下的计算与存储性能优化实战
2. ES和SSG在得物软广业务上的实践

3. 订单视角看支付|得物技术

4. 大语言模型系列—预训练数据集及其清洗框架|得物技术

5. 得物云原生容器技术探索与落地实践

6. Jedis连接池究竟是何物|得物技术



*文/Jordas

关注得物技术,每周一、三、五更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。

扫码添加小助手微信

如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:

继续滑动看下一个

R8疑难杂症分析实战 - 类反射篇|得物技术

Jordas 得物技术
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存