原文出自 jakewharton 关于 D8
和 R8
系列文章第七篇。
在前两篇文章中,我们介绍了 R8
针对变量的数据流程处理做的一些优化,比如变量是否恒为空或非空,然后进行相关的代码优化,删除无用的判断分支。
R8
的另一种优化是跟踪变量可能为空的使用范围。如果有判断条件恒成立,则那些不用的 dead-code
和多余的判断分支会被清除优化。
在上篇文章结尾的示例代码中,args
变量被 first
调用,然后在打印前进行空检查。
final class Nulls {
public static void main(String[] args) {
System.out.println(first(args));
if (args == null) {
System.out.println("null!");
}
}
public static String first(String[] values) {
if (values == null) throw new NullPointerException("values == null");
return values[0];
}
}
复制代码
上面的示例中 args
参数的取值可能为空或非空。
System.out.println(first(args/* [null, non-null] */));
if (args/* [null, non-null] */ == null) {
System.out.println("null!");
}
复制代码
在这种情况下,R8
是无法针对这种条件做任何优化的,因为 args
参数可能为空或非空。但是,如果在 first
函数中已经检查输入的 args
参数,如果参数为空就抛出异常,那么这种情况下,first
函数后面调用 args
的方法就可以被优化。
System.out.println(first(args/* [null, non-null] */));
if (args/* [non-null] */ == null) {
System.out.println("null!");
}
复制代码
如上所示,args
在检查后一直都是非空的,所以这里的判断条件一直是 false
,所以这段 dead-code
就可以被优化掉。
System.out.println(first(args/* [null, non-null] */));
if (false) {
System.out.println("null!");
}
复制代码
通过 first
函数中的检查后,后面使用到 args
参数的地方不会为空。注意检查 integer
数据是否是正数或负数的判断不会被 R8
进行相关的优化。那么,有没有一种方法可以手动帮助 R8
判断其他类型的范围?
1. Value Assumption(值假设)
R8
使用与 Proguard
相同的配置语法,以简化迁移。不过迁移之后,你可以使用一些 R8
特有的标志。下面我们就简要介绍一个标志:-assumevalues
(值假设)。
使用 -assumevalues
标志可以指定 R8
在处理特定字段或方法时,将字段的取值或方法的返回值假定在一个具体值或某个范围中,这样 R8
就可以针对假设值进行判断,模拟一些恒成立的条件。
class Count {
public static void main(String... args) {
count = 3;
sayHi();
}
private static int count = 1;
private static void sayHi() {
if (count < 0) {
throw new IllegalStateException();
}
for (int i = 0; i < count; i++) {
System.out.println("Hi!");
}
}
}
复制代码
上面的例子中,有一个静态字段 count
来控制 Hi!
的打印次数。我们使用 R8
进行 Compiling(编译)
、 dexing(dex 打包)
,然后查看字节码,发现 count < 0
的判断还是存在。
$ javac *.java
$ cat rules.txt
-keepclasseswithmembers class * {
public static void main(java.lang.String[]);
}
-dontobfuscate
$ java -jar r8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--output . \
--pg-conf rules.txt \
*.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[000148] Count.main:([Ljava/lang/String;)V
0000: const/4 v2, #int 3
0001: sput v2, LCount;.count:I
0003: sget v2, LCount;.count:I
0005: if-ltz v2, 0017
0007: const/4 v2, #int 0
0008: sget v0, LCount;.count:I
000a: if-ge v2, v0, 0016
000c: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;
000e: const-string v1, "Hi!"
0010: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V
0013: add-int/lit8 v2, v2, #int 1
0015: goto 0008
0016: return-void
0017: new-instance v2, Ljava/lang/IllegalStateException;
0019: invoke-direct {v2}, Ljava/lang/IllegalStateException;.<init>:()V
001c: throw v2
复制代码
通过上面的字节码可以看到,R8
优化后将 sayHi
内联在 main
函数中。在 0000-0001
处给 count
赋值为 3
,然后在 0003-0005
处读取 count
的值判断是否小于 0,如果小于 0 则执行 0017
处抛出异常。反之在 0007-0015
处进行循环,其中在 0016
处进行返回。
为了让 R8
去掉 < 0
的判断,需要分析整个程序如何与 count
交互。虽然在这个小例子中我们可以这样做,但在一个真正的程序中,这个任务是非常复杂的。
因为这是我们控制下的应用程序代码,所以我们对 R8
无法推断的计数域有更多的了解。将 -assumevalues
标志添加到 rules.txt
中,给 R8
指定值 count
的期望范围。
-keepclasseswithmembers class * {
public static void main(java.lang.String[]);
}
-dontobfuscate
+-assumevalues class Count {
+ static int count return 0..2147483647;
+}
复制代码
如同判断是否为空的逻辑一样,R8
同样可以判断 count
的范围。
if (count/* [0..2147483647] */ < 0) {
throw new IllegalStateException();
}
for (int i = 0; i < count/* [0..2147483647] */; i++) {
System.out.println("Hi!");
}
复制代码
因为我们指定了 count
的范围,所以它一直都是正数,这样 < 0
的判断就是多余的了,所以会成为 dead-code
进而被优化。
if (false) {
throw new IllegalStateException();
}
复制代码
我们指定 R8
使用新的混淆文件来编译。
[000128] Count.main:([Ljava/lang/String;)V
0000: const/4 v2, #int 3
0001: sput v2, LCount;.count:I
0003: sget v2, LCount;.count:I
0005: const/4 v2, #int 0
0006: sget v0, LCount;.count:I
0008: if-ge v2, v0, 0014
000a: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;
000c: const-string v1, "Hi!"
000e: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V
0011: add-int/lit8 v2, v2, #int 1
0013: goto 0006
0014: return-void
复制代码
上面的字节码中,0000-0001
是赋值, 0005-0013
是循环, 0014
是 return
,可以看到已经没有条件判断了。
2. Side-Effects(副作用)
在上面示例的字节码中,尽管从未实际使用过索引 0003
的值(它下一步被覆盖为 0),但它仍然被读取加载。在前面的几篇文章中,我们知道 R8
会删除无用的代码,比如上面例子中的使用静态成员,但是为什么这里没有被优化呢?
当 R8
基于 -assumevalues
进行优化代码时,它会显式地保持方法或字段对值的读取,因为该字段在方法的调用中可能会有一些其它的影响,如果移除,可能会导致改变函数的功能。一个字段的读取也可能导致一个静态类被加载。如果我们将 -assumevalues
标签修改为 -assumenosideeffects
标签进行编译 0003
处的代码就会被优化掉。
3. Build.VERSION.SDK_INT
作为一名 Android
开发者,我们通常会根据 Build.VERSION.SDK_INT
获取运行设备的版本号,然后改变应用程序或类库的一些功能实现。
if (Build.VERSION.SDK_INT >= 21) {
System.out.println("21+ :-D");
} else if (Build.VERSION.SDK_INT >= 16) {
System.out.println("16+ :-)")
} else {
System.out.println("Pre-16 :-(");
}
复制代码
同样我们使用 -assumevalues
设定预置值,这样 R8
就可以删除无用的检查了。
-assumevalues class android.os.Build$VERSION {
int SDK_INT return 21..2147483647;
}
复制代码
通过设定范围,上面的一些判断条件就是多余的了。
if (Build.VERSION.SDK_INT/* [21..2147483647] */ >= 21) {
System.out.println("21+ :-D");
} else if (Build.VERSION.SDK_INT/* [21..2147483647] */ >= 16) {
System.out.println("16+ :-)")
} else {
System.out.println("Pre-16 :-(");
}
复制代码
根据我们指定的范围,上面的检查中有两个是恒成立的。
if (true) {
System.out.println("21+ :-D");
} else if (true) {
System.out.println("16+ :-)")
} else {
System.out.println("Pre-16 :-(");
}
复制代码
以为第一个判断分支是恒成立的,所以后面的判断分支都是多余的,只剩下第一个判断:
System.out.println("21+ :-D");
复制代码
在我们开发的每天编码的过程中,不存在 API
版本低于 minimum SDK
版本的情况。Android
的 Lint
工具将通过 obsoletesdkint
进行检查(您应该将其设置为 error
!).
这些条件在库中更为普遍,因为它们往往比使用应用程序支持更大的 API
范围,这样可以保证库在所有的 API
版本中使用。
3. AndroidX Core
不管你是否知道,SDK_INT
的判读几乎存在你的 App
所有的地方。因为 AndroidX
(以前叫 compat
库)几乎存在于任何一个 App
中,而它通过 SDK_INT
进行版本检查。AndroidX
支持的最低版本是 API 14
,应该兼容很多 App
了。
// ViewCompat.java
public static boolean hasOnClickListeners(@NonNull View view) {
if (Build.VERSION.SDK_INT >= 15) {
return view.hasOnClickListeners();
}
return false;
}
复制代码
不管 API
你是否使用,它们都有条件判断,通常进行兼容的代码需要更过条件判断。
// ViewCompat.java
public static int getMinimumWidth(@NonNull View view) {
if (Build.VERSION.SDK_INT >= 16) {
return view.getMinimumWidth();
}
if (!sMinWidthFieldFetched) {
try {
sMinWidthField = View.class.getDeclaredField("mMinWidth");
sMinWidthField.setAccessible(true);
} catch (NoSuchFieldException e) { }
sMinWidthFieldFetched = true;
}
if (sMinWidthField != null) {
try {
return (int) sMinWidthField.get(view);
} catch (Exception e) { }
}
return 0;
}
复制代码
尽管很少(如果有的话)应用程序实际上需要 API 16
之前的实现,但在第一个 if
之后的遗留实现仍然位于 Apk
中。甚至一些兼容性实现还需要整个类来支持。
// DrawableCompat.java
public static Drawable wrap(@NonNull Drawable drawable) {
if (Build.VERSION.SDK_INT >= 23) {
return drawable;
} else if (Build.VERSION.SDK_INT >= 21) {
if (!(drawable instanceof TintAwareDrawable)) {
return new WrappedDrawableApi21(drawable);
}
return drawable;
} else {
if (!(drawable instanceof TintAwareDrawable)) {
return new WrappedDrawableApi14(drawable);
}
return drawable;
}
}
复制代码
从上面的例子可以看到,如果 minimum SDK
小于 23,则使用 WrappedDrawableApi21
,如果 minimum SDK
小于 21,则使用 WrappedDrawableApi14
。
在 AndroidX 核心库中每个 API 都有超过 850 个的 SDK_NIT 检查,AndroidX
库中会更多。我们通常会在 App
中使用一些静态助手进行检查,但是使用这些 API
的通常是其他库,比如 RecyclerView
、Fragment
、CoordinatorLayout
以及所有版本的 AppCompat
。
使用 -assumevalues
让 R8
可以删除优化掉那些不用的方法,可以带来更少的类、更少的方法、更少的字段以及更少的代码。
4. Zero-Overhead Abstraction(0 开销抽象)
这几篇文章都是围绕着 R8
对代码优化的影响,当然本文也是围绕 R8
来写。我们介绍了 AndroidX
借助 SDK_INT
进行检查。如果我们设置 minimum SDK
足够高,R8 将会消除 compat
中的条件。
import android.os.Build;
import android.view.View;
class ZeroOverhead {
public static void main(String... args) {
View view = new View(null);
setElevation(view, 8f);
}
public static void setElevation(View view, float elevation) {
if (Build.VERSION.SDK_INT >= 21) {
view.setElevation(elevation);
}
}
}
复制代码
如果上面的代码是在 minimum SDK 21
以及使用 -assumevalues
设定 SDK_INT
的范围,我们可以看到优化后的 setElevation
方法只剩下方法体。
$ javac *.java
$ cat rules.txt
-keepclasseswithmembers class * {
public static void main(java.lang.String[]);
}
-dontobfuscate
-assumevalues class android.os.Build$VERSION {
int SDK_INT return 21..2147483647;
}
$ java -jar r8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--output . \
--pg-conf rules.txt \
*.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[00013c] ZeroOverhead.main:([Ljava/lang/String;)V
0000: new-instance v1, Landroid/view/View;
0002: const/4 v0, #int 0
0003: invoke-direct {v1, v0}, Landroid/view/View;.<init>:(Landroid/content/Context;)V
0006: sget v0, Landroid/os/Build$VERSION;.SDK_INT:I
0008: const/high16 v0, #int 1090519040
000a: invoke-virtual {v1, v0}, Landroid/view/View;.setElevation:(F)V
000d: return-void
复制代码
经过 R8
处理后,静态方法 setElevation
已经在字节码中消失了,在 main
方法中,直接在 000a
处调用了 View.setElevation
方法。
当通过设置 -assumevalues
删除了无用的判断条件后,静态方法 setElevation
就变得很简单达到内联方法的阈值。当额外的方法调用和条件不再起作用时,就可以完全消除这些额外的方法调用和条件所带来的消耗。
5. No Configuration Necessary
如果你读了 the post on VM-specific workarounds 一文,你还记得 D8
和 R8
有一个 --min-api
标记,当 Android Gradle Plugin(AGP)
调用 D8
或 R8
时设置这个标记来指定 minimum SDK
版本。在 R8
1.4.22
版本(对应 AGP 3.4 beta 1
版本)中提供的 Build.VERSION.SDK_INT
规则包含了 --min-api
标记。
-assumevalues public class android.os.Build$VERSION {
public static int SDK_INT return <minApi>..2147483647;
}
复制代码
该工具不必了解这个 R8
特性,也不必用 minimum SDK
版本手动启用它,而是默认启用它,这样每个人都可以获得更小的 Apk
和更好的运行时性能。
6. 总结
为 SDK_INT
定义一个范围是迄今为止最引人注目的值假设演示,现在默认情况下启用该范围对 Apk
有积极的影响。将 view.iseditmode()
标记为 false
可能是另一个有用的默认值,但 issuetracker.google.com/issues/111763015 中提示可能会出现异常。其他示例可能因应用程序而异或取决于使用的库表现有所差别。
本系列的下一篇文章将介绍一些 R8
应用于常量值的优化。
评论