Android使用R8压缩APK体积

2,412 阅读5分钟

此读书笔记完全基于公开的Google官方文档

压缩代码

如果设置minifyEnabled true, 那么会启用R8代码压缩,摇树优化。

android {
        ...
        buildTypes {
            release {
                minifyEnabled true
            }
        }
}

我觉得类似于JVM的垃圾回收,使用引用树检查来确定哪些对象需要清理。

未被引用的就会被移除,以节省资源,提高程序效率。

看到这里,我立刻就想到,通过引用检查来移除无用类/方法/成员变量。那么只通过反射调用的类、方法、变量岂不是会出问题?

带着问题我接着往下看:

自定义要保留的代码

在某些情况下,R8 很难做出正确分析,因此可能会移除您的应用实际上需要的代码。下面列举了几个例子,说明了它在什么情况下可能会错误地移除代码:

  • 当您的应用通过 Java 原生接口 (JNI) 调用方法时
  • 当您的应用在运行时查询代码时(如使用反射)

果然,设计者肯定知道这个问题,所以留下了解决办法:

要修复错误并强制 R8 保留某些代码,请在 ProGuard 规则文件中添加 -keep 代码行。例如:

-keep public class MyClass

压缩资源

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

如上所示,使用shrinkResources true 来启用资源压缩。

而与压缩代码同理,我们会有可能使用 Resources.getIdentifier()来访问资源,那么这些运行时动态使用的资源该怎么办?

R8会默认采取比较安全的防御策略,将所有具有匹配名称格式的资源标记为可能已使用,不移除。

例如:

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

以上代码会使系统将所有带 img_ 前缀的资源标记为已使用并保留下来。

PS:资源压缩器还会浏览代码以及各种 res/raw/ 资源中的所有字符串常量,查找格式类似于 file:///android_res/drawable//ic_plus_anim_016.png 的资源网址。如果它找到这样的字符串,或发现一些其他字符串看似可用来构建这样的网址,就不会将它们移除。

那对于APK体积极其敏感的人表示,想让资源压缩和代码压缩一样,默认删除无静态引用的资源,手动保留会动态引用的资源该怎么办呢?

答案是启用严格引用检查,具体做法是在 keep.xml 文件中将 shrinkMode 设为 strict,如下所示:

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

同时,需要使用tools:keep 属性来手动保留想保留的资源:

    <?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" />

其中tools:keep 属性中指定要保留的每个资源,在 tools:discard 属性中指定要舍弃的每个资源。

解码混淆过的堆栈轨迹

在使用腾讯bugly的时候,我没弄懂它怎么解码被混淆过的代码、识别出Exception的正确行数和正确方法名,又为啥要这么做。

首先我们要知道,代码混淆就是类似于短网址逻辑,使用较短的类名、方法名、变量名来全局替换所有的。这样的一个好处是可以显著缩小代码体积,另一个好处是即使class文件被反编译,混淆过的无意义的代码也难以看懂,可以保护代码。

我们注意到,在此过程中,方法名类名都会变,所以反射调用肯定会完蛋。另一个是行号也会变化了,缩短代码后,方法、表达式所在行可能会有变动(参考代码优化),所以Exception的日志会完蛋,不仅类名方法名一脸懵逼,连行号也找不到了。

所以生产环境的bug,产生的日志要怎么看?文章中有了解答:

R8 每次运行时都会创建一个 mapping.txt 文件,其中列出了混淆过的类、方法和字段名称与原始名称的映射关系。此映射文件还包含用于将行号映射回原始源文件行号的信息。R8 将此文件保存在 /build/outputs/mapping/ 目录中。

原来如此,这个mapping.txt就相当于密码本了,bugly让每个版本都上传一份映射文件,原来如此。

显然随着每个版本代码迭代,mapping文件肯定会变化,所以一个apk对应一个mapping文件是自然的。

文章也提示了我们:

注意:您每次编译项目时都会覆盖 R8 生成的 mapping.txt 文件,因此您每次发布新版本时都必须小心地保存一个副本。通过为每个发布版本保留一个 mapping.txt 文件副本,如果用户提交了来自旧版应用的混淆过的堆栈轨迹,您将能够调试相关问题。

仍然存在的疑问

梯子挂了,所以访问很多原文不便。

我继续读文章的过程中注意到,上面说的关于 “混淆过的代码通过反射调用肯定会完蛋”是武断的。文章下面提到:

默认情况下,R8 假设您打算在运行时检查和操纵该类的对象(即使您的代码实际上并不这样做),因此它会自动保留该类及其静态初始化程序。

通俗地说,就是如果开发者通过反射调用了class A,即使A没有被其他任何地方使用到,那么R8会保留A和A的静态初始化方法,保证你还能正常反射调用。

而我们可以通过在项目的 gradle.properties 文件中添加

android.enableR8.fullMode=true

来启用更积极的优化,也就是默认不保留反射调用的类A的。

我的疑惑就是,R8如何分析得知我反射调用了class A呢,大概率还是类似于上面的防御性地保护资源文件,对编译时确定或者半确定的class进行积极的保留。对于运行时地动态获取需要反射调用的类名时,束手无策。