1. 前言
記得早期剛開始做 Android 開發(fā)的時(shí)候,一個(gè) Android 應(yīng)用也就幾兆的大小。到現(xiàn)在,一個(gè) APP少說十幾兆,大則好幾十兆甚至上百兆。所以針對 apk 包的瘦身問題,擺在了所有開發(fā)者的面前。畢竟安裝包越小,下載安裝肯定也就更快,對 APP 的運(yùn)營也是有幫助的。網(wǎng)上已經(jīng)有很多關(guān)于這方面的文章了,但是很多都泛泛而談,道理大家都懂,但是怎么實(shí)操確不清楚。所以,本人計(jì)劃將實(shí)際項(xiàng)目中用到的方案寫出來,剖析剖析原理,一是給自己做個(gè)總結(jié),二是給有需要的人做個(gè)參考,共同交流進(jìn)步。
2. R.java 文件結(jié)構(gòu)
眾所周知,R.java 是自動(dòng)生成的,它包含了應(yīng)用內(nèi)所有資源的名稱到數(shù)值的映射關(guān)系。先創(chuàng)建一個(gè)最簡單的工程,看看 R.java 文件的內(nèi)容:

從圖中可以看到,R.java 內(nèi)部包含了很多內(nèi)部類:如 layout、mipmap、drawable、string、id 等等,這些內(nèi)部類里面只有 2 種數(shù)據(jù)類型的字段:
public static final int
public static final int[]
這里面,只有 styleable 最為特殊,只有它里面有 public static final int[] 類型的字段定義,其它都只有 int 類型的字段。
public static final class styleable {
...
public static final int[] ActionBarLayout = new int[]{16842931};
public static final int ActionBarLayout_android_layout_gravity = 0;
...
}
此外,我們發(fā)現(xiàn) R.java 類的代碼行數(shù)有 1800 多行了,這還只是一個(gè)簡單的工程,壓根沒有任何業(yè)務(wù)邏輯。如果我們采用組件化開發(fā)或者在工程里創(chuàng)建多個(gè) module ,你會發(fā)現(xiàn)在每個(gè)模塊的包名下都會生成一個(gè) R.java 文件。以我的實(shí)際項(xiàng)目為例,我們采用組件化開發(fā)的架構(gòu),一個(gè) APP 由將近 30 個(gè)組件組成,編譯時(shí)則會生成將近 30 個(gè) R.java 文件,算上業(yè)務(wù)邏輯里的資源 id ,平均每個(gè) R.java 算 3000 行代碼的話,則總共有 90000 行的代碼,當(dāng)然這只是一個(gè)很籠統(tǒng)的估計(jì)。
3.為什么R文件可以刪除
所有的 R.java 里定義的都是常量值,以 Activity 為例:
public class MainActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
}
R.layout.activity_main 實(shí)際上對應(yīng)的是一個(gè) int 型的常量值,那么如果我們編譯打包時(shí),將所有這些對 R 類的引用直接替換成常量值,效果也是一樣的,那么 R.java 類在 apk 包里就是冗余的了。
前面說過 R.java 類里有2種數(shù)據(jù)類型,一種是 static final int 類型的,這種常量在運(yùn)行時(shí)是不會修改的,另一種是 static final int[] 類型的,雖然它也是常量,但它是一個(gè)數(shù)組類型,并不能直接刪除替換,所以打包進(jìn) apk 的 R 文件中,理論上除了 static final int[] 類型的字段,其他都可以全部刪除掉。以上面這個(gè)為例:我們需要做的是編譯時(shí)將 setContentView(R.layout.activity_main) 替換成:
setContentView(213196283);
4. ProGuard對R文件的混淆
通常我們會采用 ProGuard 進(jìn)行混淆,你會發(fā)現(xiàn)混淆也能刪除很多 R$*.class,但是混淆會造成一個(gè)問題:混淆后不能通過反射來獲取資源了?,F(xiàn)在很多應(yīng)用或者SDK里都有通過反射調(diào)用來獲取資源,比如大家最常用的統(tǒng)計(jì)SDK友盟統(tǒng)計(jì)、友盟分享等,就要求 R 文件不能混淆掉,否則會報(bào)錯(cuò),所以我們常用的做法是開啟混淆,但 keep 住 R 文件,在 proguard 配置文件中增加如下配置:
-keep class **.R$* {
*;
}
-dontwarn **.R$*
-dontwarn **.R
ProGuard 本身會對 static final 的基本類型做內(nèi)聯(lián),也就是把代碼引用的地方全部替換成常量,全部內(nèi)聯(lián)以后整個(gè) R 文件就沒地方引用了,就會被刪掉。如果你的應(yīng)用開啟了混淆,并且不需要keep住R文件,那么后面講的對你都不適用了,可以就此打住。
如果你的應(yīng)用需要keep住R文件,那么接下來,我們講講如何刪除所有 R 文件里的冗余字段。
4. 開發(fā)思路
具體的目標(biāo)知道了,那怎么去實(shí)現(xiàn)呢,先說說我的思路:
- 在打包 apk 編譯時(shí)找到所有的 R$*.class ;
- 收集所有 R$*.class 里的 public static final int 字段信息,將鍵值對緩存起來;
- 遍歷所有的 class,如果是 R.class,則刪除里面的 public static final int 字段,但是需要保留 R$styleable.class 里的 public static final int[] 字段;
- 如果不是 R$*.class ,則遍歷該 class 里所有引用的靜態(tài)字段,如果有對 R 文件里的靜態(tài)字段引用,則根據(jù)前面緩存的鍵值對將其替換成對應(yīng)的常量 int 值;
為了實(shí)現(xiàn)這個(gè)目標(biāo),我們需要?jiǎng)?chuàng)建一個(gè) Gralde Plugin,在編譯打包時(shí)能直接幫我們完成。這里需要用到2個(gè)技術(shù):其一是 Gradle Transform,它能夠在項(xiàng)目構(gòu)建階段即由 class 到 dex 轉(zhuǎn)換期間,讓開發(fā)者修改 class 文件;其二是 ASM 技術(shù),它能讓我們直接操作修改 class 文件。
5.R文件瘦身插件實(shí)操
這里提取出幾個(gè)主要步驟來講講,具體代碼已經(jīng)開源。
怎么判斷某個(gè) class 文件是否為 R$*.class ,主要是通過 class 的文件名來判斷,然后通過 ASM 技術(shù)來讀取 R$*.class 里的所有 int 字段:
/**
* 收集所有 R.class 及其內(nèi)部類里的 int 常量字段信息
* 存儲的 key = class全路徑類名 + 字段名,value = 該字段的常量值
*/
static Map<String, Integer> mRInfoMap = new HashMap<>()
/**
* 收集R類相關(guān)信息,將所有 R.class 類里的 int 常量值緩存起來
*
* @param file class文件
*/
static void collectRInfo(File file) {
if (!isRClass(file.absolutePath)) {
return
}
def fullClassName = getFullClassName(file.getAbsolutePath())
println "需要收集的R類信息:fullClassName = ${fullClassName}"
new FileInputStream(file).withStream { InputStream is ->
ClassReader classReader = new ClassReader(is)
ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM5) {
@Override
FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {
if (value instanceof Integer) {
//遍歷讀取所有 R.class 里的 int 常量值,例如 com/hm/library1/R$mipmap.class 里的 ic_launcher 常量值,
//存儲時(shí)存為 "com/hm/library1/R$mipmapic_launcher" = ***
mRInfoMap.put(fullClassName - ".class" + name, value)
}
return super.visitField(access, name, desc, signature, value)
}
}
classReader.accept(classVisitor, 0)
}
}
/**
* 判斷該 class 文件是否是 R.class 類,及其內(nèi)部類如 R$id.class
*
* @param classFilePath class文件的全路徑名,例如:/Users/hjy/Desktop/app/build/intermediates/classes/debug/com/hm/library1/R.class
* @return 如果是R.class及其它內(nèi)部類class則返回true,否則返回false
*/
static boolean isRClass(String classFilePath) {
classFilePath ==~ '''.*/R\\$.*\\.class|.*/R\\.class'''
}
/**
* 判斷該 class 文件是否是 R.class 類,及其內(nèi)部類如 R$id.class,但是 R$styleable.class 類排除在外
*
* @param classFilePath
* @return
*/
static boolean isRFileExceptStyleable(String classFilePath) {
classFilePath ==~ '''.*/R\\$(?!styleable).*?\\.class|.*/R\\.class'''
}
/**
* 從形如 /Users/hjy/Desktop/heima/code/gitlab/HM-ThinApk/app/build/intermediates/classes/debug/com/hm/library1/R.class 的類路徑中截取出 com/hm/library1/R.class
* 不管是當(dāng)前工程的代碼,還是遠(yuǎn)程依賴的aar包,在打包編譯時(shí),都會在工程的 app/build/intermediates/classes 路徑下生成一系列R.class文件,
* 根據(jù)打包模式是 debug 還是 release來區(qū)分,從中可以截取出 R.class 的包名了。
*
* @param filePath class文件全路徑
* @return 返回類似 "com/hm/library1/R.class"、"com/hm/library1/R$mipmap.class",其實(shí)就是類的全路徑class名
*/
static String getFullClassName(String filePath) {
String mode = "/debug/"
int index = filePath.indexOf(mode)
if (index == -1) {
mode = "/release/"
index = filePath.indexOf(mode)
}
return filePath.substring(index) - "${mode}"
}
在 Android 的 Transform 階段,我們能讀取到所有的 class 文件和 jar 包,那么 R$*.class 是在文件目錄里還是在 jar 包里呢?實(shí)際上,每個(gè) module 的代碼打包成 aar 文件時(shí),里面并不包含 R.class ,而是包含一個(gè)名為 R.txt 的文本文件,該文本文件里包含了資源 id 的映射關(guān)系,最后我們打包生成 apk 文件時(shí),打包工具會收集所有 aar 包里的 R.txt 文件,重新生成 R.class 文件,一般可以在 app/build/intermediates/classes/ 目錄下,看到所有的 R.class 文件。所有我們不需要遍歷 jar 包來查找 R$*.class 文件,只需要遍歷 class 文件目錄即可,Transform 類里的大致代碼如下:
@Override
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
inputs.each { TransformInput input ->
//第一次循環(huán),只是為了收集 R.java 類信息
input.directoryInputs.each { DirectoryInput directoryInput ->
if (directoryInput.file.isDirectory()) {
directoryInput.file.eachFileRecurse { File file ->
if (file.isFile()) {
//收集R.java類的信息
collectRInfo(file)
}
}
} else {
//收集R.java類的信息
collectRInfo(directoryInput.file)
}
}
}
......
}
通過這種方式可以收集到所有 R.class 文件,接下來我們需要二次遍歷所有的 class 文件和 jar 包,這次需要?jiǎng)h除 R.class 以及替換對 R.class 的直接引用。
/**
* 將所有對 R.class 有引用的代碼,直接替換成 int 值,這樣在其他類里就不會內(nèi)聯(lián) R.class 了,
* R.class 存不存在就不會影響編譯運(yùn)行了
*
* @param bytes
* @return
*/
private static byte[] replaceRInfo(byte[] bytes) {
ClassReader classReader = new ClassReader(bytes)
ClassWriter classWriter = new ClassWriter(0)
ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM5, 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.ASM5, methodVisitor) {
@Override
void visitFieldInsn(int opcode, String owner, String name1, String desc1) {
String key = owner + name1
Integer value = mRInfoMap.get(key)
if (value != null) {
println "替換對R.class的直接引用:${owner} - ${name1}"
super.visitLdcInsn(value)
} else {
super.visitFieldInsn(opcode, owner, name1, desc1)
}
}
}
return methodVisitor
}
}
classReader.accept(classVisitor, 0)
return classWriter.toByteArray()
}
刪除 jar 包中的 R.class 相關(guān)引用:
/**
* 遍歷 jar 文件里的所有 class,替換所有對 R.class 的直接引用
*
* @param srcJar jar文件
*/
static void replaceAndDeleteRInfoFromJar(File srcJar) {
File newJar = new File(srcJar.getParentFile(), srcJar.name + ".tmp")
JarFile jarFile = new JarFile(srcJar)
new JarOutputStream(new FileOutputStream(newJar)).withStream { JarOutputStream jarOutputStream ->
jarFile.entries().each { JarEntry entry ->
jarFile.getInputStream(entry).withStream { InputStream inputStream ->
ZipEntry zipEntry = new ZipEntry(entry.name)
byte[] bytes = inputStream.bytes
if (entry.name.endsWith(".class")) {
bytes = replaceRInfo(bytes)
}
if (bytes != null) {
jarOutputStream.putNextEntry(zipEntry)
jarOutputStream.write(bytes)
jarOutputStream.closeEntry()
}
}
}
}
jarFile.close()
srcJar.delete()
newJar.renameTo(srcJar)
}
這樣還是會有個(gè)弊端,如果刪除了所有的 R$*.class 里的字段,某些資源通過反射調(diào)用依舊會失敗,所以我們還是需要能通過配置來 keep 住某些字段。
6.資源keep
所有的代碼已經(jīng)開源,有興趣的可以查看,里面會有更具體的注釋:Android R文件瘦身插件:源碼地址
資源 keep 配置示例:
thinRConfig {
keepInfo {
demomipmap {
keepRPackageName = "com.hm.iou.thinapk.demo"
keepRClassName = "mipmap"
keepResName = ["ic_launcher"]
keepResNameReg = ["ic_launcher.*"]
}
librarystring {
keepRPackageName = "com.hm.iou.library"
keepRClassName = "string"
keepResName = ["app_name"]
keepResNameReg = [""]
}
}
}
上面這個(gè)配置,com.hm.iou.thinapk.demo.R.mipmap 類里名為 ic_launcher 的字段不會被刪除,com.hm.iou.library.R.string 類里名為 app_name 的字段不會被刪除。
- keepRPackageName:表示 R 文件所在的包名;
- keepRClassName:表示 R 文件里的內(nèi)部類名,如mipmap、string、id、drawable、layout 等等;
- keepResName:要 keep 的資源名,是個(gè)數(shù)組
- keepResNameReg:要 keep 的資源名,這是個(gè)正則表達(dá)式,會根據(jù)正則來進(jìn)行匹配;
7. 使用方法
在工程根目錄 build.gradle 里增加配置:
buildscript {
repositories {
google()
jcenter()
maven { url 'https://jitpack.io' }
}
dependencies {
......
classpath 'com.github.houjinyun:Android-ThinApk:v2.0.1'
}
}
在 app/build.gradle 里增加配置:
//使用該插件
apply plugin: 'com.hm.plugin.thinapk'
//插件配置
thinRConfig {
keepInfo {
//在 R.class 里的 com.hm.iou.thinapk.demo.R.mipmap.ic_launcher 會 keep 住,自己根據(jù)需要配置
mipmap {
keepRPackageName = "com.hm.iou.thinapk.demo"
keepRClassName = "mipmap"
keepResName = ["ic_launcher"]
keepResNameReg = ["ic_launcher.*"]
}
}
}
8.小結(jié)
本插件對采用組件化方式開發(fā)的app,或者有大量資源id定義的app可能會有顯著效果,以我自己的項(xiàng)目為例,采用該插件以后,apk包大小減小了差不多0.4M左右。對這2種情況除外的app,效果可能并不會那么顯著。當(dāng)然這種方案只是錦上添花而已,我們應(yīng)用里少用幾張圖片,可能包大小就減小了很多。但同樣的條件下,打出來的 apk 包肯定越小越好。
系列文章
Android apk瘦身最佳實(shí)踐(一):去除R.class
Android apk瘦身最佳實(shí)踐(二):代碼混淆和資源壓縮
Android apk瘦身最佳實(shí)踐(三):資源混淆原理
Android apk瘦身最佳實(shí)踐(四):采用AndResGuard進(jìn)行資源混淆
Android apk瘦身最佳實(shí)踐(五):圖片壓縮
Android apk瘦身最佳實(shí)踐(六):采用D8編譯器