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_Android apk瘦身最佳实践(一):去除R.class相关推荐

  1. App瘦身最佳实践(分析了微信、淘宝、微博图片文件的放法)

    本文会不定期更新,推荐watch下项目.如果喜欢请star,如果觉得有纰漏请提交issue,如果你有更好的点子可以提交pull request. 本文的示例代码主要是基于作者的经验来编写的,若你有其他 ...

  2. App 瘦身最佳实践

    原文链接:www.jianshu.com Android本文会不定期更新,推荐watch下项目.如果喜欢请star,如果觉得有纰漏请提交issue,如果你有更好的点子可以提交pull request. ...

  3. App瘦身最佳实践(上)

    业务方和开发都希望app尽量的小,本文会给出多个实用性的技巧来帮助开发者进行app的瘦身工作.瘦身和减负虽好,但需要注意瘦身对于项目可维护性的影响,建议根据自身的项目进行技巧的选取. 一.背景 目前a ...

  4. Android App包瘦身优化实践

    随着业务的快速迭代增长,美团App里不断引入新的业务逻辑代码.图片资源和第三方SDK,直接导致APK体积不断增长.包体积增长带来的问题越来越多,如CDN流量费用增加.用户安装成功率降低,甚至可能会影响 ...

  5. APK瘦身记,怎样实现高达53%的压缩效果

    作者:非戈@阿里移动安全,很多其它技术干货.请訪问阿里聚安全博客 1.我是怎么思考这件事情的 APK是Android系统安装包的文件格式.关于这个话题事实上是一个老生常谈的题目.不论是公司内部.还是外 ...

  6. Android优化系列之apk瘦身

    概述 为什么APK要瘦身.APK越大,在下载安装过程中,他们耗费的流量会越多,安装等待时间也会越长:对于产品本身,意味着下载转化率会越低(因为竞品中,用户有更多机会选择那个体验最好,功能最多,性能最好 ...

  7. androidstudio打包apk 文件_每天一个小知识——APK瘦身

    唯有美食 不可辜负 温度与风度同在,它虽然地处边角.远离喧嚣,但是到处都满载着精彩,周围弥漫着优雅的闲情逸致. Android性能优化之APK瘦身 面试中面试官常常会问道有关性能优化的问题,而性能优化 ...

  8. apk瘦身 提高优化效果

    APK瘦身记,如何实现高达53%的压缩效果 我是怎么思考这件事情的 APK是Android系统安装包的文件格式,关于这个话题其实是一个老生常谈的题目,不论是公司内部,还是外部网络,前人前辈已经总结出很 ...

  9. APK瘦身记,如何实现高达53%的压缩效果

    原文链接:http://drops.wooyun.org/mobiledev/14289#more-14289 0x00 我是怎么思考这件事情的 APK是Android系统安装包的文件格式,关于这个话 ...

  10. Android性能优化系列之apk瘦身

    Android性能优化系列之布局优化 Android性能优化系列之内存优化 为什么APK要瘦身.APK越大,在下载安装过程中,他们耗费的流量会越多,安装等待时间也会越长:对于产品本身,意味着下载转化率 ...

最新文章

  1. plsql如何连接oracle11g_PLSQL连接Oracle11G图文教程(含PLSQL配置文件)
  2. python基础教程:startswith()和endswith()的用法
  3. BTrace实现浅析
  4. iOS “项目名” has conflicting provisioning settings.
  5. [转]Asp.Net下导出/导入规则的Excel(.xls)文件
  6. 活动目录的物理结构逻辑结构
  7. 物联网常用无线模块 接收灵敏度及发射功率简化测量方法
  8. 多维数据库概述之一---多维数据库的选择
  9. 备受知名投资人青睐的Pocket Network,潜力几何?
  10. 金之塔用 Python 获取日内分时均价,每分钟日成交量和每时刻结算价,及交叉作用曲线延伸
  11. WordPress缩略图出现A TimThumb error has occured解决办法
  12. NOIP模拟 葫芦(分数规划)
  13. 如何制作自己的网页html,如何制作自己的网页
  14. Maix Bit K210人脸识别(内有获取机器码步骤)【保姆级教程】
  15. 人工智能换脸技术python_人工智能几行代码实现换脸,python+dlib实现图文教程
  16. 弧度和度 180/PI PI/180换算关系
  17. Linux的markdown笔记软件,3款免费好用的Markdown笔记应用,可以替代印象笔记
  18. [转载]20行Python代码爬取王者荣耀全英雄皮肤
  19. 每天学习一个设计模式(九):创建型之建造者模式
  20. 软件缺陷度量中用EXCEL制作柏拉图的方法

热门文章

  1. Springboot集成通用Mapper与Pagehelper,实现mybatis+Druid的多数据源配置
  2. mysql_error 1030
  3. AxureRP7.0基础教程系列 部件详解Text Area 文本段落
  4. 转:多线程--六种多线程方法解决UI线程阻塞
  5. calloc与malloc的区别
  6. 32.Linux/Unix 系统编程手册(上) -- 线程:线程取消
  7. 18.UNIX 环境高级编程--终端IO
  8. Prometheus监控学习笔记之PromQL简单示例
  9. (MSSQL)sp_refreshview刷新视图失败及更新Table字段失败的问题解决
  10. Android下adb shell的使用