Saturday, October 5, 2019

Try-with-resources desugaring support in Android's build tools

During one of the code reviews with my colleague I've noticed he used try-with-resources Java 7's feature. To my then latest knowledge, try-with-resources required minSdkVersion 19 (while our app's minSdkVersion is 16) - which is based on the following statement (see under SDK Tools, Revision 22.6 (March 2014)):
Added support for Java 7 language features like multi-catch, try-with-resources, and the diamond operator. These features require version 19 or higher of the Build Tools. Try-with-resources requires minSdkVersion 19; the rest of the new language features require minSdkVersion 8 or higher.
My colleague told me that neither Lint nor compiler haven't warned about that, which was strange - since I vividly remember they both did in the past. Bug in Lint could explain that, but how did it compile? We decided to review our assumptions.

Quick Google search yielded this result:
In addition to the Java 8 language features and APIs above, Android Studio 3.0 and later extends support for try-with-resources to all Android API levels.
We've somehow overlooked this announcement.

So it seems that latest build chain supports try-with-resources feature and can be safely used. However, I'm curious how exactly this support has been added - since as we all know try-with-resources implementation relies on Throwable#addSuppressed(java.lang.Throwable) method added in Java 7 (which in Android world means minSdkVersion 19).
The reasoning for Throwable#addSuppressed API requirement is explained here:
An exception from the closing of one resource does not prevent the closing of other resources. Such an exception is suppressed if an exception was thrown previously by an initializer, the try block, or the closing of a resource.
I won't go into detail for how it is being used, you can find great explanations and examples here and here.

The Android's build tools support was added in both dx and d8 by desugaring try-with-resources Java 7 bytecode. The desugaring itself was implemented slightly differently in these two tools, I'll review both of them below.

I've created a sample code that uses try-with-resources feature in the following way:

It just opens a file for writing using FileOutputStream subclass, writes the string "hello" and closes it. If an exception thrown, it will be caught and printed. On device running API 19 or later, it will try to get the suppressed exception and print it as well.

MyFileOutputStream class was added just to unconditionally throw exception in overridden close() method - so try-with-resources mechanism will generate a suppressed exception.

In order to understand how desugaring was implemented in build tools, I'll use each of them to compile the sample project with both minSdkVersion 18 and 19, and then compare the generated bytecode.

The sample project can be found in this repository. It contains branches for each [d8/dx, minSdk18/minSdk19] permutation where I've also included the (compiler-generated) smali bytecode. For example, app-debug-smali folder in d8-minsdk18-java8 branch shows the bytecode generated by d8 with minSdkVersion set to 18.

d8's implementation

To see the minsdk19/minsdk18 differences, I've used the following command:
$ git diff d8-minsdk19-java8..d8-minsdk18-java8
The main changes are:
When compiled with minSdkVersion 18, the Throwable#addSuppressed call was being removed from Sample#tryWithResourcesSample method body:
-    move-exception v3
-    invoke-virtual {v1, v3}, Ljava/lang/Throwable;->addSuppressed(Ljava/lang/Throwable;)V

+    move-exception v2
Additional significant change happened in Sample#getSuppressedOrEmpty method body - instead of invoking Throwable#getSuppressed API, the bytecode just creates an empty array:
     .line 36
-    invoke-virtual {p0}, Ljava/lang/Exception;->getSuppressed()[Ljava/lang/Throwable;
-    move-result-object v0

+    new-array v0, v1, [Ljava/lang/Throwable;
As you can see, d8's implementation either patches the generated calls to Java 7-introduced methods with effectively no-op instructions, or removes them entirely.

dx's implementation

dx takes another route. Instead of statically patching the generated code, it delegates the decision to a runtime. If device's SDK level is below 19 - same no-op logic will be performed (as done in d8), otherwise Throwable#addSuppressed and Throwable#getSuppressed methods will be invoked.

Again, I'll use the following command to find what's changing when we downgrade minSdkVersion to 18:
$ git diff dx-minsdk19-java8..dx-minsdk18-java8
Sample#tryWithResourcesSample had a single instruction change:
-    invoke-virtual {v3, v1}, Ljava/lang/Throwable;->addSuppressed(Ljava/lang/Throwable;)V
+    invoke-static {v3, v1}, Lcom/google/devtools/build/android/desugar/runtime/ThrowableExtension;->addSuppressed(Ljava/lang/Throwable;Ljava/lang/Throwable;)V
Another change was in Sample#getSuppressedOrEmpty method:
-    invoke-virtual {p0}, Ljava/lang/Exception;->getSuppressed()[Ljava/lang/Throwable;
+    invoke-static {p0}, Lcom/google/devtools/build/android/desugar/runtime/ThrowableExtension;->getSuppressed(Ljava/lang/Throwable;)[Ljava/lang/Throwable;
Both changes replace invocation of methods that were introduced in Java 7 (minSdkVersion 19) with static methods having exact same names, which implemented in ThrowableExtension third-party class.

As you might have guessed, the ThrowableExtension class (along with all its 6 static nested classes) is being injected into your application's dex. The runtime decision is being performed in ThrowableExtension's static constructor:

ReuseDesugaringStrategy just proxies the calls to framework's Throwable#addSuppressed and Throwable#getSuppressed methods; NullDesugaringStrategy is a no-op strategy.
You can also see that ThrowableExtension#addSuppressed and ThrowableExtension#getSuppressed static methods invoke the methods on a selected strategy's instance.

To summarize

Support for try-with-resources feature across all API levels is great news for those of you who still using Java for writing/maintaining Android apps. d8 and dx tools implement the desugaring in slightly different way. If you don't use the Throwable#addSuppressed/Throwable#getSuppressed APIs - you shouldn't care. If you do rely on them - you should know that with d8 they won't work as you'd expect when minSdkVersion of your app is less than 19.

1 comment:

  1. Hell of a writing!

    It's a bit surprising for me that d8, since it's newer then dx, was the one to use no-op for getSuppressed when minSdkVersion under 19 - I would have expected the opposite, that dx does not take care of getSuppressed and d8 adds that implementation.