本文主要记载

  • 1 为什么要优化包体积?
  • 2 包体积与应用性能有关系吗?
  • 3 根据 APK 组成分析优化
    • 3.1 代码相关
      • 3.1.1 Dex 探秘
      • 3.1.2 Proguard
      • 3.1.3 D8 与 R8 优化
      • 3.1.4 去除 debug 信息与行号信息
      • 3.1.5 Dex 分包
        • Redex Ubuntu 具体实践
      • 3.1.6 使用 XZ Utils 进行 Dex 压缩
      • 3.1.7 其他
    • 3.2 资源相关
      • 3.2.1 使用 Lint 的 Remove Unused Resource
      • 3.2.2 shrinkResources
      • 3.2.3 AndResGuard 工具
        • 实践
    • 3.3 Native Library 相关
      • 3.3.1 Library 压缩
      • 3.3.2 Library 合并与裁剪

1 为什么要优化包体积?

  1. 下载转化率。

    APK 刷脂在 初创项目 中优先级是比较低的,相对于 启动优化、卡顿优化 它的好处不是那么明显,包体积优化最主要的好处是对应用 下载转化率 的影响,它是 App 业务运营的重要指标之一,在项目精细化运营的阶段是非常重要的。在 2018 年的 Google I/OGoogle 透露了 Google Play 上安装包体积与下载转化率的关系图。

    图片来源于 Android 开发高手课

    从这张图上看,大体来说,安装包越小,转化率越高这个结论成立。包体积越小,用户下载等待的时间也会越短,所以下载转换成功率也就越高,一个 100MB 的应用,用户即使点了下载,也可能因为网络速度慢、突然反悔下载失败。对于一个 10MB 的应用,用户点了下载之后,在犹豫要不要下的时候已经下载完了。但是正如上图的数据,安装包大小与转化率的关系是非常微妙的。10MB15MB 可能差距不大,但是 10MB40MB 的差距还是非常明显的。

    而且,现在很多大型的 App 一般都会有一个极速版本的 App,这个也是出于下载转化率方面的考虑。

  2. 推广成本。一般来说,包体积对渠道推广和厂商预装的单价会有非常大的影响。特别是厂商预装,这主要是因为厂商留给预装应用的总空间是有限的。如果你的包体积非常大,那就会影响厂商预装其他应用。

  3. 应用市场。 Google Play 要求超过 100MB 的应用只能使用 APK 扩展文件方式 上传,由此可见应用包体积对应用市场的服务器带宽成本还是会有一点压力的。如果想避免使用扩展文件,并且想要应用程序的下载大小大于 100 MB,则应该使用 Android App Bundles 上传应用程序。

    Android App Bundle


2 包体积与应用性能有关系吗?

包体积除了转化率的影响,主要还会对 App 三个方面 的性能有一定的影响

  • 安装时间:文件拷贝、Library 解压,并且,在编译 ODEX 的时候,特别是对于 Android 5.06.0 系统来说,耗费的时间比较久,而 Android 7.0 之后有了 混合编译,所以还可以接受。最后,App 变大后,其签名校验的时间也会变长。微信 13Dex 光是编译 ODEX 的时间可能就要 5 分钟。
  • 运行内存:在内存优化的时候我们就说过,Resource 资源、Library 以及 Dex 类加载这些都会占用不少的内存。
  • ROM 空间:如果应用的安装包大小为 50MB,那么启动解压之后很可能就已经超过 100MB 了。并且,如果 闪存空间不足,很可能出现 “写入放大” 的情况。具体情况将在 I/O 优化 中讲解。

3 根据 APK 组成分析优化

我们都知道,Android 项目最终会编译成一个 .apk 后缀的文件,实际上它就是一个 压缩包。因此,它内部还有很多不同类型的文件,这些文件,按照大小,共分为 代码优化、资源优化、So 优化。

3.1 代码相关

classes.dex,我们在项目中所编写的 java 文件,经过编译之后会生成一个 .class 文件,而这些所有的 .class 文件呢,它最终会经过 dx 工具编译生成一个 classes.dex

对于大部分应用来说,Dex 都是包体积中的大头。日常开发中 Dex 数量从 1 个增长到 10 多个,我们的代码量真的增长了那么多倍吗?而且 Dex 的数量对用户安装时间也是一个非常大的挑战,在不砍功能的前提下,我们看看有哪些方法可以减少这部分空间。

