背景
各位大佬好久不见了,最近忙于搞这个黑科技的开发工作,没有时间写博客,见谅见谅。
当前项目内用了腾讯的AndResGuard对资源文件的大小进行了一次深度优化。AndResGuard负责将文件名,arsc文件和R文件也进行了一次混淆,能把整体的资源文件大小压缩。
但是奈何也不是一个尽善尽美的方案,所以我们打算在其基础上进行一次二次开发。
AndResGuard原理
我先简单的介绍下AndResGuard(后面简称ARG)是原理。
首先我们需要先编译我们的app项目,等到所有编译流程走完之后生成apk文件,然后ARG会去将apk文件解压并拷贝一份副本,之后从副本中把arsc以及其他的资源文件进行混淆重命名文件等操作,最后再把这个副本重新打包成apk,然后对apk进行重签名等操作。
只有了解了完整的ARG的流程之后,我们才可以对其进行二次开发和二次优化。首先当然先是设立目标了,我们要做什么,然后可以怎么做?
TODO
我们打算做些什么?
- 是不是能将混淆的流程放到apk编译流程中,充分的利用编译时多线程的能力呢?
- 是不是可以对混淆的规则进行二次调整,从而达到压缩比例的提升。
- 有没有办法节省一下编译速度的问题,提升插件的效率。
ACTION
在开发之前,肯定是要先进行方案梳理还有竞品分析的,先找找有没有什么竞品可以帮助我们。
我们在调研的过程中,美团,腾讯,头条都有对应的资源文件的混淆方案。其中腾讯的就是ARG,而ARG也是使用最多的。而美团貌似也没有找到开源项目所以没有后续的跟进。而头条的AabResGuard主要是肩负了头条的App Bundle的压缩,同时也做了普通的资源混淆。朋友说出海项目app bundle的压缩主要是靠这个。
我们参考了AabResGuard
的修改任务执行顺序的方式,把ARG的执行顺序进行了一次合理的变更。
如何更改编译任务的执行顺序
在对Aab的代码分析过程中,我们其实发现了一些很神奇很微妙的点,对于我们后续的优化产生了重大的启发。
代码语言:javascript复制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进行了打印。
代码语言:javascript复制 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任务进行优化,这样我们的第一个任务也就完成了。
代码语言:javascript复制private fun runGradleTask(absPath: String, outputFile: File, minSDKVersion: Int): File? {
val packageName = applicationId
val whiteListFullName = ArrayList<String>()
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就可以继续执行了,只要在编译完成之前把任务执行好是不是就可以把这部分资源混淆的时间也给优化掉呢,说干就干,直接上代码。
代码语言:javascript复制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之前做等待呢?
代码语言:javascript复制 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合成包所用的。
代码语言:javascript复制val manager = scope.transformManager
val field = manager.javaClass.getDeclaredField("transforms").apply {
isAccessible = true
}
val list = field.get(manager) as List<Transform>
list.forEach {
if (it is ShrinkResourcesTransform) {
}
}
}
我最后反射了scope内持有的transforms的列表,然后把ShrinkResourcesTransform
这个transform拉取了出来,最后获取了这个Transform转化的Task,之后在这个任务之前做了上面的await操作,这样就能保证在ShrinkResourcesTransform
执行之前,就会完成资源文件混淆的操作。