Android 方法数65536 问题的本源

138 篇文章 0 订阅

在做 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 及以下的机器份额日益减少,这个兼容已不再重要。

随着非 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 这门语言到底会在哪些对方法数产生何种影响?


1.    调用的真相


定义方法的根本目的就是要调用它。为了说明调用方法的意义,下图给出一个简单的示例:声明两个类 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 如出一辙。


2.    甜蜜的负担


纵观世界编程语言发展史,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 权限的方法和变量:

  • 继承 Runnable 的匿名类 Test$1 会访问到外部类 Test 的私有变量,

  • 外部类 Test 访问 静态内部类 Test$CS 和内部类 Test&C1 定义的私有方法

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

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


3.    结构的背后


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

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


四、    资料


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
关于64 k引用限制 Android应用程序(APK)在Dalvik可执行文件的形式包含可执行的字节码文件(DEX)文件,其中包含已编译的代码来运行你的应用程序。Dalvik可执行规格限制一个Dex文件包含65536方法:包括Android框架方法、Library方法的总、和你自己的代码方法。因为65536等于64×1024,这一限制被称为“64k引用限制”。 这个极限就要求我们配置应用程序的构建过程,需要生成多个DEX文件,所以称为multidex 配置。 分析原因与注意事项 解决方法Android 5.0及以上系统和5.0以下系统怎么做。客官们不要着急,先看我一个个分析原因,毕竟我要装下逼哈哈。 一、Android 5.0以下的版本 Android 5.0(API leve 21)之前的系统使用Dalvik执行应用程序代码。默认情况下,Dalvik限制一个apk只有一个Dex文件。为了绕过这个限制, 我们可以使用multidex support library,它成为我们APK的主要DEX文件的一部分,负责管理我们APK访问其他DEX文件和代码。 注意: 如果咱的项目minSdkVersion是20或更低,运行到Android 4.4(API leve 20)或者更低版本的设备上时需要禁用AndroidStudio的即时运行 二、Android 5.0和更高版本 Android 5.0(API leve 21)和更高的系统使用runtime是ART ,原生支持从应用的apk文件加载多个DEX文件。ART在安装应用时预编译应用程序,会扫描多个classes(..N).dex文件编译成一个.oat的文件。更多Android5.0 runtime的更多信息,请参见即时运行-instant-run。 注意: 如果你使用即时运行 , AndroidStudio自动配置你的应用程序,你应用程序的minSdkVersion应该设置为21或更高。因为即时只工作在你APP的Debug版本,你任然需要配置你的release版本构建时用multidex避免64k的限制。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值