一、背景
2021 年主要从事 SDK 相关的开发工作,其中一个需求是和一个业务方进行对接,他们的需求非常简单, 只需要拉流+消息轮训两个核心功能,其他的功能都可以删除缩减。
Google 一直推荐的 gradle 工具,是从 Module 的维度进行工程划分、构建的。理想情况下,将仅用到的几个 Module 进行打包,就能满足上面的需求。 但是理想很丰满,现实很骨感,工程架构一直都十分不合理,比如仅要用到 A Module 中的某个类,但是 A Module 中确有大量的类是我们用不到的。
碰巧这个业务接入方,对 gradle plugin 保持开发的态度,于是我有了施展拳脚的地方。
二、删除无用的 keep root
Android proguard 阶段会有一系列代码、资源的优化,其中对代码的部分优化是通过 keep root 来实现的。从 keep root 节点出发,就可以知道需要保留多少个 class(可能被混淆也可能没有混淆) 了。
keep root 据我所知有三部分组成:
- proguard rule 文件中声明的规则
- AndroidManifest.xml 中声明的 Activity、Service、Provider 等
- @keep 注解的 class
删除无用的 keep root ,就可以拔出萝卜带出泥,清理很多无用的 class。对于第一部分,没有太多可以说的,我们主要看 2 和 3 部分。
2.1 删除 AndroidManifest 无用的 root
我们知道,Android 在构建的时候,会将各个 aar 和 project 下的 AndroidManifest.xml 合并成一个。于是我们可以 Hook processReleaseManifest
这个 task。
Hook gradle task 有两种方式:Task 和 Action。如果使用 Task 的话,要注意 Task 的插入顺序,需要用到 finalizedBy 和 dependsOn。如果是 Action 的话,会灵活很多,我们只需要用到 doFirst 和 doLast。
我们这里选择使用 Action 的方式,在 processReleaseManifest 执行完毕后,也就是说插入一个 doLast。删除 AndroidManifest 的节点:
object OptAMHandler {
fun optAndroidManifest(amFile: File, unUsedActivities: List<String>) {
val root = XmlParser().parse(amFile)
val children = root.children()
val applicationNode = children.firstOrNull { it is Node && it.name() == "application" } as? Node ?: return
val iterator = applicationNode.iterator()
while (iterator.hasNext()) {
val node = iterator.next()
if (node is Node && node.name() == "activity") {
(node as Node).attributes().values.forEach { value ->
if (unUsedActivities.contains(value)) {
println("\tremove $value from AndroidManifest.")
iterator.remove()
}
}
}
}
val content = XmlUtil.serialize(root)
amFile.writeText(content)
}
}
2.2 删除 @keep 作用的 class
这部分需要借助 ASM 工具,在 transform 阶段插入一个 transform task,然后读取所有的 class,并将无用的 class,删除掉 @keep 注解。
核心代码如下:
fun process(inputStream: InputStream): ByteArray? {
val classReader = ClassReader(inputStream)
val classNode = ClassNode()
classReader.accept(classNode, ClassReader.EXPAND_FRAMES)
val classAnnotations = classNode.invisibleAnnotations?.iterator()
var shouldRemovedKeep = false
while (classAnnotations?.hasNext() == true) {
val classAnnotation = classAnnotations.next()
if (classAnnotation.desc == "Landroid/support/annotation/Keep;" || classAnnotation.desc == "Landroidx/annotation/Keep;") {
val className = classNode.name.replace("/", ".")
if (removeKeepClasses?.contains(className) == true) {
println("remove @Keep in $className")
shouldRemovedKeep = true
classAnnotations.remove()
}
break
}
}
if (!shouldRemovedKeep) {
return null
}
val writer = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
classNode.accept(writer)
return writer.toByteArray()
}
删除完 keep 注解后,我们还需要回写 jar,替换掉 jar 中的 class。
fun handleClassFromJar(jarInput: File) {
val jarFile = JarFile(jarInput)
val enumeration = jarFile.entries()
val uri: URI by lazy {
URI.create("jar:file:${jarInput.absolutePath}")
}
val zipfs: FileSystem by lazy {
FileSystems.newFileSystem(uri, HashMap<String, String>())
}
var shouldReplace = false
while (enumeration.hasMoreElements()) {
val entry = enumeration.nextElement()
val entryName = entry.name
if (!entryName.endsWith(".class")) continue
val inputStream = jarFile.getInputStream(entry)
val res = process(inputStream, options)
if (res != null) {
shouldReplace = true
val pathInZipfile = zipfs.getPath(entryName)
Files.copy(ByteArrayInputStream(res), pathInZipfile, StandardCopyOption.REPLACE_EXISTING)
}
}
if (shouldReplace) {
zipfs.close()
}
}
zipfs.close 非常关键,有两次都是忘了这个,然后查找错误了很久。
三、删除无用资源
Android 会在 proguard 阶段,对 drawable 和 layout 等资源做 shrink 处理。shrink 处理是指当探测到某个资源无用时,Android 并不会删除,而是会用一个体积最小的资源进行替换,比如 50B 的图片资源。
虽然 shrink 可以做很大的优化,但是有些情况下并不会,比如:
总而言之,如果默认的检测模式下,大概率会因为代码中用到的 Resources.getIdentifier
而保留很多不会用到的资源,从而此优化达不到效果。
我们无法要求接入方使用严格的检测模式,所以我们能做的是,删除我们确保不会用到的 sdk 资源。我们知道 android 编译流程中,aapt 会先对资源进行编译,再进行链接,最后 shrink 优化。为了不干扰接入方对资源的处理流程,我们选择在 package 之前做删除操作。
packageRelease 任务会将 dex、resource、assert、so 进行打包,在这一阶段我们可以很方便拿到 resource。resource 存在的形式是 .ap_
,本质上也是一个 zip。核心代码如下:
# intermediates/res_stripped/release/resources-release-stripped.ap_
fun removeRes(input: File, shrinkRes: ShrinkRes) {
val originalSize = Files.size(input.toPath())
val jarFile = JarFile(input)
val enumeration = jarFile.entries()
val uri: URI by lazy {
URI.create("jar:file:${input.absolutePath}")
}
val properties = mapOf(
"create" to "false"
)
val zipfs: FileSystem by lazy {
FileSystems.newFileSystem(uri, properties)
}
while (enumeration.hasMoreElements()) {
val entry = enumeration.nextElement()
val entryName = entry.name
val fileName = entryName.split("/").last()
shrinkRes.prefix?.forEach {prefix ->
if (fileName.startsWith(prefix)) {
val pathInZipFile = zipfs.getPath(entryName)
println("delete $entryName")
Files.delete(pathInZipFile)
}
}
}
zipfs.close()
val afterOptSize = Files.size(input.toPath())
println("original size: $originalSize, after PureLiveRemoveRes, current size is: $afterOptSize")
}