Jiacheng

aha

野比大雄

使用ProGuard缩减代码和资源以及混淆代码(谷歌文档翻译)

Jiacheng / 2016-06-10


缩减你的代码和资源


为了让 APK 文件尽可能的小,在 release build 中应当使用缩减(shrink)来移除那些没有用到的代码和资源文件。这篇文章将描述怎样完成缩减,以及在 build (构建)的过程中,如何指定哪些代码和资源应该保留而哪些应该抛弃。

代码缩减可以通过 ProGuard(混淆器)实现,ProGuard 会从打包的 APP 以及 APP 包含的代码库之中检测然后移除那些未使用的类文件、字段、方法和属性(它也因此在使用权宜之计解决64K引用限制 64K reference limit 的问题方面,成为了一个有价值的工具)。ProGuard 也可以优化字节码(bytecode),移除没用过的代码指令,并且通过使用简短名字混淆了保留的类文件、字段和方法。被混淆过的代码能够让你的 APK 很难被反向工程,特别是当你的 APP 使用了诸如授权验证 (licensing verification) 等对安全性敏感的功能时,这一点将非常的有用。

资源的缩减可以通过 Android 的 Gradle 插件实现,这个插件可以从打包的 APP 里面移除那些没有使用过的资源文件,包括代码库中没有使用过的资源文件。它与代码缩减以这样的方式协同工作:一旦没使用过的代码被移除,那些相对应的不再被引用的资源文件也将被安全的移除。

这个文档中的功能依赖于:

缩减你的代码

要通过 ProGuard 实现代码缩减,需要在 build.gradle 文件里,向合适的 build type (构建类型)中添加 minifyEnabled true 语句。

需要注意的是,代码缩减会增加项目构建(build)的时间,所以如果可能的话,要尽量避免在调试的构建(debug build)中使用它。 然而在用于测试的最终APK中使用代码缩减是很重要的,因为你如果没有充分地去自定义哪些代码需要保留,它将会引入一些 bug (introduce bugs)。

例如,下面来自于 build.gradle 文件中的代码片段,实现了 release build (发布构建)时的代码缩减:

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile(proguard-android.txt'),
                    'proguard-rules.pro'
        }
    }
    ...
}

注意:Android Studio 在使用 Instant Run 时,ProGuard 会无效。

除了 minifyEnabled 属性之外,proguardFiles 属性定义了 ProGuard 的规则:

如果要为每一个 build variant (构建变种版本)添加特定的 ProGuard 规则,需要在相应的 productFlavor 块中添加另一个 proguardFiles 属性。例如,下面的这个 Gradle 文件向 product flavor (产品偏好风格) flavor2 中添加 flavor2-rules.pro 。这个时候,flavor2 使用了所有这三个 ProGuard 规则,因为前面 release 块中规则也会应用到 flavor2 中。

android {
    ...
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'),
                   'proguard-rules.pro'
        }
    }
    productFlavors {
        flavor1 {
        }
        flavor2 {
            proguardFile 'flavor2-rules.pro'
        }
    }
}

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

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

自定义需要保留的代码

在一些情况下,默认的 ProGuard 配置文件(proguard-android.txt)已经足够了,ProGuard会移除所有未被使用过的代码,而且是只移除这些。然而,在很多情况下,ProGuard 会很难分析的正确,它会移除你的APP真正需要的代码。在下面这些例子中,它也许会错误的移除代码:

当测试APP的时候就会暴露所有的由于不当的移除代码造成的错误,但是你可以通过查看保存在 <module-name>/build/outputs/mapping/release/ 路径下的 usage.txt 输出文件来检查哪些代码被移除了。

要修复错误以及强制 ProGuard 保留必需的代码,需要在 ProGuard 配置文件中添加一个 -keep 行。比如:

-keep public class MyClass

或者你可以向你想要保留的代码添加注解 @keep。在一个类上添加 @keep 就会保留整个类原封不动。在一个方法或者字段上面添加这个注解,将会保留这个方法或字段(以及它的名字),和类的名字一样原封不动。例如:

@Keep
  public void foo() {
      ...
  
 }

需要注意是,只有使用了 Annotations Support Library 时,这个注解才是可用的

当你使用 -keep 时,有很多的注意事项需要考虑,有关自定义配置文件的更多信息,请阅读 ProGuard Manual 。其中的 Troubleshooting 章节概括了你的代码被剥离时可能会遇到的一些常见的问题。

