java去除 r_Android apk瘦身最佳实践(一):去除R.class

1. 前言

记得早期刚开始做 Android 开发的时候,一个 Android 应用也就几兆的大小。到现在,一个 APP少说十几兆,大则好几十兆甚至上百兆。所以针对 apk 包的瘦身问题,摆在了所有开发者的面前。毕竟安装包越小,下载安装肯定也就更快,对 APP 的运营也是有帮助的。网上已经有很多关于这方面的文章了,但是很多都泛泛而谈,道理大家都懂,但是怎么实操确不清楚。所以,本人计划将实际项目中用到的方案写出来,剖析剖析原理,一是给自己做个总结,二是给有需要的人做个参考,共同交流进步。

2. R.java 文件结构

众所周知,R.java 是自动生成的,它包含了应用内所有资源的名称到数值的映射关系。先创建一个最简单的工程,看看 R.java 文件的内容:

R.java文件结构

从图中可以看到,R.java 内部包含了很多内部类:如 layout、mipmap、drawable、string、id 等等,这些内部类里面只有 2 种数据类型的字段:

public static final int

public static final int[]

这里面,只有 styleable 最为特殊,只有它里面有 public static final int[] 类型的字段定义,其它都只有 int 类型的字段。

public static final class styleable {

...

public static final int[] ActionBarLayout = new int[]{16842931};

public static final int ActionBarLayout_android_layout_gravity = 0;

...

}

此外,我们发现 R.java 类的代码行数有 1800 多行了,这还只是一个简单的工程,压根没有任何业务逻辑。如果我们采用组件化开发或者在工程里创建多个 module ,你会发现在每个模块的包名下都会生成一个 R.java 文件。以我的实际项目为例,我们采用组件化开发的架构,一个 APP 由将近 30 个组件组成,编译时则会生成将近 30 个 R.java 文件,算上业务逻辑里的资源 id ,平均每个 R.java 算 3000 行代码的话,则总共有 90000 行的代码,当然这只是一个很笼统的估计。

3.为什么R文件可以删除

所有的 R.java 里定义的都是常量值,以 Activity 为例:

public class MainActivity extends AppCompatActivity {

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

}

}

R.layout.activity_main 实际上对应的是一个 int 型的常量值,那么如果我们编译打包时,将所有这些对 R 类的引用直接替换成常量值,效果也是一样的,那么 R.java 类在 apk 包里就是冗余的了。

前面说过 R.java 类里有2种数据类型,一种是 static final int 类型的,这种常量在运行时是不会修改的,另一种是 static final int[] 类型的,虽然它也是常量,但它是一个数组类型,并不能直接删除替换,所以打包进 apk 的 R 文件中,理论上除了 static final int[] 类型的字段,其他都可以全部删除掉。以上面这个为例:我们需要做的是编译时将 setContentView(R.layout.activity_main) 替换成:

setContentView(213196283);

4. ProGuard对R文件的混淆

通常我们会采用 ProGuard 进行混淆,你会发现混淆也能删除很多 R$*.class,但是混淆会造成一个问题:混淆后不能通过反射来获取资源了。现在很多应用或者SDK里都有通过反射调用来获取资源,比如大家最常用的统计SDK友盟统计、友盟分享等,就要求 R 文件不能混淆掉,否则会报错,所以我们常用的做法是开启混淆,但 keep 住 R 文件,在 proguard 配置文件中增加如下配置:

-keep class **.R$* {

*;

}

-dontwarn **.R$*

-dontwarn **.R

ProGuard 本身会对 static final 的基本类型做内联,也就是把代码引用的地方全部替换成常量,全部内联以后整个 R 文件就没地方引用了,就会被删掉。如果你的应用开启了混淆,并且不需要keep住R文件,那么后面讲的对你都不适用了,可以就此打住。

如果你的应用需要keep住R文件,那么接下来,我们讲讲如何删除所有 R 文件里的冗余字段。

4. 开发思路

具体的目标知道了,那怎么去实现呢,先说说我的思路:

在打包 apk 编译时找到所有的 R$*.class ;

收集所有 R$*.class 里的 public static final int 字段信息,将键值对缓存起来;

遍历所有的 class,如果是 R.class,则删除里面的 public static final int 字段,但是需要保留 R$styleable.class 里的 public static final int[] 字段;

如果不是 R$*.class ,则遍历该 class 里所有引用的静态字段,如果有对 R 文件里的静态字段引用,则根据前面缓存的键值对将其替换成对应的常量 int 值;

为了实现这个目标,我们需要创建一个 Gralde Plugin,在编译打包时能直接帮我们完成。这里需要用到2个技术:其一是 Gradle Transform,它能够在项目构建阶段即由 class 到 dex 转换期间,让开发者修改 class 文件;其二是 ASM 技术,它能让我们直接操作修改 class 文件。

