极致包体: 你懂ZIP,但你懂怎么从ZIP优化包体么

1,533 阅读15分钟

写在前面

大家好,我是你们最爱的三雒。今天我们从大家最最常见的ZIP文件切入,来看看有什么包体积优化点。 我们在做Android包体积优化时最常用的分析手段是将Apk拖入AS中分析文件的大小和组成,很自然发现Apk是由Dex、So、资源文件(resource.arsc,xml,asests等)三大部分组成,针对每一部分都可以进行相应的深入优化。AS的这个简易的包体分析工具提供给我们很多便利,但是也往往会让我们忽略Apk文件本身也是可以优化的,有点身入其中,不识庐山真面目的意思。 image.png APK文件本身是一个ZIP文件,理解ZIP格式,从ZIP文件入手优化APK也是包体积优化不可忽略的一部分。本文大致分为两大部分:

  • 先对ZIP文件格本身做简单的介绍,对ZIP文件数据区、中央目录记录区、中央目录记录区尾部区进行一定分析
  • 针对ZIP文件的每个区域进行拆分以及挖掘,引出我们耳熟能详或者可能创新的优化点

ZIP格式简介

ZIP文件作为一个压缩文件的归档格式,在大家在日常工作和学习中广泛使用,可谓是计算机文件传输家族的顶梁柱,对于它的深入了解我认为是非常必要的,接下来我们来看一下ZIP文件的格式组成。按照 ZIP标准 中,一个ZIP文件的整体格式如下,主要由三大部分组成数据区中央目录记录区中央目录记录尾部区

    
    ----- 数据区
    [local file header 1]
    [file data 1]
    [data descriptor 1]
    . 
    .
    .
    [local file header n]
    [file data n]
    [data descriptor n]
    
    ----- 中央目录记录区
    [archive decryption header] (EFS)
    [archive extra data record] (EFS)
    [central directory]
    
    -----  中央目录记录尾部区
    [zip64 end of central directory record]
    [zip64 end of central directory locator] 
    [end of central directory record]

如下使用010 editor解析后的一个ZIP文件,该文件中只包含3个webp文件,基本上也可以看出是按照上述三大部分进行解析的。

数据区

我们在日常使用都中都会往ZIP文件中放入多文件,直观地感觉是所有的文件被作为一个整体被压缩的,但其实每个文件的数据都是单独压缩的。这也不奇怪我们在使用编程语言API读取文件时候是可以随机读取出任何一个Entry的,如果是所有文件整体压缩的话,是很难高效单独读取的,因为压缩算法的原理一般解压数据是需要先解压前面的,才能解压出后面的,这意味着只想解压一个存储靠后的文件效率时候非常低的,需要几乎把所有文件全部解压。铺垫了这么多,来看看一个文件压缩后在ZIP中存储的相关信息对应的结构,主要由如下三个子部分:

[local file header]
[file data]
[data descriptor]

local file header

OffesetBytesDescription
04local file header signature文件头标识 (固定值0x04034b50)
42version needed to extract解压时遵循ZIP规范的最低版本
62general purpose bit flag通用标志位
82compression method压缩方式
102last mod file time最后修改时间(MS-DOS格式)
122last mod file date最后修改日期(MS-DOS格式)
144crc-32冗余校验码(crc-32)
184compressed size压缩后的大小
224uncompressed size未压缩之前的大小
262file name length文件名长度(n)
282extra field length扩展区长度(m)
30nfile name文件名
30+nmextra field扩展区

其中我们主要关注两个信息:

  • 解压最低版本

2个字节,记录解压缩文件所需的最低支持的ZIP规范版本,apk解压版本默认是20, 即Deflate压缩方式。当前最低功能版本定义如下:(压缩包记录的解压版本都是需要版本*10,比如:2.0 * 10 = 20)

