写点什么

[译] R8 优化:类常量操作

用户头像
Antway
关注
发布于: 刚刚

原文出自 jakewharton 关于 D8 和 R8 系列文章第九篇。



在上篇文章中,我们介绍了 D8R8 在编译时期直接对字符串常量的操作。R8 能够做到这一点是因为可以在 IR 层获取字符串常量的内容。


然而,还有另一种对象类型可以在编译时进行操作:classes(字节码)。classes 是我们在运行时与之交互的实例的模板。由于字节码从根本上存在于保存这些模板中,因此可以在编译时对类执行一些操作。

1. Log Tags(日志标签)

关于在类中定义标记字符串的最佳方法,有一个正在进行的争论(如果您甚至可以这样称呼它的话)。历史上有两种策略:字符串文本和对类调用 getSimpleName()


private static final String TAG = "MyClass";// orprivate static final String TAG = MyClass.class.getSimpleName();
复制代码


究竟孰好孰坏,让我们写个例子测试下。


class MyClass {  private static final String TAG_STRING = "MyClass";  private static final String TAG_CLASS = MyClass.class.getSimpleName();
public static void main(String... args) { Log.d(TAG_STRING, "String tag"); Log.d(TAG_CLASS, "Class tag"); }}
复制代码


对上面的代码执行,Compilingdexing 然后查看 Dalvik 字节码。


[000194] MyClass.<clinit>:()V0000: const-class v0, LMyClass;0002: invoke-virtual {v0}, Ljava/lang/Class;.getSimpleName:()Ljava/lang/String;0005: move-result-object v00006: sput-object v0, LMyClass;.TAG_CLASS:Ljava/lang/String;0008: return-void
[000120] MyClass.main:([Ljava/lang/String;)V0000: const-string v1, "MyClass"0002: const-string v0, "String tag"0004: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I0007: sget-object v1, LMyClass;.a:Ljava/lang/String;0009: const-string v0, "Class tag"000b: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I000e: return-void
复制代码


main 函数中,0000 位置处加载 tag 的字符串常量,在 0007 处,查找该静态字段并读取值。在 <clinit> 方法中,静态字段是通过加载 MyClass 类然后在运行时调用 getSimpleName 方法获取。这个方法在类第一次加载的时候调用。


可以看到使用字符串常量效率更高,但使用 Class.getSimpleName() 对于重构之类需求更灵活。我们同样使用 R8 进行编译。


[000120] MyClass.main:([Ljava/lang/String;)V0000: const-string v1, "MyClass"0002: const-string v0, "String tag"0004: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I0007: const-string v0, "Class tag"0009: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I000c: return-void
复制代码


可以看到在 0004 位置后面的操作中将变量 v1MyClass 值进行了重复。


由于 myClass 的名称在编译时已知,R8 已将 myClass.class.getSimpleName() 替换为字符串变量 "myClass"。因为字段值现在是常量,所以 <clinit> 方法变为空并被删除。在调用位置上,用常量字符串替换了 sget 对象字节码。最后,对引用同一字符串的两个常量字符串字节码进行了重复数据消除,并进行重用。


因此,R8 确保不会进行额外的加载。因为 getSimpleName() 计算很简单,D8 实际上也会执行这种优化!

2. Applicability(拓展)

MyClass.class 上能够获取 getSimpleName()(以及 getName()getCanonicalName()),这种方式的用途似乎有限——甚至可能仅限于此日志标记案例。优化只适用于类文本引用– getClass() 不起作用!再次结合其他 R8 特性,这种优化开始应用得更多。


我们来看下面的一个示例:


class Logger {  static Logger get(Class<?> cls) {    return new Logger(cls.getSimpleName());  }  private Logger(String tag) { /* … */ } }
class MyClass { private static final Logger logger = Logger.get(MyClass.class);}
复制代码


如果 Logger.get 内嵌在所有调用处,则对以前具有方法参数动态输入的 class.getSimpleName 的调用将更改为类引用的静态输入(在本例中为 myClass.class)。R8 现在可以用字符串文字替换调用,从而产生直接调用构造函数的字段初始值设定项(也将删除其私有修饰符)。


class MyClass {  private static final Logger logger = new Logger("MyClass");}
复制代码


这依赖于 get 方法足够小或者满足 R8 的内联调用方式。


Kotlin 语言提供了强制函数内联的能力。它还允许将内联函数上的泛型类型参数标记为 reified,从而确保编译器知道在编译时解析为哪个类。使用这些特性,我们可以确保我们的函数始终是内联的,并且总是在显式类引用上调用 getSimpleName


class Logger private constructor(val tag: String) { }inline fun <reified T : Any> logger() = Logger(T::class.java.simpleName)
class MyClass { companion object { private val logger = logger<MyClass>() }}
复制代码


logger 函数的初始值将始终具有与 myClass.Class.GetSimpleName() 等效的字节码,然后 R8 可以替换为字符串常量。


对于其他 Kotlin 示例,类型推断通常允许省略显式类型参数。


inline fun <reified T> typeAndValue(value: T) = "${T::class.java.name}: $value"fun main() {  println(typeAndValue("hey"))}
复制代码


上面示例输出结果为:“java.lang.String: hey”,同时编译后的字节码中只有两个字符串常量,并且用 StringBuilder 连接,然后调用 System.out.println 输出,如果这个问题被解决,你会发现只有一个字符串常量调用 System.out.println

3. 混淆和优化

由于这种优化是在字节码上进行的,因此它必须与R8 的其他功能交互,这些功能可能会影响类,如 Obfuscation(混淆) 和 Optimization(优化)


让我们回到原来的例子。


class MyClass {  private static final String TAG_STRING = "MyClass";  private static final String TAG_CLASS = MyClass.class.getSimpleName();
public static void main(String... args) { Log.d(TAG_STRING, "String tag"); Log.d(TAG_CLASS, "Class tag"); }}
复制代码


如果这个类被混淆了会发生什么?如果 R8 没有替换 getSimpleName 的调用,第一条日志消息将有一个 myclass 标记,第二条日志消息将有一个与模糊类名(如“a”)匹配的标记。


为了允许 R8 替换 getSimpleName,需要使用一个与运行时行为匹配的值。值得庆幸的是,由于 R8 也是执行混淆处理的工具,所以它可以直到类被赋予其最终名称时才进行替换。


[000158] a.main:([Ljava/lang/String;)V0000: const-string v1, "MyClass"0002: const-string v0, "String tag"0004: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I0007: const-string v1, "a"0009: const-string v0, "Class tag"000b: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I000e: return-void
复制代码


请注意 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 的另一个优化。

用户头像

Antway

关注

持续精进,尽管很慢 2019.05.27 加入

专注开源库

评论

发布
暂无评论
[译] R8 优化:类常量操作