与传统 jar 文件相比,Dex 文件的大小能够缩减 50% 左右。关于 Class 文件与 Dex 文件的结果对比图如下所示:

3.1.1 Dex 探秘

DexAndroid 系统的可执行文件,包含 应用程序的全部操作指令以及运行时数据。因为 Dalvik 是一种针对嵌入式设备而特殊设计的 Java 虚拟机,所以 Dex 文件与标准的 Class 文件在结构设计上有着本质的区别。

Java 程序被编译成 class 文件之后,还需要使用 dx 工具将所有的 class 文件整合到一个 dex 文件中,这样 dex 文件就将原来每个 class 文件中都有的共有信息合成了一体,这样做的目的是 保证其中的每个类都能够共享数据,这在一定程度上降低了信息冗余,同时也使得文件结构更加紧凑。

Dex文件格式详解

Dex 一般在应用包体积中占据了不少比重,并且 Dex 数量越多,App 的安装时间也会越长。所以,优化它们可以说是重中之重。下面就来看看有哪些方式可以优化 Dex 这部分的体积。


3.1.2 ProGuard

  1. 代码混淆的形式

    目前,代码混淆的形式主要有三种,如下所示:

    • 将代码中的各个元素,比如类、函数、变量的名字改变成无意义的名字。例如将 hasValue 转换成单个的字母 a。这样,反编译阅读的人就无法通过名字来猜测用途。
    • 重写代码中的部分逻辑,将它变成功能上等价,但是又难以理解的形式。比如它会改变循环的指令、结构体。
    • 打乱代码的格式,比如多加一些空格或删除空格,或者将一行代码写成多行,将多行代码改成一行。
  2. Proguard 的作用

    Android SDK 里面集成了一个工具 — Proguard,它是一个免费的 Java 类文件压缩、优化、混淆、预先校验的工具。它的主要作用大概可以概括为两点,如下所示:

    • 瘦身:它可以检测并移除未使用到的类、方法、字段以及指令、冗余代码,并能够对字节码进行深度优化。最后,它还会将类中的字段、方法、类的名称改成简短无意义的名字。

      • 压缩:默认开启,以减小应用体积,移除未被使用的类和成员,并且会在优化动作执行之后再次执行,因为优化后可能会再次暴露一些未被使用的类和成员。我们可以使用如下规则来关闭压缩:

        1
        2
        # 关闭压缩
        -dontshrink
      • 优化:默认开启,在字节码级别执行优化,让应用运行的更快。使用如下规则可进行优化相关操作:

        1
        2
        3
        4
        # 关闭优化
        -dontoptimize
        # 表示proguard对代码进行迭代优化的次数,Android一般为5
        -optimizationpasses 5

        优化细节包括:内联、修饰符、合并类、方法、 Gson 库的使用、把类都标记为 final、把枚举类型简化为常量、把一些类都垂直合并进当前类的结构中、把一些类都水平合并进当前类的结构中、移除 write-only 字段、把类标记为私有的、把字段的值跨方法地进行传递、把一些方法标记为私有、静态或 final、解除方法的 synchronized 标记、移除没有使用的方法参数等 30 多种优化项

    • 安全:增加代码被反编译的难度,一定程度上保证代码的安全。

      • 混淆:默认开启,增大反编译难度,类和类成员会被随机命名,除非用 优化字节码 等规则进行保护。使用如下规则可以关闭混淆:

        1
        2
        # 关闭混淆
        -dontobfuscate

    所以说,混淆不仅是保障 Android 程序源码安全第一道门槛,还可以优化字节码的大小

  3. Proguard 的配置

    混淆之后,默认会在工程目录 app/build/outputs/mapping/release 下生成一个 mapping.txt 文件,这就是混淆规则,所以我们可以根据这个文件把混淆后的代码反推回原本的代码。要使用混淆,我们只需配置如下代码即可:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    buildTypes {
    release {
    // 1、是否进行混淆
    minifyEnabled true
    // 2、开启zipAlign可以让安装包中的资源按4字节对齐,这样可以减少应用在运行时的内存消耗
    zipAlignEnabled true
    // 3、移除无用的resource文件:当ProGuard 把部分无用代码移除的时候,
    // 这些代码所引用的资源也会被标记为无用资源,然后
    // 系统通过资源压缩功能将它们移除。
    // 需要注意的是目前资源压缩器目前不会移除values/文件夹中
    // 定义的资源(例如字符串、尺寸、样式和颜色)
    // 开启后,Android构建工具会通过ResourceUsageAnalyzer来检查
    // 哪些资源是无用的,当检查到无用的资源时会把该资源替换
    // 成预定义的版本。主要是针对.png、.9.png、.xml提供了
    // TINY_PNG、TINY_9PNG、TINY_XML这3个byte数组的预定义版本。
    // 资源压缩工具默认是采用安全压缩模式来运行,可以通过开启严格压缩模式来达到更好的瘦身效果。
    shrinkResources true
    // 4、混淆文件的位置,其中 proguard-android.txt 为sdk默认的混淆配置,
    // 它的位置位于android-sdk/tools/proguard/proguard-android.txt,
    // 此外,proguard-android-optimize.txt 也为sdk默认的混淆配置,
    // 但是它默认打开了优化开关。并且,我们可在配置混淆文件将android.util.Log置为无效代码,
    // 以去除apk中打印日志的代码。而 proguard-rules.pro 是该模块下的混淆配置。
    // 如果想让各个 library 的 proguard-rules 也生效使用 consumerProguardFiles 'proguard-rules.pro'
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    signingConfig signingConfigs.release
    }
    }

    在执行完 ProGuard 之后,ProGuard 都会在 ${project.buildDir}/outputs/mapping/${flavorDir}/ 生成以下文件:

    文件名 描述
    dump.txt APK 中所有类文件的内部结构
    mapping.txt 提供原始与混淆过的类、方法和字段名称之间的转换,
    可以通过 proguard.obfuscate.MappingReader 来解析
    seeds.txt 列出未进行混淆的类和成员
    usage.txt 列出从 APK 移除的代码
  1. 混淆的基本规则

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    # * 表示仅保持该包下的类名,而子包下的类名还是会被混淆
    -keep class com.json.chao.wanandroid.*
    # ** 表示把本包和所含子包下的类名都保持
    -keep class com.json.chao.wanandroid.**

    # 既保持类名,又保持里面的内容不被混淆
    -keep class com.json.chao.wanandroid.* {*;}

    # 也可以使用Java的基本规则来保护特定类不被混淆,比如extend,implement等这些Java规则
    -keep public class * extends android.app.Activity

    # 保留MainPagerFragment内部类JavaScriptInterface中的所有public内容不被混淆
    -keepclassmembers class com.json.chao.wanandroid.ui.fragment.MainPagerFragment$JavaScriptInterface {
    public *;
    }

    # 仅希望保护类下的特定内容时需使用匹配符
    <init>; //匹配所有构造器
    <fields>; //匹配所有字段
    <methods>; //匹配所有方法
    # 还可以在上述匹配符前面加上private 、public、native等来进一步指定不被混淆的内 容
    -keep class com.json.chao.wanandroid.app.WanAndroidApp {
    public <fields>;
    }
    # 也可以加入参数,以下表示用java.lang.String作为入参的构造函数不会被混淆
    -keep class com.json.chao.wanandroid.app.WanAndroidApp {
    public <init>(java.lang.String);
    }

    # 不需要保持类名,仅需要把该类下的特定成员保持不被混淆时使用keepclassmembers
    # 如果拥有某成员,要保留类和类成员使用-keepclasseswithmembers

    添加在 AndroidMainfest 中的类默认不会被混淆,所以四大组件和 Application 的子类和 Framework 层下所有的类默认不会进行混淆,自定义的 View 默认也不会被混淆。所以下面代码加不加都可以

    1
    2
    3
    4
    5
    6
    -keep public class * extends android.app.Activity
    -keep public class * extends android.app.Application
    -keep public class * extends android.app.Service
    -keep public class * extends android.content.BroadcastReceiver
    -keep public class * extends android.content.ContentProvider
    -keep public class * extends android.view.View

    “十个 ProGuard 配置九个坑”,特别是各种第三方 SDK。我们需要仔细检查最终合并的 ProGuard 配置文件,是不是存在过度 keep 的现象。

    可以通过下面的方法输出 ProGuard 的最终配置,尤其需要注意各种的 keep *,很多情况下我们只需要 keep 其中的某个包、某个方法,或者是类名就可以了。

    1
    -printconfiguration  configuration.txt

