概述
一般情况下,我们在做apk瘦身清理资源的时候都是直接在工程中修改相关文件。但是当我们对要改动的文件没有把握时,通常都需要进行充分的测试和线上灰度,这种情况下需要通过编译参数控制打包产物,使我们能打出专门的apk包,通常的做法都在编译时通配置packagingOptions或者productFlavor来得到带有不同资源的apk,这种方式有几个问题:
packagingOptions只支持删除apk中.|META-INF|libs目录下的文件,不能删除res和assets中的文件。productFlavor来配置不同的文件目录方式特别繁琐,一旦要跨版本验证,维护起来也很吃力。- 通过自定义gradle插件确实可以做到在编译期修改文件,但这种方式会与AGP耦合,随着AGP更新会有兼容问题。
通过借鉴AndResguard的思路,我们可以把清理任务放在Apk打包完成后,将原始的Apk先解压缩,执行完任务后再重新压缩签名,这样就可以做到删除Apk中的任意资源,并且不与编译流程耦合,方便后续的维护。
方案流程
- 解压Apk,保存压缩信息
- 清理资源
- 重新打包apk
- 对齐压缩包,签名
val archivesDir =
"${project.buildDir}/tmp/cleanApk/${apkFile.nameWithoutExtension}-${UUID.randomUUID()}"
// 解压apk
val compressedData = ConcurrentHashMap(FileOperation.unZipAPK(apkFile.absolutePath, archivesDir))
val archivesFileTree = project.fileTree(archivesDir)
val archivesPath = Paths.get(archivesDir)
var filteredFileList: Collection<File> = archivesFileTree.files
// 清理资源
if (excludes.isNotEmpty()) {
val pathMatchers: List<PathMatcher> = excludes.map { compileGlob(it) }
filteredFileList = archivesFileTree.files.filter { file ->
var relativePath = file.toPath().relativeTo(archivesPath)
relativePath = Paths.get("${File.separatorChar}$relativePath")
val matches = !pathMatchers.none { it.matches(relativePath) }
if (matches) {
project.log("exclude file ${relativePath.pathString}")
}
return@filter !matches
}
}
filteredFileList = CopyOnWriteArrayList(filteredFileList)
// webp转换功能
webpConvertAction.execute(archivesDir, filteredFileList, compressedData)
val finalOutputFile = if (cleanApkExtension.overwriteOutput) {
apkFile
} else if (!cleanApkExtension.outputFileName.isNullOrEmpty()) {
File(apkFile.parentFile, cleanApkExtension.outputFileName)
} else {
File(apkFile.parentFile, "${apkFile.nameWithoutExtension}-cleanApk.apk")
}
// 压缩过滤后的文件
FileOperation.zipFiles(filteredFileList, archivesPath.toFile(), finalOutputFile, compressedData)
if (variant.signingConfig == null) {
project.log("signingConfig is null")
}
// 对齐zipalign
val zipAlignPath = "${appExtension.sdkDirectory.absolutePath}/build-tools/${appExtension.buildToolsVersion}/zipalign"
val zipAlignFile = File(finalOutputFile.parent, "${finalOutputFile.nameWithoutExtension}-aligned.apk")
if (zipAlignFile.exists()) {
zipAlignFile.delete()
}
val xmlParser = AndroidBinXmlParser(ByteBuffer.wrap(File("$archivesDir/AndroidManifest.xml").readBytes)))
while (xmlParser.name != "application") {
xmlParser.next()
}
var compressNativeLib = variant.buildType.name != "debug" // debug默认不压缩so
for (idx in 0 until xmlParser.attributeCount) {
if (xmlParser.getAttributeName(idx) == "extractNativeLibs") {
compressNativeLib = xmlParser.getAttributeBooleanValue(idx)
break
}
}
FileOperation.alignApk(zipAlignPath, finalOutputFile, zipAlignFile, compressNativeLib)
val signedFile = File(zipAlignFile.parent, "${zipAlignFile.nameWithoutExtension}-signed.apk")
if (signedFile.exists()) {
signedFile.delete()
}
// 签名apk
variant.signingConfig?.apply {
if (isSigningReady) {
val params = arrayOf(
"sign",
"--v1-signing-enabled=$isV1SigningEnabled",
"--v2-signing-enabled=$isV2SigningEnabled",
"--ks=${storeFile?.absolutePath}",
"--ks-key-alias=$keyAlias",
"--ks-pass=pass:$storePassword",
"--key-pass=pass:$keyPassword",
"--in=${zipAlignFile.absolutePath}",
"--out=${signedFile.absolutePath}",
"--v"
)
ApkSignerTool.main(params)
}
}
if (!signedFile.exists()) {
throw IOException("can not find signed file, ApkSigner may failed")
}
finalOutputFile.delete()
zipAlignFile.delete()
signedFile.renameTo(finalOutputFile)
project.delete(archivesPath.toFile())
详细代码可以参考CleanApkPlugin。