5.R文件瘦身插件实操

这里提取出几个主要步骤来讲讲,具体代码已经开源。

怎么判断某个 class 文件是否为 R$*.class ,主要是通过 class 的文件名来判断,然后通过 ASM 技术来读取 R$*.class 里的所有 int 字段:

/**

* 收集所有 R.class 及其内部类里的 int 常量字段信息

* 存储的 key = class全路径类名 + 字段名,value = 该字段的常量值

*/

static Map mRInfoMap = new HashMap<>()

/**

* 收集R类相关信息,将所有 R.class 类里的 int 常量值缓存起来

*

* @param file class文件

*/

static void collectRInfo(File file) {

if (!isRClass(file.absolutePath)) {

return

}

def fullClassName = getFullClassName(file.getAbsolutePath())

println "需要收集的R类信息:fullClassName = ${fullClassName}"

new FileInputStream(file).withStream { InputStream is ->

ClassReader classReader = new ClassReader(is)

ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM5) {

@Override

FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {

if (value instanceof Integer) {

//遍历读取所有 R.class 里的 int 常量值,例如 com/hm/library1/R$mipmap.class 里的 ic_launcher 常量值,

//存储时存为 "com/hm/library1/R$mipmapic_launcher" = ***

mRInfoMap.put(fullClassName - ".class" + name, value)

}

return super.visitField(access, name, desc, signature, value)

}

}

classReader.accept(classVisitor, 0)

}

}

/**

* 判断该 class 文件是否是 R.class 类,及其内部类如 R$id.class

*

* @param classFilePath class文件的全路径名,例如:/Users/hjy/Desktop/app/build/intermediates/classes/debug/com/hm/library1/R.class

* @return 如果是R.class及其它内部类class则返回true,否则返回false

*/

static boolean isRClass(String classFilePath) {

classFilePath ==~ '''.*/R\\$.*\\.class|.*/R\\.class'''

}

/**

* 判断该 class 文件是否是 R.class 类,及其内部类如 R$id.class,但是 R$styleable.class 类排除在外

*

* @param classFilePath

* @return

*/

static boolean isRFileExceptStyleable(String classFilePath) {

classFilePath ==~ '''.*/R\\$(?!styleable).*?\\.class|.*/R\\.class'''

}

/**

* 从形如 /Users/hjy/Desktop/heima/code/gitlab/HM-ThinApk/app/build/intermediates/classes/debug/com/hm/library1/R.class 的类路径中截取出 com/hm/library1/R.class

* 不管是当前工程的代码,还是远程依赖的aar包,在打包编译时,都会在工程的 app/build/intermediates/classes 路径下生成一系列R.class文件,

* 根据打包模式是 debug 还是 release来区分,从中可以截取出 R.class 的包名了。

*

* @param filePath class文件全路径

* @return 返回类似 "com/hm/library1/R.class"、"com/hm/library1/R$mipmap.class",其实就是类的全路径class名

*/

static String getFullClassName(String filePath) {

String mode = "/debug/"

int index = filePath.indexOf(mode)

if (index == -1) {

mode = "/release/"

index = filePath.indexOf(mode)

}

return filePath.substring(index) - "${mode}"

}

在 Android 的 Transform 阶段,我们能读取到所有的 class 文件和 jar 包,那么 R$*.class 是在文件目录里还是在 jar 包里呢?实际上,每个 module 的代码打包成 aar 文件时,里面并不包含 R.class ,而是包含一个名为 R.txt 的文本文件,该文本文件里包含了资源 id 的映射关系,最后我们打包生成 apk 文件时,打包工具会收集所有 aar 包里的 R.txt 文件,重新生成 R.class 文件,一般可以在 app/build/intermediates/classes/ 目录下,看到所有的 R.class 文件。所有我们不需要遍历 jar 包来查找 R$*.class 文件,只需要遍历 class 文件目录即可,Transform 类里的大致代码如下:

@Override