3.1.3 D8 与 R8 优化

  • D8 优化

    1, Dex编译时间更短

    2, .dex 文件大小更小

    3, D8 编译的 .dex 文件将拥有相同或者是更好的运行时性能

    4, 包含 Java 8 语言支持的处理

gradle.properties 文件中新增如下代码。Android Studio 3.1 或之后的版本 D8 将会被作为默认的 Dex 编译器。

1
android.enableD8 = true
  • R8 优化

如果我们当前使用的是 Android Studio 3.4Android Gradle 插件 3.4.0 及其更高版本, R8 会作为默认编译器。否则,我们必须要在 gradle.properties 中配置如下代码让 App 的混淆去支持 R8,如下所示:

1
2
android.enableR8=true
android.enableR8.libraries=true
  • R8 与混淆相比优势在哪里呢

ProGuardR8 都应用了基本名称混淆:它们都使用简短,无意义的名称重命名类,字段和方法。他们还可以删除调试属性。但是,R8inline 内联容器类中更有效,并且在删除未使用的类,字段和方法上则更具侵略性。例如,R8 本身集成在 ProGuard V6.1.1 版本中,在压缩 apk 的大小方面,与 ProGuard8.5% 相比,使用 R8 apk 尺寸减小了约 10%。并且,随着 Kotlin 现在成为 Android 的第一语言,R8 进行了 ProGuard 尚未提供的一些 Kotlin 的特定的优化。

