写给 Android 开发者的 Gradle 系列(四)plugin 实战包体积瘦身

本文由玉刚说写做平台提供写做赞助,版权归玉刚说微信公众号全部html

原做者:jokerjava

版权声明:未经玉刚说许可,不得以任何形式转载android

欢迎关注本人公众号,扫描下方二维码或搜索公众号 id: mxszgggit

本文插件基于 Android Gradle Plugin 3.0.1 版本github

前言

平常开发中,为了不运行时 R 文件反射失败,通常在混淆的时候都会将 R 文件 keep 住,可是所以也会致使包体积有必定的上升,那么有没有减小 R 文件未混淆带来的体积增加呢?json

众所周知,Android 中的 R 文件用来存储资源的映射值,而每每一个 apk 中的 R 文件中的字段值的数量是十分庞大的,笔者将一个未进行任何操做的 debug apk 进行解压后发现该文件行数就已超过 1800 行——api

为了减小包体积,能够将混淆压缩的话,能够降到500行+左右——bash

可是混淆后就会存在两个问题——R 文件被混淆了以后,那么资源反射就不能使用了;混淆过程当中删除了除 R$styleable.class 之外的其余的 R$*.class,可是 R$styleable.class 仍然是能够优化的。那么该如何解决这两个问题呢?一个方案是不开启混淆,这显然不太现实;另外一个方案是开启混淆,同时在 proguard-rules.pro 中 keep 住 R 文件,再手动删除 R 文件中的字段信息。实际上美丽说团队早期已经开源过一个 thinrPlugin 就是使用方案二,但其针对的是 Gradle plugin 1.0/2.0 的版本,并未对 3.0 作支持,本文将重写该插件,并命名为 thinr3。微信

写插件前笔者谈及一点前提知识以及插件思路——为何 R 文件中的字段能够删除?R 文件中的字段分为两种类型,一种是 static final int,另外一种是 static final int[]。其中,static final int 做为常量在运行时是不会被改变的,那么将这些常量打进 apk 中很明显是多余的,因此实际上打包进 apk 的 R 文件有很大一部分是冗余的闭包

例如上图中的 R.layout.activity 是彻底能够被其所对应的常量替换的,可是因为 keep 住了 R 文件,因此它不会进行替换。

thinr3 的思想就是找到使用 R 字段的地方,若是该字段是常量则将其替换成这个常量所对应的值,并删除 R 文件中该字段,去除冗余——

插件的运行须要对 .class 文件分两次遍历,第一次遍历先是获取到 R.class 文件,遍历其中全部的常量值,封装成键值对用之后面将字段替换成相应的常量;第二次遍历分两种状况,若是是 R 文件那么则将其常量进行删除,若是是其余 .class 文件,则将当中的 R 字段引用根据前面的键值对进行替换。为了对 .class 文件进行操做,须要引入 ASM,不了解 ASM 没有关系,本文阐述的插件中运用到 ASM 的部分很少。

那么由此可知,什么时候何地获取 .class 文件实际上就是整个 task 的核心所在——理论上越靠后 .class 文件将会被修改的风险就越小,笔者选择了在混淆 task (transformClassesAndResourcesWithProguardFor${variant.name.capitalize()})执行以后启用 plugin。缘由有两点,其一就是混淆 task 的执行时期已经比较晚了;其二,混淆 task 的产物当中包含全部的 .class 文件信息(Task 接口中包含 outputs 字段,开发者能够经过 Task.outputs.files.files 获取 task 的产物)。

综上所述,将会在 transformClassesAndResourcesWithProguardFor${variant.name.capitalize()} task 执行以后遍历两次获取该 task 的 outputs.files.files,第一次遍历是找到 R 文件并收集该文件中的 static final int 常量的键值对信息,第二次遍历是根据键值对替换其余文件中的 R 文件字段并删除 R 文件中的该字段。

建立项目

  1. 新建一个项目,将 app/build.gralde 文件下 release 闭包中的 minifyEnabled 设置为 true 以开启 release 包混淆,同时为了不 R 文件被混淆,须要在 R 文件下添加如下代码以 keep 住 R 文件——

    -keepclassmembers class **.R$* {
    	 public static <fields>;
    }
    -keep class **.R {*;}
    -keep class **.R$* {*;}
    -keep class **.R$*
    -keep class **.R
    复制代码
  2. 新建一个 java module,命名为 buildSrc,接着将 src/main/java 改为 src/main/groovy,并添加 src/main/resources/META-INF/gradle-plugins 文件夹,在该文件夹下建立 com.joker.thinr3.properties 文件并填写 implementation-class 指向 plugin,最终以下图:

  1. 修改该 module 文件夹下的 build.gradle 文件:

    apply plugin: 'groovy'
    
    dependencies {
        implementation gradleApi()
        implementation localGroovy()
        implementation 'com.android.tools.build:gradle:3.0.1'
    }
    
    allprojects {
        repositories {
            jcenter()
            google ()
        }
    }
    复制代码

因为 Android Gradle Plugin 中依赖了 ASM 库,因此在依赖基础库的前提下再依赖 Android Gradle Plugin 便可。

