[译] R8 优化:类常量操作
原文出自 jakewharton 关于 D8 和 R8 系列文章第九篇。
原文作者 : jakewharton
译者 : Antway
在上篇文章中,我们介绍了 D8 和 R8 在编译时期直接对字符串常量的操作。R8 能够做到这一点是因为可以在 IR 层获取字符串常量的内容。
然而,还有另一种对象类型可以在编译时进行操作:classes(字节码)。classes 是我们在运行时与之交互的实例的模板。由于字节码从根本上存在于保存这些模板中,因此可以在编译时对类执行一些操作。
1. Log Tags(日志标签)
关于在类中定义标记字符串的最佳方法,有一个正在进行的争论(如果您甚至可以这样称呼它的话)。历史上有两种策略:字符串文本和对类调用 getSimpleName()。
究竟孰好孰坏,让我们写个例子测试下。
对上面的代码执行,Compiling、dexing 然后查看 Dalvik 字节码。
在 main 函数中,0000 位置处加载 tag 的字符串常量,在 0007 处,查找该静态字段并读取值。在 <clinit> 方法中,静态字段是通过加载 MyClass 类然后在运行时调用 getSimpleName 方法获取。这个方法在类第一次加载的时候调用。
可以看到使用字符串常量效率更高,但使用 Class.getSimpleName() 对于重构之类需求更灵活。我们同样使用 R8 进行编译。
可以看到在 0004 位置后面的操作中将变量 v1 的 MyClass 值进行了重复。
由于 myClass 的名称在编译时已知,R8 已将 myClass.class.getSimpleName() 替换为字符串变量 "myClass"。因为字段值现在是常量,所以 <clinit> 方法变为空并被删除。在调用位置上,用常量字符串替换了 sget 对象字节码。最后,对引用同一字符串的两个常量字符串字节码进行了重复数据消除,并进行重用。
因此,R8 确保不会进行额外的加载。因为 getSimpleName() 计算很简单,D8 实际上也会执行这种优化!
2. Applicability(拓展)
在 MyClass.class 上能够获取 getSimpleName()(以及 getName() 和 getCanonicalName()),这种方式的用途似乎有限——甚至可能仅限于此日志标记案例。优化只适用于类文本引用– getClass() 不起作用!再次结合其他 R8 特性,这种优化开始应用得更多。
我们来看下面的一个示例:
如果 Logger.get 内嵌在所有调用处,则对以前具有方法参数动态输入的 class.getSimpleName 的调用将更改为类引用的静态输入(在本例中为 myClass.class)。R8 现在可以用字符串文字替换调用,从而产生直接调用构造函数的字段初始值设定项(也将删除其私有修饰符)。
这依赖于 get 方法足够小或者满足 R8 的内联调用方式。
Kotlin 语言提供了强制函数内联的能力。它还允许将内联函数上的泛型类型参数标记为 reified,从而确保编译器知道在编译时解析为哪个类。使用这些特性,我们可以确保我们的函数始终是内联的,并且总是在显式类引用上调用 getSimpleName。
logger 函数的初始值将始终具有与 myClass.Class.GetSimpleName() 等效的字节码,然后 R8 可以替换为字符串常量。
对于其他 Kotlin 示例,类型推断通常允许省略显式类型参数。
上面示例输出结果为:“java.lang.String: hey”,同时编译后的字节码中只有两个字符串常量,并且用 StringBuilder 连接,然后调用 System.out.println 输出,如果这个问题被解决,你会发现只有一个字符串常量调用 System.out.println。
3. 混淆和优化
由于这种优化是在字节码上进行的,因此它必须与R8 的其他功能交互,这些功能可能会影响类,如 Obfuscation(混淆) 和 Optimization(优化)。
让我们回到原来的例子。
如果这个类被混淆了会发生什么?如果 R8 没有替换 getSimpleName 的调用,第一条日志消息将有一个 myclass 标记,第二条日志消息将有一个与模糊类名(如“a”)匹配的标记。
为了允许 R8 替换 getSimpleName,需要使用一个与运行时行为匹配的值。值得庆幸的是,由于 R8 也是执行混淆处理的工具,所以它可以直到类被赋予其最终名称时才进行替换。
请注意 0007 现在将如何为第二个日志调用加载标记值(与原始 R8 输出不同),以及它如何正确反映混淆名称。
即使禁用了混淆,R8 还有其它优化会影响类名。虽然我打算在以后的文章中介绍它,如果 R8 能够证明不需要超类,并且子类是唯一的, 有时 R8 会将一个超类合并成一个子类。发生这种情况时,类名字符串优化将正确反映子类型名称,即使原始代码等效于 superType.class.getSimpleName()。
3. String Data Section
前一篇文章讨论了如何在编译时执行 string.substring 或字符串串联之类的操作,从而导致 dex 文件的 string 部分的大小增加。本文中讨论的优化也会生成一些不存在的字符串,也可能会变大。
所以有两种场景需要考虑:“什么时候开启混淆?什么时候关闭混淆”。
启用混淆处理时,对 getSimpleName() 的调用不应创建新字符串。类和方法都将使用同一个字典进行混淆处理,默认字典以单个字母开头。这意味着,对于名为 b 的混淆类,插入字符串 “b” 几乎总是免费的,因为将有一个方法或字段的名称也是 b。在 DEX 文件中,所有字符串都存储在一个池中,该池包含文字、类名、方法名和字段名,使模糊时匹配的可能性大于 Y 高。
但是,在禁用模糊处理的情况下,替换 getSimpleName()永远都不是免费的。尽管 dex 文件有统一的字符串部分,类名还是以类型描述符的形式存储。这包括包名称,使用/作为分隔符,前缀为 L,后缀为;。对于 myclass,如果在假设的 com.example 包中,字符串数据包含 lcom/example/myclass;的条目。由于这种格式,字符串“myclass”不存在,需要添加。
getName() 和 getCanonicalName() 都会产生新的字符串,都会返回全限定符字符串,而不是考虑存在的限定符。
由于混淆潜在创建了大量的字符串对象,所以它现在除了对顶级类型才可用。在 MyClass 中起作用,但是对于匿名类和内部类无法起作用。同样有研究表明不在一个单独的方法中使用混淆,来避免增加 dex 文件大小。
4. 总结
下篇文章中,我们将讨论 R8 的另一个优化。











评论