在做Android应用研发时,尤其是开发大型应用时,我们很容易遇到Android方法超过65536的现象。即便进行分 dex 处理,在功能日益增加的今天,主 dex 依然会面临方法数不够用的窘境,然后不得不通过各种压缩、裁剪代码,才得以上线。虽然现在已有广为人知的现成解决方案,然墨子有云:"治于神者,众人不知其功,争于明者,众人知之",回想起这几年间 Android 程序员和方法数之间林林总总的相爱相杀,发现很多问题既没有事前疏导,也缺乏事后防范总结,所以此刻谈谈方法数这个问题的本源,对达到“治于神”这一境界是存在其必要性的。

一、引子

方法,对于开发者来说是程序中一段代码的定义,而对于执行方(OS、虚拟机、解释器等)来说,就是一个存储在可执行对象(C 的 elf、Windows 的 pe、Java 的 jar等)中的符号或指令。方法数也不是什么新奇玩意,java 的 class 文件中就有定义,elf 的符号表也有隐含体现,类似的还有变量数等定义。在 Android 平台大行其道之前,对方法数讨论的问题不多。直到 Facebook 2013 年的一篇文章[1],提到一些大型应用会遇到的两个方法数问题:

  1. dex 方法数超标
  2. linearAlloc 存储方法数的空间在 Android 2.3 及以下只有 5 MB

当时国内少数巨无霸应用在遇到这类问题后,也根据 Facebook 这篇文章的思想实现了分 dex 的方案(如下图的代码片段);甚至完成对 linearAlloc 的修改,但 Android 2.3 及以下的机器份额日益减少,这个兼容已不再重要。

Android 一套代码装两个app 安卓代码数量_Test

      

随着非 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 在 5.0 已推出分 dex 的 workaround: multdex,虽然不够完美,但已经使得这类问题的解决开始趋向集中。

二、 正文

实际上,控制方法数问题的根本要义就是减少打入到 dex 中的方法。Dex 是 dalvik 虚拟机的字节码文件,class 是 java 虚拟机的字节码,虽然两者在格式、语法和实现上有一些差别,但本质还是存在一一映射的关系,如下图:

Android 一套代码装两个app 安卓代码数量_Android 一套代码装两个app_02

与 class 格式类似,dex 用一段连续的空间存放方法的索引集,每个方法被一个 method_id_item 数据结构所描述,由 class_idxproto_idxname_idx 这三个元素组成[3,4], 它们分别代表方法所在类类型索引方法声明的索引以及方法名的索引

如下图所示,Dex 中所有方法都来自 Android 的 java 代码(不排除以后有其他语言可以被编译为 dex 格式),而 dex 是由 Android 打包时会通过 dx 工具将编译为 class 的 java 文件转化而来。

Android 一套代码装两个app 安卓代码数量_Test_03

可以发现 java 文件的来源如下:

  • 引入的 aidl 文件
  • 参与编译的 java 源码
  • 根据资源生成的 R 文件
  • 依赖的其他库(会被一同打入到编译结果的)

       

事实 99% 的方法都来自开发者创建的 java 文件或者引入的库中,那么 java 文件会从哪些方面对方法数产生影响?

1. 调用的真相  

定义方法的根本目的就是要调用它。为了说明调用方法的意义,下图给出一个简单的示例:声明两个类 MainActivity 和 Test,这两个类都有一个 foo 函数,里面执行了 Activity 的 startActivity。

Android 一套代码装两个app 安卓代码数量_字节码_04

反编译生成的 APK,得到 dex 对应的 smali 文件(smali 是 dex 的汇编器,和 dalvik 一样都是冰岛语,是一脉相承的东西)。可以看到调用 Activity 的 startActivity 的字节码出现在 Test 和 MainActivity 中。

Android 一套代码装两个app 安卓代码数量_Test_05

那么这种方法的调用会不会增加 dex 的方法?先记录下当前的方法数为24个。

Android 一套代码装两个app 安卓代码数量_字节码_06

  

继续验证,这次只改动一个地方:将 Test 类中 foo 函数的参数类型改为 MainActivity。依旧是调用库方法,不同的是调用者的类型由父类 Activity 变成 子类 MainActivity。

Android 一套代码装两个app 安卓代码数量_字节码_07

经过反编译分析,发现smali 红框中的方法其所在的类也相应地变为 MainActivity,再计算方法数变为 25,__增加 1 个__。所以即便是调用方法,也会增加方法数。

Android 一套代码装两个app 安卓代码数量_Android 一套代码装两个app_08

Android 一套代码装两个app 安卓代码数量_Test_09

导致方法增加的事实是:当类 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 如出一辙。

Android 一套代码装两个app 安卓代码数量_Android 一套代码装两个app_10

2. 甜蜜的负担