解码混淆过的堆栈追踪信息

在 ProGuard 缩减你的代码之后,阅读堆栈追踪信息是很困难的(如果因可能的话),因为方法的名字都被混淆了。幸运地是,ProGuard 在它每次运行的时候都会生成一个 mapping.txt 文件,这个文件显示了原始的类、方法和字段的名字与混淆之后的名字之间的映射。ProGuard 在APP <module-name>/build/outputs/mapping/release/ 目录下保存这个文件。需要注意的是,每一次你通过 ProGuard 创建 release build 的时候,mapping.txt 文件都会被重写,所以每一次发布一个新的 release 的时候,你应该小心谨慎的保存一个副本。通过为每一次的 release build 保留 mapping.txt 文件的一个副本,当用户从 APP 前一版本提交一个混淆过的堆栈追踪时,你就能够调试这些问题。

在 Google Play 上发布 APP 的时候,你可以上传你的 APK的每一个版本的 mapping.txt 文件。然后 Google Play 将会反混淆从用户问题报告那里得来的堆栈追踪信息,你可以在 Google Play 开发者控制台(developer console)查看这些信息。如果要获得更多信息,可以查看帮助中心关于如何 反混淆崩溃堆栈跟踪(deobfuscate crash stack traces.)。

如果想要自己转换混淆过的堆栈跟踪变成一个可读的信息,可以使用 retrace 脚本(retrace.bat on Windows; retrace.sh on Mac/Linux),它位于 <sdk-root>/tools/proguard/ 目录下。这个脚本利用 mapping.txt 文件和你的堆栈追踪信息,产生一个可读的堆栈追踪信息。 使用 retrace 工具的语法如下:

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

例如:

retrace.bat -verbose mapping.txt obfuscated_trace.txt

如果你没有指定堆栈追踪的文件,retrace 工具将从标准输入读取文件。

缩减你的资源文件

资源文件的缩减只有在结合代码缩减时才会起作用。在代码缩减移除所有未使用的代码之后,资源缩减器就能辨别哪些资源文件是 APP 依然在使用的。特别是当你添加了那些包含资源文件的代码库时,尤其如此——你必须移除没有用到的库代码,所以对应的库资源文件也就不再被引用,然后,资源文件缩减器就可以把这些资源文件移除。

如果要实现资源文件的缩减,需要在 build.gradle 中将 shrinkResources 属性设置为 true (在代码缩减 minifyEnabled 的旁边) ,例如:

android {
    ...
    buildTypes {
        release {
            shrinkResources true
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'),
                    'proguard-rules.pro'
        }
    }
}

在构建你的 APP 的时候,如果没有使用代码缩减 ,你应该在使用 shrinkResources 之前先使用代码缩减 minifyEnabled ,因为在你移除资源文件之前,你可能需要编辑你的 roguard-rules.pro 文件,用来保留那些动态创建或调用的类或方法。

Note: 资源文件缩减器一般不会移除在 values/ 文件夹下定义的资源(比如 strings, dimensions, styles, and colors)。这是因为 Android 资源打包工具(Aandroid Asset Packaging Tool)(AAPT)不允许 Gradle Plugin 指定那些预先定义的版本资源(specify predefined versions for resources)。详细信息查看 issue 70869

自定义需要保留的资源

如果你想要指定哪些资源被保留或被抛弃,你需要在项目里创建一个含有 <resources> 标签的 XML 文件,然后在属性 tools:keep 里指定每一个想要保留的资源,在属性 tools:discard 里指定每一个想要抛弃的资源。这两个属性都可我一使用逗号(,)来分隔多个资源名称。你可以使用星号(*)作为通配符。 例如:

<?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 中。

指定哪些资源被抛弃看起来是一种很傻的做法,因为你可以直接删除那些资源,但是当你需要构建多个变种版本(build variants)的时候,它是很有用的。比如,你可以把所有的资源文件放在一个共用的项目目录下,如果你只是知道一个给定的资源似乎在代码中被用到了,你可以在构建变种版本的时候创建不同的 keep.xml 文件(它也因此不会被缩减器移除),除非你确切地知道这个资源在指定的变种版本中不会被用到。

*** 实现严格的引用检查

