在做 Android 应用研发时,尤其是开发大型应用时,我们很容易遇到 Android 方法超过 65536 的现象。即便进行分 dex 处理,在功能日益增加的今天,主 dex 依然会面临方法数不够用的窘境,然后不得不通过各种压缩、裁剪代码,才得以上线。虽然现在已有广为人知的现成解决方案,然墨子有云:“治于神者,众人不知其功,争于明者,众人知之”,回想起这几年间 Android 程序员和方法数之间林林总总的相爱相杀,发现很多问题既没有事前疏导,也缺乏事后防范总结,所以此刻谈谈方法数这个问题的本源,对达到“治于神”这一境界是存在其必要性的。
方法,对于开发者来说是程序中一段代码的定义,而对于执行方(OS、虚拟机、解释器等)更多是一个存储在可执行对象(C 的 ELF、Windows 的 PE、Java 的 Jar 等)中的符号或指令。方法数并非新奇概念,Java 的 Class 文件中已有定义,ELF 的符号表也有隐含体现,类似的还有变量数等定义。在 Android 平台大行其道之前,对方法数讨论的问题不多,直到 Facebook 2013 年的一篇文章 [1],才提到一些大型应用会遇到的两个方法数问题:
-
dex 方法数超标
-
linearAlloc 存储方法数的空间在 Android 2.3 及以下只有 5 MB
当时国内少数巨无霸应用在遇到这类问题后,也根据 Facebook 这篇文章的思想实现了分 dex 的方案(如下图的代码片段);甚至完成对 linearAlloc 的修改,但 Android 2.3 及以下的机器份额日益减少,这个兼容已不再重要。
随着非 BAT 企业对繁荣和需求的进一步诉求,遇到 Android 方法数问题的产品也日益增多。通过对 dex 格式进行分析,会发现 dex 本身并没有对方法数进行限制,而 dex 方法数受约束的真正原因源于 dex 字节码的设计:
The storage unit in the instruction stream is a 16-bit unsigned quantity
由于字节码在调用方法时,必须显示寻址方法在 dex 存储的索引,即 meth@BBBB[2]。BBBB 的含义是每个四位,四个 B 就是十六位,所以最多支持 2^16 个方法。为保护 dex 字节码的执行,所以在生成、合并 dex 时会对方法数、变量等进行检查和保护。Google 在 Android 5.0 已推出分 dex 的 workaround: Multidex,虽然不够完美,但已经使得这类问题的解决开始趋向集中。
实际上,控制方法数问题的根本要义在于减少打入到 dex 中的方法。Dex 是 dalvik 虚拟机的字节码文件,Class 是 Java 虚拟机的字节码,虽然两者在格式、语法和实现上有一些差别,但本质上还是存在一些映射关系,如图:
与 class 格式类似,dex 用一段连续的空间存放方法的索引集,每个方法被一个
method_id_item
数据结构所描述,由
class_idx
、
proto_idx
、
name_idx
三个元素组成 [3,4], 它们分别代表方法所在类类型索引、方法声明的索引以及方法名的索引。
如下图所示,Dex 中所有方法均来自 Android 的 Java 代码(也不排除其他语言可以被编译为 dex 格式的情况),通过 Android 打包的 dx 工具,我们能将编译为 class 的 Java 文件转化为 dex。
定位 dex 方法的来源的关键在于找到其所属的 Java 文件,按图索骥可知 Java 文件的来源无非几种情况:
-
引入的 aidl 文件
-
参与编译的 Java 源码
-
根据资源生成的 R 文件
-
依赖的其他库(会被一同打入到编译结果的)
事实上我们工程中 99% 的方法都来自开发者创建的 Java 文件或者引入的库,那么 Java 这门语言到底会在哪些对方法数产生何种影响?
定义方法的根本目的就是要调用它。为了说明调用方法的意义,下图给出一个简单的示例:声明两个类 MainActivity 和 Test,这两个类都有一个 foo 函数,里面执行了 Activity 的 startActivity。
反编译生成的 APK,得到 dex 对应的 smali 文件(smali 是 dex 的汇编器,和 dalvik 一样都是冰岛语,是一脉相承的东西)。可以看到调用 Activity 的 startActivity 的字节码出现在 Test 和 MainActivity 中。
那么这种方法的调用会不会增加 dex 的方法?先记录下当前的方法数为 24 个。
继续验证,这次只改动一个地方:将 Test 类中 foo 函数的参数类型改为 MainActivity。依旧是调用库方法,不同的是调用者的类型由父类 Activity 变成子类 MainActivity。
经过反编译分析,发现 smali 红框中的方法其所在的类也相应地变为 MainActivity,再计算方法数变为 25,
增加 1 个
。所以即便是调用方法,也会增加方法数。
导致方法增加的事实是:当类 A 的实例 a 调用了被 invoke-virtual 所修饰的方法 f。在编译期,A 的 字节码中会增加方法 f(如果 f 不在 A 中),即便 f 没被 A 复写或者 f 在 A 的父类中被标记为 final,也阻止不了编译器这样的行为,这是由于虚拟机要实现多态特性而决定的。在运行期,当虚拟机执行到 A 的实例 a 调用 f,如找不到 f 则会出现 NoSuchMethodException。
因为多态和复写是 OO 最常见的编程手段,假如滥用继承且祖先类中的方法很多,那么所有祖先类定义过的方法都会添加到子类中,从而导致方法数膨胀。所以除了进行字面意义上地减少方法,还可以从设计角度来解决这类问题。
综上所述,决定一个方法的三个要素是方法参数列表和返回值、方法名称以及该方法所在的类,修改任何三要素之一都会导致方法数的增加。换一个角度思考,其实不同 class 文件中的相同方法符号会在生成 dex 时被合并,这也是我认为 dex 和 class 两者设计理念的最大区别:dex 格式提供聚合能力。
至于用栈还是寄存器来实现相比顶层设计的意义便没有那么显著。其实这个优化思路更早的痕迹出现在 C 语言的链接器中,如下图所示,链接器通过合并目标文件相似段(ELF 格式)来获取更好的性能和扩展性,这个过程和 dx 将 一系列 class 转化为 dex 如出一辙。
纵观世界编程语言发展史,java 经常被拿来与 C# 对比,但两者的发展理念早已大相径庭。例如 C# 吸取了很多语言的特点,也更像一个大杂烩,很早就提供了 lambda 表达式、 async 关键字以及丰富的异步 api 接口,看过去的确琳琅满目、功能强大且能帮助快速开发,但实质上如果不清楚其内部原理和实现机制,很容易使用不当且造成隐晦甚至是灾难性的后果。java 在这方面并没有亦步亦趋,更像是一个按着既有计划前进的长者。
为了让使用者更为得心应手,Java 每个版本也持续都引入了不少新特性,例如 1.1 的内部类、1.5 的泛型、1.8 的 lambda 等,满足了开发者不同的诉求。
这里我们来看看语法糖对方法数的影响,下面两个文件分别在类 Test 中定义了 foo 和 toArray 两个方法,类 Test2 继承 Test,并重写了 foo 的返回值。
除此之外,java 中最常见的语法就是使用大量的内部类、匿名类,这一块比 C++ 方便不少。在类 Test 中我们使用匿名类和内部类来观察他们对方法数的影响。
类 Test 中的内部类和外部类会相互访问一些具有 private 权限的方法和变量:
对于匿名类访问外部私有变量的情况,可以发现 Test$1 会通过 Test 的 access$000 静态方法来获取其私有变量的值,access$000 是编译期在 Test 中生成。