plugin 实操

建立 ThinR3Plugin.groovy,其源码以下:

package com.joker.thinr3

import com.android.build.gradle.api.ApkVariantOutput
import com.android.build.gradle.api.ApplicationVariant
import org.gradle.api.Plugin
import org.gradle.api.Project

class ThinR3Plugin implements Plugin<Project> {
  @Override
  void apply(Project project) {
    project.afterEvaluate {
      project.plugins.withId('com.android.application') {
        project.android.applicationVariants.all { ApplicationVariant variant ->
          variant.outputs.each { ApkVariantOutput variantOutput ->
            if (variantOutput.name.contains("release")) {
              project.tasks.
                  findByName("transformClassesAndResourcesWithProguardFor${variant.name.capitalize()}")
                  .doLast { ProcessAndroidResources task ->
                    task.outputs.files.files.each {
                      collectRInfo()
                    }
                    task.outputs.files.files.each {
                      replaceAndDelRInfo()
                    }
                  }
            }
          }
        }
      }
    }
  }
}
复制代码

经过 hook project 的 afterEvaluate {} 才能获取到 project 中全部的 task 信息。因为 thinr3 是经过 hook 混淆 task 来实现的,这意味着当前 project 必定得是主工程,因此能够经过 project.plugins.withId('com.android.application') 判断当前工程是否为主工程;通常状况下,插件针对 release 包进行 R 文件缩减,对于其余变种包没有必要,根据官方文档可知 AppExtension 中的 applicationVariants 闭包中包含全部的 apk 变种信息。ApplicationVariant 中有一个字段名为 outputs 的集合(这是 AbstractTask 中的字段),它是该 task 的最终全部变种的集合,通常将 outputs 做为最终的变种输出,说了这么多,实际上仅须要获取到变种的 name 信息,再经过 String#contains("release") 判断当前 apk 是否为 release 包;接下来就是经过 project.tasks.findByName(taskName) 来寻找到混淆 task 并经过 doLast {} 来 hook 它最终的执行阶段;最后,获取混淆 task 产物的方式就是前文提到的 outputs.files.files

那么既然已经得到了混淆以后的产物,那么就能够针对该产物进行操做了。首先毋庸置疑的是最终必定是针对 class 文件进行操做,那么混淆以后的产物是 class 文件么?不妨在 each {} 闭包中输出文件的路径:

很明显前四个文件和 __content__.json 都不是关键文件,惟有 0.jar 多是,不妨打开 0.jar 看一看——

因此能够肯定 0.jar 就是获取 class 信息的地方,那么首先就须要针对 jar 包进行解析,既然仅仅只须要对 jar 包须要解析而没有其余的文件,不妨修改上述代码,使用更加 groovy 的方式直接筛选出 jar 包,修改后代码以下:

it.outputs
	.files
	.files
	.grep { File file -> file.isDirectory() }
	.each { File dir ->
          dir.listFiles({ File file -> file.name.endsWith(".jar") } as FileFilter)
          .each { File jar ->
              // 对 jar 包进行操做
              ASMHelper.collectRInfo(jar)
          }
        }
复制代码

在 jar 包中收集 R 文件信息的源码以下:

static void collectRInfo(File jar) {
    JarFile jarFile = new JarFile(jar)
    jarFile
        .entries()
        .grep { JarEntry entry -> isRFile(entry.name) }
        .each { JarEntry jarEntry ->
      jarFile.getInputStream(jarEntry).withStream { InputStream inputStream ->
        ClassReader classReader = new ClassReader(inputStream)
        ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM4) {
          @Override
          FieldVisitor visitField(int access, String name, String desc, String signature,
              Object value) {
            if (value instanceof Integer) {
              map.put(jarEntry.name - ".class" + name, value)
            } else {
              styleableSet.add(jarEntry.name)
            }
            return super.visitField(access, name, desc, signature, value)
          }
        }
        classReader.accept(classVisitor, 0)
      }
    }

    jarFile.close()
  }
复制代码

经过 JarFile#entries() 获取 jar 包的 Enumeration,Enumeration 当中的每个对象实际上就是 jar 包中的一个文件。同理,使用 groovy 的写法匹配类名来筛选出 R.class 文件,最终借助 ASM 获取到 R.class 中全部可替换字段的键值对信息。ASM 获取 class 字段的信息可分为四步,第一步经过 byte[]/InputStream/className 来建立 ClassReader 对象;第二步建立 ClassVisitor 类并实现其 visitField() 方法,该方法名已经可以让开发者知道该方法是用来访问类中字段的,前面提到,最终能且只能替换的字段是 static final int 类型的,因此能够根据方法中最后一个参数的类型是否为 Integer 来判断当前字段是否能够被替换,若是能够替换,将其存入 Map 中;第三步是调用 ClassReader#accept(ClassVisitor, flag) 使得 ClassVisitor 经过 ClassReader 来获取 class 文件信息。

收集完信息以后就是要进行替换其余 class 文件中信息,并删除 R.class 中的信息。源码以下:

static void replaceAndDelRInfo(File jar) {
    File newFile = new File(jar.parentFile, jar.name + ".bak")
    JarFile jarFile = new JarFile(jar)
    new JarOutputStream(new FileOutputStream(newFile)).withStream { OutputStream jarOutputStream ->
      jarFile.entries()
          .grep { JarEntry entry -> entry.name.endsWith(".class") }
          .each { JarEntry entry ->
        jarFile.getInputStream(entry).withStream { InputStream inputStream ->
          def fileBytes = inputStream.bytes

          switch (entry) {
            case { isRFileExceptStyleable(entry.name) }:
              fileBytes = null
              break
            case { isRFile(entry.name) }:
              fileBytes = deleteRInfo(fileBytes)
              break
            default:
              fileBytes = replaceRInfo(fileBytes)
              break
          }

          if (fileBytes != null) {
            jarOutputStream.putNextEntry(new ZipEntry(entry.name))
            jarOutputStream.write(fileBytes)
            jarOutputStream.closeEntry()
          }
        }
      }
      jarFile.close()

      jar.delete()
      newFile.renameTo(jar)
    }
  }
复制代码

建立 0.jar.bak 以备替换原来的 0.jar;一样地,利用 groovy 的语言优点过滤出 .class 文件;获取 0.jar 文件中的 bytes[] 进行修改,共有三种状况:

  • 是 R 文件而且不是 R$styleable.class 文件(例如R$id.class),那么该文件将会被删掉。
  • 是 R$styleable.class 文件,经过 deleteRInfo() 返回利用 ASM 删除了 static final int 字段(保留了 static final int[] 字段)的 class 文件字节。
  • 不是 R 文件而且不是它的内部类文件,那么就是普通 class 文件,经过 replaceRInfo() 返回利用 ASM 和前面包含替换字段信息的 map 替换字段后的普通 class 文件字节。

最后经过 0.jar.bak 的 FileOutputStream 写入一个名字和 jarEntry 名称相同的 ZipEntry (JarEntry 是 ZipEntry 的子类,扩展了证书等属性,可是 class 文件不包含这些内容)并向其中填入前面方法返回的字节。固然,最后不要忘了关闭资源、删除 0.jar、将 0.jar.bak 更名为 0.jar。

  • replaceRInfo() 源码以下:
private static byte[] replaceRInfo(byte[] bytes) {
    ClassReader classReader = new ClassReader(bytes)
    ClassWriter classWriter = new ClassWriter(classReader, 0)
    ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM4, classWriter) {
      @Override
      MethodVisitor visitMethod(int access, String name, String desc, String signature,
          String[] exceptions) {
        def methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)
        methodVisitor = new MethodVisitor(Opcodes.ASM4, methodVisitor) {
          @Override
          void visitFieldInsn(int opcode, String owner, String name1, String desc1) {
            Integer constantValue = map.get(owner + name1)
            constantValue != null ? super.visitLdcInsn(constantValue) :
                super.visitFieldInsn(opcode, owner, name1, desc1)
          }
        }
        return methodVisitor
      }
    }
    classReader.accept(classVisitor, 0)

    classWriter.toByteArray()
  }
复制代码

核心内容在 visitMethod() 中,其余的都是固定套路。因为须要修改 class 文件,因此使用原有的 MethodVisitor 确定是不行的,借助原 MethodVisitor 建立一个新的 MethodVisitor 并返回,覆写新 MethodVisitor 的 visitFieldInsn() 以替换字段值,替换的方式借助前文的 map 当前字段是否存在,若是存在则替换成相应的常量,不然不变(MethodVisitor 的 visitFieldInsn() 不只会替换方法中的字段,也会替换类中的字段)。

  • deleteRInfo() 源码以下:
private static byte[] deleteRInfo(byte[] fileBytes) {
    ClassReader classReader = new ClassReader(fileBytes)
    ClassWriter classWriter = new ClassWriter(classReader, 0)
    ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM4, classWriter) {
      @Override
      FieldVisitor visitField(int access, String name, String desc, String signature,
          Object value) {
        value instanceof Integer ? null : super.visitField(access, name, desc, signature, value)
      }
    }
    classReader.accept(classVisitor, 0)

    return classWriter.toByteArray()
  }
复制代码

只须要借助 ClassVisitor 的 visitField() 来判断当前字段是否为 Integer 类型的,若是是则返回为 null,不然不作任何改动。

后话

参考 ThinRPlugin 的 [README](https://github.com/meili/ThinRPlugin/blob/master/README.zh-cn.md) 可知在蘑菇街 app 的实践上,app 体积缩减了有 1M(40M -> 39M)。因此在项目中若是 id、layout 等文件量比较大的时候,thinr3 的优化能力仍是比较可观的。

本文项目源码请戳我

另外,笔者建了一个微信群,若是对 Gradle 感兴趣或者有什么想和笔者讨论的,欢迎入群,群内也有一堆乐于助人的小伙伴。因为群满100人,须要先添加笔者的微信,备注:入群。(只会在群里抢红包和发文章的请绕道)

相关文章
相关标签/搜索