1.0 - 默认值
1.1 - 文件是卷标
2.0 - 文件是一个文件夹(目录)
2.0 - 使用 Deflate 压缩来压缩文件
2.0 - 使用传统的 PKWARE 加密对文件进行加密
2.1 - 使用 Deflate64™ 压缩文件
2.5 - 使用 PKWARE DCL Implode 压缩文件
2.7 - 文件是补丁数据集
4.5 - 文件使用 ZIP64 格式扩展
4.6 - 使用 BZIP2 压缩文件压缩
5.0 - 文件使用 DES 加密
5.0 - 文件使用 3DES 加密
5.0 - 使用原始 RC2 加密对文件进行加密
5.0 - 使用 RC4 加密对文件进行加密
5.1 - 文件使用 AES 加密进行加密
5.1 - 使用更正的 RC2 加密对文件进行加密
5.2 - 使用更正的 RC2-64 加密对文件进行加密
6.1 - 使用非 OAEP 密钥包装对文件进行加密
6.2 - 中央目录加密
  • 压缩方法

记录当前文件的压缩方式,有如下12种,其中0表示原文件存放不压缩,8表示使用Deflate算法压缩。JDK 7的Zip实现只支持0和8两种,其他的均不支持。对于Andorid Apk而言,大部分文件都是使用Defalte压缩,也有一些情况下为了提升文件的运行时加载速度是选择不压缩的,比如resource.arsce, so文件等, 在不压缩的情况下可以直接mmap,加快IO的速度。

0 - The file is stored (no compression)
1 - The file is Shrunk
2 - The file is Reduced with compression factor 1
3 - The file is Reduced with compression factor 2
4 - The file is Reduced with compression factor 3
5 - The file is Reduced with compression factor 4
6 - The file is Imploded
7 - Reserved for Tokenizing compression algorithm
8 - The file is Deflated
9 - Enhanced Deflating using Deflate64™
10 - PKWARE Data Compression Library Imploding
11 - Reserved by PKWARE
12 - File is compressed using BZIP2 algorithm

file data

file data紧跟在local file header之后,存储文件的具体数据,根据其压缩方式不同可能是源文件本身数据也可能是压缩后的数据。

data descriptor

只有当 local file header的 general purpose bit flag 字段第3位bit置1时,data descriptor才会存在。 它是字节对齐的,紧跟在文件数据的最后一个字节之后。当且仅当无法在ZIP 文件中查找时才使用此描述符,例如:当输出的ZIP文件是标准输出或不可查找设备时使用文件描述。 说人话就是,看看就好,正常情况下都不需要使用。

OffsetBytesDescription
44crc-32冗余校验码
84compressed size压缩后的大小
124uncompressed size未压缩之前的大小

中央目录记录区

中央目录记录区是由一系列的file header所组成,一个file header对应数据区中的一个压缩文件。

[file header 1]
      .
      .
      . 
[file header n]
[digital signature] 

file header中存储的信息如下:

OffsetBytesDescription
04central file header signature文件头标识 (固定值0x02014b50)
42version made by高位字节表示文件属性信息的兼容性, 低位字节表示压缩软件支持的ZIP规范版本
62version needed to extract解压时遵循ZIP规范的最低版本
82general purpose bit flag通用标志位
102compression method压缩方式
122last mod file time最后修改时间(MS-DOS格式)
142last mod file date最后修改日期(MS-DOS格式)
164crc-32冗余校验码
204compressed size压缩后的大小
244uncompressed size未压缩之前的大小
282filename length文件名长度(n)
302extra field length扩展区长度(m)
322file comment length文件注释长度(k)
342disk number start文件开始位置的磁盘编号
362internal file attributes内部文件属性
384external file attributes外部文件属性
424relative offset of local header对应 [local file header] 的偏移位置
46nfile name文件名
46+nmextra field扩展域
46+n+mkfile comment文件注释内容

中央目录记录尾部区

中央目录记录尾部主要作用是用来定位中央目录记录区的开始位置,同时记录压缩包的注释内容

