专栏首页逮虾户AndResGuard编译速度优化

AndResGuard编译速度优化

背景

各位大佬好久不见了,最近忙于搞这个黑科技的开发工作,没有时间写博客,见谅见谅。

当前项目内用了腾讯的AndResGuard对资源文件的大小进行了一次深度优化。AndResGuard负责将文件名,arsc文件和R文件也进行了一次混淆,能把整体的资源文件大小压缩。

但是奈何也不是一个尽善尽美的方案,所以我们打算在其基础上进行一次二次开发。

AndResGuard原理

我先简单的介绍下AndResGuard(后面简称ARG)是原理。

首先我们需要先编译我们的app项目,等到所有编译流程走完之后生成apk文件,然后ARG会去将apk文件解压并拷贝一份副本,之后从副本中把arsc以及其他的资源文件进行混淆重命名文件等操作,最后再把这个副本重新打包成apk,然后对apk进行重签名等操作。

只有了解了完整的ARG的流程之后,我们才可以对其进行二次开发和二次优化。首先当然先是设立目标了,我们要做什么,然后可以怎么做?

TODO

我们打算做些什么?

  1. 是不是能将混淆的流程放到apk编译流程中,充分的利用编译时多线程的能力呢?
  2. 是不是可以对混淆的规则进行二次调整,从而达到压缩比例的提升。
  3. 有没有办法节省一下编译速度的问题,提升插件的效率。

ACTION

在开发之前,肯定是要先进行方案梳理还有竞品分析的,先找找有没有什么竞品可以帮助我们。

我们在调研的过程中,美团,腾讯,头条都有对应的资源文件的混淆方案。其中腾讯的就是ARG,而ARG也是使用最多的。而美团貌似也没有找到开源项目所以没有后续的跟进。而头条的AabResGuard主要是肩负了头条的App Bundle的压缩,同时也做了普通的资源混淆。朋友说出海项目app bundle的压缩主要是靠这个。

我们参考了AabResGuard的修改任务执行顺序的方式,把ARG的执行顺序进行了一次合理的变更。

如何更改编译任务的执行顺序

在对Aab的代码分析过程中,我们其实发现了一些很神奇很微妙的点,对于我们后续的优化产生了重大的启发。

private fun createAabResGuardTask(project: Project, scope: VariantScope) {
        val variantName = scope.variantData.name.capitalize()
        val bundleTaskName = "bundle$variantName"
        if (project.tasks.findByName(bundleTaskName) == null) {
            return
        }
        val aabResGuardTaskName = "aabresguard$variantName"
        val aabResGuardTask: AabResGuardTask
        aabResGuardTask = if (project.tasks.findByName(aabResGuardTaskName) == null) {
            project.tasks.create(aabResGuardTaskName, AabResGuardTask::class.java)
        } else {
            project.tasks.getByName(aabResGuardTaskName) as AabResGuardTask
        }
        aabResGuardTask.setVariantScope(scope)

        val bundleTask: Task = project.tasks.getByName(bundleTaskName)
        val bundlePackageTask: Task = project.tasks.getByName("package${variantName}Bundle")
        bundleTask.dependsOn(aabResGuardTask)
        aabResGuardTask.dependsOn(bundlePackageTask)
        // AGP-4.0.0-alpha07: use FinalizeBundleTask to sign bundle file
        // FinalizeBundleTask is executed after PackageBundleTask
        val finalizeBundleTaskName = "sign${variantName}Bundle"
        if (project.tasks.findByName(finalizeBundleTaskName) != null) {
            aabResGuardTask.dependsOn(project.tasks.getByName(finalizeBundleTaskName))
        }
    }
复制代码

这一部分代码是Aab的plugin在构造一个混淆任务的时候篡改的任务执行的依赖顺序。

variantName代表构建的一个变种,可以是多渠道构建也可以是debug release的变种。

一个普通的安卓app Bundle 执行的顺序是bundlevariantName之后马上执行一个package{variantName}Bundle。

