腾讯性能监控框架Matrix源码分析(二十二)ApkChecker3 删除无用资源插件

377 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情 在前面的文章中,我们说过了 ApkChecker 是如何查找 apk 中的无用资源的,那么找到之后,如果你不想手动删除资源,然后重新打包的话,该怎么办?

Matrix 也提供了一个插件来做这个事情,也是在 matrix-gradle-plugin 中,我们介绍方法插桩的时候分析过其中一个插件,还有一个没有介绍,就留到现在说,串起来舒服些。

里面有个 RemoveUnusedResourcesTask,它就是用来删除无用资源的。

直接上源码部分:

com.tencent.matrix.plugin.task.RemoveUnusedResourcesTask#removeResources

// 获取未签名的 apk 路径,这些都是 gradle 的 api,就不介绍了,需要自己看文档
String unsignedApkPath = output.outputFile.getAbsolutePath();
Log.i(RemoveUnusedResourcesTask.TAG, "original apk file %s", unsignedApkPath);
long startTime = System.currentTimeMillis();
// 获取 R.txt 文件路径,已经签名信息
removeUnusedResources(unsignedApkPath, project.getBuildDir().getAbsolutePath() + "/intermediates/symbols/${variant.name}/R.txt", variant.variantData.variantConfiguration.signingConfig);

上面就是获取必要的信息。

com.tencent.matrix.plugin.task.RemoveUnusedResourcesTask#removeUnusedResources

removeUnusedResources 还是比较长的,只截取部分代码,主要流程有就行

File inputFile = new File(originalApk);
Set<String> ignoreRes = project.extensions.matrix.removeUnusedResources.ignoreResources;
// 加载配置的应该忽略的资源
for (String res : ignoreRes) {
    // 通配符转转正则表达式
    // 配置的语法应该支持 * 啥的吧
    ignoreResources.add(Util.globToRegexp(res));
}
// 加载配置的 未使用的资源,这个就是 ApkChecker 分析出来的资源
Set<String> unusedResources = project.extensions.matrix.removeUnusedResources.unusedResources;

这里是从我们的 build.gradle 文件中,读取配置信息,配置信息长这样:

apply plugin: 'com.tencent.matrix-plugin'
matrix {
    trace {
        enable = true
        baseMethodMapFile = "${project.projectDir}/matrixTrace/methodMapping.txt"
        blackListFile = "${project.projectDir}/matrixTrace/blackMethodList.txt"
    }
    removeUnusedResources {
        enable true
        variant = "debug"
        needSign true
        shrinkArsc true
        //Notice: You need to modify the  value of $apksignerPath on different platform. the value below only suitable for Mac Platform,
        //if on Windows, you may have to  replace apksigner with apksigner.bat.
        apksignerPath = "${android.getSdkDirectory().getAbsolutePath()}/build-tools/${android.getBuildToolsVersion()}/apksigner.bat"
        unusedResources = project.ext.unusedResourcesSet
        ignoreResources = ["R.id.*", "R.bool.*"]
    }
}

看上面的removeUnusedResources部分,里面有个 unusedResources,这里就应该填上 ApkChecker 分析出来的资源,我们直到 ApkChecker 分析出来的结果是一个 json 文件,那么应该怎么与它关联起来呢?

官方的 Sample 里面,有一个用法是这样的。

首先,我们定义一个 ext 属性:

ext.unusedResourcesSet = new HashSet<String>();

然后,在打包的时候,插入如下动作:

applicationVariants.all { variant ->
    // 只对 debug 包做处理
    if (variant.name.equalsIgnoreCase("debug")) {
        // packageDebug 是一个内置属性
        packageDebug.doLast {
            // 打包完成之后,使用 apkchecker 分析这个包
            ProcessBuilder processBuilder = new ProcessBuilder();
            println configurations.apkCheckerDependency.getAt(0).getAbsolutePath()
            processBuilder.command("java",
                                   "-jar", configurations.apkCheckerDependency.getAt(0).getAbsolutePath(),
                                   "--apk", variant.outputs.first().outputFile.getAbsolutePath(),
                                   "--output", project.getProjectDir().getAbsolutePath() + "/unused_resources",
                                   "--format", "json",
                                   "-unusedResources", "--rTxt", project.getBuildDir().getAbsolutePath() + "/intermediates/symbols/${variant.name}/R.txt");
            Process process = processBuilder.start();
            // 等待程序执行完成
            process.waitFor();
            File outputFile = new File(project.getProjectDir().getAbsolutePath() + "/unused_resources.json");
            // 读取 json 文件到 unusedResourcesSet 里面
            if (outputFile.exists()) {
                Gson gson = new Gson();
                JsonArray jsonArray = gson.fromJson(outputFile.text, JsonArray.class);
                for (int i = 0; i < jsonArray.size(); i++) {
                    if (jsonArray.get(i).asJsonObject.get("taskType").asInt == 12) {
                        JsonArray resList = jsonArray.get(i).asJsonObject.get("unused-resources").asJsonArray;
                        for (int j = 0; j < resList.size(); j++) {
                            project.ext.unusedResourcesSet.add(resList.get(j).asString);
                        }
                        println "find unused resources:\n" + unusedResourcesSet
                        break;
                    }
                }
                outputFile.delete();
            }
        }
    }
}

这样,我们就拿到了 ApkChecker 里面分析出来的结果,而且还是一步到位。回到源码部分,接着是读取 rTxt 文件:

readResourceTxtFile(resTxtFile, resourceMap, styleableMap);

就是将 R.txt 中的符号表内存读到map里面。

比如:

