原文出自 jakewharton 关于 D8
和 R8
系列文章第十篇。
近来我在 he economics of generated code 文章中讨论了优化自动生成的代码,在人工编写过程中是非常不值得的。虽然文章中的代码是我过去工作中写的,但是也做了一些新的改动。
[Moshi](https://github.com/square/moshi/)
中提议的一项改变是通过 StringBuilder
来进行字符串的拼接。在 JSON
模型中的每个非空属性都会产生一个异常来确保读取的数据非空。
name = stringAdapter.fromJson(reader) ?:
throw JsonDataException(
- "Non-null value 'name' was null at ${reader.path}")
+ StringBuilder("Non-null value '").append("name")
+ .append("' was null at ").append(reader.path).toString())
复制代码
当非空属性缺少默认值且 JSON
中没有值时,会生成第二个异常。
return Person(
name = name ?: throw JsonDataException(
- "Required property 'name' missing at ${reader.path}"),
+ StringBuilder("Required property '").append("name")
+ .append("' missing at ").append(reader.path).toString()),
复制代码
上面的两个情况是进行这个提议的原因。
每个属性都会产生这些异常,这就意味着假如你有 10
个这样的属性会产生 20
个这样的异常,最终会导致创建很多 StringBuilder
对象。
所以给 [Moshi](https://github.com/square/moshi/)
的一个提议是可以通过提取一个包含四个参数的 (prefix, name, suffix, path)
的私有方法来减少字节码的创建量。然而我们不是生成一个方法,因为它最终会由于 R8
优化降低 APK
的大小。让我们找出原因。
1. 典型例子
与直接使用 Moshi
、kapt
和 Kotlin
生成不同,使用具有代表性的示例更容易。首先,我们需要一些 JSON
模型对象。为了要求以上两种 StringBuilder
用法,每个属性都有一个非空类型,并且没有默认值。
data class User(
val id: String,
val username: String,
val displayName: String,
val email: String,
val created: OffsetDateTime,
val isPublic: Boolean
)
data class Tweet(
val id: String,
val userId: String,
val content: String,
val created: OffsetDateTime
)
复制代码
当使用 [Moshi](https://github.com/square/moshi/)
时,这个类型会被声明为 @JsonClass
注解,利用注解处理器生成代码。然后使用 [Moshi](https://github.com/square/moshi/)
中的 JsonReader
处理每个属性。我们可以使用 Android
内置的 JsonReader
手工编写生成的代码。
object TweetParser {
fun fromJson(reader: JsonReader): Tweet {
var id: String? = null
// other properties…
reader.beginObject()
while (reader.peek() != JsonToken.END_OBJECT) {
when (reader.nextName()) {
"id" -> id = reader.nextString() ?:
throw IllegalStateException(
StringBuilder("Non-null value '").append("id")
.append("' was null at").append(reader).toString())
// other properties…
else -> reader.skipValue()
}
}
reader.endObject()
return Tweet(
id = id ?: throw IllegalStateException(
StringBuilder("Required property '").append("id")
.append("' missing at ").append(reader).toString()),
// other properties…
)
}
}
复制代码
在上面的例子中,我们只演示了 Tweet
中的一个属性,你可以使用同样的方式添加别的属性。如果我们使用 kotlinc
、D8
进行编译打包,通过 dexdump
查看打包后的文件,可以看到 StringBuilder
被多次重复利用。
0181: new-instance v1, Ljava/lang/IllegalStateException;
0183: new-instance v4, Ljava/lang/StringBuilder;
0185: invoke-direct {v4, v3}, Ljava/lang/StringBuilder;.<init>:(Ljava/lang/String;)V
0188: invoke-virtual {v4, v12}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
018b: invoke-virtual {v4, v2}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
018e: invoke-virtual {v4, v0}, Ljava/lang/StringBuilder;.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
0191: invoke-virtual {v4}, Ljava/lang/StringBuilder;.toString:()Ljava/lang/String;
0194: move-result-object v0
0195: invoke-direct {v1, v0}, Ljava/lang/IllegalStateException;.<init>:(Ljava/lang/String;)V
0198: check-cast v1, Ljava/lang/Throwable;
019a: throw v1
复制代码
通过这种方式比生成一个单独的字符串常量轻量级很多,但这仍然是一种浪费。在每种解析器类型中使用此代码生成一个方法将减少其影响。那我们为什么不选择呢?
2. Outlining
在本系列的多数文章中,我们提到使用 inlining
来进行优化。这种优化是当一个方法足够小并且不被频繁调用的时候,可以将该方法的实现直接放到调用处的方法中并且在字节码中删除该方法。Outlining
是一种相反的优化,在这种优化中,公共字节码序列被识别并提取到共享方法中。
在使用 R8
编译前,我们先添加一个 main
方法。
fun main() {
println(TweetParser.fromJson(JsonReader(StringReader(""))))
println(UserParser.fromJson(JsonReader(StringReader(""))))
}
复制代码
我们不运行上面的代码,所以我们用 ""
来模拟数据,只需要关心 R8
优化的结果。
$ cat rules.txt
-keepclasseswithmembers class * {
public static void main(...);
}
-dontobfuscate
$ java -jar r8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--output . \
--pg-conf rules.txt \
*.class
复制代码
与D8
生成的代码相比,R8
的输出显示了异常代码非常不同的画面。
0181: new-instance v1, Ljava/lang/IllegalStateException;
-0183: new-instance v4, Ljava/lang/StringBuilder;
-0185: invoke-direct {v4, v3}, Ljava/lang/StringBuilder;.<init>:(Ljava/lang/String;)V
-0188: invoke-virtual {v4, v12}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
-018b: invoke-virtual {v4, v2}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
-018e: invoke-virtual {v4, v0}, Ljava/lang/StringBuilder;.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
-0191: invoke-virtual {v4}, Ljava/lang/StringBuilder;.toString:()Ljava/lang/String;
+0183: invoke-static {v3, v12, v2, v0}, Lcom/android/tools/r8/GeneratedOutlineSupport;.outline0:(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/String;
0194: move-result-object v0
0195: invoke-direct {v1, v0}, Ljava/lang/IllegalStateException;.<init>:(Ljava/lang/String;)V
-0198: check-cast v1, Ljava/lang/Throwable;
019a: throw v1
复制代码
outlining
的优化中是被到 StringBuilder
被重复多次利用,字节码片段被放到 com.android.tools.r8.GeneratedOutlineSupport
类中的 outline0
方法中,所以出现这个字节码的片段都被新提取的方法替代。我们看看新方法生成的字节码。
[000eb4] com.android.tools.r8.GeneratedOutlineSupport.outline0:(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/String;
0000: new-instance v0, Ljava/lang/StringBuilder
0002: invoke-direct {v0, v1}, Ljava/lang/StringBuilder;.<init>:(Ljava/lang/String;)V
0005: invoke-virtual {v0, v2}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder
0008: invoke-virtual {v0, v3}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder
000b: invoke-virtual {v0, v4}, Ljava/lang/StringBuilder;.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder
000e: invoke-virtual {v0}, Ljava/lang/StringBuilder;.toString:()Ljava/lang/String
0011: move-result-object v1
0012: return-object v1
复制代码
R8
已经创建了我们正在考虑添加自己的助手方法!我在示例中特别选择使用两种类型,它们一起具有 10
个属性,从而产生 20
个 StringBuilder
。这是 R8
将 Outlining
操作优化的下限要求。重复的字节码也必须在 3 - 99
个字节之间。
如果 moshi 生成一个私有的 StringBuidler 帮助器方法,那么我们的示例仍然有两个副本。在 R8 介入并消除 helper 方法的重复之前,您需要 20 个 JSON 模型对象。通过选择复制 StringBuilder 代码,在 R8 大纲开始之前,任何数量的 JSON 模型对象中只需要 20 个属性。一旦发生这种情况,不管使用多少 JSON 模型对象和属性,我们只需支付一次代码。
3. 总结
Outlining
对于重复生成的代码非常有效。在上面的例子中,您可以避免将一个助手函数放在运行时库中,而当它重复足够多时,您可以依靠 R8
来消除重复的字节码。而且,由于 R8
正在进行整个程序分析,不相关的代码(恰好具有相同的字节码模式)参与了重复数据消除。
考虑到它如何与 Kotlin
的内联函数修饰符交互也很有趣。使用内联函数越多(尤其是在其他内联函数内部调用内联函数),R8
就越有可能将一些函数体重新概括为常规方法。确保您正在使用 inline
来处理类似于已具体化的泛型的事情,或者避免按预期分配 lambda
对象。
在上一篇关于 R8
的文章中,我取笑了下一篇文章(又称本篇文章)将涉及创建 const
类字节码的优化。在写了本系列以外的关于生成代码的两篇文章并讨论了 moshi 的变化之后,然而,对于包含大纲的内容来说,这是一个自然的进展。在概述的方式之外,下一个 R8 帖子将回到生产 const 类字节码的轨道上。
评论