深入探索编译插桩(二:app打包编译)

845 阅读15分钟

🔥 Hi,我是小余。

本文已收录到 GitHub · Androider-Planet 中。这里有 Android 进阶成长知识体系,关注公众号 [小余的自习室] ,在成功的路上不迷路!

前言

现如今随着组件化, 插件化框架以及热修复,AOP编程等高级用法的新起,不得不驱使大家去了解更加底层的原理,上一篇文章笔者介绍了关于JVM字节码的理解,这篇文章笔者就来讲解下我们apk从编译到安装的过程。

首先来了解下我们apk的成员

apk文件组成

apk组成.png

1.AndroidManifest.xml:

apk的配置文件,内部包含了应用的名称,权限声明,四大组件等信息的声明,如果说app是一本书,它就是这本书的封面

2.classes.dex:

由源码编译后的的.class文件经过进一步转换成Android系统可识别的Dalvik Byte Code,包括第三方jar或aar中的class文件

3.resources.arsc:

资源索引表,使用的是一个二进制流的格式,记录R文件id和资源文件路径的一个映射表以及我们在res文件夹下放入一个资源文件,aapt就会自动在R文件中生成对应的资源文件id, 而R文件只能保证编译不报错,实际运行时还是需要依赖resources.arsc中的映射关系,通过R文件id找到实际资源文件路径。

resources.ars.png

4.res 目录:

存放未编译的资源文件

5.asserts:

也是资源文件夹,和res的区别:res文件会在R文件中生成索引,运行时使用索引获取资源文件,而asserts目录下的文件不会在R文件中生成索引,使用AssetManager访问文件

6.libs 目录:

存储so动态库

7.META-INF 目录:

存储签名和校验信息,确保程序的完整性。 Apk打包时会使用签名文件对apk进行签名校验,并将结果记录到META-INF 目录下,Apk安装的时候设备也会对应用进行一次校验,并和META-INF 目录下的校验信息对比,防止apk在出包后被篡改

apk打包编译过程

放一张官方apk编译过程图:

官方编译.awebp

旧版本打包编译过程更详细的描述了具体过程

详细编译过程.awebp

  • 1.将aidl文件使用aidl工具转换为编译器能够识别编译的java文件
  • 2.将资源文件(AndroidManifest.xml,xml布局文件,资源resources文件,asserts下的资源文件)使用aapt工具(最近几个版本已经改为优化后的aapt2)一部分打包为编译后的资源文件(resources.arsc),并生成对应的R文件, 以及生成Proguad 配置文件
  • 3.将java源文件以及aidl生成的java文件还有R文件,android类库文件使用javac一起编译为class文件
  • 4.使用proguard对class文件和资源文件进行混淆和优化
  • 5.然后和第三方类库class文件一起打包生成Android虚拟机可以识别的Dalvik Byte Code类型的dex文件
  • 6.使用apkBuilder工具,将dex文件和资源文件以及so库打包生成未签名的apk文件
  • 7.使用jarSigner工具将apk文件使用keystore签名生成签名后的apk文件
  • 8.使用zipAlign对apk进行对齐处理,对齐的过程就是将 APK 文件中所有的资源文件距离文件的起始位置都偏移4字节的整数倍,这样通过 mmap 访问 APK 文件的速度会更快,并且会减少其在设备上运行时的内存占用

到此我们已经了解另外apk的打包和编译的一个完整流程

这里我们提出两个问题

  • 1.为什么要将xml文件编译为resources.arsc中的二进制流格式?
  • 2.app运行的时候,系统是如何找到对应的资源文件的?

这两个问题其实答案都是相通的。

在前面一篇文章讲解class文件结构的时候,说到过,class文件有一个常量池的结构,其存储了当前class文件所需的所有常量字符串,外部访问常量的时候,是通过索引的方式进行访问

这里xml文件编译为resources.arsc过程也是这么一个方式。

那为什么要这么使用呢?

