Android Apk瘦身方案1——R.java文件常量内联

2022-06-23 14:07:47 浏览数 (1)

R.java 文件结构

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

R文件生成的目录为app/build/generated/not_namespaced_r_class_sources/xxxxxDebug/processXXXXDebugResources/r/com/xxx/xxx/R.java

R.java 内部包含了很多内部类:如 layout、mipmap、drawable、string、id 等等

这些内部类里面只有 2 种数据类型的字段:

代码语言:javascript复制
public static final int 
public static final int[]

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

此外,我们发现 R.java 类的代码行数最少也1000行了,这还只是一个简单的工程,压根没有任何业务逻辑。如果我们采用组件化开发或者在工程里创建多个 module ,你会发现在每个模块的包名下都会生成一个 R.java 文件。

为什么R文件可以删除

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

代码语言:javascript复制
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) 替换成:

代码语言:javascript复制
setContentView(213196283);

ProGuard对R文件的混淆

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

代码语言:javascript复制
-keep class **.R$* {
    *;
}
-dontwarn **.R$*
-dontwarn **.R

ProGuard 本身会对 static final 的基本类型做内联,也就是把代码引用的地方全部替换成常量,全部内联以后整个 R 文件就没地方引用了,就会被删掉。如果你的应用开启了混淆,并且不需要keep住R文件,那么app下的R文件会被删掉,但是module下的并不会被删掉,因为module下R文件内容不是static final的,而是静态变量。

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

删除不必要的 R

对于 Android 工程来说,通常,library 的 R 只是 application 的 R 的一个子集,所以,只要有了全集,子集是可以通通删掉的,而且,application 的 R 中的常量字段,一旦参与编译后,就再也没有利用价值(反射除外)。在 R 的字段,styleable 字段是一个例外,它不是常量,它是 int[]。所以,删除 R 之前,我们要弄清楚要确定哪些是能删的,哪些是不能删的,根据经验来看,不能删的索引有:

1.ConstraintLayout 中引用的字段,例如:

代码语言:javascript复制
<android.support.constraint.Group
    android:id="@ id/group"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="visible"
    app:constraint_referenced_ids="button4,button9" />

其中,R.id.button4 和 R.id.button9 是必须要保留的,因为 ContraintLayout 会调用 TypedArray.getResourceId(int, int) 来获取 button4 和 button9 的 id 索引。

总结下来,在 ConstraintLayout 中引用其它 id 的属性如下:

代码语言:javascript复制
constraint_referenced_ids
layout_constraintLeft_toLeftOf
layout_constraintLeft_toRightOf
layout_constraintRight_toLeftOf
layout_constraintRight_toRightOf
layout_constraintTop_toTopOf
layout_constraintTop_toBottomOf
layout_constraintBottom_toTopOf
layout_constraintBottom_toBottomOf
layout_constraintBaseline_toBaselineOf
layout_constraintStart_toEndOf
layout_constraintStart_toStartOf
layout_constraintEnd_toStartOf
layout_constraintEnd_toEndOf

也就是说系统通过反射来获取的,包含反射属性的R是不能进行删除的,不然就会获取不到

因此,采用了解析 xml 的方式,从 xml 中提取以上属性。

其它通过 TypedArray.getResourceId(int, int) 或 Resources.getIdentifier(String, String, String) 来获取索引值的资源 针对这种情况,需要对字节码进行全盘扫描才能确定哪些地方调用了 TypedArray.getResourceId(int, int) 或 Resources.getIdentifier(String, String, String),考虑到增加一次 Transform 带来的性能损耗, 可以提供通过配置白名单的方式来保留这些资源索引

删除不必要的 Field

由于 Android 的资源索引只有 32 位整型,格式为:PP TT NNNN,其中:

PP 为 Package ID,默认为 0x7f; TT 为 Resource Type ID,从 1 开始依次递增; NNNN 为 Name ID,从 1 开始依次递增;

为了节省空间,在构建 application 时,所有同类型的资源索引会重排,所以,library 工程在构建期间无法确定资源最终的索引值,这就是为什么 library 工程中的资源索引是变量而非常量,既然在 application 工程中可以确定每个资源最终的索引值了,为什么不将 library 中的资源索引也替换为常量呢?这样就可以删掉多余的 field 了,在一定程度上可以减少 dex 的数量,收益是相当的可观。

我们可以看一下,这个是app module下的R.java

这个是module下的R.java

可以很明显发现app下是常量,library下是静态的变量

在编译期间获取索引常量值有很多种方法:

1)反射 R 类文件 2)解析 R 类文件 3)解析 Symbol List (R.txt) 经过 测试发现,解析 Symbol List 的方案性能最优,因此,在 Transform 之前拿到所有资源名称与索引值的映射关系。