想对 D8R8 的实现细节有更多地了解前往 Jake Wharton 个人博客


3.1.4 去除 debug 信息与行号信息

在讲解什么是 deubg 信息与行号信息之前,我们需要先了解 Dex 的一些知识。

我们都知道,JVM 运行时加载的是 .class 文件,而 Android 为了使包大小更加紧凑、运行时更加高效就发明了 DalvikART 虚拟机,两种虚拟机运行的都是 .dex 文件,当然 ART 虚拟机还可以同时运行 oat 文件。

所以 Dex 文件里的信息内容和 Class 文件包含的信息是一样的,不同的是 Dex 文件对 Class 中的信息做了去重,一个 Dex 包含了很多的 Class 文件,并且在结构上有比较大的差异,Class 是流式的结构,Dex 是分区结构,Dex 内部的各个区块间通过 offset 来进行索引。

某个应用通过相同的 ProGuard 规则生成一个 Debug 包和 Release 包,其中 Debug 包的大小是 4MBRelease 包只有 3.5MB

既然它们 ProGuard 的混淆与优化的规则是一样的,那它们之间的差异在哪里呢?那就是 DebugItem

图片来源于 Android 开发高手课

从图中可以看到,Dex 文件的结构主要分为四大块:header 区,索引区,data 区,map 区。而我们的 debug 与行号信息就保存在 data 区中的 DebugItems 区域。

DebugItem 里面主要包含两种信息:

  • 调试的信息。函数的参数变量和所有的局部变量。
  • 排查问题的信息。所有的指令集行号和源文件行号的对应关系。

为了在应用出现问题时,我们能在调试的时候去显示相应的调试信息或者上报 crash 或者主动获取调用堆栈的时候能通过 debugItem 来获取对应的行号,我们都会在混淆配置中加上下面的规则:

1
-keepattributes SourceFile, LineNumberTable

对于去除 debuginfo 以及行号信息更详细的分析,看一下支付宝的一篇文章《Android 包大小极致压缩》 。通过这个方法,我们可以实现既保留行号,但是又可以减少大约 5%Dex 体积。

事实上,支付宝参考的是 Facebook 的一个开源编译工具 ReDexReDex 除了没有文档之外,绝对是客户端领域非常硬核的一个开源库,非常值得去认真研究。ReDex 这个库去除 Debug 信息是通过 StripDebugInfoPass 完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"redex" : {
"passes" : [
"StripDebugInfoPass",
"RegAllocPass"
]
},
"StripDebugInfoPass" : {
"drop_all_dbg_info" : false, // 去除所有的debug信息,0表示不去除
"drop_local_variables" : true, // 去除所有局部变量,1表示去除
"drop_line_numbers" : false, // 去除行号,0表示不去除
"drop_src_files" : false,
"use_whitelist" : false,
"drop_prologue_end" : true,
"drop_epilogue_begin" : true,
"drop_all_dbg_info_if_empty" : true
},
"RegAllocPass" : {
"live_range_splitting": false
}
}

3.1.5 Dex 分包

当我们的 APK 过大时,Dex 的方法数就会超过 65536 个,因此,必须采用 mutildex 进行分包,但是此时每一个 Dex 可能会调用到其它 Dex 中的方法,这种跨 Dex 调用的方式会造成许多冗余信息,

