Android性能优化 - 包体积杀手之R文件内联原理与实现

5,579 阅读7分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言&背景

包体积也是性能优化的常客之一了,在包体积简化的历史潮流中,已经涌现了很多包体积杀手级别方案,比如动态so方案,R文件内联等等,由于笔者已经在之前的文章中介绍过动态so方案,那么本次专栏就不重复,于是就介绍另一个包体积杀手方案,从R文件内联的角度出发,看看我们是怎么完成一次华丽的R文件精简

在android 日常开发中,我们对资源的引用都会用到R.xx.xx 去获取,R文件这个贯穿着整个项目周期,从而简化了我们很多工作。R文件带给我们便利的同时,也会带来很多对包体积的负面影响,其中有一个就是R文件过大导致的,一个中型项目的R文件,可能会达到10M,其中资源重复的部分/多余的部分,可能就有4/5M,下面我们从实战角度出发,探索一下R文件的“今生前世”

R文件产生

在Android编译打包的过程中,位于res/目录下的文件,就会通过aapt工具,对里面的资源进行编译压缩,从而生成相应的资源id,且生成R.java文件,用于保存当前的资源信息,同时生成resource.arsc文件,建立id与其对应资源的值,如图

image.png 其中R文件中还有几个static final 修饰的子类,比如anim,attr,layout,分别对应着res/目录下相应的子目录,同时资源id用一个16进制的int数值表示。比如0x7f010000,我们来解释一下具体含义

  1. 第一个字节7f:代表着这个资源属于本应用apk的资源,相应的以01代表开头的话(比如0x01010000)就代表这是一个与应用无关的系统资源。0x7f010000,表明abc_fade_in 属于我们应用的一个资源
  2. 第二个字节01:是指资源的类型,比如01就代表着这个资源属于anim类型
  3. 第三,四个字节0000:指资源的编号,在所属资源类型中,一般从0000开始递增

同时我们可以留意到,这个R文件的内部的属性,都是以static final 修饰的,这就意味着是一个常量类型,常量在编译过程中,可以被“内联化”,即R.anim.abc_fade_in 可以用0x7f010000所代表的数值进行直接替换。

正常情况下,即app目录下的R文件的过程就是如此,那么类似lib(子module),aar包的这种,生成的R文件也是这样吗?不是的!如果说lib/aar 也是这样生成R文件的话,那么最终的资源id也会是常量,比如lib中有个R.anim.other,按照默认模式的话,生成的id也应该是0x7f010000,这样一来就会产生了一个问题,就是资源id冲突的问题!因为app模块下R.anim.abc_fade_in 跟子module/aar中有个R.anim.other产生了同一个id。所以gradle给出了一个解决方法,就是在编译子module或者aar中,编译的时候产生一个R.def.txt(只在module有效,不同AGP版本会有不同实现,低版本是R.txt)文件,该文件记录着当前的资源映射关系,比如我在子module中有一个布局layout文件activity_my_test

image.png

此时生成的这个映射关系就不包含真正的资源id,而是把其当作一个输入传给app模块一起编译,从而得到最终的资源id,并且生成一个全局的R文件(该R文件包含了所有子module/aar的R文件与自身的R文件)与全局R.txt文件(记录所有id映射,这个大有用途,我们下面会讲),并且此时通过gralde中任务generateDebugRFile,生成一个专属于子module的R文件(此时的R文件就是static final的,因为经过了资源id的重新定位)所以此时子module中也就可以用R.xx.xx去引用到自己的资源,比如上述的activity_my_test所属于的子module生成的R文件如下:

image.png 全局的R文件也会包含子R文件的内容

image.png 我们再把大致的过程如图展示

image.png

R文件过大的原因

通过上述R文件生成的分析,我们可以看到R文件占用多余的原因,下层的module/aar生成的id除了自己会生成一个R文件外,同时也会在全局的R文件生成一个一个同样的属性,比如activity_my_test存在了两份,如果module多的话,R文件的数量同样会膨胀上升!在我们组件化过程中,多module肯定是很正常的,前文我们也说过,app module中的R文件会被内联化替代,所以appmodule 中的R文件内容如果没有被直接引用了,是可以通过proGuard去直接删除掉的,所以release环境下我们可以通过proGuard去移除不必要的R文件,但是被引用到的R文件(module/aar中的R文件)就无法这么做了,同时如果项目中存在(反向引用,比如其他模块依赖了app,情况比较少)那么所有的R文件就都无法被proGuard删除了。

R文件内联方案

R.txt

在上面讲解中,我们留下了一个疑问,就是R.txt,它记录了所有的资源映射关系,那么这个R.txt存放在哪里呢?我们以com.android.tools.build:gradle:3.4.1中的源码为例子:生成R.txt是在