字符串复用,资源复用:当前xml文件的标签,属性名称以及属性值都会被编译到resources.arsc资源池中,且去除重复的字段,外部访问的时候,只需要 访问使用当前资源索引就可以在资源池中找到对应的资源信息,且不需要每次都去解析对应的字符串,解析资源效率高。 第二个问题在第一个问题解中就可以找到了

前面都是讲解源文件编译过程,这里我们再来突出下apk的签名校验过程

APK签名

密钥算法:

1.消息摘要

如MD5加密后的字符串就是一个消息摘要 特点:可以保证数据完整性,但是无法保证数据安全性和不可篡改性

2.数字签名

两部分组成:签名算法和验证算法

知识点:

公钥密码体制 对称加密算法和非对称加密算法

  • 1.公钥密码体制 :分为公钥和私钥和加密解密算法:常见为RSA算法

加密:通过公钥使用加密算法对明文进行加密,得到密文

解密:通过私钥使用解密算法对密文进行解密,得到明文

公钥加密的内容一定需要知道私钥才能解出明文 由管理员生成一套公钥和私钥。公钥和算法是公开的,私钥是只有管理员才有,保证数据不可以篡改

  • 2.对称加密算法:常见有DES 3DES AES等, 对称加密算法使用同一个密钥对明文进行加解密操作,所以需要保证密钥的安全性。

  • 3.非对称加密算法:常见有RSA算法 加密使用的密钥和解密使用的密钥是不相同的。

RSA

  • 1.加密终端使用公钥加密,服务端使用私钥解密:数据只能由服务端解密,可以保证不可篡改性。

  • 2.签名服务端使用私钥加密,终端使用公钥解密:可以保证数据是由服务端使用私钥加密后的数据,且服务端不能否认这个结果

  • 数字签名简介:非对称加密技术 + 消息摘要技术的结合

如果单独使用私钥进行加密只可以保证数据的完整性,无法保证数据的保密性且数据大时加密的效率速度很低,所以采用加密摘要算法生成的摘要消息弥补缺点

具体步骤:

发送者

  • 1.发送者使用使用MD5获得摘要
  • 2.发送者使用私钥对摘要加密获得数字签名
  • 3.发送者需要将原始信息和数字签名一同发送给接收者

接收者

  • 4.接收者先把接收到的原始消息用同样的摘要算法摘要,形成“准签体”。
  • 5.对附加上的那段数字签名,使用预先得到的公钥解密。
  • 6.比较前两步所得到的两段消息是否一致: 如果一致,则表明消息确实是期望的发送者发的,且内容没有被篡改过; 相反,如果不一致,则表明传送的过程中一定出了问题,消息不可信。 数字签名和验证过程.png

v1签名:

签名过程:

签名三兄弟1.MANIFEST.MF 2.CERT.SF 3.CERT.RSA

  • 1.MANIFEST.MF

该内容保存的是逐一遍历 APK 中的所有条目,使用摘要算法如SHA1或者SHA256算出摘要信息,并使用base64编码后,存放到MANIFEST.MF块中 每个块包含一个Name属性:存放该文件的路径

MANIFEST.MF.png

  • 2.CERT.SF

SHA1-Digest-Manifest:对整个 MANIFEST.MF 文件做 SHA1(或者 SHA256)后再用 Base64 编码

SHA1-Digest:对 MANIFEST.MF 的各个条目做 SHA1(或者 SHA256)后再用 Base64 编码

CERT.SF.png

  • 3.CERT.RSA

这里会把之前生成的 CERT.SF 文件,用私钥计算出签名, 然后将签名以及包含公钥信息的数字证书一同写入 CERT.RSA 中保存

CERT.RSA.png

这个数字证书就是我们android签名的时候使用的签名keystore文件:

使用下面命令查看签名证书情况

penssl pkcs7 -inform DER -in /<文件存放路径>/Sample-release_new/original/META-INF/CERT.RSA -text -noout -print_certs

完整签名流程:

apk完整签名流程.png