简单来说,如下图所示如果将 Class AClass B 分别编译到不同的 Dex 中,由于 method a 调用了 method b,所以在 classes2.dex 中也需要加上 method bid

图片来源于 Android 开发高手课

因为跨 Dex 调用造成的这些冗余信息,它对我们 Dex 的大小会造成下如下两点:

  • method id 爆表:每个 Dexmethod id 需要小于 65536,因为 method id 的大量冗余导致每个 Dex 真正可以放的 Class 变少,这是造成最终编译的 Dex 数量增多。
  • 信息冗余:因为需要记录跨 Dex 调用的方法的详细信息,所以在 classes2.dex 我们还需要记录 Class B 以及 method b 的定义,造成 string_idstype_idsproto_ids 这几部分信息的冗余。

为了进一步减少 Dex 的数量,我们希望每个 Dex 的方法数都是满的,即分配了 65536 个方法。最好保证 Dex 有效率应该在 80% 以上。

1
Dex信息有效率 = define methods数量/reference methods数量

define methodsreference methods 可以通过 Android Studio 查看 APK 选择一个 dex 文件查看。define classes and methods 是指真正在这个 Dex 中定义的类以及它们的方法。而 reference methods 指的是 define methods 以及 define methods 引用到的方法。

图片来源于 Android 开发高手课

关于 Dex 的格式以及各个字段的定义,可以参考 Dex文件格式详解

那如何实现 Dex 信息有效率提升呢?

关键在于我们需要将有调用关系的类和方法分配到同一个 Dex 中,即减少跨 Dex 的调用的情况。但是由于类的调用关系非常复杂,我们不太可能可以计算出最优解,只能得到局部的最优解。

所幸的是,ReDexCrossDexDefMinimizer 类分析了类之间的调用关系,并使用了贪心算法去计算局部的最优解(编译效果和 dex 优化效果之间的某一个平衡点)。使用 InterDexPass 配置项可以把互相引用的类尽量放在同个 Dex,增加类的 pre-verify,以此提升应用的冷启动速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"redex" : {
"passes" : [
"InterDexPass",
"RegAllocPass"
]
},
"InterDexPass" : {
"minimize_cross_dex_refs": true,
"minimize_cross_dex_refs_method_ref_weight": 100,
"minimize_cross_dex_refs_field_ref_weight": 90,
"minimize_cross_dex_refs_type_ref_weight": 100,
"minimize_cross_dex_refs_string_ref_weight": 90
},
"RegAllocPass" : {
"live_range_splitting": false
}
}

Redex Ubuntu 具体实践

ubuntu 下载 我试过 16、18、20 的 Ubuntu 最终 20 的编译成功了。