TaskManagerprivate void createNonNamespacedResourceTasks(
        @NonNull VariantScope scope,
        @NonNull File symbolDirectory,
        InternalArtifactType packageOutputType,
        @NonNull MergeType mergeType,
        @NonNull String baseName,
        boolean useAaptToGenerateLegacyMultidexMainDexProguardRules) {
    File symbolTableWithPackageName =
            FileUtils.join(
                    globalScope.getIntermediatesDir(),
                    FD_RES,
                    "symbol-table-with-package",
                    scope.getVariantConfiguration().getDirName(),
                    "package-aware-r.txt");
    final TaskProvider<? extends ProcessAndroidResources> task;
    // 重点
    File symbolFile = new File(symbolDirectory, FN_RESOURCE_TEXT);

FN_RESOURCE_TEXT是个常量,代表着 R.txt,同时可以看到symbolDirectory就是路径,而这个赋值在createApkProcessResTask 中

private void createApkProcessResTask(@NonNull VariantScope scope,
        InternalArtifactType packageOutputType) {
    createProcessResTask(
            scope,
            new File(
                    globalScope.getIntermediatesDir(),
                    "symbols/" + scope.getVariantData().getVariantConfiguration().getDirName()),
            packageOutputType,
            MergeType.MERGE,
            scope.getGlobalScope().getProjectBaseName());
}

所以路径我们一目了然了,就是在build/intermediates/symbols之后的子目录下(这个在4.多的版本有变化,4.多在runtime_symbol_list 子目录下,需要注意)这样一来,我们就得到了R.txt,之后解析的时候有用

R文件字节码替换

了解方案之前,我们看一下平常的R文件是怎么被使用的,我们以setContentView这个举例,当我们在app模块中,调用了

setContentView(R.layout.activity_main)

编译后的字节码是这样的:

 LDC 2131427356
 INVOKEVIRTUAL com/example/spider/MainActivity.setContentView (I)V

但是当我们在子moudle中

setContentView(R.layout.activity_my_test)

同样看一下字节码

 GETSTATIC com/example/test/R$layout.activity_my_test : I
 INVOKEVIRTUAL com/example/test/MyTestActivity.setContentView (I)V

看看我们发现了什么!同样是setContentView,入参是int类型情况下,在app模块中,是通过常量引入的方式 LDC 2131427356放入操作数栈,提供给setContentView消费的,那么这个2131427356是什么呢?其实就是资源id,我们可以在R.txt中找到

image.png 而7f0b001c换算成10进制,就是2131427356。

既然app模块能这样做,我们又知道了R.txt内容,所以我们能不能把子module的GETSTATIC换成跟app模块一样的实现呢?这样我们就不用依赖了R文件,后期就可以通过proGuard删除了!答案是可以的!这个时候只需要我们从R.txt 找到activity_my_test 对应的id(例子是0x7f0b001d),换算成10进制就是2131427357,我们再把GETSTATIC 指令替换成LDC指令就完事了,代码如下:

ASM tree api

if(node.opcode == Opcodes.GETSTATIC && node.desc == "I" && node.owner.substring(node.owner.lastIndexOf('/') + 1).startsWith('R$')&& !(node.owner.startsWith(COM_ANDROID_INTERNAL_R) || node.owner.startsWith(ANDROID_R))){
    println("get node ")
    def ldc = new LdcInsnNode(2131427357)
    method.instructions.insertBefore(node,ldc)
    method.instructions.remove(node)
}

LdcInsnNode(2131427357) 通过指令集替换,我们就实现了R文件内联的操作了,同时这里还有很多小玩法,比如LdcInsnNode(特定的id),我们甚至能够实现编译时替换布局,这里就不过多展开

扩展

看到这里,我们就能明白了R文件内联究竟干了什么,但是实际上我们也只是替换了一个R文件罢了,如果我们想要替换更多的R文件怎么办?没事!这里已经有很多成熟的开源库帮我们做啦!比如bytex booster

当然,R文件内联不一定适用所有场景,比如直接用到R文件id的场景就不适合了

public static int getId(String num){ 
 try { 
        String name = "drawable" + num; 
        Field field = R.drawable.class.getField(name); 
 return field.getInt(null); 
    } catch (Exception e) { 
        e.printStackTrace(); 
    } 
 return 0; 
} 

因为涉及到了R文件属性的读写,这种情况我们就不能用内联的方式了!同时常见的还有ConstraintLayout中,会涉及到id的直接使用,这部分就要加入Transform转换的白名单,避免被内联化啦!

总结

通过本文的阅读,相信你已经了解到包体积优化中-R文件内联的实现思路啦!我们在实际项目中也用到了R文件内联,收益大概是5M左右。如果你的项目是agp 4.1.0 的话,可以直接开启R 文件的内联,不需要引入三方库即可实现!!官方都引入了这个特性,这个稳定性是可以保证的啦!详细可查看: developer.android.com/studio/rele…