米娜桑,是时候揭开DEX的面纱了!我们都知道multidex,都知道65535方法数超标,那DEX到底是个什么东西呢?或许又有些同学知道DEX会优化为ODEX,那ODEX又是什么鬼,优化了什么呢?为什么ClassLoader热补丁方案插入构造函数导致CLASS_ISPREVERIFIED为false后,会对性能造成影响,和ODEX又有什么关系呢?
我们又知道5.0以上Android虚拟机变成了Art,那DEX在art上变成了什么呢?为什么安装特别耗时间?有时候我看着我的Nexus6安装一个应用在那进度条读啊读的好像卡住了,有一种想砸了它的想法,所以当我拿到Nexus 5测试机的时候,第一件事就是刷到4.4,不然每次安装的效率实在不能忍(捂脸)。
直接把apk当成zip打开后,第一级目录你就会看见有classes.dex,这就是我们要揭开面纱的东西了。
为什么需要DEX,class不行吗?相应地,为什么需要Dalvik虚拟机,JVM不行吗?
Dalvik虚拟机是专门为了Android移动平台设计的。目标系统的RAM有限,数据存储在缓慢的内部闪存上,而且性能和上个世纪的周免系统相当。它们运行Linux,来提供虚拟内存,进程和线程,以及基于UID的安全机制。
这些特征和限制使我们聚焦在这些目标上:
典型的虚拟机执行从压缩文件解压独立的类,然后把它们存到heap上。这就导致了每个class可能在每个进程有独立的拷贝,从而使得应用启动变慢,因为代码必须被解压(或者至少需要从磁盘的很多小片段去读取)。另一方面,在本地heap放置字节码简化了首次使用时的指令重写,从而可能导致一些不同的优化。
这些目标指引了一些基本决定:
而Dalvik虚拟机和DEX也就应运而生。
让我们手动来生成一个java,编译成javac,然后转换为dex看看:
echo 'class Foo {'/
'public static void main(String[] args) {'/
'System.out.println("Hello, world"); }}' > Foo.java
javac Foo.java
dx --dex --output=foo.jar Foo.class
adb push foo.jar /sdcard/
adb shell dalvikvm -cp /sdcard/foo.jar Foo
当我们在dx命令的output中指定输出文件后缀为.jar,.zip,或者.apk,名为classes.dex的文件就会被创建并保存在压缩包内。解开Foo.jar你就会看到classes.dex和META-INF文件夹(里面只有一个MANIFEST.MF文件)。
我们创建完该jar后直接push到设备上,并通过shell直接让dalvik虚拟机去运行它,如果操作无误,会看到命令行的反馈 - Hello, world。
这次我打算多画点图,所以看图说话吧:
为什么DEX不能被内存映射,或者说,不能直接从zip去执行呢?因为数据是压缩的,文件头也不保证是词对齐的。这些问题可以通过不压缩直接保存为classes.dex和填充zip文件来解决,但会导致数据网络间传输的包体积变大。
我们需要在使用前把zip包里的classes.dex解压。当我们拿到文件的时候,我们可能还会做些之前提到的其他操作(对齐、优化、验证)。这又引出了另一个问题:谁去负责做这些,我们又该把输出放在哪儿?
ODEX,全名Optimized DEX,即优化过的DEX。
有至少3种方法去创建一个“准备好的”DEX文件,即ODEX:
dalvik-cache目录更准确地说是$ANDROID_DATA/data/dalvik-cache。里面的文件的名字来源于源DEX的完整路径。在设备上该目录被system所拥有,而system拥有0771权限,保存在那里的ODEX被系统和应用的组所拥有,权限为0644。数字权限保护的应用会使用640权限来防止其他应用去检测它们。底线是你可以读取自己的与其他大部分应用的DEX文件,但你不能创建、修改,或删除它们。
前两种方法的执行分为以下三个步骤:
首先,dalvik-cache文件被创建。这必须在一个有恰当权限的进程进行,所以在“系统安装器”的场景,是在运行为root的installd进程执行的。
接着,classes.dex从zip包中解压出来。文件头部留出一小块空间给ODEX header。
最后,文件被内存映射以便读取,并被调整以使用在当前系统。这包括了字节交换(byte-swapping),结构重新排列(structure realigning),但并没有对DEX文件做有意义的改变。还做了一些其他的基本结构检查,比如确保文件偏移量和数据索引落在有效范围内。
构建系统不在桌面上运行工具,而宁愿去启动模拟器,强制所有相关DEX文件的即时优化,然后从dalvik-cache把结果提取出来。这样做的原因,在解释完优化后会变得更显而易见。
一旦代码被字节替换和对齐,我们就可以继续了。我们添加了一些预计算的数据,在文件头填写ODEX header,然后开始执行。然而,如果我们对验证和优化有兴趣,就需要在初始准备后再插入一个步骤。
在Android 2.3版本以前,系统源码中提供了生成odex的工具dexopt-wrapper,位于Android 2.2系统源码的 build/tools/dexpreopt/dexopt-wrapper/ 目录下,查看 DexOptWrapper.cpp
文件会发现实际调用的是 /system/bin/dexopt 程序。在5.0及以上版本的设备上,你可能已经再也找不到dexopt了,取而代之的是dex2oat。
我们想要验证和优化DEX文件里的所有class。最简单和安全的方法就是把所有class读到虚拟机,然后跑一遍。任何读取失败的就是验证/优化失败的。不幸的是,这可能导致一些资源的分配难以释放(比如native共享库的读取),所以我们不想执行在应用运行的虚拟机里。
解决方案就是起一个叫做dexopt的程序(事实上就是虚拟机的后门)。它会执行一个简短的虚拟机初始化,从引导的类路径载入0个或多个DEX文件,然后开始做一切从目标DEX可以做的验证和优化。结束后,进程退出,释放所有资源。
因为多个虚拟机可能同时需求同一个DEX文件,文件锁被用来确保dexopt仅被执行一次。
我们继续玩耍之前生成的dex,来做一个odex:
adb push dexopt-wrapper /sdcard/
adb shell
# 不然没权限去/data/local
su
chmod 777 dexopt-wrapper
# 直接在sdcard执行会提示权限错误
cp dexopt-wrapper /data/local/
cp foo.jar /data/local/
cd /data/local
/dexopt-wrapper foo.jar foo.odex
--- BEGIN 'foo.jar' (bootstrap=0) ---
--- waiting for verify+opt, pid=5220
--- would reduce privs here
--- END 'foo.jar' (success) ---
cp foo.odex /sdcard
exit
exit
adb pull /sdcard/foo.odex .
这样子就拿到了优化后的odex,赶紧把手机还给同事。
下一次让我们利用本次讲到的这些知识,来改一改apktool,让它能重返19岁,反编译腾讯的apk。最后代码会丢到GitHub上。
参考资料