本文由玉剛說寫做平臺提供寫做贊助,版權歸玉剛說微信公衆號全部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 文件中的該字段。
新建一個項目,將 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
複製代碼
新建一個 java module,命名爲 buildSrc
,接着將 src/main/java 改爲 src/main/groovy,並添加 src/main/resources/META-INF/gradle-plugins 文件夾,在該文件夾下建立 com.joker.thinr3.properties 文件並填寫 implementation-class
指向 plugin,最終以下圖:
修改該 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 便可。
建立 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[] 進行修改,共有三種狀況:
deleteRInfo()
返回利用 ASM 刪除了 static final int
字段(保留了 static final int[]
字段)的 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,不然不作任何改動。
本文項目源碼請戳我
另外,筆者建了一個微信羣,若是對 Gradle 感興趣或者有什麼想和筆者討論的,歡迎入羣,羣內也有一堆樂於助人的小夥伴。因爲羣滿100人,須要先添加筆者的微信,備註:入羣。(只會在羣裏搶紅包和發文章的請繞道)
![]()