本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
在本章开始之前,先带大家探讨一下我们为什么要优化 APK 包体积。包体积优化的重要性首先体现在推广和留存上:
- 安装包越小,下载转化率越高;
- 安装包越小,渠道推广成本和厂商预装的单价成本越少;
- Google Play 对安装包大小有明确限制,超过限制则无法上架。
其次体现在性能上。
- 安装包越小,安装时间越短:APK 安装过程中需要对文件进行拷贝、解压、dex 编译等一些操作,相关文件的体积越小,则这个过程越短。
- 安装包越小,运行时内存占用越小:应用启动后,应用包中的 dex、resource、lib 等文件都需要映射到内存中,所以这些文件越小,所占用的内存就越小。
由此可见,APK 包体积优化也非常重要。事实上,大公司在包体积优化上往往也会投入很多人力。
和内存和速度优化一样,想要做好包体积优化,我们需要先掌握底层原理,这样才能由下而上进行体系且全面的优化。所以在这一章中,我们主要通过 APK 包的组成和 APK 包的构建流程, 带大家全方位重新认识 APK 包,并基于原理性知识,衍生出 APK 包体积的优化方法论。
APK 组成分析
APK 实际是一个 ZIP 压缩包,当我们将一个 APK 包通过 ZIP 解压缩,或者直接拖进 AndroidStudio 后,可以直接看到 APK 的组成。
APK 包通常由下面几部分组成。
- classes.dex 文件:Java 代码编译后生成的字节码文件。
- lib 目录:存放 so 库文件。
- res 目录:存放资源文件。
- resources.arsc 文件:存放 res 目录下文件类型资源的索引,以及非文件类型资源的值,如名称、类型信息、配置信息和值。
- assets 目录和 res/raw 目录:存放原文件,不会被编译,直接打包进 APK 包中。
- AndroidManifest.xml:程序全局配置文件。
- META-INF 目录:包信息描述的文件,里面存放的也是 mainfest 文件。
上面的目录和文件,主要可以归为这四类:dex 文件,so 库文件、资源文件、以及 manifest 配置文件,其中配置文件体积都很小,并不需要优化,所以我们主要优化前三类文件。
dex 文件
我们都知道 Java 代码编译后会生成 class 字节码文件,而 dex 文件实际只是将 class 文件再进行一次整合,将多个 class 文件都放在一个 dex 文件中。这样做的好处是降低了冗余,因为多个 class 文件中的重复数据都会合并成一份,所以根据官方的数据,同样的 Java 代码编译成 dex 文件后的大小只有 class 文件大小的 50% 左右。
通过上图我们能了解到 class 文件和 dex 文件的区别以及数据段组成,这里需要注意的是 class 文件中的数据段和 dex 文件中的数据段并不是一一对应的关系,比如 class 文件有常量池,但是 dex 文件没有;比如 class 文件中的 method 段里的数据,实际上会散落在 dex 的 methods_ids、data、string_ids 等段,并且 class 中一些数据在 dex 文件中也会被删除。
了解文件中各个数据段中有哪些数据可以帮助我们更好地优化文件体积,下面简单介绍一下 dex 文件中数据段的含义。
数据段 | 解释 |
---|---|
header | dex 文件头,记录了文件的信息等描述数据 |
string_ids | 字符串数据索引,记录了每个字符串在数据区的偏移量 |
type_ids | 类型数据索引,记录了每个类型的字符串索引 |
proto_ids | 原型数据索引,记录了方法声明的字符串,返回类型字符串,参数列表 |
field_ids | 字段数据索引,记录了所属类,类型以及方法名 |
method_ids | 类方法索引,记录方法所属类名 |
class_def | 类的定义数据,记录指定类各类信息,包括接口,超类,类数据偏移量 |
data | 数据区,真正存放数据的区域 |
索引区并不存储真正的数据,真正的数据都存在 data 数据区中,程序在运行过程中,会拿着索引去数据区查找真正的数据,所以 dex 文件体积主要是 data 数据段占用的。
至于 so 文件,我们前面已经讲过很多了,它实际就是一个 ELF 文件,大家可以结合这张图一起回忆一下。
资源文件
APK 包中的资源文件包含 res 目录下的资源文件,resources.ars 资源文件以及 assets 目录下的资源文件,我们分别来看一下。
res 资源文件
res 目录下的文件主要包含了下面这八类资源
目录 | 资源描述 |
---|---|
res/anim/ | 该目录存放定义动画属性的 XML 文件。代码中通过 R.anim 类访问。 |
res/color/ | 该目录存放定义颜色状态列表的 XML 文件。代码中通过 R.color 类访问。 |
res/drawable/ | 该目录存放图片文件,如.png、.jpg、.gif 或者 XML 文件,被编译为位图、状态列表、形状、动画图片。代码中通过 R.drawable 类访问。 |
res/layout/ | 该目录存放定义用户界面布局的 XML 文件。代码中通过 R.layout 类访问。 |
res/menu/ | 该目录存放定义应用程序菜单的 XML 文件,如选项菜单,上下文菜单,子菜单等。代码中通过 R.menu 类访问。 |
res/raw/ | 该目录存放源文件,不会被编译。需要根据名为 R.raw.filename 的资源 ID,在代码中调用Resource.openRawResource() 来打开 raw 文件 |
res/values/ | 该目录下保存包含简单值(如字符串,整数,颜色等)的 XML 文件。 |
res/xml/ | 该目录下保存运行时使用的各种配置文件 |
这些资源中,除了 raw 目录下的文件以及 png、jpg、gif 等图片文件,其他的 xml 文件都已经被编译成二进制格式。
resources.ars 文件
resources.arsc 文件存放的是 res 目录下文件类型资源的索引,以及非文件类型资源的值。 我们在代码中通过调用 getResource() 接口,并传入对应资源的 ID 便能获取到 res 目录中的资源,这背后的逻辑实际上是资源管理器会根据资源 ID 去 resources.arsc 文件中寻找真正的资源,如果是文件类型资源,resources.arsc 文件中便记录了文件对应的路径,如果是非文件类型资源,resources.arsc 文件中记录了对应的值。
我们在 AndroidStudio 打开一个 resources.ars 文件,就可以看到这个文件内容,里面包含了 res 目录中资源对应 ID、Name、资源路径等数据,以及字符串、id 等非文件资源的值。
assets 目录文件
res/raw 以及 assets 目录中的文件都是源文件,我们在工程代码中该目录下所放的文件都直接会被打进 apk 包中。所以对这两个文件下的数据,我们需要更加谨慎,避免放多体积太大的文件进去。
上面这三类文件,我们通常可以采用精简、压缩、动态化这三条方法论来进行体积优化。
- 精简:精简就是减少文件的大小或者优化文件中数据段里数据。比如针对 dex 文件,我们可以通过减少类文件,又或者移除 dex 文件中 data 数据区中的 debug 信息等方案来进行精简优化。再比如针对 so 文件,我们可以减少不必要的 so 引入,又或者移除 so 文件中符号表这一数据段等方案进行精简优化。
- 压缩:只要是文件能就能进行压缩,只要有压缩,我们就可以尝试替换更优的压缩算法。 dex、so、资源文件都可以被压缩,比如我们可以将 dex 文件压缩,然后在使用时接管系统对 dex 文件的解压来实现这个方案,so 文件同样适用。
- 动态化:插件或者网络拉取都属于动态化的方案。比如 dex 、so、res 资源,我们都可以做成插件的形式进行下发,图片我们也可以尽量通过网络来下载。
这里只是简单介绍一下基于这三种方法论衍生出来的一些可行的优化方案,在后面的章节中,会带着大家会进行更加详细和深入的优化实战。
APK 包构建流程
了解了 APK 包内部的组成,我们再来看一下 APK 包的构建流程。上面提到的文件,都是在 APK 包构建流程中的产物,所以只有当我们熟悉了这个流程,才能够在这个流程中发现可以降低产物文件体积的优化点。
APK 包的构建主要分为编译和打包两个流程,分别如下。
- 编译流程:将源代码转换成 dex 文件,以及将其他所有文件转换成编译后的文件的过程。
- 打包流程:将 dex 文件和编译后的资源组合、签名和对齐后,生成 APK 安装包文件。
通过上面这张详细的流程图,我们来深入了解一下编译和打包这两个流程都做了哪些事情。
编译流程
编译流程主要是资源文件和 Java 源码的编译,实际上除了 Java 代码,还有 kotlin 代码,不过为了简化流程,本章中不介绍了。我们先来看看资源文件。
资源文件编译
Android 资源打包工具(Android Asset Packaging Tool), 简称为 aapt ,会根据 res 目录中的文件生成 R.java 文件和 resource.arsc 文件,这两个文件实际上都是索引文件。在代码中直接使用的是 R.java 中 int 类型的 ID 值,资源管理器会通过这个 ID 值再去 resource.arsc 文件中寻找真正的资源。
aapt 除了生成上面两个文件,还会将 res 目录中的 xml 资源文件进行压缩并生成二进制文件。至于 res/raw 目录下的文件,以及 assets 目录下的文件,都会被原封不动打入到 apk 包中,并不会进行编译操作。
从上面的流程可以看到,aapt 主要作用是解析资源、为资源编制索引并生成对于的 R.java 和 resource.arsc 文件,并将资源编译为针对 Android 平台进行过优化的二进制格式。
dex 文件编译
我们再接着来看一看 dex 文件的编译流程。Java 文件首先需要被编译成 class 文件,这个过程我们使用 JDK 的 javac 命令即可,属于 Java 的流程,并不涉及 Android 的知识。但是 class 文件编译成 dex 文件这一步就属于 Android 的流程了,并且在这个流程中,Android 做了比较多的事情。将 class 文件编译成 dex 文件这一流程,到目前为止经历了三个版本,分别如下:
- Proguard + DX 编译;
- Proguard + D8 编译;
- R8 编译。
下面我们来一个个看一下。
Proguard + DX 编译
我们可以通过下面的流程图了解 Java 文件变成 dex 文件的流程。
图中经历的流程如下:
- 通过 javac 生成 class 字节码文件;
- class 字节码文件接着经过 desugar,即脱糖过程,这里主要是为了能兼容 JDK8 中新的语法糖特性,比如 lambda 表达式;
- 接着字节码文件经过一些三方的脚本处理流程,比如我们的字节码操作等流程;
- 接着 Proguard 脚本则会对字节码文件进行缩减和优化;
- 最后通过 dx 编译器将 class 字节码文件编译成可以运行的 dex 文件。
Proguard + D8 编译
性能优化是永无止境的,D8 就是作为 dx 的优化版本出现的,它的流程和上面基本一直,只不过 dx 编译器换成了 D8 编译器,同时 desugar 脱糖处理也融入到了 D8 中,而不是通过第三方脚本进行。
R8 相比 dx,性能有了很大的提升,根据 Android 官方的数据,通过 d8 编译 dex 文件,编译时间缩短了 20%,体积也减少了 4%。从Android Studio 3.1开始,D8 成为了默认的 dex 编译器。
R8 编译
因为新的语言 Kotlin 的出现,使得 Android 需要再次对 d8 编译器进行改进和优化,于是就有了目前最主流使用的编译器:R8。从Android Studio 3.4 开始,便默认使用 R8 编译器。
R8 整合了 Proguard 脚本,不过依然使用与 Proguard 一样的混淆和 keep 规则,并支持对 kotlin 的编译。它的流程如下:
从官方文档可以看到,R8 的编译速度提升非常大,提升了近一半,dex 文件体积有一定的优化,但是并不多。
Android 的编译工具虽然升级了好几个版本,从 dx 到 D8 再到 R8 ,但在编译过程中对包体积和性能的优化也越来越强。通过官方文档,可以看到主要有下面这些优化。
- 代码缩减:从应用及其库依赖项中检测并安全地移除不使用的类、字段、方法和属性。例如,如果您仅使用某个库依赖项的少数几个 API,那么缩减功能可以识别应用不使用的库代码并仅从应用中移除这部分代码。
- 资源缩减:从封装应用中移除不使用的资源,包括应用库依赖项中不使用的资源。此功能可与代码缩减功能结合使用,这样一来,移除不使用的代码后,也可以安全地移除不再引用的所有资源。
- 混淆:缩短类和成员的名称,从而减小 DEX 文件的大小。
- 优化:检查并重写代码,以进一步减小应用的 DEX 文件的大小。例如,如果 R8 检测到从未采用过给定 if/else 语句的 else 分支,则会移除 else 分支的代码。
上面这些优化都需要我们通过配置来开启,官方文档中有详细的配置使用介绍,后面的实战篇章中,我们也会继续介绍。
打包流程
打包流程相比编译流程就简单很多,apkbuilder 工具会将编译后生成的文件以及不需要编译的 assets raw 等文件通过 zip 格式进行压缩,这样一个 apk 安装包就生成了。生成 apk 包后,为了安全考虑,还需要进行签名,同时为了性能考虑,还需要进行字节对齐(Linux 系统是按照一页 4K 的大小来读取数据的,字节对齐就是将包中各个文件起始偏移地址对齐为 4 字节的整数倍,这样通过内存映射访问 apk 文件时的速度会更快)。
上面的编译和打包流程的逻辑,都是通过一个个 gradle task 来完成的,我们可以通过下面的方式来打印构建过程中的 task 任务名 :
./gradlew android-gradle-plugin-source:assembleDebug --console=plain
打印的结果如下:
:android-gradle-plugin-source:preBuild UP-TO-DATE
:android-gradle-plugin-source:preDebugBuild
:android-gradle-plugin-source:compileDebugAidl
:android-gradle-plugin-source:compileDebugRenderscript
:android-gradle-plugin-source:checkDebugManifest
:android-gradle-plugin-source:generateDebugBuildConfig
:android-gradle-plugin-source:prepareLintJar UP-TO-DATE
:android-gradle-plugin-source:generateDebugResValues
:android-gradle-plugin-source:generateDebugResources
:android-gradle-plugin-source:mergeDebugResources
:android-gradle-plugin-source:createDebugCompatibleScreenManifests
:android-gradle-plugin-source:processDebugManifest
:android-gradle-plugin-source:splitsDiscoveryTaskDebug
:android-gradle-plugin-source:processDebugResources
:android-gradle-plugin-source:generateDebugSources
:android-gradle-plugin-source:javaPreCompileDebug
:android-gradle-plugin-source:compileDebugJavaWithJavac
:android-gradle-plugin-source:compileDebugNdk NO-SOURCE
:android-gradle-plugin-source:compileDebugSources
:android-gradle-plugin-source:mergeDebugShaders
:android-gradle-plugin-source:compileDebugShaders
:android-gradle-plugin-source:generateDebugAssets
:android-gradle-plugin-source:mergeDebugAssets
:android-gradle-plugin-source:transformClassesWithDexBuilderForDebug
:android-gradle-plugin-source:transformDexArchiveWithExternalLibsDexMergerForDebug
:android-gradle-plugin-source:transformDexArchiveWithDexMergerForDebug
:android-gradle-plugin-source:mergeDebugJniLibFolders
:android-gradle-plugin-source:transformNativeLibsWithMergeJniLibsForDebug
:android-gradle-plugin-source:transformNativeLibsWithStripDebugSymbolForDebug
:android-gradle-plugin-source:processDebugJavaRes NO-SOURCE
:android-gradle-plugin-source:transformResourcesWithMergeJavaResForDebug
:android-gradle-plugin-source:validateSigningDebug
:android-gradle-plugin-source:packageDebug
:android-gradle-plugin-source:assembleDebug
这里也列一下构建过程中主要的 task 的作用,供大家用于查询:
Task | 作用 |
---|---|
preBuild | 空 task,只做锚点使用 |
preDebugBuild | 空 task,只做锚点使用,与 preBuild 区别是这个 task 是 variant 的锚点 |
compileDebugAidl | 处理 aidl |
compileDebugRenderscript | 处理 renderscript |
checkDebugManifest | 检测 manifest 是否存在 |
generateDebugBuildConfig | 生成 BuildConfig.java |
prepareLintJar | 拷贝 lint jar 包到指定位置 |
generateDebugResValues | 生成 resvalues,generated.xml |
generateDebugResources | 空 task,锚点 |
mergeDebugResources | 合并资源文件 |
createDebugCompatibleScreenManifests | manifest 文件中生成 compatible-screens,指定屏幕适配 |
processDebugManifest | 合并 manifest 文件 |
splitsDiscoveryTaskDebug | 生成 split-list.json,用于 apk 分包 |
processDebugResources | aapt 打包资源 |
generateDebugSources | 空 task,锚点 |
javaPreCompileDebug | 生成 annotationProcessors.json 文件 |
compileDebugJavaWithJavac | 编译 java 文件 |
compileDebugNdk | 编译 ndk |
compileDebugSources | 空 task,锚点使用 |
mergeDebugShaders | 合并 shader 文件 |
compileDebugShaders | 编译 shaders |
generateDebugAssets | 空 task,锚点 |
mergeDebugAssets | 合并 assets 文件 |
transformClassesWithDexBuilderForDebug | class 打包 dex |
transformDexArchiveWithExternalLibsDexMergerForDebug | 打包三方库的 dex,在 dex 增量的时候就不需要再 merge 了,节省时间 |
transformDexArchiveWithDexMergerForDebug | 打包最终的 dex |
mergeDebugJniLibFolders | 合并 jni lib 文件 |
transformNativeLibsWithMergeJniLibsForDebug | 合并 jnilibs |
transformNativeLibsWithStripDebugSymbolForDebug | 去掉 native lib 里的 debug 符号 |
processDebugJavaRes | 处理 java res |
transformResourcesWithMergeJavaResForDebug | 合并 java res |
validateSigningDebug | 验证签名 |
packageDebug | 打包 apk |
assembleDebug | 空 task,锚点 |
小结
到这里,我们就通过 APK 的组成以及 APK 的构建流程,全方位了解了什么是 APK 包。只有当我们完全了解什么是 APK 包后,才能更好地优化它的体积。
本章也从 APK 的文件组成出发,介绍了精简、压缩和动态化三项文件体积优化的方法论,从 APK 的构建流程出发,介绍了一些在生成文件产物时可进行的优化项。在后面的章节中,我们会一起深入到优化实战中去,希望你能掌握本章的知识,这样才能更容易理解后面的内容。