Redex 文档

  1. 首先我们安装必要东西

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    sudo apt-get install \
    g++ \
    automake \
    autoconf \
    autoconf-archive \
    libtool \
    liblz4-dev \
    liblzma-dev \
    make \
    zlib1g-dev \
    binutils-dev \
    libjemalloc-dev \
    libiberty-dev \
    libjsoncpp-dev
  2. 然后拉取代码

    1
    2
    git clone https://github.com/facebook/redex.git
    cd redex
  3. 一般情况下都需要升级 boost ,可以先执行以下编译试试~

    -j4 表示使用 4 个线程编译。

    1
    2
    3
    # if you're using gcc, please use gcc-5
    autoreconf -ivf && ./configure && make -j4
    sudo make install
  4. 如果提示 error boost 版本的问题,升级 boost ,在 redex 目录内输入如下命令,然后重新执行第三步

    1
    sudo ./get_boost.sh
    在第三步编译过程中 18 版本的 `Ubuntu` 一直报错,各种办法未解决,`16` 版本 `boost` 升级配置不上。
  5. config 配置

    Redex 在运行的时候,它是根据 redex/config/default.config 这个配置文件中的通道 passes 中添加不同的优化项来对 APKDex 进行处理的,我们可以参考 redex/config/default.config 这个默认的配置,里面的 passes 中不同的配置项都有特定的优化。为了优化 App 的包体积,我们再加上 interdex_stripdebuginfo.config 中的配置项去删除 debugInfo 和减少跨 Dex 调用的情况,最终的 interdex_stripdebuginfo.config 配置代码 如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    {
    "redex" : {
    "passes" : [
    "StripDebugInfoPass",
    "InterDexPass",
    "RegAllocPass"
    ]
    },
    "StripDebugInfoPass" : {
    "drop_all_dbg_info" : false,
    "drop_local_variables" : true,
    "drop_line_numbers" : false,
    "drop_src_files" : false,
    "use_whitelist" : false,
    "cls_whitelist" : [],
    "method_whitelist" : [],
    "drop_prologue_end" : true,
    "drop_epilogue_begin" : true,
    "drop_all_dbg_info_if_empty" : true
    },
    "InterDexPass" : {
    "minimize_cross_dex_refs": true,
    "minimize_cross_dex_refs_method_ref_weight": 100,
    "minimize_cross_dex_refs_field_ref_weight": 90,
    "minimize_cross_dex_refs_type_ref_weight": 100,
    "minimize_cross_dex_refs_string_ref_weight": 90
    },
    "RegAllocPass" : {
    "live_range_splitting": false
    },
    "string_sort_mode" : "class_order",
    "bytecode_sort_mode" : "class_order"
    }
  6. 最后,执行相应的 redex 优化命令

    它使用了贪心这种局部最优解的方式去减少跨 Dex 调用造成的信息冗余,命令如下所示(注意,在 redex 的前面可能需要加上 Android sdk 的路径,因为 redex 中使用到了 sdk 下的 zipalign 工具):

    1
    ANDROID_SDK=/usr/lib/android-sdk redex --sign -s <xxx>.jks -a <alias> -p 123456 -c redex/config/interdex_stripdebuginfo.config -P proguard-rules.pro <youapk>.apk -o redexSign.apk

    上述 redex 命令的 关键参数含义 如下所示:

    • –sign: 对生成的 apk 进行签名。
    • -s: 配置应用的签名文件。
    • -a: 配置应用签名的 key_alias
    • -p: 配置应用签名的 key_password
    • -c: 指定 redex 进行 Dex 处理时需要依据的 CONFIG 配置文件。
    • -o: 指定生成 APK 的全路径。
    • -P: 是混淆文件
  7. 补充:

    • Linux 中下载 android-sdk
      1
      sudo apt update && sudo apt install android-sdk

    我最后在 /usr/lib/android-sdk 中找到

    • 运行 test 需要在 redex 目录下执行 ./test/setup.sh 文件中有个 pushd test ,还需要安装 curlunzip

    • 如果报错 fatal error:Killed signal terminated program cc1plus 是内存不足了,请分配足够大的内存,还有可能 cpu 太高,请适当减少运行线程,autoreconf -ivf && ./configure && make -j4 这句话中 -j4 表示开启 4 个线程,如果不是多核的开线程没有用。我最后用 make -j2 给了 4G 内存执行成功。

经过三天努力,终于编译成功,但是在 release 包下 体积并没有减少, debug 有变少。

通过搜索,可能是没有做到压缩,只做了 dex 重排加速了启动,所以体积可能不小反而变大一点点。

也有可能项目太小发挥不了作用。

还会再继续探究 ~


3.1.6 使用 XZ Utils 进行 Dex 压缩

XZ Utils7-Zip 一样,内部使用的都是 LZMA 算法。LZMA 提供了高压缩比和快速解压缩,因此非常适合嵌入式应用。对于 Dex 格式来说,XZ 的压缩率可以比 Zip30% 左右。

FaceBookApp 中就使用了 Dex 压缩 的方式,而且它将 Dex 压缩后的文件都放在了 assets 目录中

图片来源于 Android 开发高手课

图片来源于 Android 开发高手课

我们先看到上图中的 classes.dex,其中仅包含了启动时要用到的类,这样可以为 Dex 压缩文件 secondary.dex.jar.xzs 的解压争取时间。

但是,这套方案还存在一些问题:

  • 首次启动解压:应用首次启动的时候,需要将 secondary.dex.jar.xzs 解压缩,Facebook 使用多线程解压的方式,这个耗时在高端机是几百毫秒左右,在低端机可能需要 3~5 秒。
  • ODEX 文件生成:当 Dex 非常多的时候会增加应用的安装时间,如果还使用了压缩 Dex 的方式,那么首次生成 ODEX 的时间可能就会超过 1 分钟。Facebook 为了解决这个问题,使用了 ReDex 另外一个超级硬核的方法,那就是 oatmeal
    • 对于正常的流程,我们需要 fork 进程来生成 dex2oat,这个耗时一般都比较大。通过 oatmeal,我们直接在本进程生成 ODEX 文件。一个 10MBDex,如果在 Android 5.0 生成一个 ODEX 的耗时大约在 10 秒以上,在 Android 8.0 使用 speed 模式大约在 1 秒左右,而通过 oatmeal 这个耗时大约在 100 毫秒左右。

