Android 中 Enum 和 When 的隐藏开销

2,138 阅读2分钟

去年我写过一篇文章《Android 中不应该使用 Enum 吗?》,如果你没有看过这篇文章,我可以简单为你介绍一下,在这篇文章中,我向大家说明了,“Android 中不应该使用 Enum ” 这句话的历史原因,以及在现阶段,我们到底可不可以使用 Enum,以及在 Kotlin 中的替代方案。

今天我们继续来聊聊 Enum,并且说说当 Enum 和 When 一起使用时,为什么会有隐藏开销,以及如何避免这种隐藏开销。

在这里插入图片描述
这是一段非常简单和常见的代码,给你三秒中思考一下,并如实回答以下问题:你觉得这段代码存在隐藏开销吗?
在这里插入图片描述
在这里插入图片描述
这是反编译后的代码,我们可以看到当我们使用 when 来做判断的时候,编译器为我们生成了一个类 MainActivityWhenMappings,MainActivityWhenMappings 中声明了和 Enum 长度相等的 Int 数组 $EnumSwitchMapping0,EnumSwitchMapping$0 数组中以 Enum 的 ordinal 为索引,按顺序存放这整数。

那么 Enum 中的 ordinal 表示什么?

在这里插入图片描述
根据文档的定义,ordinal 表示每一个值在 Enum 中定义的位置,且 ordinal 从 0 开始。看到这里我们应该就明白了编译器是如何处理这段代码的了,它将 Enum 中的值与生成的数组 $EnumSwitchMapping$0 做了一个映射,以此来实现 when 逻辑的判断。

那么开销在哪里?

在这里插入图片描述
当我在另一个类 MainFragment 中,需要做 Enum 逻辑判断的代码,与第一段 MainActivity 中的代码完全一致,但是在反编译这个文件后,发现了不一样的地方。
在这里插入图片描述
编译器为我们生成了新的 MainFragment$WhenMappings 以此来支持在 MainFragment 中的 when 逻辑判断。

也就是说,只要我们在某一个地方使用 Enum 和 when 的时候,编译器都会为我们生成一个新的类 XXX$WhenMappings 来辅助实现 when 逻辑的处理。

如果你有看过《深入理解 Java 虚拟机》这本书的话,在第七章虚拟机类加载机制中有介绍:

在 Java 语言里面,类型的加载、链接和初始化过程都是在程序运行期间完成的。

同时还有

如果类没有进行过初始化,则需要先触发其初始化。

至此我们终于发现来 Enum 和 When 的隐藏开销在哪里:

如果你在许多地方都有 Enum 和 When 配合使用,那么编译器会为我们生成无数的 XXXWhenMappings  类以及无数的EnumSwitchMapping$0 数组,在运行时执行这些代码的时候,会因为每一个类的加载和实例化增加时间开销,当然由于增加了新的类,同样也会增加最终的到的二进制文件的大小。

那么造成这样的原因是为什么?

我们先来看看 XXXWhenMappings 的作用,以下是我通过 JavaP -C 获取到的 MainActivity onCreate 的字节码指令。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20200318093355819.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTM0NDQ4Mzk=,size_16,color_FFFFFF,t_70)
从这段字节码中,我们可以发现,在第 27 行,找到了EnumSwitchMapping0 的静态数组,并为其进行了赋值(赋值的过程在 MainFragmentWhenMappings 的初始化方法里,这里就不贴了),然后做了比较操作。

所以可以看出 XXXWhenMappings 在这里起到了一个临时变量的作用,我们的 when 比较操作,并不是直接那当前变量与原始 Enum 做比较,而是与 Enum 做映射的 XXXWhenMappings 来做比较。

在查资料时,我找到到 jakewharton 的一篇文章中写着这样一句话

The switch map indirection created by javac is useful when the enum may be recompiled separately from the callers.

即当 Enum 和其被调用方可以分别编译时,javac 创建的这个临时变量是非常有用的。也就是说,当我们用 javac 编译 Enum 和 MainActivity 这两个文件时,编译器会将此时 Enum 的值存入一个临时变量中,并保存在 MainActivity 的调用堆栈中,当你如果单独修改了 Enum 类,并只编译了 Enum 时,MainActivity 的堆栈中仍然保持的是先前 Enum 的值。

这在我看来是一个出于安全性的考虑。

这里我就要把 jakewharton 说的另一句话分享给大家:

Android applications are packaged as a single unit, so the indirection is nothing but wasted binary size and runtime overhead.

意思是 Android 是整体编译的,根本不会存在上面说的分别编译的问题,所以编译器引入的这个临时变量完全是多此一举,只会造成二进制文件的增大和运行时的开销。

至此我们就知道了造成这个开销的原因,原来是编译器的锅,那么如何避免这个开销呢?

那就是使用 minifyEnabled true 开启混淆

当我们开启混淆的时候 R8 编译器会为我们移除这段不必要的临时变量,下图是开启混淆后的字节码堆栈。

在这里插入图片描述
可以看到 XXX$WhenMappings 这个临时变量不见了,这样就消除了这个由编译器产生的隐藏开销。

但是需要注意的是,如果你是用的是 Android Studio 3.6 以下,使用 kotlin 编写 Enum 和 when 时即使开启了混淆,可能仍会有这个隐藏开销存在,因为 java 与 Kotlin 生成的这个临时变量的命名规则不同,3.6 之前版本的 R8,并没有针对此做优化,所以只有 3.6 之后的版本才会消除这个隐藏开销。

今天这期推送就到这里,记得关注【Android|Kotlin】!