而aab的plugin则是在其中过程中插入了一个自定义的混淆task,也就是上述代码中的aabResGuardTaskName,这样当一个package{variantName}Bundle被执行的时候,则是会把顺序变更为bundlevariantName-aabResGuardTaskName-package

这里科普个小姿势,gradle task的任务顺序是通过有向无环图(DAG)的数据结构进行排序的,所以当任务之间有依赖关系的情况下,gradle会根据DAG的排序顺序执行。基本上如果有任意出现dependsOn的你都可以简单的把他们理解为DAG。

观察一个项目编译的流程

有时候会有同学说,面试的时候问什么编译流程吗,真实开发中完全不会用到呀。但是有时候多个技能也没啥不好的呀。

还是用了之前打印Task耗时的一段代码逻辑,将一个Apk编译的task进行了打印。

    159ms  :libres:generateDebugRFile
    186ms  :libres:compileDebugJavaWithJavac
    181ms  :app:processFlavor2Flavor1DebugManifest
    121ms  :app:mergeFlavor2Flavor1DebugResources
    999ms  :app:processFlavor2Flavor1DebugResources
   1025ms  :app:compileFlavor2Flavor1DebugKotlin
   1163ms  :app:resguardFlavor2Flavor1Debug
   1183ms  :app:mergeFlavor2Flavor1DebugNativeLibs
    296ms  :app:compileFlavor2Flavor1DebugJavaWithJavac
    451ms  :app:transformClassesWithDexBuilderForFlavor2Flavor1Debug
     99ms  :app:mergeProjectDexFlavor2Flavor1Debug
    124ms  :app:mergeFlavor2Flavor1DebugJavaResource
    295ms  :app:packageFlavor2Flavor1Debug

当我们开始编译一个Apk的时候,从上到下的任务栈大概就是和上面的类似了,我demo中增加了plavor变种,但是并不影响任务。其中混进的resguardFlavor2Flavor1Debug这个任务就是我们的资源混淆的任务,实现规则基本就和字节的aab的方案类似。然后我们插入的节点选择是在processFlavor2Flavor1DebugResources之后,同时在mergeFlavor2Flavor1DebugJavaResource之前去执行我们的混淆任务。

为什么要选择这个节点?

当我们编译一个apk的时候,会在build/intermediates文件夹下生成很多输入输出的文件,这个是我之前在开发transform的时候找到的小技巧。然后我就在这个文件夹下搜索,并观察哪个是我们资源文件编译完成的任务节点呢?

我们可以先看下aapt编译的大概的一个过程,最后我发现了一个有意思的目录processed_res,也就是上面说的processFlavor2Flavor1DebugResources这个任务了。这个文件夹下面会有个out文件目录,其中会包含一个.ap_的文件,基于一个开发的敏锐的嗅觉,我发现真相只有一个(shi n ji tsu wa i tsu mo hi to tsu),我用jadx去反编译了下这个文件,发现里面存放的就是所有的资源文件,arsc文件。

同时我又做了个大胆的实验,如果我把混淆的ap_放在这里,然后覆盖同名文件。那么会不会在后续编译出来的apk就是一个混淆过的apk呢?

而实验结果也正如我所推测的是一样的,最后编译出来的apk就是一个混淆过的apk。

这里要留一些小遗憾了,我本来想把整个编译流程的Task源代码摸一摸的,但是尝试性的看了下这部分源代码,但是奈何太难了而且debug成本太高了,所以我也没有仔细看懂。

第一个任务完成

从上述流程走通之后,我们只要把ARG的代码进行二次开发,根据对应task任务进行优化,这样我们的第一个任务也就完成了。

