apk是什么

全称:Android application package,Android应用程序包,是一个标准的 ZIP 文件,狭义上说,他不是可执行文件,linux 上可执行文件是 ELF 文件,但是 APK 不是 ELF 文件。因此 aaa.apk == aaa.zip

apk由什么组成

APK 的组成有 Dex 文件资源资源表签名摘要信息等四部分组成,这四部分是不可或缺的,不然任何一个 OS 都无法正常的运行你带 Activity 的 Android 应用。

image.png

一个APK文件通常包含以下文件:

  • META-INF文件夹:用于保存 App 的签名和校验信息,以保证程序的完整性。当生成 APK 包时,系统会对包中的所有内容做一次校验,然后将结果保存在这里。而手机在安装这一 App 时还会对内容再做一次校验,并和 META-INF 中的值进行比较,以避免 APK 被恶意篡改。其中包含如下 三个文件,如下所示:
    • 1)、MANIFEST.MF:其中每一个资源文件都有一个对应的 SHA-256-Digest(SHA1) 签名,MANIFEST.MF 文件的 SHA256(SHA1) 经过 base64 编码的结果即为 CERT.SF 中的 SHA256(SHA1)-Digest-Manifest 值。
    • 2)、CERT.SF:除了开头处定义的 SHA256(SHA1)-Digest-Manifest 值,后面几项的值是对 MANIFEST.MF 文件中的每项再次 SHA256(SHA1) 经过 base64 编码后的值。
    • 3)、CERT.RSA:其中包含了公钥、加密算法等信息。首先,对前一步生成的 CERT.SF 使用了 SHA256(SHA1)生成了数字摘要并使用了 RSA 加密,接着,利用了开发者私钥进行签名。然后,在安装时使用公钥解密。最后,将其与未加密的摘要信息(MANIFEST.MF文件)进行对比,如果相符,则表明内容没有被修改。
  • res: APK所需要的资源文件夹。
  • AndroidManifest.xml: 一个传统的Android清单文件,用于描述该应用程序的名字、版本号、所需权限、注册的服务、链接的其他应用程序。
  • classes.dex: classes文件通过DEX编译后的文件格式,用于在Dalvik虚拟机上运行的主要代码部分。
  • resources.arsc: 资源索引表文件,通过该文件对资源进行定位。也可以用ApkTool等工具反编译后再开始进行软件修改

Dex文件是什么

先了解一下 JVM,Dalvik 和 ART。

  • JVM 是 JAVA 虚拟机,用来运行 JAVA 字节码程序。
  • Dalvik 是 Google 设计的用于 Android 平台的运行时环境,适合移动环境下内存和处理器速度有限的系统。
  • ART 即 Android Runtime,是 Google 为了替换 Dalvik 设计的新 Android 运行时环境,在 Android 4.4 推出。ART 比 Dalvik 的性能更好。

Dalvik 虚拟机不支持直接执行 JAVA 字节码,所以会对编译生成的 .class 文件进行翻译、重构、解释、压缩等处理,这个处理过程是由 dx/d8/r8(这是一个工具,位置为$ANDROID_HOME/build-tools/(不同版本号)/dx) 进行处理,处理完成后生成的产物会以 .dex 结尾,称为 Dex 文件。
Dex 文件格式是专为 Dalvik 设计的一种压缩格式。

那么可以知道,

  • JVM的输入是java文件,输出是class文件
  • Dalvik的输入时class文件,输出是dex文件,Dex 文件是很多 .class 文件处理后的产物


java文件是如何变成dex文件的

首先在java中,java文件通过javac命令编译生成.class文件,dex文件的流程也是类似。
假设我们编写了一个HelloWorld程序,并且只有一个main函数,main函数里只有打印一个HelloWorld,通过javac生成.class文件后,再通过 dx 工具的如下命令:

  1. $ANDROID_HOME/build-tools/28.0.3/dx --dex --output=classes.dex HelloWorld.class

可以输出一个classes.dex文件,拿到dex文件后,我们需要把他放到一个可以运行它的OS,就是Android系统,我们直接push到手机上,下一步通过如下命令:

  1. dalvikvm -cp HelloWorld.dex HelloWorld

