Shrink Your Code and Resources

原文链接

为了使你的APK文件尽可能小,你应该启用压缩代码为你的发布版本移除不用的代码和资源。本文介绍如何做压缩,以及如何在构建时指定要保留或舍弃的代码和资源。

代码压缩通过ProGuard提供,ProGuard会检测和移除你的应用程序包中的无用类、字段、方法和属性,包括引入的代码库中的未使用项(这使其成为解决64k引用限制的有用工具)。ProGuard也会优化字节码,移除无用的代码指令,并且使用短命混淆类、字段和方法名。混淆后的代码是你的APK很难被逆向,这在应用使用许可验证等安全敏感性问题时特别有用。

资源压缩通过Android plugin for Gradle实现,它会从你的APK中移除无用的资源,包括代码库中无用的资源。它可与代码压缩协同工作,使得在移除未使用的代码后,任何不再被引用的资源也能安全地移除。

本文介绍的功能依赖以下组件:

压缩代码

启用ProGuard的代码压缩,需要在build.gradle文件中将相应的构建类型的属性minifyEnabled设置为true

请注意,代码压缩会是构建时间变慢,因此你应该避免在debug模式下使用。但是,测试启用了代码压缩的最终APK很重要,因为代码压缩可能会因为没有充分的自定义要保留的代码而导致一些错误。

比如,下面build.gradle文件的代码段在发行版本启用了代码压缩:

1
2
3
4
5
6
7
8
9
10
android {
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
...
}

注意: Android Studio在使用Instant Run时禁用了ProGuard。如果在增量构建中需要代码压缩,可以尝试Gradle shrinker。