private fun runGradleTask(absPath: String, outputFile: File, minSDKVersion: Int): File? {
        val packageName = applicationId
        val whiteListFullName = ArrayList()
        configuration?.let {
            val sevenzip = project.extensions.findByName("sevenzip") as ExecutorExtension
            configuration.whiteList.forEach { res ->
                if (res.startsWith("R")) {
                    whiteListFullName.add("$packageName.$res")
                } else {
                    whiteListFullName.add(res)
                }
            }
            val builder = InputParam.Builder()
                .setMappingFile(configuration.mappingFile)
                .setWhiteList(whiteListFullName)
                .setUse7zip(configuration.use7zip)
                .setMetaName(configuration.metaName)
                .setFixedResName(configuration.fixedResName)
                .setKeepRoot(configuration.keepRoot)
                .setMergeDuplicatedRes(configuration.mergeDuplicatedRes)
                .setCompressFilePattern(configuration.compressFilePattern)
                .setZipAlign(getZipAlignPath())
                .setSevenZipPath(sevenzip.path)
                .setOutBuilder(useFolder(outputFile))
                .setApkPath(absPath)
                .setUseSign(configuration.useSign)
                .setDigestAlg(configuration.digestalg)
                .setMinSDKVersion(minSDKVersion)

            if (configuration.finalApkBackupPath != null && configuration.finalApkBackupPath.isNotEmpty()) {
                builder.setFinalApkBackupPath(configuration.finalApkBackupPath)
            } else {
                builder.setFinalApkBackupPath(absPath)
            }
            builder.setSignatureType(InputParam.SignatureType.SchemaV1)
            val inputParam = builder.create()
            return Main.gradleRun(inputParam)
        }
        return null
    }
复制代码

这个就是ARG调用资源文件混淆的代码了,我们基本不需要对其进行大改造就能把这个编译的优化完成了,而且可以充分的利用gradle的多线程,因为processRes的task和transform是并行的。

数据对比

图1 是我们更改之后的解压速度以及执行顺序,图2则是使用原生的ARG的速度,可以发现我们虽然只是变更了下任务的执行,但是从速度上也得到了很大的优化。其中一部分原因是因为ARG解压重新打包的是整个apk项目,而我们则只是操作了资源文件生成的假的apk项目而已。而且由于是并发任务,所以其实速度会更快一点。

通过多线程完成并行

就这?有没有办法将这个编译速度更提升一步呢?

我们是不是可以考虑直接把任务执行在线程内,这样下一个task就可以继续执行了,只要在编译完成之前把任务执行好是不是就可以把这部分资源混淆的时间也给优化掉呢,说干就干,直接上代码。

open class ResProguardTask : DefaultTask() {
    private var executor: ExecutorService = Executors.newSingleThreadExecutor()
    private var future: Future<out Any>? = null
    
    @TaskAction
    fun execute() {
        future = executor.submit {
          // 资源文件混淆了
        }
    }

    fun await(){
        future?.get()
    }
}
复制代码

假定上面就是我们定义的资源文件的Task,我们把其中资源文件混淆的操作全部放在了ExecutorService内,当TaskAction被执行之后,由于后续执行在线程内,所以马上就会执行下一个Task,那么这种写法是不是就完全Ok了呢。

秉承着程序猿的严谨性,其实如果假定我们这个future比较耗时1分半,然后编译的总时长是1分钟,那么当我们在合并打包的时候就会出现问题,就会导致这次资源混淆失败。有没有办法在最后Task执行之前等待我们的Future完全执行完呢?大家有没有注意到我下面写的await操作,由于Future的特性,只有当所有方法被执行完之后get才会有值,否则这里就是个while(true)的循环。那么我们如何在最后合包的Task之前做等待呢?

        val bundlePackageTask: Task = project.tasks.getByName("package${variantName}")
        bundlePackageTask.doFirst {
                val resProguardTask = project.tasks.getByName(resGuardTaskName)
                        as ResProguardTask
                resProguardTask.await()
        }
复制代码

这个地方,我们只要充分的利用Task提供的doFirst和doLast方法,就能在任务的前后进行任意的操作,这里我们做了一次等待,等待所有我们资源文件混淆的future执行完成之后才允许packageTask执行。