校验过程: 在安装步骤校验:

  • 1.检查APK中所有的文件,对应的摘要值和MANIFEST.MF中的值保持一致
  • 2.对MANIFEST.MF中的值做二次摘要,查看是否和CERT.SF中的值保持一致
  • 3.使用数字证书文件CERT.RSA检查SF文件有没被修改过,使用公钥证书解密
  • 4.解密后的摘要信息和步骤2中的二次摘要信息对比,看下是否一致
  • 5.一致就通过,说明apk信息没有被更改过

apk v1完整的验证流程.png 为什么使用这样的签名流程呢?

  • 1.更改APK文件里面的文件,MANIFEST.MF中的摘要信息不一致,校验失败
  • 2.在1的基础上更改MANIFEST.MF的摘要信息,那么和CERT.SF中的摘要信息会不一致,校验失败
  • 3.在2的基础上更改CERT.SF中的摘要信息,那么CERT.RSA中的签名信息不一致,校验失败。
  • 4.在3的基础上更改证书,不行,因为没有私钥。

所有可以基本上保证数据的不可篡改性

v2签名:

v1缺点:

  • 签名速度慢,需要对每个文件进行签名
  • 完整性保持不够:因为META-INF 目录用于签名,所以不会记录到校验信息内,用户可以在这个文件中随意添加文件

v2签名

就是把 APK 按照 1M 大小分割,分别计算这些分段的摘要, 最后把这些分段的摘要在进行计算得到最终的摘要也就是 APK 的摘要。 然后将 APK 的摘要 + 数字证书 + 其他属性生成签名数据写入到 APK Signing Block 区块。

V2签名过程.png v2校验

v2 签名机制是在 Android 7.0 以及以上版本才支持。因此对于 Android 7.0 以及以上版本,在安装过程中,如果发现有 v2 签名块,则必须走 v2 签名机制,不能绕过。否则降级走 v1 签名机制。 v1 和 v2 签名机制是可以同时存在的,其中对于 v1 和 v2 版本同时存在的时候,v1 版本的 META_INF 的 .SF 文件属性当中有一个 X-Android-APK-Signed 属性: X-Android-APK-Signed: 2

V2校验过程.png v2 对多渠道打包的影响

之前的渠道包生成方案是通过在 META-INF 目录下添加空文件,用空文件的名称来作为渠道的唯一标识。但在新的应用签名方案下 META-INF 已经被列入了保护区了,向 META-INF 添加空文件的方案会对区块 1、3、4 都会有影响。

总结V2

  • 1.V2并行计算加快签名速度
  • 2.V2保证META-INFO目录不会被篡改

apk安装过程

  • 1.复制APK到/data/app目录下。解压并扫描包
  • 2.资源管理器解析apk里面的资源文件
  • 3.解析AndroidManifest文件,并在/data/data/目录下创建对应的应用数据目录
  • 4.然后对dex文件进行优化,并保存在dalvik-cache目录下
  • 5.将AndroidManifest文件解析出的四大组件信息注册到PackageManagerService中
  • 6.安装完成后,发送广播

广义编译-CI

CI 即 持续集成,在大型开发团队中,CI 的建设是重中之重,CI 主要包括 打包构建、Code Review、代码工程管理、代码扫描 等一系列流程。它的 整套运转体系 可以简化为下图:

CO.awebp

1、持续集成的原因

构建 CI 的目的主要是为了解决以下四个问题。

1)、项目依赖复杂

随着业务的发展,基础组件库的数量会持续上涨,这个时候组件间的关系就会变得错综复杂,这将会导致如下 两个问题

  • 1、如果某个开发同学需要修改代码,极有可能会影响到其它业务,牵一发而动全身
  • 2、人工维护组件间复杂的依赖关系非常困难

2)、琐碎的研发流程

在日常的功能开发中,我们一般都会经 代码开发、组件发版、组件集成、打包、测试这五个步骤。如果测试发现 Bug 需要进行修复,然后会再次经历代码修改、组件发版、组件集成、打包、测试,直到测试通过交付产品。传统的研发流程如下图所示:

ci.awebp