可以直接运行HelloWorld程序,并输出HelloWorld,其中 cp 指定的是 classpath,后面指定的类名,毕竟 dex 文件一旦有多个类存在 main 函数的话,就不知道选哪个类去运行了。

Dalvik虚拟机** 除了能接受一个裸露的 dex 文件以外,还能接受一个 zip 格式的文件,只要求里面的 dex 文件名必须是 classes.dex 就行或者是一个zip文件解压出来有dex文件。比如我们传一个 zip/apk/jar 都能接受,毕竟他们的本质都是 zip。

热修复

现在对于 java 代码的热修复主要从 DexClassLoader 里面的 dexPathList 入手,这里应用的原理就是 classloader 双亲委派里对于加载后的类的缓存机制。

如果一个类在一个类加载器中加载过,就不会从其他类加载器中装载了。

Android 提供的 DexClassloader 是按提供的 dex 顺序找的,因此对于 java 代码的热修复变得很简单 —— 只要把想要被修复的 Dex 放到最前面,加载相关的类就好了,Tinker 和 DexPatch 当然还做了更多的事情,比如对 dex 进行 merge 之类的工作。
我们可以通过 cp命令来对两个dex文件处理,如下:

  1. dalvikvm -cp new.dex:classes.dex HelloWorld

这样就可以让new.dex比classes.dex 先运行,如果new中有一个和classes一模一样的类路径类名,那么classes就不会再去加载这个类

那项目中的资源文件怎么处理的

上一步的产物是将项目中的java文件进行统一处理,那项目剩下的还有资源部分。
资源文件指的就是res下除了raw的文件和 AndroidManifest.xml ,通过 aapt/aapt2(文件位于SDK下的build-tools中) 一起编译和链接出相应的二进制版本

AAPT2(Android 资源打包工具)是一个构建工具,Android Studio 和 Android Gradle Plugin 使用它来编译和打包应用的资源。AAPT2 会解析资源、为资源编制索引,并将资源编译为针对 Android 平台进行过优化的二进制格式。

从 Android Gradle Plugin 3.0.0 开始,AAPT2 默认开启,相对于 AAPT,资源打包流程由原来的单一编译过程拆分为「编译」和「链接」两个阶段。

编译

Android 所有类型的资源的编译都是通过 AAPT2 来完成,资源的编译使用 compile 子命令,编译成功后,会生成一个扩展名为 .flat 的中间二进制文件,正常情况下,每一个输入的资源文件对应输出一个 .flat 文件,然后在后续的链接阶段使用。

语法

使用 compile 的一般语法如下:

  1. aapt2 compile path-to-input-files [options] -o output-directory/

对于资源文件,输入文件的路径必须符合以下结构: path/resource-type[-config]/file

编译单个资源

  1. aapt2 compile -o build ./app/src/main/res/mipmap-xxxhdpi/ic_launcher.png

编译多个资源

  1. aapt2 compile -o build \
  2. ./app/src/main/res/mipmap-xxxhdpi/ic_launcher.png \
  3. ./app/src/main/res/layout/activity_main.xml \
  4. ./app/src/main/res/values/strings.xml

编译整个目录

  1. aapt2 compile -o build/resources.ap_ --dir ./app/src/main/res/

编译出的文件解压后,都是.flat文件

编译选项


选项 说明
-o path 指定已编译资源的输出路径。这是一个必需的标记,因为您必须指定 AAPT2 可将已编译的资源输出并存储到其中的目录的路径。
--dir directory 指定要在其中搜索资源的目录。虽然您可以使用此标记通过一个命令编译多个资源文件,但这样就无法获得增量编译的优势,因此不建议对大型项目使用。

链接阶段

在链接阶段,AAPT2 会合并在编译阶段生成的所有中间文件(.flat 文件与AndroidManifest.xml),并将它们打包成 ZIP 包(最终 APK 的原型,由于不包括 DEX 文件且未签名,所以无法正常安装)

链接语法

  1. aapt2 link path-to-input-files [options] -o
  2. outputdirectory/outputfilename.apk --manifest AndroidManifest.xml