int attr layout_editor_absoluteY 0x7f0200c5 
就会变成 {"R.attr.layout_editor_absoluteY":0x7f0200c5}
int[] styleable ViewStubCompat { 0x010100d0, 0x010100f2, 0x010100f3 } 
会变成 {"R.styleable.ViewStubCompat" : [Pair("R.styleable.ViewStubCompat", 0x010100d0)]}

接下来是,拷贝apk里面的文件,针对 res 文件做如下处理:

if (zipEntry.name.startsWith("res/")) {
    // zipEntry.name --> res/mipmap-hdpi-v4/ic_launcher_round.png
    // resourceName --> R.mipmap.ic_launcher_round.png
    String resourceName = entryToResouceName(zipEntry.name);
    if (!Util.isNullOrNil(resourceName)) {
        // 如果有配置了 unusedResources,这里就不拷贝这个资源到新的 apk 里面了
        if (removeResources.containsKey(resourceName)) {
            Log.i(TAG, "remove unused resource %s", resourceName);
            continue;
        } else {
            addZipEntry(zipOutputStream, zipEntry, zipInputFile);
        }
    } else {
        addZipEntry(zipOutputStream, zipEntry, zipInputFile);
    }
}

因为 unusedResources 里面的资源是需要移除的,所以这里只拷贝不在该集合中的资源。

拷贝文件,针对非 res 文件做如下处理:

// 为啥 META-INF/ 下的文件也不拷贝
// 里面的几个签名文件可以不用管,因为后面会重新签名,但是还有别的文件呢
if (needSign && zipEntry.name.startsWith("META-INF/")) {
    continue;
} else {
    // shrinkArsc 需要精简 arsc 文件,这是大头
    if (shrinkArsc && zipEntry.name.equalsIgnoreCase("resources.arsc") && unusedResources.size() > 0) {
        File srcArscFile = new File(inputFile.getParentFile().getAbsolutePath() + "/resources.arsc");
        File destArscFile = new File(inputFile.getParentFile().getAbsolutePath() + "/resources_shrinked.arsc");
        if (srcArscFile.exists()) {
            srcArscFile.delete();
            srcArscFile.createNewFile();
        }
        // 将 zip 文件中的 .asrs 文件解压出来
        unzipEntry(zipInputFile, zipEntry, srcArscFile);

        // 分析 .arsc 文件,需要一张图配合看
        // https://user-gold-cdn.xitu.io/2019/5/24/16ae9b85b2f4e918?imageView2/0/w/1280/h/960/format/webp/ignore-error/1
        // 或者使用 010 打开看看(推荐)
        ArscReader reader = new ArscReader(srcArscFile.getAbsolutePath());
        ResTable resTable = reader.readResourceTable();
        for (String resName : removeResources.keySet()) {
            ArscUtil.removeResource(resTable, removeResources.get(resName), resName);
        }
        // 重新生成 .arsc 文件
        ArscWriter writer = new ArscWriter(destArscFile.getAbsolutePath());
        writer.writeResTable(resTable);
        Log.i(TAG, "shrink resources.arsc size %f KB", (srcArscFile.length() - destArscFile.length()) / 1024.0);
        addZipEntry(zipOutputStream, zipEntry, destArscFile);
    } else {
        addZipEntry(zipOutputStream, zipEntry, zipInputFile);
    }
}

里面主要是针对 arsc 文件做了处理,其他的文件原封不动的拷贝就好了。对于 arsc 文件结构,番外篇已经介绍了一部分,这里就只说说它处理了什么吧。

使用 010 Editor 打开 arsc 文件,会发现如下结构:

TablePackageType
	...
	--ResTable_typeSpec
	--ResTable_type
		--ResTable_entry
		--Res_value

当我们从apk中删除了一些资源后,比如,我们删除了一个 drawable 资源(因为 values 下面的资源都在一个文件中,比如 string.xml 等,所以拷贝时无法删除其中的某一项),那么它的 ResTable_type 这个结构就需要改一下,需要将这个资源对应的 ResTable_entry 与 Res_value 删除才行。这里还要考虑文件的格式,删除还是挺麻烦的,具体可以看代码。

需要注意的时,删除的时候,需要保证原来的索引不变。比如,有两个 String,A 与 B,假设他们生成的 id 为 (A)0x01 与 (B)0x02,A是个无用资源,当你删除之后,仍然要保证 arsc 文件中,B的id是0X02,而 id 是与该 entry 在数组中的index有关。

文件都拷贝完成之后,就需要进行签名:

// 调用 apksigner 程序,进行签名
Log.i(TAG, "resign apk...");
ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.command(apksigner, "sign", "-v",
                       "--ks", signingConfig.storeFile.getAbsolutePath(),
                       "--ks-pass", "pass:" + signingConfig.storePassword,
                       "--key-pass", "pass:" + signingConfig.keyPassword,
                       "--ks-key-alias", signingConfig.keyAlias,
                       outputFile.getAbsolutePath());
//Log.i(TAG, "%s", processBuilder.command());
Process process = processBuilder.start();
process.waitFor();

直接调用了 apksigner 来做这件事。

然后是移除 styleable,上面的逻辑只处理了非 styleable 资源:

Iterator<String> styleableItera =  styleableMap.keySet().iterator();
while (styleableItera.hasNext()) {
    String styleable = styleableItera.next();
    Pair<String, Integer>[] attrs = styleableMap.get(styleable);
    int i = 0;
    for (i = 0; i < attrs.length; i++) {
        if (!removeResources.containsValue(attrs[i].right)) {
            break
        }
    }
    if (attrs.length > 0 && i == attrs.length) {
        Log.i(TAG, "removed styleable " + styleable);
        styleableItera.remove();
    }
}

最后,压缩 R.txt,因为删除了些资源:

shrinkResourceTxtFile(newResTxtFile, resourceMap, styleableMap);

这样,整个流程就完毕了。