我在用 Kotlin,但是我用的库用了 Java 8+ API

在浏览 readium kotlin-toolkit 项目时注意到 README 里面提了一句:

If you target Android devices running below API 26, you must enable core library desugaring in your application module.

脱糖(desugaring) 是 Android 编程中支持在旧设备上使用新的 Java API 的方式。README 中提到的 API 26 对应的是 Android 8,先看 Android 8 Updated Java language support

Android 8.0 (API level 26) adds support for several additional OpenJDK Java APIs:

  • java.time from OpenJDK 8.
  • java.nio.file and java.lang.invoke from OpenJDK 7.

根据以上信息在代码里搜索,可知代码使用了 java.nio.file 包下的类:

1
2
3
4
5
6
7
8
readium\shared\src\main\java\org\readium\r2\shared\util\zip\compress\utils\MultiReadOnlySeekableByteChannel.java
27:import java.nio.file.Path;

readium\shared\src\main\java\org\readium\r2\shared\util\zip\compress\archivers\zip\ZipSplitReadOnlySeekableByteChannel.java
29:import java.nio.file.Path;

readium\shared\src\main\java\org\readium\r2\shared\util\zip\compress\archivers\zip\ZipArchiveEntry.java
31:import java.nio.file.attribute.FileTime;

字节码探究

找到 org.readium.kotlin-toolkit:readium-shared:3.0.3class.jar 文件,注意到 readium-shared-3.0.3\classes\org\readium\r2\shared\util\zip\compress\utils\MultiReadOnlySeekableByteChannel.class 文件的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
Constant pool:
...
#141 = Class #142 // java/nio/file/Path
#142 = Utf8 java/nio/file/Path
...
static {};
descriptor: ()V
flags: (0x0008) ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: iconst_0
1: anewarray #141 // class java/nio/file/Path
4: putstatic #143 // Field EMPTY_PATH_ARRAY:[Ljava/nio/file/Path;
7: return
LineNumberTable:
line 46: 0
...

字节码中指示程序分配一个 java.nio.file.Path 的数组,这要求系统上存在 java.nio.file.Path 类。

Desugaring

虽然库提供的 jar 内没有看到明显的脱糖内容(从下文的脱糖流程图中可以得到解释,因 Android 脱糖的步骤在于从原始的 .class 文件生成脱糖 .dex 文件),但 AAR 内的 aar-metadata.properties 内写明了相关信息:

1
2
3
4
5
6
7
aarFormatVersion=1.0
aarMetadataVersion=1.0
minCompileSdk=1
minCompileSdkExtension=0
minAndroidGradlePluginVersion=1.0.0
coreLibraryDesugaringEnabled=true
desugarJdkLib=com.android.tools:desugar_jdk_libs:2.0.4

不开启 isCoreLibraryDesugaringEnabled 则不能通过 AAR metadata 检查,而要通过编译还需要增加脱糖依赖 coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4")

Desugaring 的流程如下:

gradlew assembleRelease 后可以发现 apk 中有一个 j$ 包下包含了大量脱糖代码,这些代码原本是在 java 这个父包下,因此 java.nio.file.Path 现在在 apk 内就是 j$.nio.file.Path。注意到,对应的 MultiReadOnlySeekableByteChannel 下的包引入也产生了变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package org.readium.r2.shared.util.zip.compress.utils;

import j$.nio.file.Path; // this!
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import org.readium.r2.shared.util.zip.jvm.ClosedChannelException;
import org.readium.r2.shared.util.zip.jvm.NonWritableChannelException;
import org.readium.r2.shared.util.zip.jvm.SeekableByteChannel;

/* loaded from: classes6.dex */
public class MultiReadOnlySeekableByteChannel implements SeekableByteChannel {
// ...
}

这是可以理解的:这个包名是一个与系统上其他任何包都不一样的名字,这样做可以保证当新系统升级后提供了对应的原始类支持时,j$ 包名下的脱糖类和原类之间不会产生同名的冲突。

结语

我开发 App 都在用 Kotlin,目前并不直接使用 Java 8+ 的 API(用也是 Kotlin 封好了的,或者 SDK 在更低),但是用到的库可能用的是 Java 并用到了 Java 8+ API(虽然 Kotlin 自己的版本问题也会引来混乱(经典的如 kotlin-dsl 自带 kotlin))。但不管怎么样,明白了脱糖的基本运作方式,下一次碰到相关问题就有能力解决了。

参考

Jake Wharton - D8 Library Desugaring

闲话:不脱糖的后果

1
2
3
4
5
6
7
8
9
Process: me.galiren.desugartest, PID: 6422
java.lang.NoClassDefFoundError: Failed resolution of: [Ljava/nio/file/Path;
at org.readium.r2.shared.util.zip.compress.utils.MultiReadOnlySeekableByteChannel.<clinit>(MultiReadOnlySeekableByteChannel.java:46)
at me.galiren.desugartest.MainActivity.onCreate(MainActivity.kt:21)
...
Caused by: java.lang.ClassNotFoundException: Didn't find class "java.nio.file.Path" on path: DexPathList[[zip file "/data/app/me.galiren.desugartest-1/base.apk"],nativeLibraryDirectories=[/data/app/me.galiren.desugartest-1/lib/x86, /system/lib, /vendor/lib]]
at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:56)
at java.lang.ClassLoader.loadClass(ClassLoader.java:380)
at java.lang.ClassLoader.loadClass(ClassLoader.java:312)