R8 优化:字节码常量操作

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

  • 原文链接 : R8 Optimization: Class Constant Operations
  • 原文作者 : jakewharton
  • 译者 : 小伟

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

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

1. Log Tags(日志标签)

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

private static final String TAG = "MyClass";
// or
private 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>:()V
0000: const-class v0, LMyClass;
0002: invoke-virtual {v0}, Ljava/lang/Class;.getSimpleName:()Ljava/lang/String;
0005: move-result-object v0
0006: sput-object v0, LMyClass;.TAG_CLASS:Ljava/lang/String;
0008: return-void

[000120] MyClass.main:([Ljava/lang/String;)V
0000: 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;)I
0007: 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;)I
000e: return-void

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

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

[000120] MyClass.main:([Ljava/lang/String;)V
0000: 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;)I
0007: const-string v0, "Class tag"
0009: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
000c: return-void

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

由于 myClass 的名称在编译时已知,R8 已将 myClass.class.getSimpleName() 替换为字符串变量 "myClass"。因为字段值现在是常量,所以 方法变为空并被删除。在调用位置上,用常量字符串替换了 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;)V
0000: 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;)I
0007: 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;)I
000e: 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 的另一个优化。

你可能感兴趣的:(Android进阶)