ReDex 提供的功能:

  1. Interdex:类重排和文件重排、Dex 分包优化。
  2. Oatmeal: 直接生成 Odex 文件。
  3. StripDebugInfo: 去除 Dex 中的 Debug 信息。
  4. 源码中 access-marking 模块: 删除 Java access 方法 。
  5. 源码中 type-erasure 模块:类型擦除。

3.1.7 其他


3.2 资源相关

resassets、编译后的二进制资源文件 resources.arsc 和 清单文件 等等。resassets 的不同在于 res 目录下的文件会在 .R 文件中生成对应的资源 ID,而 assets 不会自动生成对应的 ID,而是通过 AssetManager 类的接口来获取。此外,每当在 res 文件夹下放一个文件时,aapt 就会自动生成对应 id 并保存在 .R 文件中,但 .R 文件仅仅只是保证编译程序不会报错,实际上在应用运行时,系统会根据 ID 寻找对应的资源路径,而 resources.arsc 文件就是用来记录这些 ID 和 资源文件位置对应关系 的文件。

3.2.1 使用 Lint 的 Remove Unused Resource

Refactor -> Remove Unused Resource -> preview 可以预览找到的无用资源

需要注意的,Android Lint 不会分析 assets 文件夹下的资源,因为 assets 文件可以通过文件名直接访问,不需要通过具体的引用,Lint 无法判断资源是否被用到。

3.2.2 shrinkResources

Android shrinkResources 资源压缩功能,它需要配合 ProGuradminifyEnabled 功能同时使用。如果 ProGuard 把部分无用代码移除,这些代码所引用的资源也会被标记为无用资源,然后通过资源压缩功能将它们移除。

1
2
3
4
5
6
7
8
9
android {
...
buildTypes {
release {
shrinkResources true
minifyEnabled true
}
}
}

是不是看起来很完美,但是目前的 shrinkResources 实现起来还有几个缺陷。

  • 没有处理 resources.arsc 文件。 这样导致大量无用的 StringIDAttrDimen 等资源并没有被删除。
  • 没有真正删除资源文件。 对于 DrawableLayout 这些无用资源,shrinkResources 也没有真正把它们删掉,而是仅仅替换为一个空文件。为什么不能删除呢?主要还是因为 resources.arsc 里面还有这些文件的路径,具体你可以查看这个 issues

如果想解答系统为什么不能直接把这些资源删除,我们需要先回过头来重温一下 Android 的编译流程。

  • 由于 Java 代码需要用到资源的 R.java 文件,所以我们就需要把 R.java 提前准备好。
  • 在编译 Java 代码过程,已经根据 R.java 文件,直接将代码中资源的引用替换为常量,例如将 R.String.sample 替换为 0x7f0c0003
  • .ap_ 资源文件的同步编译,例如 resources.arsc、XML 文件的处理等。

如果我们在这个过程强行把无用资源文件删除,resources.arscR.java 文件的资源 ID 都会改变(因为默认都是连续的),这个时候代码中已经替换过的 0x7f0c0003 就会出现资源错乱或者找不到的情况。

因此系统为了避免发生这种情况,采用了折中的方法,并没有二次处理 resources.arsc 文件,只是仅仅把无用的 DrawableLayout 文件替换为空文件。

那怎么样才能真正实现无用资源的删除功能呢?我们可以利用 resources.arscPublic ID 的机制,实现非连续的资源 ID。

简单来说,就是 keep 住保留资源的 ID,保证已经编译完的代码可以正常找到对应的资源。

图片来源于 Android 开发高手课

但是重写 resources.arsc 的方法会比资源混淆更加复杂,我们既要从这个文件中抹去所有的无用资源相关信息,还要 keep 住所有保留资源的 ID,相当于把整个文件都重写了。

正因为异常复杂,所以目前 Android 还没有提供这套方案的完整实现。

深入探索 Android 包体积优化 优化 shrinkResources 流程真正去除无用资源 一文中给出一个方法。

matrix 也实现了相关内容 UnusedAssetsTask

3.2.3 AndResGuard 工具

在我们的安装包中,资源相关的文件具体有下面这几个,它们都是我们需要优化的目标文件。

图片来源于 Android 开发高手课

