很多 Android 开发者第一次被绕晕,往往不是栽在源码上,而是栽在安装后的那一堆文件后缀上:APK 里明明只有一个 classes.dex,为什么安装完 /data/app 下面又冒出来 base.odex、base.vdex、base.art?dex、odex、oat、vdex、art 这五个词到底是什么关系?我最早接触这套东西,是入行做 ROM 定制和启动优化的时候。当时听人一句“odex 就是优化后的 dex”,觉得懂了,结果去真机上一看,Android 5.0 之后那些 .odex 文件用 file 命令查出来竟然是 ELF,当场人傻了。
这篇文章就把这五个格式的来龙去脉拆开讲:谁是谁的前身,谁和谁住在一起,系统在安装和启动时到底拿它们干了什么,以及你在真机调试、空间清理、性能优化时应该怎么看待它们。搞懂这套逻辑,你在排查启动慢、安装异常、被“垃圾清理”误删文件这类问题时,至少不会被后缀名带偏。
1. 五个后缀先各就各位:它们分别解决什么问题
1.1 dex:唯一从开发阶段一路走来的字节码主角
dex(Dalvik Executable)是 Android 应用可执行字节码的文件格式。项目里的 Java/Kotlin 源码经过 javac 编译成 .class 文件,再由 d8(老项目里是 dx)转换成 dex,最终和资源文件打包进 APK。所以 APK 里面那个 classes.dex、classes2.dex,才是虚拟机真正要加载执行的代码本体。
dex 和 JVM 里的 .class 最核心的区别是“整合”。class 文件是一个类一个文件,成千上万个 class 堆在 APK 里,类加载器每次要打开文件、解析符号引用,I/O 成本极高。dex 把多个 class 合并到一个紧凑文件中,字符串、类型、方法签名全部做全局去重,同一个字符串在文件里只保存一份。这样文件体积更小、按序读取更连续,类加载效率也更高。
dex 文件内部并不是黑盒。头部有一串魔数字节(比如dex\n035\0、dex\n037\0、dex\n039\0),后面跟着文件大小、校验和、各张索引表的偏移位置。string_ids、type_ids、method_ids、class_defs 这些区域构成了整个字节码的“目录”。对做底层分析和逆向排查的人来说,这等于打开 dex 就有一张清晰的地图。
1.2 odex:名字很老,但今天你看到的可能不是原装
odex 全称 Optimized Dalvik EXecutable,从名字就能看出它是 Dalvik 时代的产物。那时候系统在安装 APK 时会对 dex 做预优化,生成一个 odex 文件缓存下来,这样虚拟机冷启动时不用重复验证和解析,加载速度会快很多。
问题出在 Android 5.0 之后。ART 取代 Dalvik,预编译产物变成了 oat 格式,但大量系统目录为了兼容和习惯,文件名仍然叫 .odex。所以你会在现代设备上看到 base.odex,用 file 命令一看,里面其实是 ELF 格式的本地机器码。说白了,今天的 .odex 绝大多数是披着旧马甲的 oat 文件。这一条不理解,后面许多目录分析都会跑偏。
1.3 vdex、oat、art:ART 运行时的三件套
vdex 是 Android 8.0 才出现的文件,里面保存的是验证后的 dex 副本以及快速索引信息,相当于给 dex 做了一次“预审备案”;oat 是 ART 编译出的本地机器码文件,采用 ELF 格式,应用运行时能直接执行的就是这部分;art 文件则是一份堆镜像,里面保存着预初始化的类对象、字符串等,用来加快进程启动时类加载的速度。
先给一张对照总表,后面每类文件再逐个拆解:
| 文件后缀 | 生成方 | 主要内容 | 出现阶段 |
|---|---|---|---|
| .dex | 开发者(d8/R8) | Java/Kotlin 字节码 | APK 打包阶段 |
| .odex | 系统(dexopt/dex2oat) | Dalvik 时代为优化 dex,ART 时代实为 oat | 安装阶段 |
| .oat | 系统(dex2oat) | ELF 格式的编译机器码 | 安装/OTA 阶段 |
| .vdex | 系统(dex2oat) | dex 原始副本 + 验证状态 | Android 8.0+ 安装阶段 |
| .art | 系统(dex2oat) | 类对象/字符串堆镜像 | 安装/首启阶段 |
2. 从源码到 APK:dex 是怎么被造出来的
2.1 为什么 Android 不能直接跑 .class 文件
很多人刚接触 Android 开发时都会问:JVM 能跑 .class,为什么 Android 还要多转换一层?核心原因是移动端资源和加载效率都不允许按 JVM 那套来。
class 文件按类拆分,符号引用多,运行时解析链路长。一个中型应用几万个 class,如果全部原样塞进 APK,ClassLoader 光在类加载阶段就要数不清地打开文件、读取头部、解析常量池。这在服务器上不是问题,但在内存小、闪存读取速度也有限的手机上是灾难。而且 class 文件结构里有大量冗余信息,不经过整合转换,APK 体积也会明显变大。
dex 的设计就是解决这两个问题:一是压缩,通过全局共享字符串和类型表减少重复;二是聚合,让同一个 APK 里的所有类尽量在连续区域内按序排列。还有一个历史因素,Android 早期用的 Dalvik 虚拟机本身不是为 class 文件设计的,它的执行模型和 JVM 差异很大,所以必须有自己专属的字节码格式。
2.2 dx、d8、R8 三代工具链的演进
从工具链上看,dex 的生成经历了三个阶段:
- dx:Android Studio 3.0 之前的默认转换器,负责任务是把 .class 批量转成 dex。工作稳定但性能一般,大项目编译时间经常让人抓狂。
- d8:Google 在 Android Studio 3.0 开始默认使用的 dex 编译器,dx 的官方替代者。它生成的 dex 更紧凑,编译速度更快,对脱糖(desugaring)的支持也更友好。
- R8:在 d8 基础上加入代码裁剪、混淆、优化和脱糖的完整工具链,release 构建默认使用,兼容 ProGuard 规则配置。
在实际项目中,你不需要直接调用 dx/d8,Gradle 会在 transform 阶段自动处理。但了解这段演进对你排查构建问题有帮助,尤其是老项目升级 Gradle 插件后 DEX 文件体积突然变小,往往就是 d8 的功劳,不要误以为是代码被清掉了。
multidex 也要提一下。单个 dex 文件能引用的方法数上限是 65536,这个限制来自 dex 指令集里的方法索引宽度,早期是 16 位,最多表示 0 到 65535。一旦项目方法数超过这个量级,构建工具就会自动生成 classes2.dex、classes3.dex。R8 天然支持 multidex 拆分,开发者基本不用手动介入,但 main dex 里必须包含应用启动阶段会用到的核心类,这个规则在极旧设备上仍然有影响。
查看 APK 是否是多 dex,最简单的方式是:
unzip -l demo.apk | grep classes如果看到 classes.dex 和 classes2.dex 同时出现,那这个应用就是 multidex 结构。
2.3 dex 文件头的基础信息,值得扫一眼
不要求你能手写 dex 解析器,但知道文件头有哪些字段,对后面了解 odex/vdex 的“优化”很有帮助。dex 文件头主要包含魔数、checksum、签名、文件大小、各索引表的偏移和数量。其中 method_ids_size 和 field_ids_size 决定了一个 dex 能承载多少方法引用、字段引用,这也是“方法数上限”最直观的体现。
当你用十六进制编辑器打开 dex,前几个字节如果是64 65 78 0A,也就是 ASCII 的 “dex\n”,说明这是标准的 dex。看到不同版本号,比如 035、037、038、039,对应的 dex 格式特性会有细微差异。这些版本号在兼容性排查中偶尔会露面,知道它是什么就行了。
3. odex:一个从 Dalvik 时代活到今天的“老古董”
3.1 原始的 odex 到底优化了什么
回到 Dalvik 时代,那时虚拟机靠解释执行跑字节码。每一次冷启动,虚拟机都要重新加载 dex、做字节码安全校验、解析类型引用,这些工作非常消耗 CPU。为了把耗时前置到安装阶段,Android 引入了 dexopt 优化,安装时对 APK 里的 dex 做一次预处理,产出 odex,之后虚拟机加载时直接复用预处理的成果。
dexopt 具体优化了这么几类东西:
- 字节码验证:运行期要做的安全检查提前做完,并把验证结果固化;
- 指令对齐与重排:在 dex 中把指令和数据结构尽可能对齐到 4 字节边界,并调整指令顺序,让处理器读取更高效;
- 预解析引用:把方法索引、字段索引预先处理成更接近运行时状态的结构,省掉运行时的查找开销。
所以说 odex 是“优化后的 dex”确实没错,但它依赖具体设备的 CPU 架构、系统版本和 VM 参数,并不具备跨设备通用性。这也是为什么你几乎不可能把 A 手机上的 odex 复制到 B 手机上用。
3.2 提取与非提取:odex 曾经存在的两种形态
如果你刷过老安卓 ROM,一定见过“提取 odex”和“非提取 odex”的说法。简单解释下:
- 提取模式(extracted):安装 APK 时,系统把 dex 从 APK 中解出来做优化,odex 独立存放于 /data/dalvik-cache 下。这种方式加载效率高,但 APK 和 odex 各占一份空间。
- 非提取模式:系统不单独解出 dex,而是直接从 APK 内部读取 dex,odex 只保存优化后的额外信息,更省磁盘空间。
Google 在后续版本里逐步倾向非提取模式,因为存储规划和启动体验更平衡。到 ART 接管运行时之后,传统意义上的 dexopt 退场,由 dex2oat 全程替代,“odex 是优化后 dex”的说法就已经不完全成立了。
3.3 为什么 Android 5.0 之后 .odex 文件还在
这是最容易被绕晕的点:ART 时代明明生成的是 oat,为什么文件后缀还叫 .odex?
答案其实很朴素:兼容和习惯。Android 系统的不少既有接口和目录结构是以 .odex 为命名基准的,突然全改成 .oat 会导致大量工具链和脚本失效。加上从安装器角度看,这个文件的职责仍然和“优化后的可执行文件”相近,所以名称就保留了下来。
现在的真机上,一个应用目录下的 base.odex,本质上是 ELF 格式的 oat 编译产物,里面除了编译机器码,还嵌入了和 dex、vdex 相关的元数据。你要是用file命令看它,会直接输出 ELF 64-bit LSB shared object。记住这个事实,比死记概念有用得多。
4. ART 三件套:vdex、oat、art 到底是怎么配合作战的
4.1 从解释执行到提前编译:ART 的动机
Dalvik 时代解释执行效率有限,4.4 时代虽然加了 JIT,但每次冷启动仍然需要在“解释执行”或“边跑边编译”之间切换。ART 的出现,核心思路是把编译提前:APK 安装或系统更新时,直接把 dex 编译成当前 CPU 架构的本地机器码,运行时能执行机器码就不走解释器。
这个转换过程由 dex2oat 完成。名字很直白:dex to oat。早期 ART 倾向于全量编译,一个几十 MB 的 APK 能生成上百 MB 的 oat 文件,换来的是更快的后续启动速度,但占空间,安装耗时也长。后来 Google 加入了编译过滤机制,比如 profile-guided 编译,只编译实际运行中的热点方法,大幅降低了 dex2oat 的负担。
4.2 vdex:编译源的“保险箱”加“预审报告”
vdex 是 Android 8.0 开始出现的文件。里面保存了两类关键信息:
- dex 的原始未修改副本;
- dex 验证结果,包括类型检查、访问检查等编译前必须完成的审核数据。
为什么要把 dex 单独放在 vdex 里?在 Android 7 及以前,dex 常常直接嵌在 oat 文件内部,oat 既大又难拆分。每次运行时想确认 dex 是否有效,都要先跑一遍验证。vdex 把“原始输入”和“验证结论”独立出来,dex2oat 进行一次验证后结果直接落盘,后续编译和运行都不需要重复验证。同时,运行期 ART 能根据 vdex 中的状态快速判断 dex 是否合法、能否直接执行。
打个比方,vdex 相当于一份带着预审意见的原始合同原件,oat 是已经施工完成的建筑主体,两者各自保存,互不干扰,组合使用。
4.3 oat:真正变成 CPU 指令的那部分
oat 文件采用 ELF 格式,和 Linux 下的 .so 文件同构。它里面有 .text、.data、.rodata 这类标准段。对 ART 来说,oat 里的核心内容是 dex 方法对应的原生机器码。当应用调用一个方法,ART 会优先看 oat 里有没有对应的编译版本,有就直接执行,没有再回退到解释器或 JIT。
oat 文件的大小和编译策略强相关。全量编译(speed)会把几乎所有方法都编译,文件大、安装慢;profile 编译只编译热方法,oat 体积明显小。所以你在不同设备上看到同一个应用的 base.odex 大小差距很大,非常正常,这只是系统对“空间、时间、性能”三者做了不同取舍。
这里有个经验:如果你在用-f强制全量编译某个应用,结果它变得特别大,不用恐慌,这不是文件重复,而是编译粒度变细了。想要恢复默认,让系统重新安装或编译一次即可。
4.4 art:堆里的“预制菜”
.art 文件保存的是 ART 虚拟机启动时所需的堆镜像,里面是已经构造好的类对象、方法对象、字符串对象等。系统启动时加载 boot.art,可以跳过大量类解析和对象创建,直接把堆“恢复”到一个可用的状态,进程初始化速度自然更快。
应用安装时也可能生成 base.art,原理相同:把一些应用启动早期必须用到的类提前做成快照。但它很挑环境,系统版本、堆参数、甚至 dex2oat 策略的变化都会让它失效。失效后系统会安全忽略它,下次编译条件合适时再重新生成。
所以给一个建议:看到 .art 缺失或异常,不代表系统坏了,多半是编译策略或运行环境发生变化。不要把它当作“必须存在”的文件,它更像一种加速缓存,没有它,应用顶多首次启动慢一点。
4.5 四个文件的实际生成顺序
把流程彻底捋顺:APK 安装时,dex2oat 读入 APK 里的 dex → 验证并写入 vdex → 根据编译过滤策略将 dex 编译为本地代码 → 编译产物写入 oat/odex → 在启用堆镜像策略时,再生成 art 文件。运行时,ART 先加载 boot.art/boot.oat 初始化进程,然后加载应用的 vdex 和 odex,验证结果直接可用,编译代码直接执行,缺的部分临时解释或 JIT。整个过程环环相扣,任何一个环节缺失都会降级回退,但不会直接导致系统崩掉。
5. 真机验证:去手机目录里亲手看一眼这些文件
5.1 不同 Android 版本下文件的落盘位置
真机上最容易看到全家福的位置是 /data/app 下每个应用包的 oat 目录。拿 ADB 操作一遍:
adb shell pm path com.example.demo # 输出类似 package:/data/app/com.example.demo-abcdef/base.apk adb shell ls -lh /data/app/com.example.demo-abcdef/oat/arm64/正常情况下你会看到 base.art、base.odex、base.vdex。这里的文件命名规律是:主 APK 叫 base.apk,生成的文件就带 base 前缀;如果是 split APK,就有 split_config.arm64_v8a.odex 这种名字。
系统框架层的文件看 /system/framework,典型如 boot.art、boot.oat、boot.vdex,它们预先编译了整个框架运行时,系统启动时先加载它们。老版本 Android 还可能出现 /data/dalvik-cache 目录,文件名经常用@分割,形如/data/dalvik-cache/system@app@TestApp.apk@classes.dex,这是旧机制的残留。
| 目录 | 典型文件 | 说明 |
|---|---|---|
| /data/app/<pkg>-xxx/oat/arm64/ | base.odex / base.vdex / base.art | 普通应用安装产物 |
| /system/framework/arm64/ | boot.oat / boot.vdex / boot.art | 系统框架预编译 |
| /data/dalvik-cache/ | @ 分割命名文件 | 老版本或部分定制系统 |
| /apex/com.android.art/ | 运行时模块文件 | Android 10+ 模块化 ART |
5.2 用 file 和 readelf 判断文件真身
这招非常实用。在电脑上执行:
adb shell file /data/app/com.example.demo-abcdef/oat/arm64/base.odex输出如果是 ELF 64-bit LSB shared object,说明它其实是 oat。再用 readelf 看段信息:
adb shell readelf -S /data/app/com.example.demo-abcdef/oat/arm64/base.odex | head -30能看到 .text、.rodata 等标准段,基本可以确认这就是 ART 的编译产物。vdex 文件本身不能用 readelf 直接看,它是 dex + 验证信息的合成格式,需要专门的导出工具才能取出内嵌 dex,这在做逆向和深度排查时才会用上。
5.3 文件缺失、损坏或者被“清理”之后会发生什么
这一节值得反复强调。很多用户喜欢用各种清理工具删“垃圾文件”,有时候会把 oat 目录一并删掉。我实测过几种情况:
- 应用自己的 base.odex/base.vdex/base.art 被删,APK 完整的情况下,系统会在下次启动时尝试重新 dex2oat,最严重的结果是首次启动明显变慢,应用通常会自愈;
- 如果删的是 /system/framework 下的 boot.art/boot.oat,问题就大条了,系统进程初始化时会受阻,手机可能卡开机动画,甚至无限重启;
- 如果只是删掉 .art 文件,损失较小,下次加载时重新生成即可。
另外一个常见场景是 root 后修改系统分区,导致 vdex 里的验证状态和当前 dex 不一致,ART 的安全策略会拒绝加载相关代码,表现出来就是某个系统应用在 root 后打不开。这种问题别说普通用户头疼,有时候重刷镜像才能彻底解决。
6. 围绕这些派生文件,我在实战中的排障与调优经验
6.1 首启慢,先查编译过滤策略
有一次接到反馈,某中端机首次启动应用要 3 秒多。我先不怀疑业务代码,直接查 dex2oat 策略:
adb shell dumpsys package <package> | grep -E "compile|dm"发现应用安装后只走了 verify-only 过滤,相当于完全没预编译,冷启动全靠解释器。调整编译策略为 speed-profile,让应用跑一段典型路径生成 profile,再把 profile 反馈给 dex2oat,第二次冷启动降到了 1.5 秒左右。
这也就是为什么做启动优化时,一定要区分“安装后第一次启动”和“正常后续启动”。前者受 dex2oat 影响很大,不能直接代表你的代码性能。任何性能基线测试都要等安装后的初次编译完成后才开始取数。
6.2 热修复方案与 vdex 的兼容性
vdex 是 Android 8.0 之后引入的。对于热修复,如果你的方案只是替换 classloader 或在运行时修改 ArtMethod,基本碰不到 vdex;但如果方案会直接篡改 dex 文件,又没有同步更新 vdex,ART 在加载时发现 dex 的验证状态和当前内容不匹配,会拒绝加载,造成“热修复后反而崩溃”的诡异现象。
选热修复框架之前,最好确认它针对 ART 8.0+ 做兼容适配。否则每次系统升级,你都可能踩进“vdex 验证不通过”的坑里,而且日志不容易直接看出问题,看起来就像崩溃在启动早期。
6.3 老设备存储清理的正确顺序
低配机经常碰到存储空间告急。很多“一键清理”会把 oat 当作缓存删掉,这其实得不偿失。合理的清理顺序应该是:
- 先从系统设置清理应用缓存(这个最安全);
- 对不常用的应用选择清除数据(会移除应用本地数据,谨慎);
- 考虑卸载不常用应用;
- 最后才考虑系统 dalvik-cache 是否异常膨胀,而且不建议普通用户动。
绝对不要听信“删除所有 .odex 文件可加速”的说法。现代设备删错 oat,系统会在后台大范围重新编译,CPU 和内存占用暴涨,手机变得又卡又热,体验极差。
6.4 几个开发阶段很有用的命令
做系统层调试的时候,下面这些命令帮我定位过不少问题:
# 查看应用安装路径和分包信息 adb shell pm path <package> # 查看应用生成的 oat/vdex/art 文件 adb shell ls -lh /data/app/<package>-*/oat/arm64/ # 查看应用当前编译状态和 profile 信息 adb shell dumpsys package <package> | grep -E "compile|dm" # 强制全量编译应用(仅开发机使用) adb shell cmd package compile -m speed -f <package> # 重置为默认 profile 编译策略 adb shell cmd package compile -m speed-profile -f <package>cmd package compile只建议在开发调试机上手动手,用户手机别乱执行全量 speed 编译,有性能收益但同时带来安装耗时和功耗问题。日常调试用 speed-profile 更接近用户实际体验,也更安全。
说了这么多,其实最想强调的还是那句:别被 .odex 这个旧代号迷惑,也别把 .vdex/.art 当成可有可无的杂物。它们是 ART 编译体系里互相配合的三个组件,理解了它们的定位,之后再遇到“文件被误删”“启动慢”“热修复不生效”这些问题,你至少知道该往哪查。接下来如果遇到某个应用占用几个 GB 存储,其中一大半是 oat 文件,你也知道它不是系统抽风,只是编译策略比较“用力”。