OffsetBytesDescription
04end of central dir signature中心目录结束标识 (固定值0x06054b50)
42number of this disk当前磁盘编号
62number of the disk with the start of the central directory中心目录开始位置的磁盘编号
82total number of entries in the central directory on this disk该磁盘上所记录的entry数量
102total number of entries in the central directory中心目录中总共的entry数量
124size of the central directory中心目录大小
164offset of start of central directory with respect to the starting disk number中心目录开始位置相对于.ZIP archive开始的位移
202.ZIP file comment lengthZIP文件注释内容长度(n)
22n.ZIP file commentZIP文件注释内容

APK体积优化分析

上述我们已经对ZIP文件有了基本了解,知道三大部分中中央目录记录尾部只有固定数量的字节,是很小的,因此从ZIP视角看主要是针对中央目录记录区、数据区进行优化。

中央目录记录区优化

中央目录区是由一系列的file header组成的,占用的空间大致受file heaer大小数量两个因素影响。

资源混淆

从减少单个file header大小的角度出发,分析其中包含的的信息格式,字段大小基本上是固定的,有些无从下手,经过一通猜测有两个地方我们可能是可以优化的,因为它们的内容长度可变,一个是file name, 另一个是file comment。 但对于Android Apk文件而言一般打包过程中并不会写入file comment. 那么file name到底能不能优化呢?

Apk中的很多file name我们是可以自定义的,比如res目录下的文件res/xxhdpi/test.webp,我们完全可以叫做res/xxhdpi/a.wep,这样就比原来的字符更加短,所占用的空间也更加少。那我们是不是可以将其缩短为r/a.webp或者更极端缩短为a.webp,答案是可以的,这也就是我们耳熟能详资源混淆。由于我们代码或者编译过后的xml中基本上都是使用资源id来进行资源加载的,而资源id和资源文件路径的对应关系时候存储在resoure.arsc文件中的,这样就给了我们可乘之机,我们通过修改文件路径,并且同时修改resource.arsc文件,即可保证运行时资源加载的正确性。这个优化除了优化ZIP中央目录记录区之外,也同时能优化resource.arsc文件大小。

shrikResources

从减少file header的数量角度出发,主要就是尽可能删除APK内的无用文件,由于Apk中数量最多的是资源文件,所以shrinkResources对这部分有明显的贡献,最好开启模式效果更好。不过删除无用文件的收益主要还是来自文件大小本身,减少file header只是其”隐形“的附加的收益。

总的来说中央目录记录区占用的大小并不是很大,优化空间也比较有限。

数据区优化

数据区是占用空间的大头,同样受单个大小和数量两个因素影响。

提升压缩率

单个大小的主体是文件压缩后的数据,从ZIP的视角看就是如何提高压缩率。上面我知道ZIP支持很多压缩方法,总体切入点有两个。

  • APK内并不是所有的文件都是压缩的,有些文件是直接Store的,可以考虑将Sotore改为压缩状态。
  • APK使用的Deflate其实并不是压缩率最高的算法,可以考虑更换压缩率更高的算法。不过更换压缩算法的话需要考虑解压器,Android(JDK) 的ZIP实现只支持Store和Deflate两种,为了保证能正常安装,这就限制APK只能使用Deflate算法,那这样就没有优化空间了么?不然,Deflate算法只是个标准,具体的实现也是有优劣的,JDK的Deflate压缩并不算很优,使用更优Defalte算法也是尝试的方向之一。

Store改为压缩

public class PackagingUtils {
/**
* List of file formats which are already compressed or don't compress well, same as the one
* used by aapt.
*/
public static final ImmutableList<String> DEFAULT_AAPT_NO_COMPRESS_EXTENSIONS =
        ImmutableList.of(
                ".jpg", ".jpeg", ".png", ".gif", ".opus", ".wav", ".mp2", ".mp3", ".ogg",
                ".aac", ".mpg", ".mpeg", ".mid", ".midi", ".smf", ".jet", ".rtttl", ".imy",
                ".xmf", ".mp4", ".m4a", ".m4v", ".3gp", ".3gpp", ".3g2", ".3gpp2", ".amr",
                ".awb", ".wma", ".wmv", ".webm", ".mkv");
                
}