通常来说,资源缩减器可以精确地判断一个资源是否被使用。但是,当你的代码调用了 [Resources.getIdentifer()](https://developer.android.com/reference/android/content/res/Resources.html#getIdentifier(java.lang.String, java.lang.String, java.lang.String))(或者你使用的库调用了它—— AppCompat 库就是如此),这意味着你的代码会基于动态生成的字符串名称(dynamically-generated strings)查找资源名称。如果你确实这样做了,资源缩减器会默认表现地很保守,它会把与名称格式相匹配的所有资源都标记为可能会使用不可删除

例如,下面的代码就会造成所有前缀为 img_ 的资源都被标记为会使用到

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

资源缩减器也会遍历代码中所有的字符串常量(string constants)以及 res/raw/ 里的各种资源,去寻找资源 URLs(统一资源定位符),类似于这种格式,file:///android_res/drawable//ic_plus_anim_016.png 。如果它找到了这种格式的字符串,或者是看起来能被用于构建这种格式 URLs 的字符串,它将不会移除它们。

下面的例子是在默认情况下实现的安全缩减模式。当然你也可以关掉这种“小心不出大错”(“better safe than sorry”)的处理模式,然后指定资源缩减器只保留那些确实被用到的资源。为此,需要在 keep.xml 文件中把 shrinkMode 设置为 strict,如下所示:

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

如果你启用了 strict 缩减模式,而你的代码又像前面所说的那样,使用了动态生成的字符串引用资源,那么你就需要通过 tools:keep 属性手动地保留这些资源。

移除未使用的备用资源

Gradle 资源缩减器只会移除 APP 代码中未引用的资源,这意味着它不会移除那些为不同配置的设备准备的备用资源。如果有必要的话,你可以使用 Android Gradle Plugin 里的 resConfigs 属性移除 APP 不需要的备用资源文件。

例如,你使用了一个包含语言资源的库(比如 AppCompat 或者 Google Play Service),这时,不论你的 APP 是否被翻译成了另一种语言,APK 都会包含库中信息所对应的所有译本语言字符串。如果你想在 APP 只保留对官方语言的支持,你可以是使用 resConfig 属性指定要使用的语言。其他没有被指定的语言将会被移除。

下面的代码段演示了如何只将英语和法语作为语言资源:

android {
    defaultConfig {
        ...
        resConfigs "en", "fr"
    }
}

如果要自定义 APK 包含的屏幕分辨率ABI 资源,需要使用 APK splits 为不同的设备构建不同的 APKs。

合并重复的资源

默认情况下,Gradle 也会合并命名相同的资源,比如在不同文件夹下却具有相同名称的图片。这一行为是不能通过 shrinkResources 属性控制的,而且也不能被禁用,因为它能有效地避免多个资源与代码正在查找的资源名称相匹配。

只有当有两个或者两个以上的文件共用相同的资源名称、类型和修饰时,才会发生资源合并。Gradle 会在重复的文件中选择它认为最好的一个(这基于下面将要描述的优先序列),并且只把这一个文件作为 APK 文件的分配传递给 APPT 。

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

Gradle 根据下面的优先序列合并重复的资源: Dependencies → Main → Build flavor → Build type

例如,如果一个重复的资源同时存在于 main resource 和 build flavor 中,Gradle 会选择 build flavor 中的那个。

如果两个完全相同的资源出现在同一个资源集合中,Gradle 将不能合并它们,同时报出一个资源合并错误。如果你在 build.gradle 文件中 sourceSet 属性下设置了多个源文件集合,比如src/main/res/src/main/res2/ ,而且这两个文件下含有完全相同的资源,这时就会发生这种错误。

资源缩减时的问题排除

当你缩减资源时,Gradle Console 会显示被移除出 app package 的资源的概要信息。例如:

: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 的文件。这个文件包含了哪些文件引用了其他的资源、哪些资源被使用或者被移除这些详细信息。

例如,想查出为什么 @drawable/ic_plus_anim_016 依然存在于 APK 中,打开 `` 文件,搜索文件的名字,你会发现它被另一个文件引用了,如下:

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 为 reachable——如果你继续顺着向上搜索,你会发现这个文件在 “The root reachable resources are:” 下面列出。这意味着有代码引用了 add_schedule_fab_icon_anim (也就是说它的 R.drawable ID 在 reachable code 中被发现)

如果没使用 strict checking ,资源 IDs ,如果它们含有字符串常量,这些字符串常量看起来可能会被用来构造动态加载的资源的名称,就会被标记为 reachable。这种情况下,如果你在 build 的输出中搜索资源名称,你可能会发现这样的一条信息:

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 属性告诉 build 系统把它移除,就如在章节 customize which resources to keep 中描述的那样。