此读书笔记完全基于公开的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进行积极的保留。对于运行时地动态获取需要反射调用的类名时,束手无策。