如上代码所示,考虑到运行时的性能,AGP在package阶段针对如上的文件格式不进行压缩,这些文件以Store形式存放到APK中。从包体积的角度考虑的话可以配置这些类型的文件压缩,AndResguard也提供了这项能力,如下我们的应用已经配置了其中四种格式的文件进行压缩。

andResGuard {

    use7zip = true
   
    compressFilePattern = [
                "*.png",
                "*.jpg",
                "*.jpeg",
                "resources.arsc",
                ]
        
 }

但仍然有一些文件是未压缩的,通过unzip -lv test.apk > zipinfo.txt来查看文件的压缩状态

如上有.webp , .mp3 , .jar文件没有进行压缩,但经过尝试发现仅有jar文件能压缩并有效。

jar文件的压缩前后变化

压缩前:

压缩后:

  • webp本身就是一种数据高度压缩的文件格式,很多webp经过Defalte压缩之后会更大,因此一些ZIP压缩器在压缩过程中选择将其以Stored形式存放。

  • mp3倒是压缩之后可以获取80KB的收益,但由于MediaPlayer在播放assets或者raw目录下的mp3时候经常会用到如下两个api,会在native层直接mmap,而mmap要求不压缩并且四字节对齐,否则会报错。

更优Defalte算法

按照 7z官网的说法 7-Zip 创建的 zip 格式比大多数其它压缩软件创建的都小 2-10%,因此AndResguard使用命令 7z a -tzip out.apk ./apkdir/* -mx=9 对APK进行重新压缩,此时使用的仍然是Deflate算法,压缩等级为最大9,得到的APK确实小了2%左右。我目前并未对Deflate以及7z的实现进行深入研究,按照微信的说法7z使用了大字典优化。

我们也测试和验证了另一个对ZIP重压缩的库advzip,其使用libdeflate算法,发现其比7z压缩之后的更小,可以再小1%左右,已经在我们的应用上做了验证。压缩前后信息对比如下图,左侧为7z压缩之后的,右侧为advzip压缩之后的。其中Defl:X表示压缩的最好,Defl:N表示正常压缩,从压缩前后entry的size上也可以看出收益。 advzip 库重压缩会把apk内所有的文件都压缩,不支持配置一些文件不压缩,这个需要修改代码扩展一下功能。

删除无用文件

从减少数量上看,主要还是无用文件删除,这个主要还是依赖APK内部文件所对应的优化手段去实现,本文不做详细讨论。

文件合并

从提升ZIP文件整体压缩视角看,还有另一个切入点文件合并,因为ZIP文件是单个文件压缩,无损压缩的方式只有重复数据压缩、编码压缩两种,而多个文件合并到一起之后重复数据会更多,而且编码压缩需要的字典也只需要一份,因此总体上能提高压缩率。该部分对于Dex这种大小有限制的文件并没有什么空间,如果能将一些So文件合并或者业务上的资源文件合并应该会有些优化效果,目前为止这部分并没有重大效果的优化实践。

总结

本文从ZIP视角切入系统地分析能够进行包体积优化的点,对于没有包体积优化经验的同学来说可能略微枯燥难懂,以下总结了本文的核心知识点。

  • APK文件是一个ZIP文件
  • ZIP文件是由数据区,中央目录记录区、中目录记录区尾部三部分组成
  • ZIP文件中单个文件是单独压缩的,压缩算法是可选的,常用的是Defalte或者Store
  • 缩短文件名字长度对于中央目录记录区是有一定的优化的
  • ZIP文件的压缩算法我们可以更换为压缩率更高的,但要考虑到解压端是否支持

这些知识点在做任何其他ZIP包的优化时都是通用的,希望能对大家有所帮助,也希望后来者能有更多的探索和创新吧。

参考文档

ZIP压缩算法详细分析及解压实例解释

zip 的压缩原理与实现

浅析ZIP格式

压缩包Zip格式详析