void transform(Context context, Collection inputs, Collection referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {

inputs.each { TransformInput input ->

//第一次循环,只是为了收集 R.java 类信息

input.directoryInputs.each { DirectoryInput directoryInput ->

if (directoryInput.file.isDirectory()) {

directoryInput.file.eachFileRecurse { File file ->

if (file.isFile()) {

//收集R.java类的信息

collectRInfo(file)

}

}

} else {

//收集R.java类的信息

collectRInfo(directoryInput.file)

}

}

}

......

}

通过这种方式可以收集到所有 R.class 文件,接下来我们需要二次遍历所有的 class 文件和 jar 包,这次需要删除 R.class 以及替换对 R.class 的直接引用。

/**

* 将所有对 R.class 有引用的代码,直接替换成 int 值,这样在其他类里就不会内联 R.class 了,

* R.class 存不存在就不会影响编译运行了

*

* @param bytes

* @return

*/

private static byte[] replaceRInfo(byte[] bytes) {

ClassReader classReader = new ClassReader(bytes)

ClassWriter classWriter = new ClassWriter(0)

ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM5, classWriter) {

@Override

MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {

def methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)

methodVisitor = new MethodVisitor(Opcodes.ASM5, methodVisitor) {

@Override

void visitFieldInsn(int opcode, String owner, String name1, String desc1) {

String key = owner + name1

Integer value = mRInfoMap.get(key)

if (value != null) {

println "替换对R.class的直接引用:${owner} - ${name1}"

super.visitLdcInsn(value)

} else {

super.visitFieldInsn(opcode, owner, name1, desc1)

}

}

}

return methodVisitor

}

}

classReader.accept(classVisitor, 0)

return classWriter.toByteArray()

}

删除 jar 包中的 R.class 相关引用:

/**

* 遍历 jar 文件里的所有 class,替换所有对 R.class 的直接引用

*

* @param srcJar jar文件

*/

static void replaceAndDeleteRInfoFromJar(File srcJar) {

File newJar = new File(srcJar.getParentFile(), srcJar.name + ".tmp")

JarFile jarFile = new JarFile(srcJar)

new JarOutputStream(new FileOutputStream(newJar)).withStream { JarOutputStream jarOutputStream ->

jarFile.entries().each { JarEntry entry ->

jarFile.getInputStream(entry).withStream { InputStream inputStream ->

ZipEntry zipEntry = new ZipEntry(entry.name)

byte[] bytes = inputStream.bytes

if (entry.name.endsWith(".class")) {

bytes = replaceRInfo(bytes)

}

if (bytes != null) {

jarOutputStream.putNextEntry(zipEntry)

jarOutputStream.write(bytes)

jarOutputStream.closeEntry()

}

}

}

}

jarFile.close()

srcJar.delete()

newJar.renameTo(srcJar)

}

这样还是会有个弊端,如果删除了所有的 R$*.class 里的字段,某些资源通过反射调用依旧会失败,所以我们还是需要能通过配置来 keep 住某些字段。

6.资源keep

所有的代码已经开源,有兴趣的可以查看,里面会有更具体的注释:Android R文件瘦身插件:源码地址

资源 keep 配置示例:

thinRConfig {

keepInfo {

demomipmap {

keepRPackageName = "com.hm.iou.thinapk.demo"

keepRClassName = "mipmap"

keepResName = ["ic_launcher"]

keepResNameReg = ["ic_launcher.*"]

}

librarystring {

keepRPackageName = "com.hm.iou.library"

keepRClassName = "string"

keepResName = ["app_name"]

keepResNameReg = [""]

}

}

}

上面这个配置,com.hm.iou.thinapk.demo.R.mipmap 类里名为 ic_launcher 的字段不会被删除,com.hm.iou.library.R.string 类里名为 app_name 的字段不会被删除。

keepRPackageName:表示 R 文件所在的包名;

keepRClassName:表示 R 文件里的内部类名,如mipmap、string、id、drawable、layout 等等;

keepResName:要 keep 的资源名,是个数组

keepResNameReg:要 keep 的资源名,这是个正则表达式,会根据正则来进行匹配;

7. 使用方法

在工程根目录 build.gradle 里增加配置:

buildscript {

repositories {

google()

jcenter()

maven { url 'https://jitpack.io' }

}

dependencies {

......

classpath 'com.github.houjinyun:Android-ThinApk:v2.0.1'

}

}

在 app/build.gradle 里增加配置:

//使用该插件

apply plugin: 'com.hm.plugin.thinapk'

//插件配置

thinRConfig {

keepInfo {

//在 R.class 里的 com.hm.iou.thinapk.demo.R.mipmap.ic_launcher 会 keep 住,自己根据需要配置

mipmap {

keepRPackageName = "com.hm.iou.thinapk.demo"

keepRClassName = "mipmap"

keepResName = ["ic_launcher"]

keepResNameReg = ["ic_launcher.*"]

}

}

}

8.小结

本插件对采用组件化方式开发的app,或者有大量资源id定义的app可能会有显著效果,以我自己的项目为例,采用该插件以后,apk包大小减小了差不多0.4M左右。对这2种情况除外的app,效果可能并不会那么显著。当然这种方案只是锦上添花而已,我们应用里少用几张图片,可能包大小就减小了很多。但同样的条件下,打出来的 apk 包肯定越小越好。

你可能感兴趣的:(java去除,r)