原文出自 jakewharton 关于 D8 和 R8 系列文章第九篇。
在上篇文章中,我们介绍了 D8
和 R8
在编译时期直接对字符串常量的操作。R8
能够做到这一点是因为可以在 IR
层获取字符串常量的内容。
然而,还有另一种对象类型可以在编译时进行操作:classes
(字节码)。classes
是我们在运行时与之交互的实例的模板。由于字节码从根本上存在于保存这些模板中,因此可以在编译时对类执行一些操作。
关于在类中定义标记字符串的最佳方法,有一个正在进行的争论(如果您甚至可以这样称呼它的话)。历史上有两种策略:字符串文本和对类调用 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");
}
}
对上面的代码执行,Compiling
、dexing
然后查看 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
位置后面的操作中将变量 v1
的 MyClass
值进行了重复。
由于 myClass
的名称在编译时已知,R8
已将 myClass.class.getSimpleName()
替换为字符串变量 "myClass"
。因为字段值现在是常量,所以
方法变为空并被删除。在调用位置上,用常量字符串替换了 sget
对象字节码。最后,对引用同一字符串的两个常量字符串字节码进行了重复数据消除,并进行重用。
因此,R8
确保不会进行额外的加载。因为 getSimpleName()
计算很简单,D8
实际上也会执行这种优化!
在 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
。
由于这种优化是在字节码上进行的,因此它必须与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()
。
前一篇文章讨论了如何在编译时执行 string.substring
或字符串串联之类的操作,从而导致 dex
文件的 string
部分的大小增加。本文中讨论的优化也会生成一些不存在的字符串,也可能会变大。
所以有两种场景需要考虑:“什么时候开启混淆?什么时候关闭混淆”。
启用混淆处理时,对 getSimpleName()
的调用不应创建新字符串。类和方法都将使用同一个字典进行混淆处理,默认字典以单个字母开头。这意味着,对于名为 b
的混淆类,插入字符串 “b”
几乎总是免费的,因为将有一个方法或字段的名称也是b。在DEX文件中,所有字符串都存储在一个池中,该池包含文字、类名、方法名和字段名,使模糊时匹配的可能性大于Y高。
但是,在禁用模糊处理的情况下,替换getSimpleName()永远都不是免费的。尽管dex文件有统一的字符串部分,类名还是以类型描述符的形式存储。这包括包名称,使用/作为分隔符,前缀为L,后缀为;。对于myclass,如果在假设的com.example包中,字符串数据包含lcom/example/myclass;的条目。由于这种格式,字符串“myclass”不存在,需要添加。
getName()
和 getCanonicalName()
都会产生新的字符串,都会返回全限定符字符串,而不是考虑存在的限定符。
由于混淆潜在创建了大量的字符串对象,所以它现在除了对顶级类型才可用。在 MyClass
中起作用,但是对于匿名类和内部类无法起作用。同样有研究表明不在一个单独的方法中使用混淆,来避免增加 dex 文件大小。
下篇文章中,我们将讨论 R8 的另一个优化。