链接资源使用 link 子命令,如下所示:

  1. aapt2 link -o resources.ap_
  2. -I $ANDROID_HOME/platforms/android-29/android.jar
  3. --manifest build/intermediates/manifests/full/debug/AndroidManifest.xml
  4. build/layout_activity_main.xml.flat
  5. build/values_styles.arsc.flat
  6. build/values_colors.arsc.flat
  7. build/values_strings.arsc.flat
  8. build/mipmap-xxxhdpi_ic_launcher.png.flat
  9. build/mipmap-xxxhdpi_ic_launcher_round.png.flat

链接选项

选项 说明
-o path 指定链接的资源 APK 的输出路径。这是一个必需的标记,因为您必须指定可以存放链接资源的输出 APK 的路径。
--manifest file 指定要构建的 Android 清单文件的路径。这是一个必需的标记,因为清单文件中包含有关您应用的基本信息(如软件包名称和应用 ID)。
-I 提供平台的 android.jar 或其他 APK(如 framework-res.apk)的路径,这在构建功能时可能很有用。如果您要在资源文件中使用带有 android 命名空间(例如 android:id)的属性,则必须使用此标记。
--java directory 指定要在其中生成 R.java 的目录。

具体语法可查看 https://developer.android.com/studio/command-line/aapt2#link_options

链接结束后,就会生成一个apk文件,如果你将这个apk文件拖入AS中查看,可以看到他只有资源文件,没有dex文件。

拿一个APK作为例子,可以查看他的资源索引文件 resources.arsc
image.png
红色部分的16进制ID,对应的就是R文件中的值,像这样
image.png
AssetManager 就是这么定位资源的

转储语法

除了把apk拖入as中查看外,还可以使用dump命令

  1. aapt2 dump sub-command filename.apk [options]

子命令 sub-command

子命令 说明
apc 输出在编译期间生成的 AAPT2 容器(APC)的内容。
badging 输出从 APK 的清单中提取的信息。
configurations 输出 APK 中的资源使用的每项配置。
packagename 输出 APK 的软件包名称。
permissions 输出从 APK 的清单提取的权限。
strings 输出 APK 的资源表字符串池的内容。
styleparents 输出 APK 中使用的样式的父项。
resources 输出 APK 的资源表的内容。
xmlstrings 输出 APK 的已编译 xml 中的字符串。
xmltree 输出 APK 的已编译 xml 树。
选项 说明
--no-values 禁止在显示资源时输出值。
--file file 将文件指定为要从 APK 转储的参数。
-v 提高输出的详细程度。

flat(aapt2的产出物)AAPT 产物的关系

Android Gradle Plugin 3.0 以前的版本中,AAPT 的产物主要有 3 类:

  1. 已编译的二进制 XML,例如:布局 XML 文件;
  2. 字符串池(String Pool),内嵌于 Resource Table 中,一般不会独立存在;
  3. 资源表(Resource Table),例如:ARSC 文件;

AAPT2 的大部分数据结构都采用 protobuf 重新进行编码,但还有一小部分数据结构仍然复用了AAPT 的格式,例如:String Pool ,我们从 AAPT2proto 定义便可以看出来:

  1. message StringPool {
  2. bytes data = 1;
  3. }
  4. message ResourceTable {
  5. // The string pool containing source paths referenced throughout the resource table. This does
  6. // not end up in the final binary ARSC file.
  7. StringPool source_pool = 1;
  8. // Resource definitions corresponding to an Android package.
  9. repeated Package package = 2;
  10. }

AAPT2 为什么要将中间产物编码成 flat 格式

主要原因在于 AAPT2 将资源打包过程拆分成了两个阶段:「编译阶段」和「链接阶段」,为了在链接阶段得到资源更详细的信息,例如:资源名称、配置信息(Configuration) 等,因此,直接将资源的元信息连同资源本身一同编码进 AAPT2 容器文件中,这样,资源链接的过程可以完全与编译过程解耦了,而且,对于增量构建来说,这样大大提升了资源打包的性能。

以上步骤完成就可以使用apk了吗

首先,一个apk的结构大致如下,

  • classes.dex
  • 资源文件
  • resources.arsc
  • 签名摘要
  • 可选的 assets 等

前三个在前面已经单独编译过,现在需要整合他们。也就是通过appt2得到的文件重命名为 apk后 ,通过如下命令整合dex文件

  1. zip -ur app-debug.apk classes.dex