除了minifyEnabled属性外,还有用于定义ProGuard规则的proguardFiles属性:

  • getDefaultProguardFile(‘proguard-android.txt')方法可从Android SDK的tools/proguard/文件夹中获取默认ProGuard设置。

    提示:要想做进一步的代码压缩,可尝试使用在同一文件夹下的proguard-android-optimize.txt文件。它使用相同的ProGuard规则,但还包括其他在字节码级别(方法内和方法间)执行分析的优化,以进一步减小APK大小和帮助其提高运行速度。

  • 可以在proguard-rules.pro文件中添加自定义的ProGuard规则。在默认情况下,这个文件在module的根目录中(build.gradle文件的下面)。

如果要为不同构建变种版本添加特定的ProGuard规则,就需要在productFlavor块中添加相应的proguardFiles属性。比如,在下面的Gradle文件中为flavor2版本添加了flavor2-rules.pro文件。现在flavor2就使用了所有3个ProGuard规则,因为还应用了来自release版本块的规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
android {
...
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
productFlavors {
flavor1 {
}
flavor2 {
proguardFile 'flavor2-rules.pro'
}
}
}

对于每一次构建,ProGuard输出以下文件:

  • dump.txt

    说明APK中所有类文件的内部结构。

  • mapping.txt

    提供原始与混淆过的类、方法和字段名称之间的映射。

  • seeds.txt

    列出没有混淆的类和成员。

  • usage.txt

    列出从APK移除的代码。

这些文件保存在<module-name>/build/outputs/mapping/release/

自定义要保留的代码

对于一些情形,默认的ProGuard配置文件(proguard-android.txt)可以满足需求,ProGuard会移除所有未使用的代码。但是,有些情形ProGuard可能会移除一些应用程序需要的代码。可能错误移除的情形如下:

  • 当一个类只在AndroidManifest.xml文件中被引用时
  • 当应用程序调用Java Native Interface(JNI)的方法时
  • 当在运行时调用代码时(例如使用反射或自检)

测试应用可以发现由于不正当的移除而导致的错误,你也可以检查<module-name>/build/outputs/mapping/release/下的usage.txt输出文件,查看你移除的代码。

要修正错误并强制ProGuard保留特定代码,请在ProGuard配置文件中添加一行-keep代码。例如:

1
-keep public class MyClass

或者可以在要保留的代码上添加@Keep注解。如果在一个类上添加@Keep会保留整个类。在方法或字段上添加它可保留方法/字段(及其名称)以及类名称。注意,只有在使用注解支持库时,才能使用此注解。

在使用-keep选项时,有许多事项需要考虑;如需了解有关自定义配置文件的详细信息,请阅读ProGuard手册问题排查一章概述了你可能会在混淆代码时遇到的其他常见问题。

解码混淆过的堆栈记录

ProGuard压缩后的代码,由于方法名被混淆阅读堆栈追踪很困难。幸运的是,ProGuard每次运行会创建一个mapping.txt文件,这个文件中有原始类、方法、字段的名字和混淆后的名字的映射。ProGuard将这个文件保存在<module-name>/build/outputs/mapping/release/目录下。

注意在每次构建一个发布版本时,mapping.txt文件会被覆盖,因此你每次发布新版本是都要保存一个副本。通过为每个发布构建保留一个mapping.txt文件副本,你就可以根据用户提交的已混淆的堆栈追踪来针对旧版本应用的问题进行调试。

在Google Play上发布应用时,你可以针对每个版本APK上传mapping.txt文件。Google Play会根据用户报告的问题对收到的堆栈追踪进行去混淆处理,以便你在Google Play Developer Console中进行查看。

To convert an obfuscated stack trace to a readable one yourself, use the retrace script (retrace.bat on Windows; retrace.sh on Mac/Linux). It is located in the /tools/proguard/ directory. The script takes the mapping.txt file and your stack trace, producing a new, readable stack trace. The syntax for using the retrace tool is:

要自行将混淆过的堆叠追踪转换成可读的堆叠追踪,请使用retrace脚本(Windows上为retrace.bat;在Mac/Linux 上为retrace.sh)。它在<sdk-root>/tools/proguard/目录中。该脚本利用mapping.txt文件和你的堆栈追踪生成新的可读堆栈追踪。使用retrace工具的语法如下:

1
retrace.bat|retrace.sh [-verbose] mapping.txt [<stacktrace_file>]

比如:

1
retrace.bat -verbose mapping.txt obfuscated_trace.txt

如果你不指定堆栈追踪文件,retrace工具会从标准输入读取。

在Instant Run中启用代码压缩

如果在增量开发中代码压缩对你来说很重要,可以使用目前处于实验阶段的Gradle的Android插件shrinker。这个插件和ProGuard不一样,它可以支持Instant Run。

在配置Android插件shrinker时,可以和ProGuard使用相同的配置文件。但是,Android插件shrinker不能混淆和优化你的代码,它只能移除未使用的代码。因此你应该只在调试中使用它,在发布版本中启用ProGuard去混淆和优化代码。

To enable the Android plugin shrinker, simply set useProguard to false in your “debug” build type (and keep minifyEnabled set true):

使用Android插件shrinker,只需要在build type中设置userProguard属性为false,并且将minifyEnabled设为true,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
android {
buildTypes {
debug {
minifyEnabled true
useProguard false
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
}

注意: 如果Android插件shrinker先移除了一个方法,接着你又使用了这个方法,Instant Run会将这种情形作为代码结构更改而执行cold swap

压缩资源

资源压缩只与代码压缩协同工作。代码压缩器移除完所有的未使用的代码后,资源压缩器就可以确定应用仍然使用的资源。这在添加包含资源的代码库时体现得尤为明显——你必须移除未使用的代码库代码,使代码库资源变为未引用资源,才能通过资源压缩器将它们移除。

在build.gradle文件中设置shrinkResources属性为true可以启用资源压缩(在用于代码压缩的minifyEnabled属性旁边)。比如:

1
2
3
4
5
6
7
8
9
10
11
android {
...
buildTypes {
release {
shrinkResources true
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
}

如果你在构建应用时,没有使用minifyEnabled做代码压缩,请先使用它,然后再启用资源压缩,因为你可能需要编辑proguard-rules.pro文件以保留动态创建或调用的类或方法,然后再移除资源。

注意: 资源压缩器目前不会移除values/文件夹中定义的资源(例如字符串、尺寸、样式和颜色)。这是因为Android资源打包工具(AAPT)不允许Gradle插件为资源指定预定义版本。有关详情,请参阅问题70869。?

自定义要保留的资源

如果你有明确想要保留或舍弃的资源,请在你的项目中创建一个包含<resources>标记的XML文件,并在tools:keep属性中指定每个要保留的资源,在tools:discard属性中指定每个要舍弃的资源。这两个属性都接受逗号分隔的资源名称列表。你可以使用星号字符作为通配符。比如:

1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@layout/l_used*_c,@layout/l_used_a,@layout/l_used_b*"
tools:discard="@layout/unused2" />

将这个文件保存在你的项目资源中,比如,在res/raw/keep.xml。构建不会将该文件打包到APK之中。

指定要舍弃的资源可能看似愚蠢,因为你本可将它们删除,但在使用构建变体时,这样做可能很有用。例如,如果你明知给定资源表面上会在代码中使用(并因此不会被压缩器移除),但实际不会用于给定构建变体,就可以将所有资源放入同一项目目录,然后为每个构建变体创建一个不同的keep.xml文件。构建工具也可能会错误地将资源标识为需要的资源,因为编译器会在内联中添加资源ID,然后资源分析器可能不知道具有相同的值的真正引用的资源与代码中的整数值之间的差异。

启用严格引用检查

通常,资源压缩器可以准确的决定一个资源是否被使用。但是,如果你的代码调用了Resources.getIdentifier()(或任何库进行了这一调用——AppCompat库会执行该调用),这说明你的代码是根据动态生成的字符串查询资源名称。当执行这一调用时,默认情况下资源压缩器会采取防御性行为,将所有具有匹配名称格式的资源标记为可能已使用,无法移除。

比如,下面的代码会将所有带有img_前缀的的资源标记为已使用:

1
2
String name = String.format("img_%1d", angle + 1);
res = getResources().getIdentifier(name, "drawable", getPackageName());

资源压缩器会浏览你代码中的字符串常量,大量的res/raw/资源,寻找像这种格式的URL资源file:///android_res/drawable//ic_plus_anim_016.png。如果它找到与其类似的字符串,或找到其他看似可用来构建与其类似的网址的字符串,则不会将它们移除。

这些情况是安全的压缩模式,默认情况下被启用。但你可以停用这一“有备无患”处理方式,并指定资源压缩器只保留其确定已使用的资源。要执行此操作,请在keep.xml中将shrinkMode设置为strict,如下所示:

1
2
3
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:shrinkMode="strict" />

如果你启用了严格压缩模式,并且代码也引用了包含动态生成字符串的资源(如上所示),你就必须利用tools:keep属性手动保留这些资源。

移除未使用的备用资源

Gradle的资源压缩器只会移除代码中未使用的资源,它不会移除适配于不同设备上的备用资源。如果你需要移除这些资源,你可以通过配置Android Gradle插件的resConfigs属性来移除你的App不需要的备用资源。

比如,你使用了一个包含语言资源的库(像AppCompat或Google Play Services),则APK将包括这些内容库中字符串的所有已翻译语言字符串,无论应用的其余部分是否翻译为同一语言。如果您想只保留应用正式支持的语言,可以利用resConfig属性指定这些语言。系统会移除未指定语言的所有资源。

下面这段代码展示了如何将语言资源限定为仅支持英语和法语:

1
2
3
4
5
6
android {
defaultConfig {
...
resConfigs "en", "fr"
}
}

同理,您也可以利用构建多APK为不同设备构建不同的APK,自定义在APK中包括的屏幕密度或ABI资源。

合并重复资源

默认情况下,Gradle还会合并同名资源,例如可能位于不同资源文件夹中的同名可绘制对象。这一行为不受shrinkResources属性控制,也无法停用,因为在有多个资源匹配代码查询的名称时,有必要利用这一行为来避免错误。

只有在两个或更多个文件中具有完全相同的资源名称、类型和限定符时,才会进行资源合并。Gradle会在重复项中选择其为最佳选择文件(根据下述优先顺序),并只将这一个资源传递给AAPT,以供在APK文件中分发。

Gradle会在下列位置寻找重复资源:

  • 与主资源集关联的主资源,一般位于src/main/res/
  • 变体叠加,来自构建类型和构建flavor。
  • 项目依赖库。

Gradle会按以下优先顺序合并重复资源:

依赖项 → 主资源 → 构建flavor → 构建类型

例如,如果某个重复资源同时出现在主资源和构建flavor中,Gradle会选择构建flavor中的重复资源。

如果完全相同的资源出现在同一资源集中,Gradle无法合并它们,并且会发出资源合并错误。如果您在build.gradle文件的sourceSet属性中定义了多个资源集,在特定情况下(例如src/main/res/src/main/res2/包含完全相同的资源时),就可能发生这种情况。

排查资源压缩问题

当压缩资源时,Gradle Console会显示它从应用软件包中移除的资源的摘要。例如:

1
2
3
:android:shrinkDebugResources
Removed unused resources: Binary resource data reduced from 2570KB to 1711KB: Removed 33%
:android:validateDebugSigning

Gradle还会在<module-name>/build/outputs/mapping/release/(ProGuard 输出文件所在的文件夹)中创建一个名为resources.txt的诊断文件。该文件包括诸如哪些资源引用了其他资源以及使用或移除了哪些资源等详情。

例如,要了解APK为何仍包含@drawable/ic_plus_anim_016,请打开resources.txt文件并搜索该文件名。你可能会发现,有其他资源引用了它,如下所示:

1
2
16:25:48.005 [QUIET] [system.out] @drawable/add_schedule_fab_icon_anim : reachable=true
16:25:48.009 [QUIET] [system.out] @drawable/ic_plus_anim_016

现在你需要了解为何@drawable/add_schedule_fab_icon_anim可以访问——如果你向上搜索,就会发现在“The root reachable resources are:”之下列有该资源。这意味着存在对add_schedule_fab_icon_anim的代码引用(即在可访问代码中找到了其R.drawable的ID)。

如果你使用的不是严格检查,则存在看似可用于为动态加载资源而构建的资源名称的字符串常量时,可将资源ID标记为可访问。在这种情况下,如果你在构建输出中搜索资源名称,可能会找到类似下面这样的消息:

1
2
10:32:50.590 [QUIET] [system.out] Marking drawable:ic_plus_anim_016:2130837506
used because it format-string matches string pool constant ic_plus_anim_%1$d.

如果你看到一个这样的字符串,并且你能确定该字符串未用于动态加载给定资源,就可以按照有关如何自定义要保留的资源部分中所述利用tools:discard属性通知构建系统将它移除。