关于解析 Symbol List (R.txt)的思路来源,可以参考gradle源码 TaskManager#createNonNamespacedResourceTasks

代码语言:javascript复制
private 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);
        BuildArtifactsHolder artifacts = scope.getArtifacts();
        if (mergeType == MergeType.PACKAGE) {
            // MergeType.PACKAGE means we will only merged the resources from our current module
            // (little merge). This is used for finding what goes into the AAR (packaging), and also
            // for parsing the local resources and merging them with the R.txt files from its
            // dependencies to write the R.txt for this module and R.jar for this module and its
            // dependencies.

            // First collect symbols from this module.
            taskFactory.register(new ParseLibraryResourcesTask.CreateAction(scope));

            // Only generate the keep rules when we need them.
            if (generatesProguardOutputFile(scope)) {
                taskFactory.register(new GenerateLibraryProguardRulesTask.CreationAction(scope));
            }

            // Generate the R class for a library using both local symbols and symbols
            // from dependencies.
            task =
                    taskFactory.register(
                            new GenerateLibraryRFileTask.CreationAction(
                                    scope, symbolFile, symbolTableWithPackageName));
        } else {
            // MergeType.MERGE means we merged the whole universe.
            task =
                    taskFactory.register(
                            createProcessAndroidResourcesConfigAction(
                                    scope,
                                    () -> symbolDirectory,
                                    symbolTableWithPackageName,
                                    useAaptToGenerateLegacyMultidexMainDexProguardRules,
                                    mergeType,
                                    baseName));

            if (packageOutputType != null) {
                artifacts.createBuildableArtifact(
                        packageOutputType,
                        BuildArtifactsHolder.OperationType.INITIAL,
                        artifacts.getFinalArtifactFiles(InternalArtifactType.PROCESSED_RES));
            }

            // create the task that creates the aapt output for the bundle.
            taskFactory.register(new LinkAndroidResForBundleTask.CreationAction(scope));
        }
        artifacts.appendArtifact(
                InternalArtifactType.SYMBOL_LIST, ImmutableList.of(symbolFile), task.getName());

        // Synthetic output for AARs (see SymbolTableWithPackageNameTransform), and created in
        // process resources for local subprojects.
        artifacts.appendArtifact(
                InternalArtifactType.SYMBOL_LIST_WITH_PACKAGE_NAME,
                ImmutableList.of(symbolTableWithPackageName),
                task.getName());
    }

就是会在以下路径app/build/intermediates/symbols/debug/R.txt生成文件,我们打开这个文件查看

可以看到R.txt里就有资源和索引的对应关系

代码实现

通过编写gradle插件,在 这里代码分析实现都是参考开源项目booster下代码 如何解析Symbol List (R.txt)

代码语言:javascript复制
fun from(file: File) = SymbolList.Builder().also { builder ->
            if (file.exists()) {
                file.forEachLine { line ->
                    val sp1 = line.nextColumnIndex(' ')
                    val dataType = line.substring(0, sp1)
                    when (dataType) {
                        "int" -> {
                            val sp2 = line.nextColumnIndex(' ', sp1   1)
                            val type = line.substring(sp1   1, sp2)
                            val sp3 = line.nextColumnIndex(' ', sp2   1)
                            val name = line.substring(sp2   1, sp3)
                            val value: Int = line.substring(sp3   1).toInt()
                            builder.addSymbol(IntSymbol(type, name, value))
                        }
                        "int[]" -> {
                            val sp2 = line.nextColumnIndex(' ', sp1   1)
                            val type = line.substring(sp1   1, sp2)
                            val sp3 = line.nextColumnIndex(' ', sp2   1)
                            val name = line.substring(sp2   1, sp3)
                            val leftBrace = line.nextColumnIndex('{', sp3)
                            val rightBrace = line.prevColumnIndex('}')
                            val vStart = line.skipWhitespaceForward(leftBrace   1)
                            val vEnd = line.skipWhitespaceBackward(rightBrace - 1)   1
                            val values = mutableListOf<Int>()
                            var i = vStart

                            while (i < vEnd) {
                                val comma = line.nextColumnIndex(',', i, true)
                                i = if (comma > -1) {
                                    values.add(line.substring(line.skipWhitespaceForward(i), comma).toInt())
                                    line.skipWhitespaceForward(comma   1)
                                } else {
                                    values.add(line.substring(i, vEnd).toInt())
                                    vEnd
                                }
                            }

                            builder.addSymbol(IntArraySymbol(type, name, values.toIntArray()))
                        }
                        else -> throw MalformedSymbolListException(file.absolutePath)
                    }
                }
            }
        }.build()

结合debug