可以看到,开发同学在整个开发流程中需要手动提交 MR、升级组件、触发打包以及去实时监控流程的状态,这样肯定会严重影响开发的专注度,降低研发的生产力。

3)、与 App 性能监控体系的融合

随着 App从 项目初期 => 成长期 => 成熟期,对性能的要求会越来越高,为了保障性能的足够稳定,我们需要制造出许多性能监控的工具,以实时监控我们应用的性能。而 App 性能监控体系必须和 CI 结合起来,以实现流程的自动化和平台化。

4)、项目的编译构建速度缓慢

随着 App 的体积变大,依赖变多,项目的编译构建速度会越来越慢,缓慢的编译速度会严重拖垮开发同学的研发效率。因此,提升 App 的编译构建速度刻不容缓。

2、持续集成的主要步骤

持续集成涉及的流程非常多,但是有 两个主要的步骤是非常重要 的,具体如下所示:

1)、代码检查

为了防止不符合规范的代码提交到远程仓库中,我们需要 自定义一套符合自身项目的编码规范,并使用专门的插件来检测。自定义代码检测可以通过完全自己实现或者扩展 Findbugs 插件,例如美团就利用 Findbugs 实现了 Android 漏洞扫描工具 Code Arbiter,其中 FindBugs 是一个静态分析工具,它一般用来检查类或者 JAR 文件,将字节码与一组缺陷模式进行对比来发现可能存在的问题,它可以以独立的 JAR 包形式运行,也可以作为集成开发工具的插件形式而存在。而 FindBugs 插件具有着极强的可扩展性,只需要将扩展的 JAR 包导入 FindBugs 插件,重启 AS,即可完成相关功能的扩展。

在 FindBugs 有一款专门对安全问题进行检测的扩展插件 Find Security Bugs,该插件主要用于对 Web 安全问题进行检测,也有极少对Android相关安全问题的检测规则。我们只需要 定制化自己的 Find Security Bugs,通过增加检测项来检测尽可能多的安全问题,通过优化检测规则来减少检测的误报 即可,这里我们可以直接使用 Android_Code_Arbiter 这个插件,它 去除了其中跟 Android 漏洞无关的漏洞,保留了与 Android 相关的,并增加了其它的一些检测项,以此形成了针对与于 Android 的源码审计工具

此外,我们也可以使用 第三方的代码检查工具,例如收费的 Coverity,以及 Facebook 开源的 Infer

然而,尽管将问题代码扫描出来了,可是还是会有不少开发同学不知道如何修改,对于这种情况,我们可以给在自定义代码扫描工具的时候,对于每一个问题检查项都给出对应的修改方针

最后,我们可以据此建立一个解决项目异常的流程:建立一个服务专门每天跑项目的 Lint 检查,跑完将警告汇总分配到对应的负责人身上,并邮件告知他,直到上线。

2)、Code Review

Code Review 非常重要,在每一次提交代码时,我们都需要自己进行一次 Code Review,然后再让别人去 Review,以建立自身良好的技术品牌。

有些同学可能会认为 CI 并不重要,它好像跟具体的技术并无关联。但是,我们需要知道,学会不仅仅是钻在开发角度看问题,跳脱出来,站在用户角度,站在产品角度,或许会有意外的收获

总结

在本篇文章,我们即涉及到了 Android 编译的深度方面:App 的编译和打包流程签名算法的原理,也涉及到了 Android 编译的广度方面持续集成。因此,在我们学习的过程中,技术就像是一棵树,在顶部叶子上各个领域看似毫不相干,但是在一个领域越往下深入,各个领域相互交错到的知识或者设计方式就越多,所以技术深度和广度并不是对立面,对技术深度的探索不仅有利于你在特定领域有更深理解,更加可以帮助你轻松切换到另一个领域,特别是像前端的各细分领域的工作,很多领域的知识背后都殊途同归,而技术的广度也不是有的人说的那样不堪,在有技术深度的基础上,去拓展自己的技术广度,其实会让你对原有技术的理解变得更加地深入。

参考

MCI:移动持续集成在大众点评的实践

编译插桩(一、编译基础)