AndResGuard 工具它主要有两个功能,一个是资源混淆,一个是资源的极限压缩。

  1. 资源混淆

    资源混淆的思路其实非常简单,就是把资源和文件的名字混淆成短路径:

    1
    2
    3
    Proguard          -> Resource Proguard
    R.string.name -> R.string.a
    res/drawable/icon -> res/s/a

    那么这样的实现究竟对哪些资源文件有优化作用呢?

    • resources.arsc。因为资源索引文件 resources.arsc 需要记录资源文件的名称与路径,使用混淆后的短路径 res/s/a,可以减少整个文件的大小。
    • metadata 签名文件。签名文件 MF 与 SF 都需要记录所有文件的路径以及它们的哈希值,使用短路径可以减少这两个文件的大小。
    • ZIP 文件索引。 ZIP 文件格式里面也需要记录每个文件 Entry 的路径、压缩算法、CRC、文件大小等信息。使用短路径,本身就可以减少记录文件路径的字符串大小。
  2. 极限压缩

    AndResGuard 的另外一个优化就是极限压缩,它的极限压缩功能体现在两个方面:

    • 更高的压缩率。虽然我们使用的还是 Zip 算法,但是利用了 7-Zip 的大字典优化,APK 的整体压缩率可以提升 3% 左右。

    • 压缩更多的文件。 Android 编译过程中,下面这些格式的文件会指定不压缩;在 AndResGuard 中,我们支持针对 resources.arscPNGJPG 以及 GIF 等文件的强制压缩。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      /* these formats are already compressed, or don't compress well */
      static const char* kNoCompressExt[] = {
      ".jpg", ".jpeg", ".png", ".gif",
      ".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"
      };

    为什么 Android 系统会专门选择不去压缩这些文件呢?

    • 压缩效果并不明显。这些格式的文件大部分本身已经压缩过,重新做 Zip 压缩效果并不明显。例如 PNGJPG 格式,重新压缩只有 3%~5% 的收益,并不是十分明显。
    • 读取时间与内存的考虑。如果文件是没有压缩的,系统可以利用 mmap 的方式直接读取,而不需要一次性解压并放在内存中。

AndResGuard 实践

配置根据 AndResGuard 走就可以

编译在 Gradle -> app -> Tasks -> andresguard-> resguardRelease 可以进行编译打包,结果生成在 app -> build -> outputs -> apk -> release -> AndResGuard_xxx 下。

此外,抖音 Android 团队还开源了针对于海外市场 App Bundle APKAabResGuard 资源混淆工具

在美团的一篇文章《Android App 包瘦身优化实践》 中,也讲到了很多资源优化相关的方法,例如 WebPSVGR 文件、无用资源、资源混淆以及语言压缩等。

3.3 Native Library 相关

对于 Native Library,传统的优化方法可能就是去除 Debug 信息、使用 c++_shared 这些。那我们还有没有更好的优化方法呢?

3.3.1 Library 压缩

Dex 压缩一样,Library 优化最有效果的方法也是使用 XZ 或者 7-Zip 压缩。

在默认的 lib 目录,我们只需要加载少数启动过程相关的 Library,其他的 Library 我们都在首次启动时解压。对于 Library 格式来说,压缩率同样可以比 Zip30% 左右,效果十分惊人。

Facebook 有一个 So 加载的开源库 SoLoader ,它可以跟这套方案配合使用。和 Dex 压缩一样,压缩方案的主要缺点在于首次启动的时间,毕竟对于低端机来说,多线程的意义并不大,因此我们要在包体积和用户体验之间做好平衡。

3.3.2 Library 合并与裁剪

对于 Native LibraryFacebook 中的编译构建工具 Buck 也有两个比较硬核的高科技。当然在官方文档中是完全找不到的,它们都隐藏在 源码 中。

  • Library 合并。Android 4.3 之前,进程加载的 Library 数量是有限制的。在编译过程,我们可以自动将部分 Library 合并成一个。具体思路可以参考文章《Android native library merging》 以及 Demo
  • Library 裁剪。 Buck 里面有一个 relinker 的功能,原理就是分析代码中 JNI 方法以及不同 Library 的方法调用,找到没有无用的导出 symbol,将它们删掉。这样 linker 在编译的时候也会把对应的无用代码同时删掉,这个方法相当于实现了 LibraryProGuard Shrinking 功能。

感谢

极客时间 Android开发高手课

深入探索 Android 包体积优化

Android新Dex编译器D8与新混淆工具R8

以及上文中的链接