寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

jokermonn發表於2018-05-27

本文由玉剛說寫作平臺提供寫作贊助,版權歸玉剛說微信公眾號所有

原作者:joker

版權宣告:未經玉剛說許可,不得以任何形式轉載

歡迎關注本人公眾號,掃描下方二維碼或搜尋公眾號 id: mxszgg

寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

本文外掛基於 Android Gradle Plugin 3.0.1 版本

前言

日常開發中,為了避免執行時 R 檔案反射失敗,一般在混淆的時候都會將 R 檔案 keep 住,但是因此也會導致包體積有一定的上升,那麼有沒有減少 R 檔案未混淆帶來的體積增長呢?

眾所周知,Android 中的 R 檔案用來儲存資源的對映值,而往往一個 apk 中的 R 檔案中的欄位值的數量是十分龐大的,筆者將一個未進行任何操作的 debug apk 進行解壓後發現該檔案行數就已超過 1800 行——

寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

為了減少包體積,可以將混淆壓縮的話,可以降到500行+左右——

寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

但是混淆後就會存在兩個問題——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 檔案有很大一部分是冗餘的

寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

例如上圖中的 R.layout.activity 是完全可以被其所對應的常量替換的,但是由於 keep 住了 R 檔案,所以它不會進行替換。

thinr3 的思想就是找到使用 R 欄位的地方,如果該欄位是常量則將其替換成這個常量所對應的值,並刪除 R 檔案中該欄位,去除冗餘——

寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

外掛的執行需要對 .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,最終如下圖:

寫給 Android 開發者的 Gradle 系列(四)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 {} 閉包中輸出檔案的路徑:

寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

很明顯前四個檔案和 __content__.json 都不是關鍵檔案,唯有 0.jar 可能是,不妨開啟 0.jar 看一看——

寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

所以可以確定 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人,需要先新增筆者的微信,備註:入群。(只會在群裡搶紅包和發文章的請繞道)

寫給 Android 開發者的 Gradle 系列(四)plugin 實戰包體積瘦身

相關文章