有人在代码里投毒

在插件实际上线的阶段,我们碰到了一个非常奇怪的问题,资源文件混淆失败了。最后实际调试中发现了由于项目开启了shrink,所以在r8阶段项目重新生成了一个ap_文件,而这个文件才是最后apk合成包所用的。

val manager = scope.transformManager
        val field = manager.javaClass.getDeclaredField("transforms").apply {
            isAccessible = true
        }
        val list = field.get(manager) as List
        list.forEach {
            if (it is ShrinkResourcesTransform) {
            }
        }
}

我最后反射了scope内持有的transforms的列表,然后把ShrinkResourcesTransform这个transform拉取了出来,最后获取了这个Transform转化的Task,之后在这个任务之前做了上面的await操作,这样就能保证在ShrinkResourcesTransform执行之前,就会完成资源文件混淆的操作。

吐槽

讲道理groovy真的烂,由于没有编译时的告警,所以你也不知道你的代码写的到底是正确还是错误的。而且以前编写一个gradle插件,以前都采用的是上传本地aar的方式,所以吧尤其的恶心,导致我最近写新的插件的时候用的都是kotlin,真香。

最后

(゜-゜)つロ 干杯~-bilibili。

我们组最近开源的路由项目 BRouter,欢迎各位大佬点赞啊。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Android 统计页面渲染时长

    文章开头还是先抛出几个小小的问题,大家在开发的时候有没有考虑过一个问题,onCreate方法执行完了是不是页面已经完全打开了呢?为什么呢?

    逮虾户
  • Kotlin拓展函数的真身

    kotlin也写了很长一段时间了,香是真的很香这个东西。但是很多东西也是不求甚解,都是直接开始用,但是为什么我也不关心。举个栗子,就拿拓展函数来说。

    逮虾户
  • 聊聊Android编译流程

    看起来我们貌似已经回答出了这个问题的答案,但是今天是来屠龙的,所以我们不能就这么简单的放过这个题目。

    逮虾户
  • Appium+python自动化20-查看iOS上app元素属性

    前言 学UI自动化首先就是定位页面元素,玩过android版的appium小伙伴应该都知道,appium的windows版自带的Inspector可以定位app...

    上海-悠悠
  • 孩子王1个活动让乐享“出圈”,单月访问达20W!

    3月23日,一篇名为《听说,在孩子王90%的人都在逛乐享》的文章在孩子王内部发布,引起全员热烈反响。“有这样一家公司”系列客户案例再度启动,这次,我们将镜头探...

    腾讯乐享
  • MySQL 中 update 修改数据与原数据相同会再次执行吗

    本文主要测试MySQL执行update语句时,针对与原数据(即未修改)相同的update语句会在MySQL内部重新执行吗?

    芋道源码
  • 聊聊kafka client chunkQueue 与 MaxLag值

    前面一篇文章讨论了ConsumerFetcherManager的MaxLag与ConsumerOffsetChecker的lag值的区别。但是关于MaxLag的...

    codecraft
  • Linux下性能调试工具-top和sar运维笔记

    作为一名资深的linux运维工程师,必须要熟练运用一些必要的系统性能调试工具,如top、sar工具。下面简单介绍下这几个工具的使用: 一、top top是Lin...

    洗尽了浮华
  • 如何基于ERP的sales organization 创建CRM对应的数据

    版权声明:署名,允许他人基于本文进行创作,且必须基于与原先许可协议相同的许可协议分发本文 (Creative Commons)

    Jerry Wang
  • 带你认识 flask 优化应用结构

    目前状态下的应用有两个基本问题。如果你观察应用的组织方式,你会注意到有几个不同的子系统可以被识别,但支持它们的代码都混合在了一起,没有任何明确的界限。我们来回顾...

    公众号---人生代码

扫码关注云+社区

领取腾讯云代金券