这样就得到了一个未签名的apk,app-unsigned.apk,下一步就是对apk签名,可以通过 apksigner 工具,使用 android debug key进行签名

签名成功后,就可以安装到手机上了。

d8? R8?

d8

Android Studio 3.0 推出了d8,并在 3.1 正式成为默认工具。它的作用是将“.class”文件编译为 Dex 文件,取代之前的 dx 工具。

image.png

d8 除了更快的编译速度之外,还有一个优化是减少生成的 Dex 大小。根据 Google 的测试结果,大约会有 3%~5% 的优化。
image.png

R8

R8 在 Android Studio 3.1 中引入,它的志向更加高远,它的目标是取代 ProGuard 和 d8。我们可以直接使用 R8 把“.class”文件变成 Dex。现在默认为使用gradle插件3.4.0及更高版本的应用程序和Android库项目启用。
image.png
同时,R8 还支持 ProGuard 中混淆、裁剪、优化这三大功能,R8 的最终目的跟 d8 一样,一个是加快编译速度,一个是更强大的代码优化。

总结

现在再来谈谈apk的整个构建流程,会显得更加清晰,下面是官方的图:

image.png

通过上面的分析,再做一个流程的总结

  1. 使用aapt工具,编译res/文件,生成编译后的二进制资源文件(.ap_文件)、R.java文件。(目前新版使用aapt2工具,R.java也替换成了R.jar)
  2. 使用aidl工具,根据aidl文件生成对应的Java接口文件
  3. 使用Java Compiler工具,Java Compiler(俗称javac)将R.java、项目中的代码、Aidl接口文件编译成.class文件。
  4. 使用dex工使用apkbuilder工具,将编译后的资源(.ap_文件)、dex文件及其他资源文件(例如:so文件),压缩成一个.apk文件。
  5. 使用apkbuilder工具,将编译后的资源(.ap_文件)、dex文件及其他资源文件(例如:so文件),压缩成一个.apk文件。
  6. 使用Jarsigner工具,读取签名文件,对上一步中产生的apk文件进行签名,生成一个已签名的apk文件。
  7. 使用zipalign工具,对已签名的apk文件进行体积优化(只有v1签名才有这一步,v2签名的apk会在zipalign后签名被破坏)。


工具

  1. 把下面的代码放进app/build.gradle里把时间花费超过50ms的任务时间打印出来 ```groovy public class BuildTimeListener implements TaskExecutionListener, BuildListener { private Clock clock private times = []

    @Override void beforeExecute(Task task) {

    1. clock = new org.gradle.util.Clock()

    }

    @Override void afterExecute(Task task, TaskState taskState) {

    1. def ms = clock.timeInMs
    2. times.add([ms, task.path])
    3. //task.project.logger.warn "${task.path} spend ${ms}ms"

    }

    @Override void buildFinished(BuildResult result) {

    1. println "Task spend time:"
    2. for (time in times) {
    3. if (time[0] >= 50) {
    4. printf "%7sms %s\n", time
    5. }
    6. }

    }

    …… }

project.gradle.addListener(new BuildTimeListener())

  1. 执行./gradlew assembleDebug
  2. 2. 写入如下代码,输出每个Task对应的类,然后查看Task的具体工作:
  3. ```groovy
  4. //build.gradle
  5. gradle.taskGraph.whenReady {
  6. it.allTasks.each { task ->
  7. println("Task Name : ${task.name}")
  8. task.dependsOn.each{ t->
  9. println "-----${t.class}"
  10. }
  11. //def outputFileStr = task.outputs.files.getAsPath();
  12. //def inputFileStr = task.inputs.files.getAsPath()
  13. }
  14. }
  15. dependencies {
  16. ...
  17. testImplementation "com.android.tools.build:gradle:4.0.0"
  18. ...
  19. }

参考文章

https://mp.weixin.qq.com/s/69ndd2NCx27JWxJGEqTQBg
https://mp.weixin.qq.com/s/oAZWeZEIbgnYQELJwtW7ZA
https://booster.johnsonlee.io/architecture/aapt2-output-reversing.html#aapt2