代码语言:javascript复制
int anim abc_slide_in_bottom 0x7f010006

其实就是解析第一个看是int还是int[] 然后解析出type=anim,name=abc_slide_in_bottom,value=0x7f010006.toInt,然后构建IntSymbol,然后添加到一个list中 val symbols = mutableListOf<Symbol<*>>()

如果是int[]

代码语言:javascript复制
 public static int[] MsgView = { 0x7f040204, 0x7f040205, 0x7f040206, 0x7f040207, 0x7f040208, 0x7f040209 };

同样进行解析

对多余的R进行删除

寻找多余的R

代码语言:javascript复制
   private fun TransformContext.findRedundantR(): List<Pair<File, String>> {
        return artifacts.get(ALL_CLASSES).map { classes ->
            val base = classes.toURI()

            classes.search { r ->
                r.name.startsWith("R") && r.name.endsWith(".class") && (r.name[1] == '$' || r.name.length == 7)
            }.map { r ->
                r to base.relativize(r.toURI()).path.substringBeforeLast(".class")
            }
        }.flatten().filter {
            it.second != appRStyleable // keep application's R$styleable.class
        }.filter { pair ->
            !ignores.any { it.matches(pair.second) }
        }
    }

这里过滤掉了application’s R$styleable.class,还有白名单的 可以从debug中看到多余的R文件有哪些

对R常量内联

通过ASM对所有的class文件进行扫描,并利用其进行修改

代码语言:javascript复制
private fun ClassNode.replaceSymbolReferenceWithConstant() {
        methods.forEach { method ->
            val insns = method.instructions.iterator().asIterable().filter {
                it.opcode == GETSTATIC
            }.map {
                it as FieldInsnNode
            }.filter {
                ("I" == it.desc || "[I" == it.desc)
                        && it.owner.substring(it.owner.lastIndexOf('/')   1).startsWith("R$")
                        && !(it.owner.startsWith(COM_ANDROID_INTERNAL_R) || it.owner.startsWith(ANDROID_R))
            }

            val intFields = insns.filter { "I" == it.desc }
            val intArrayFields = insns.filter { "[I" == it.desc }

            // Replace int field with constant
            intFields.forEach { field ->
                val type = field.owner.substring(field.owner.lastIndexOf("/R$")   3)
                try {
                    method.instructions.insertBefore(field, LdcInsnNode(symbols.getInt(type, field.name)))
                    method.instructions.remove(field)
                    logger.println(" * ${field.owner}.${field.name} => ${symbols.getInt(type, field.name)}: $name.${method.name}${method.desc}")
                } catch (e: NullPointerException) {
                    logger.println(" ! Unresolvable symbol `${field.owner}.${field.name}`: $name.${method.name}${method.desc}")
                }
            }

            // Replace library's R fields with application's R fields
            intArrayFields.forEach { field ->
                field.owner = "$appPackage/${field.owner.substring(field.owner.lastIndexOf('/')   1)}"
            }
        }
    }

对这段代码进行debug

以androidx/appcompat/app/AlertController.java这个类为例子 通过如下方法过滤出可以内联的field

代码语言:javascript复制
("I" == it.desc || "[I" == it.desc)
                        && it.owner.substring(it.owner.lastIndexOf('/')   1).startsWith("R$")
                        && !(it.owner.startsWith(COM_ANDROID_INTERNAL_R) || it.owner.startsWith(ANDROID_R))

例如过滤出上面这个field 查看AlertController.java中确实有用到地方

代码语言:javascript复制
  private static boolean shouldCenterSingleButton(Context context) {
        final TypedValue outValue = new TypedValue();
        context.getTheme().resolveAttribute(R.attr.alertDialogCenterButtons, outValue, true);
        return outValue.data != 0;
    }

即可以对R.attr.alertDialogCenterButtons进行内联替换 代码如下:

代码语言:javascript复制
 method.instructions.insertBefore(field, LdcInsnNode(symbols.getInt(type, field.name)))
 method.instructions.remove(field)
代码语言:javascript复制
context.getTheme().resolveAttribute(R.attr.alertDialogCenterButtons, outValue, true);

1.通过symbols.getInt(type, field.name)获取R.attr.alertDialogCenterButtons对应的常量值 2.通过ASM在R.attr.alertDialogCenterButtons前插入这个常量值即method.instructions.insertBefore(field, LdcInsnNode(symbols.getInt(type, field.name)))

3.删除R.attr.alertDialogCenterButtons

对于int[]的修改就简单多了

代码语言:javascript复制
intArrayFields.forEach { field ->
                field.owner = "$appPackage/${field.owner.substring(field.owner.lastIndexOf('/')   1)}"
            }

直接将field.owner修改,从module的包路径改为app包名下主路径

0 人点赞