一种Apk瘦身的方式

129 阅读1分钟

概述

一般情况下,我们在做apk瘦身清理资源的时候都是直接在工程中修改相关文件。但是当我们对要改动的文件没有把握时,通常都需要进行充分的测试和线上灰度,这种情况下需要通过编译参数控制打包产物,使我们能打出专门的apk包,通常的做法都在编译时通配置packagingOptions或者productFlavor来得到带有不同资源的apk,这种方式有几个问题:

  1. packagingOptions只支持删除apk中.|META-INF|libs目录下的文件,不能删除resassets中的文件。
  2. productFlavor来配置不同的文件目录方式特别繁琐,一旦要跨版本验证,维护起来也很吃力。
  3. 通过自定义gradle插件确实可以做到在编译期修改文件,但这种方式会与AGP耦合,随着AGP更新会有兼容问题。

通过借鉴AndResguard的思路,我们可以把清理任务放在Apk打包完成后,将原始的Apk先解压缩,执行完任务后再重新压缩签名,这样就可以做到删除Apk中的任意资源,并且不与编译流程耦合,方便后续的维护。

方案流程

  1. 解压Apk,保存压缩信息
  2. 清理资源
  3. 重新打包apk
  4. 对齐压缩包,签名
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