纵观世界编程语言发展史,java 经常被拿来与 C# 对比,但两者的发展理念早已大相径庭。例如 C# 吸取了很多语言的特点,也更像一个大杂烩,很早就提供了 lambda 表达式、 async 关键字以及丰富的异步 api 接口,看过去的确琳琅满目、功能强大且能帮助快速开发,但实质上如果不清楚其内部原理和实现机制,很容易使用不当且造成隐晦甚至是灾难性的后果。java 在这方面并没有亦步亦趋,更像是一个按着既有计划前进的长者。

为了让使用者更为得心应手,java 每个版本也持续都引入了不少新特性,例如 1.1 的内部类、1.5 的泛型、1.8的 lambda 等,满足了开发者不同的诉求。

这里我们来看看语法糖对方法数的影响,下面两个文件分别在类 Test 中定义了 foo 和 toArray 两个方法,类 Test2 继承 Test,并重写了 foo 的返回值。

Android 一套代码装两个app 安卓代码数量_Test_11

我们发现 foo 返回值的类型被改写,基类 Test 中的方法 foo 返回的是 Object,而子类 Test2 返回的是 Object 的子类 Long,这种用法的好处在于为多态提供了更多的扩展性,能够让子类的实现更为聚焦,平时一些常见的程序库中就采用了类似用法。分析和对比字节码发现:子类 Test2 中会存在两个 foo 方法,原因是编译器会在子类 Test2 中生成(synthetic)一个与父类一致的方法来做被复写类型的方法的桥接(bridge),从而实现这一便捷的语法。

Android 一套代码装两个app 安卓代码数量_字节码_12

       

除此之外,java 中最常见的语法就是使用大量的内部类、匿名类,这一块比 C++ 方便不少。在类 Test 中我们使用匿名类和内部类来观察他们对方法数的影响。

Android 一套代码装两个app 安卓代码数量_字节码_13

类 Test 中的内部类和外部类会相互访问一些具有 private 权限的方法和变量:

  • 继承 Runnable 的匿名类 Test$1 会访问到外部类 Test 的私有变量,
  • 外部类 Test 访问 静态内部类 Test$CS 和内部类 Test&C1 定义的私有方法

Android 一套代码装两个app 安卓代码数量_Test_14

对于匿名类访问外部私有变量的情况,可以发现 Test$1 会通过 Test 的 access$000 静态方法来获取其私有变量的值,access$000 是编译期在 Test 中生成。

Android 一套代码装两个app 安卓代码数量_Android 一套代码装两个app_15

对于外部类访问内部类私有方法的情况,也会生成相应的静态方法 access$xxx 来帮助突破限制。

Android 一套代码装两个app 安卓代码数量_Android 一套代码装两个app_16

值得注意的是 Test 访问内部类的 private 变量却没有增加方法。这是因为由于 Test$CS 和 Test$C1 是常量,编译期就已经确定 c1_.I + CS.I

综上所述,语法糖的本质是带给开发者以更为便利的使用,这种便捷如果建立在与语言原有设定不一之处,要不就是缺陷,要不就是编译器在背后做了不少。无论是复写返回值还是突破访问权限或者一些类似 lambda 等新语法,它们无一例外地以增加方法、内部类等字节码为代价来实现这种便捷,通过这种手段来屏蔽掉一些不重要的细节,将最令人关心的特性呈现给开发者。

3. 结构的背后  

如果要书写一个 java 文件,难免要在 abstract class、annotation、class、enum、interface 这五种结构中选取或者组合,它们又在方法数上又有何差异?我们定义这五种结构最简实现,即没有任何方法和成员(用 T_XX.java 命名,XX 表示这些结构前两个字的缩写),来看看不同结构对方法数的影响。

Android 一套代码装两个app 安卓代码数量_字节码_17

通过反编译 smali 文件分析可得:

Android 一套代码装两个app 安卓代码数量_Android 一套代码装两个app_18

这里篇幅问题就不列出字节码了,综上所述:

  • 接口和注解没有引入方法,字节码的大小也是最少
  • 类和抽象类引入了一个方法(会调用 Object 的默认构造函数),大小理论上相同。(除去类名长度等因素,上图 T_AB 与 T_CL 的字节码大小相差 9 个字节是因为抽象类的描述比类的多了 abstract 关键字 加 1 个空格所致)
  • 枚举引入了 1 个默认构造函数和 3 个静态方法,所需的字节码最多,是其他结构的数倍甚至二十倍。枚举有其特性和优雅之处,但使用过多也会对方法数和程序大小产生影响。

三、 小结

本文简单介绍了方法数的来龙去脉,优化方法数的文章也很多。但最有效的还是要在设计初期就把这个问题考虑进去,这里只聊点 the principle underlying。具体的优化和设计方案如对引入依赖的处理、怎样避免方法数膨胀等还需围绕原则,结合项目实际特点来选取和开展。