[译] R8 优化:值假设

615 阅读4分钟

这是我参与更文挑战的第 20 天,活动详情查看: 更文挑战

原文出自 jakewharton 关于 D8R8 系列文章第七篇。

在前两篇文章中,我们介绍了 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 是循环, 0014return,可以看到已经没有条件判断了。

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 版本的情况。AndroidLint 工具将通过 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 的通常是其他库,比如 RecyclerViewFragmentCoordinatorLayout 以及所有版本的 AppCompat

使用 -assumevaluesR8 可以删除优化掉那些不用的方法,可以带来更少的类、更少的方法、更少的字段以及更少的代码。

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 一文,你还记得 D8R8 有一个 --min-api 标记,当 Android Gradle Plugin(AGP) 调用 D8R8 时设置这个标记来指定 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/1117… 中提示可能会出现异常。其他示例可能因应用程序而异或取决于使用的库表现有所差别。

本系列的下一篇文章将介绍一些 R8 应用于常量值的优化。