眾所周知,應(yīng)用安裝包的體積會(huì)十分影響用戶的應(yīng)用下載速度和安裝速度。據(jù) googlePlay 平臺(tái)對(duì)外發(fā)布相關(guān)的包大小對(duì)轉(zhuǎn)化率影響的數(shù)據(jù),我們可以看到隨著包大小的增加,安裝轉(zhuǎn)化率總體呈下降的趨勢(shì)。
因此對(duì)于我們的應(yīng)用來(lái)說(shuō),為了提升我們用戶下載的轉(zhuǎn)化率(即下載安裝激活用戶與潛在用戶的比例),我們對(duì)包體積必須給予一定的優(yōu)化和管控。
我們應(yīng)用商店中提供給用戶下載的安裝包,是 Android 定義的 APK 格式,其實(shí)質(zhì)則是一個(gè)包含應(yīng)用所有所需資源的 zip 包,它包含了如下所示的幾個(gè)組成部分:
這其中最主要的組成部分便是 DEX 文件,它們都是由 JAVA/Kotlin 代碼編譯而成。過(guò)去的兩年中,抖音的 DEX 的個(gè)數(shù)從 8 個(gè)漲到了 21 個(gè),DEX 的總大小從 26M 漲到了 48M,增長(zhǎng)十分迅猛。誠(chéng)然,隨著抖音的快速發(fā)展,業(yè)務(wù)復(fù)雜度的提高,代碼量級(jí)一定是在增加的,但如何在業(yè)務(wù)無(wú)感的情況下,對(duì)代碼進(jìn)行通用優(yōu)化,也是我們一個(gè)很重要的優(yōu)化方向。
在介紹具體優(yōu)化手段之前,我們首先需要了解下針對(duì) DEX 整體上的優(yōu)化思路。
DEX 通用優(yōu)化思路
在 AGP 的構(gòu)建過(guò)程中,Java 或 Kotlin 源代碼在經(jīng)過(guò)編譯之后會(huì)生成 Class 字節(jié)碼文件,在這個(gè)階段 AGP 提供了 Transform 來(lái)做字節(jié)碼的處理,我們非常熟悉的 Proguard 就是在這個(gè)階段工作的,之后 Class 文件經(jīng)由 dexBuilder 生成一堆較小的 DEX 文件,再經(jīng)由 mergeDex 合并成最終的 DEX 文件,然后打入 APK 中。具體過(guò)程如下圖所示:
因此,我們針對(duì) DEX 文件的優(yōu)化時(shí)機(jī)可以從分別從三個(gè)階段切入,分別是.kt 或.java 源文件、class 文件、DEX 文件:
- 在源文件進(jìn)行處理也就是手動(dòng)改造代碼,這種方式對(duì)程序設(shè)計(jì)本身有侵入,并且有較強(qiáng)的局限性;
- 在 class 字節(jié)碼階段對(duì)開(kāi)發(fā)者無(wú)感知,而且基本上能完成大多數(shù)的優(yōu)化,但對(duì)于像跨 DEX 引用優(yōu)化這樣涉及 DEX 格式本身的優(yōu)化無(wú)法完成;
- 在 DEX 文件階段進(jìn)行優(yōu)化是最理想的,在這個(gè)階段我們除了能對(duì) DEX 字節(jié)碼本身進(jìn)行優(yōu)化,也可對(duì) DEX 文件格式進(jìn)行操作。
優(yōu)化的手段總體上來(lái)說(shuō)也就是冗余去除、內(nèi)容精簡(jiǎn)、格式優(yōu)化等方式。
由于早期抖音 class 字節(jié)碼修改工具建設(shè)比較成熟,我們很多包體積的優(yōu)化都是通過(guò)修改 class 字節(jié)碼完成的,隨著優(yōu)化的深入,后期也有很多優(yōu)化是在 DEX 文件階段處理的。關(guān)于 DEX 階段相關(guān)的優(yōu)化我們后續(xù)會(huì)有相關(guān)文章介紹,這里主要介紹 Class 字節(jié)碼階段進(jìn)行的相關(guān)優(yōu)化,主要分為兩大類(lèi):
- 單純?nèi)コ裏o(wú)用的代碼指令,包括去除冗余賦值,無(wú)副作用代碼刪除等
- 除了能減少代碼指令數(shù)量外,同時(shí)減少方法和字段的數(shù)量,從而有效減少 DEX 的數(shù)量。我們知道 DEX 中引用方法數(shù)、引用字段數(shù)等不能超過(guò) 65535,超過(guò)之后就需要新開(kāi)一個(gè) DEX 文件,因此減少 DEX 中方法數(shù)、字段數(shù)可以減少 DEX 文件數(shù)量,像短方法內(nèi)聯(lián)、常量字段消除、R 常量?jī)?nèi)聯(lián)就屬于這類(lèi)優(yōu)化。
接下來(lái)我們會(huì)針對(duì)每一項(xiàng)優(yōu)化的背景、優(yōu)化思路和收益進(jìn)行詳細(xì)介紹。
去除冗余賦值
在我們平時(shí)的代碼開(kāi)發(fā)中,我們可能會(huì)寫(xiě)出以下的代碼:
class MyClass {
private boolean aBoolean = false;
private static boolean aBooleanStatic = false;
private void boo() {
if (!aBoolean) {
System.out.println("in aBoolean false!");
}
if (!aBooleanStatic) {
System.out.println("in aBooleanStatic false!");
}
}
}
我們常常為了保證一個(gè) Class 的成員變量的初始滿足我們期望的值,手動(dòng)對(duì)其進(jìn)行一次賦值,如上述代碼里的 aBoolean 和 aBooleanStatic。這是一種邏輯上非常安全的做法,但這真是必須的嗎?
其實(shí) Java 官方在虛擬機(jī)規(guī)范(https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-2.html#jvms-2.3)中定義了,Class對(duì)象在虛擬機(jī)中加載時(shí),所有的靜態(tài)字段(也就是靜態(tài)成員變量,下面統(tǒng)稱為Field)都會(huì)首先加載一個(gè)默認(rèn)值
2.3. Primitive Types and Values
...
The integral types are:
byte, whose values are 8-bit signed two's-complement integers, and whose default value is zero
short... whose default value is zero
int... whose default value is zero
long... whose default value is zero
char... whose default value is the null code point ('u0000')
The floating-point types are:
float... whose default value is positive zero
double... whose default value is positive zero
2.4. Reference Types and Values
...The null reference initially has no run-time type, but may be cast to any type. The default value of a referencetype is null.
總結(jié)來(lái)說(shuō),在 Java 中的基本類(lèi)型和引用類(lèi)型的 Field 都會(huì)在 Class 被加載的同時(shí)賦予一個(gè)默認(rèn)值,byte、short、int、long、float、double類(lèi)型都會(huì)被賦為 0, char 類(lèi)型會(huì)被賦為'u0000',引用類(lèi)型會(huì)被賦為 null。
我們將開(kāi)頭那段代碼通過(guò)命令行java -p -v轉(zhuǎn)化為字節(jié)碼:
public com.bytedance.android.dexoptimizer.MyClass();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #2 // Field aBoolean:Z
9: return
static {};
Code:
0: iconst_0
1: putstatic #6 // Field aBooleanStatic:Z
4: return
private void boo();
Code:
0: aload_0
1: getfield #2 // Field aBoolean:Z
4: ifne 15
7: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
10: ldc #5 // String in aBoolean false!
12: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
15: aload_0
16: getfield #3 // Field aBooleanStatic:Z
19: ifne 30
22: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
25: ldc #7 // String in aBooleanStatic false!
27: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
30: return
通過(guò)上述字節(jié)碼發(fā)現(xiàn),雖然 JVM 會(huì)在運(yùn)行時(shí)將 aBoolean 賦值為 0,但是我們?cè)谧止?jié)碼中仍然會(huì)再賦值一次 0 給到 aBoolean,aBooleanStatic 同理。
public com.bytedance.android.dexoptimizer.MyClass();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #2 // Field aBoolean:Z
9: return
以上標(biāo)紅部分出現(xiàn)了重復(fù)賦值,去除了不影響運(yùn)行時(shí)邏輯。因此,我們考慮在 Class 字節(jié)碼處理階段,將這種冗余的字節(jié)碼移除來(lái)獲取包大小收益。
優(yōu)化思路
理解了問(wèn)題產(chǎn)生的原因后,就很容易得到對(duì)應(yīng)的解決方案。首先,能夠被優(yōu)化的 Field 賦值,需要滿足這三個(gè)條件:
- Field 是屬于其直接定義的 Class 的,而非在父類(lèi)定義過(guò)的;
- Field 賦值是在 Class 的clinit、init方法中,這樣做很大程度是為了降低復(fù)雜度(因?yàn)橹辉谶@兩個(gè)方法中調(diào)用的 private 方法也是能做這樣的優(yōu)化,但分析這樣的方法復(fù)雜度很高);
- Field 賦值是默認(rèn)值,當(dāng)出現(xiàn)多個(gè)賦值時(shí),在非默認(rèn)賦值后的賦值都無(wú)法被優(yōu)化。
我們結(jié)合下面的代碼,具體說(shuō)明一下各種情況是否可以被優(yōu)化:
Class MyClass {
// 可以優(yōu)化,直接定義的,且是默認(rèn)值
private boolean aBoolean = false;
// 不可優(yōu)化,因?yàn)橘x值為非默認(rèn)值
private boolean bBoolean = true;
// 可以優(yōu)化,直接定義的,且是默認(rèn)值
private static boolean aBooleanStatic = false;
static {
// 可以優(yōu)化,第一處出現(xiàn),且是默認(rèn)值
aBooleanStatic = false;
// 其他代碼
...
// 可以優(yōu)化,前面沒(méi)有非默認(rèn)值賦值,且是默認(rèn)值
aBooleanStatic = false;
// 其他代碼
...
// 不可優(yōu)化,因?yàn)橘x值為非默認(rèn)值
aBooleanStatic = true;
// 其他代碼
...
// 不可優(yōu)化,因?yàn)橹俺霈F(xiàn)了非默認(rèn)值的賦值
aBooleanStatic = false;
}
private void boo() {
// 不可優(yōu)化,因?yàn)楹瘮?shù)為非clinit或init
aBoolean = false;
}
}
具體實(shí)現(xiàn)上,我們的優(yōu)化思路是這樣的:
- 遍歷 Class 所有方法,找到<clinit>和<init>方法,從上往下進(jìn)行字節(jié)碼指令遍歷
- 遍歷這兩種方法的所有字節(jié)碼指令,找到所有的 putfield 指令,將 putfield 指令的目標(biāo) ClassName 和 FieldName 使用-連接,構(gòu)建一個(gè)唯一的 Key,如果
- putfield 目標(biāo) Class 不是當(dāng)前 Class,跳過(guò)
- putfield 前的 load 指令不為iconst_0,fconst_0,dconst_0,lconst_0,aconst_null,并將該 putfield 所關(guān)聯(lián)的唯一的 Key 放入已經(jīng)遍歷過(guò)的 Key 的集合中
- putfield 前的 load 指令為iconst_0,fconst_0,dconst_0,lconst_0,aconst_null,且該 putfield 所關(guān)聯(lián)的唯一的 Key 沒(méi)有在遍歷過(guò)的 Key 的集合出現(xiàn)過(guò),則標(biāo)記為可清除的字節(jié)碼指令
- 遍歷完成后,刪除所有被標(biāo)記為可清除的字節(jié)碼指令
我們用一個(gè)簡(jiǎn)單的例子來(lái)說(shuō)明下我們的思路:
public com.bytedance.android.dexoptimizer.MyClass(); // 1. 判斷是<init>方法,進(jìn)入優(yōu)化邏輯
Code: // 2. 從上往下進(jìn)行代碼遍歷
0: aload_0
1: invokespecial #Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #Field MyClass.aBoolean:Z. // 3.發(fā)現(xiàn)是該Class的域,且賦值為iconst_0,標(biāo)記往上三個(gè)指令可以刪除
7: aload_0
8: iconst_1
9: putfield #Field MyClass.aBoolean:Z // 4.發(fā)現(xiàn)是該Class的域,且賦值不為iconst_0,則在遍歷過(guò)的Key的集合中添加MyClass-aBoolean,繼續(xù)往下
10: aload_0
11: iconst_0
12: putfield #Field MyClass.aBoolean:Z // 5.發(fā)現(xiàn)是該Class的域,但在遍歷過(guò)的Key的集合中發(fā)現(xiàn)存在MyClass-aBoolean,繼續(xù)往下
15: return
最終發(fā)現(xiàn)上述字節(jié)碼中,標(biāo)紅的部分可以刪除,刪除對(duì)應(yīng)的字節(jié)碼指令,優(yōu)化完成。
使用抖音之前開(kāi)源的字節(jié)碼處理框架 ByteX,可以比較方便地獲取 Field 的 Class,遍歷 Class 的所有方法,以及所有方法的字節(jié)碼。我們也已經(jīng)將此方案進(jìn)行了開(kāi)源,有興趣的同學(xué)可以前往查看詳細(xì)代碼:
- https://github.com/bytedance/ByteX/blob/master/field-assign-opt-plugin
刪除無(wú)副作用代碼
冗余賦值是利用了虛擬機(jī)在類(lèi)加載時(shí)為字段默認(rèn)賦值的特性,從而刪除多余的的賦值指令,而我們代碼中本身也有一些對(duì)線上包是沒(méi)有作用的,最常見(jiàn)的就是日志打印,除了占用包體積之外,還會(huì)造成性能問(wèn)題以及安全風(fēng)險(xiǎn),因此一般都會(huì)將其移除掉,接下來(lái)我們以 Log.i 調(diào)用為例來(lái)介紹如何刪除代碼中的無(wú)用函數(shù)調(diào)用。比如下面代碼中的日志打印語(yǔ)句:
public static void click() {
clickSelf();
Log.i("Logger", "click time:" + System.currentTimeMillis());
}
一開(kāi)始我們嘗試了 proguard 的 -assumenosideeffects,這個(gè)指令需要我們假定要?jiǎng)h除的方法調(diào)用沒(méi)有任何的副作用,并且從程序分析的角度來(lái)說(shuō)這個(gè)方法是不會(huì)修改堆上某個(gè)對(duì)象或者棧上方法參數(shù)的值。使用如下配置,proguard 就會(huì)在 optimize 階段幫我們刪除 Log 相關(guān)的方法調(diào)用。
-assumenosideeffects class android.util.Log {
public static boolean isLoggable(java.lang.String, int);
public static int v(...);
public static int i(...);
public static int w(...);
public static int d(...);
public static int e(...);
}
但是這種刪除并不徹底,它只會(huì)刪除方法調(diào)用指令本身,比如上面的代碼中刪除 Log.i 方法調(diào)用之后,會(huì)遺留一個(gè) StringBuilder 對(duì)象的創(chuàng)建:
public static void click() {
clickSelf();
new StringBuilder("click time:")).Append(System.currentTimeMillis();
}
這個(gè)對(duì)象的創(chuàng)建我們?nèi)藶榕袛嗟脑捯彩菬o(wú)用的,但是僅從簡(jiǎn)單的靜態(tài)程序指令分析的角度并不能判定其是無(wú)用的,因此 proguard 并沒(méi)有將其刪除。
既然 assumenosideeffects 刪除不干凈,我們就自己來(lái)實(shí)現(xiàn)更加徹底的優(yōu)化方案。
優(yōu)化思路
public static void click();
Code:
0: invokestatic #6 // Method clickSelf:()V
3: ldc #7 // String Logger
5: new #8 // class java/lang/StringBuilder
8: dup
9: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
12: ldc #10 // String click time:
14: invokevirtual #11 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: invokestatic #12 // Method java/lang/System.currentTimeMillis:()J
20: invokevirtual #13 // Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;
23: invokevirtual #14 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
26: invokestatic #2 // Method android/util/Log.i:(Ljava/lang/String;Ljava/lang/String;)I
29: pop
如上可以看到一行Log.i("Logger", "click time:" + System.currentTimeMillis());在編譯完成之后會(huì)生成多條指令(從 ldc 到 pop),除了目標(biāo)方法 Log.i 調(diào)用 invokestatic 指令外,還有很多參數(shù)創(chuàng)建和入棧指令。
我們要?jiǎng)h除相關(guān)方法的調(diào)用的話,主要是就是找到這行代碼所產(chǎn)生的起始指令和終止指令,然后起始到終止位置之間的指令就是我們要?jiǎng)h除的全部指令。
1. 查找終止指令位置
終止指令的查找相對(duì)簡(jiǎn)單,主要就是找到要?jiǎng)h除的目標(biāo)方法調(diào)用指令,再根據(jù)方法的返回值類(lèi)型確定是否要包含其后的 pop 或 pop2 指令。
比如上述代碼我們通過(guò)遍歷就能找到目標(biāo)方法調(diào)用invokestatic #2的位置,因?yàn)?Log.i 的返回值類(lèi)型是 int,終止指令就是下一條的 pop。
注意 pop 指令的作用是主動(dòng)讓 int 類(lèi)型的值出棧,也就是不會(huì)使用該方法的返回值,只有這種情況下我們才能安全刪除目標(biāo)方法,否則不能刪除。當(dāng)然如果方法的返回值類(lèi)型是 void,就不會(huì)有 pop 指令。
2. 查找起始指令位置
起始指令的查找則需要我們對(duì)于 java 字碼指令設(shè)計(jì)有基本的認(rèn)識(shí): java 字節(jié)碼指令是基于堆棧設(shè)計(jì)的,每一條字節(jié)碼指令會(huì)對(duì)應(yīng)操作數(shù)棧的若干參數(shù)的入棧和出棧,并且一個(gè)完整獨(dú)立代碼/代碼塊執(zhí)行前和執(zhí)行后操作數(shù)棧應(yīng)該是一樣的。
因此我們找到終止指令后,倒序遍歷指令,根據(jù)指令的作用進(jìn)行反向的入棧和出棧操作,當(dāng)我們的棧中 size 減為 0 時(shí),就找到了起始指令的位置。注意在入棧時(shí)候要記錄參數(shù)的類(lèi)型,并在出棧時(shí)候做類(lèi)型匹配校驗(yàn)。如上面的示例:
- pop 指令效果是單 slot 參數(shù)(像 int,float)出棧 ,那我們就在棧存入一個(gè) slot 類(lèi)型的參數(shù)
- invokestatic 要看方法的參數(shù)和返回值,正常效果是對(duì)應(yīng)方法的參數(shù)從右至左依次出棧,方返回值 int 入棧。我們就根據(jù)方法返回值出棧一個(gè) int 類(lèi)型的參數(shù),發(fā)現(xiàn)棧頂目前是 slot,類(lèi)型匹配。然后按照方法參數(shù)從左至右依次入棧兩個(gè) String 類(lèi)型的參數(shù)。
- invokevirtual 指令正常方法調(diào)用參數(shù)依次從右至左依次出棧,然后 this 對(duì)象出棧,最后方法返回值 String 入棧。我們彈出棧頂一個(gè)參數(shù),發(fā)現(xiàn)其和 String 匹配,然后依次入棧 this 對(duì)應(yīng)的類(lèi)型 StringBuilder,這里調(diào)用的是 toString 方法沒(méi)有參數(shù)就不用再入棧。
- 中間其他的指令類(lèi)似,直到 ldc 指令,本身是向棧中放入一個(gè) int,float 或 String 常量,我們這里彈出一個(gè)參數(shù),發(fā)現(xiàn)其是 String 匹配,并且此時(shí)棧的大小變?yōu)?0,也就找到了起始指令的位置。
方案缺陷
不過(guò)上述方案存在兩個(gè)缺陷:
1.因?yàn)榉治鲋辉趩蝹€(gè)方法內(nèi)分析,針對(duì) Log 方法封裝的情況,必須需要配置封裝方法作為目標(biāo)方法,才能刪除完全刪除,比如下面的方法需要配置 AccountLog.d 才能刪除其調(diào)用處的 StringBuilder 創(chuàng)建。
object AccountLog {
@JvmStatic
fun d(tag: String, msg: String) = Log.d(tag, msg)
}
2.可能會(huì)誤刪除一些有用的指令,因?yàn)闊o(wú)法認(rèn)為 Log.i 的兩個(gè)參數(shù)的構(gòu)建指令都是沒(méi)有用的,我們只能確定 StringBuilder 的創(chuàng)建是沒(méi)用的,但是一些其他的方法調(diào)用可能會(huì)改變一些對(duì)象的狀態(tài),因此存在一定風(fēng)險(xiǎn)。
Proguard 方案
在我們上述方案在線上運(yùn)行一年之后,嘗試針對(duì)上述弊端進(jìn)行優(yōu)化,然后發(fā)現(xiàn) proguard 還提供了 assumenoexternalsideeffects 指令,它可以讓我們指定沒(méi)有任何外部副作用的方法。
指定了以后,它只會(huì)修改調(diào)用這個(gè)方法的實(shí)例本身,但不會(huì)修改其他的對(duì)象。通過(guò)如下的配置可以刪除無(wú)用的 StringBuilder 創(chuàng)建。
-assumenoexternalsideeffects class java.lang.StringBuilder {
public java.lang.StringBuilder();
public java.lang.StringBuilder(int);
public java.lang.StringBuilder(java.lang.String);
public java.lang.StringBuilder append(java.lang.Object);
public java.lang.StringBuilder append(java.lang.String);
public java.lang.StringBuilder append(java.lang.StringBuffer);
public java.lang.StringBuilder append(char[]);
public java.lang.StringBuilder append(char[], int, int);
public java.lang.StringBuilder append(boolean);
public java.lang.StringBuilder append(char);
public java.lang.StringBuilder append(int);
public java.lang.StringBuilder append(long);
public java.lang.StringBuilder append(float);
public java.lang.StringBuilder append(double);
public java.lang.String toString();
}
-assumenoexternalreturnvalues public final class java.lang.StringBuilder {
public java.lang.StringBuilder append(java.lang.Object);
public java.lang.StringBuilder append(java.lang.String);
public java.lang.StringBuilder append(java.lang.StringBuffer);
public java.lang.StringBuilder append(char[]);
public java.lang.StringBuilder append(char[], int, int);
public java.lang.StringBuilder append(boolean);
public java.lang.StringBuilder append(char);
public java.lang.StringBuilder append(int);
public java.lang.StringBuilder append(long);
public java.lang.StringBuilder append(float);
public java.lang.StringBuilder append(double);
}
不過(guò),這個(gè)配置只適用于 Log 里只傳入 String 的情況。如果是int Log.w (String tag, Throwable tr)這種情況,就無(wú)法把Throwable參數(shù)也一起去掉。那還是應(yīng)該采用我們自己實(shí)現(xiàn)的插件才能優(yōu)化干凈。
此優(yōu)化對(duì)抖音包體積收益,約為 520KB。
短方法內(nèi)聯(lián)
上面介紹的兩個(gè)優(yōu)化是從去除無(wú)用的指令的角度出發(fā),開(kāi)篇 DEX 優(yōu)化思路中我們有講過(guò),減少定義方法或者字段數(shù)從而減少 DEX 數(shù)量也是我們常用優(yōu)化思路之一,短方法內(nèi)聯(lián)就是精簡(jiǎn)代碼指令的情況下,同時(shí)減少定義方法數(shù)。
在和海外競(jìng)品的對(duì)比過(guò)程中,我們發(fā)現(xiàn)單個(gè) DEX 文件中的定義方法數(shù)遠(yuǎn)比競(jìng)品要多,進(jìn)一步對(duì) DEX 進(jìn)行分析,發(fā)現(xiàn)抖音的 DEX 中有大量的 access,getter-setter 方法,而競(jìng)品中幾乎沒(méi)有。因此我們打算針對(duì)短方法做一些內(nèi)聯(lián)優(yōu)化,減少定義方法數(shù)。
在介紹優(yōu)化方案前,先來(lái)了解下內(nèi)聯(lián)的基礎(chǔ)知識(shí),內(nèi)聯(lián)作為最常見(jiàn)的代碼優(yōu)化手段,被稱為優(yōu)化之母。一些語(yǔ)言像 C++、Kotlin 提供了 inline 關(guān)鍵字給程序員做函數(shù)的內(nèi)聯(lián),而 Java 語(yǔ)言本身并沒(méi)有給程序員提供控制或建議 inline 的機(jī)會(huì),甚至 javac 編譯過(guò)程中也沒(méi)有做方法內(nèi)聯(lián)。為了便于理解,我們通過(guò)一個(gè)簡(jiǎn)單的例子來(lái)看內(nèi)聯(lián)是如何工作的,如下代碼中 callMethod 調(diào)用 print 函數(shù):
public class InlineTest {
public static void callMethod(int a) {
int result = a + 5;
print(result);
}
public static void print(int result) {
System.out.println(result);
}
}
在內(nèi)聯(lián)之后 inlineMethod 的內(nèi)容直接被展開(kāi)到 callMethod 中, 從字節(jié)碼的角度看變化如下:
內(nèi)聯(lián)前:
public static void callMethod(int);
Code:
0: iload_0
1: iconst_5
2: iadd
3: istore_1
4: iload_1
5: invokestatic #2 // Method print:(I)V
8: return
內(nèi)聯(lián)后:
public static void callMethod(int);
Code:
0: iload_0
1: iconst_5
2: iadd
3: dup
4: istore_0
5: istore_0
6: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
9: iload_0
10: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
13: return
從執(zhí)行時(shí)間的角度看,減少了一次函數(shù)調(diào)用,從而提升了執(zhí)行性能。從空間占用角度看,減少了一處函數(shù)聲明,從而減少了代碼體積。
那是不是所有的方法都適合內(nèi)聯(lián)呢?
顯然不是的,對(duì)于單次調(diào)用的方法說(shuō)內(nèi)聯(lián)能同時(shí)取得時(shí)間和空間的收益;對(duì)于多次調(diào)用的的方法則需要考慮方法本身的長(zhǎng)短,比如上面的 print 方法展開(kāi)之后的指令是比 invokestatic 指令本身要長(zhǎng)很多的,但是像 access、getter-setter 方法本身比較短就很適合內(nèi)聯(lián)。
access 方法內(nèi)聯(lián)
public class Foo {
private int mValue;
private void doStuff(int value) {
System.out.println("Value is " + value);
}
private class Inner {
void stuff() {
Foo.this.doStuff(Foo.this.mValue);
}
}
}
如上述代碼,大家都知道 Java 可以在內(nèi)部類(lèi) Foo$Inner 中直接訪問(wèn)外部類(lèi) Foo 的私有成員,但是 JVM 并沒(méi)有什么內(nèi)部類(lèi)外部類(lèi)的概念,認(rèn)為一個(gè)類(lèi)直接訪問(wèn)另一個(gè)類(lèi)的私有成員是非法的。編譯器為了能實(shí)現(xiàn)這種語(yǔ)法糖,會(huì)在編譯期生成以下靜態(tài)方法:
static int Foo.access$100(Foo foo) {
return foo.mValue;
}
static void Foo.access$200(Foo foo, int value) {
foo.doStuff(value);
}
內(nèi)部類(lèi)對(duì)象創(chuàng)建時(shí)候會(huì)傳入外部類(lèi)的引用,這樣當(dāng)內(nèi)部類(lèi)需要訪問(wèn)外部類(lèi)的mValue 或調(diào)用doStuff()方法時(shí),會(huì)通過(guò)調(diào)用這些靜態(tài)方法來(lái)實(shí)現(xiàn)。這里需要生成靜態(tài)的方法的原因,是因?yàn)楸辉L問(wèn)的成員是私有的,而私有訪問(wèn)控制更多地是在源碼層面去約束,防止破壞程序的設(shè)計(jì)。在字節(jié)碼層面只要不破壞語(yǔ)法邏輯,因此我們完全可以將這些私有成員改成 public 的,直接刪除掉編譯器生成的橋接靜態(tài)方法。
優(yōu)化思路
具體的優(yōu)化分為分為以下幾步:
1.收集字節(jié)碼中的 access 方法。
static int access$000(com.bytedance.android.demo.inline.Foo);
descriptor: (Lcom/bytedance/android/demo/inline/Foo;)I
flags: ACC_STATIC, ACC_SYNTHETIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field mValue:I
4: ireturn
static void access$100(com.bytedance.android.demo.inline.Foo, int);
descriptor: (Lcom/bytedance/android/demo/inline/Foo;I)V
flags: ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: iload_1
2: invokespecial #1 // Method doStuff:(I)V
5: return
如上面的字節(jié)碼所示,它的特征非常明顯,因?yàn)槭蔷幾g生成的方法,它有 synthetic 標(biāo)記,并且是靜態(tài)方法,方法名字以"access$"開(kāi)頭,通過(guò)這些特征在 ClassVisitor visitMethod 時(shí)就很容易匹配到相關(guān)方法。
2.分析并記錄 access 方法調(diào)用處要替換的目標(biāo)指令。
access 橋接的訪問(wèn)只有字段和方法兩種,相對(duì)應(yīng)的指令是方法訪問(wèn)指令(invokvirtual, invokspecial 等)和字段訪問(wèn)指令(getfield, putfield 等) ,只需遍歷方法找到相應(yīng)的指令,同時(shí)解析出指令訪問(wèn)的字段或方法信息,然后再將對(duì)應(yīng)的 private 成員改為 public。比如 access$000 方法會(huì)找到如下指令,訪問(wèn)的字段是類(lèi) Foo 的 mValue。
getfield #2 // Field mValue:I
3.替換 access 方法調(diào)用處的 invokestatic 為對(duì)應(yīng)的目標(biāo)指令,并刪除 access 方法的定義。
遍歷查找所有對(duì) access 方法的調(diào)用點(diǎn),如下面的 invokestatic 指令,其調(diào)用方法在我們第一步收集的 access 方法中,將它替換為 getfield,然后便可以刪除 Foo.access$000 方法本身。
invokestatic #3 // Method com/bytedance/android/demo/inline/Foo.access$000:(Lcom/bytedance/android/demo/inline/Foo;)I
getter-setter 內(nèi)聯(lián)
封裝是面向?qū)ο缶幊?OOP)的基本特性之一,使用 getter 和 setter 方法是在程序設(shè)計(jì)中常見(jiàn)的封裝方法之一。在日常開(kāi)發(fā)中,我們常常會(huì)為一些類(lèi)寫(xiě)一些 getter-setter 方法,如下代碼所示:
public class People {
private int age;
public int getAge() {
return this.age;
}
public void setAge(int age) {
this.age = age;
}
}
這些方法完全就是短方法內(nèi)聯(lián)的最佳 case。
優(yōu)化思路
getter-setter 內(nèi)聯(lián)整體實(shí)現(xiàn)和 access 方法大同小異,整體也分為收集、分析和刪除三步。
public int getAge();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field age:I
4: ireturn
public void setAge(int);
descriptor: (I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: iload_1
2: putfield #2 // Field age:I
5: return
- 收集代碼中要內(nèi)聯(lián)的 getter-setter 方法信息。參考上面的字節(jié)碼指令,主要是找出只有參數(shù)入棧(LOAD 類(lèi)指令)、字段訪問(wèn)(GETFIELD, PUTFIELD)、RETURN 指令 的方法。這里需要注意的是要過(guò)濾被 proguard 規(guī)則 keep 的方法,這些刪除風(fēng)險(xiǎn)很大,因?yàn)榭赡軙?huì)有插件內(nèi)調(diào)用或者反射調(diào)用。
- 記錄每個(gè)方法訪問(wèn)字段的指令以及目標(biāo)字段,如果字段訪問(wèn)權(quán)限是非 public 的話,修改成 public 的。
- 針對(duì)調(diào)用 getter-setter 的方法的地方,直接替換為相應(yīng)的字段訪問(wèn)指令,并刪除 getter-setter 的方法的定義。
為什么不用 Proguard
Proguard 除了混淆、shrink 無(wú)用代碼之外,也會(huì)對(duì)代碼進(jìn)行諸多的優(yōu)化,其中就包括短方法內(nèi)聯(lián),唯一方法內(nèi)聯(lián)等。那我們的 App 為什么沒(méi)有直接使用呢?主要還是因?yàn)槭褂昧?robust 熱修,auto-patch 對(duì)內(nèi)聯(lián)層級(jí)過(guò)高以及像 builder 方法這種情況支持的不好,會(huì)導(dǎo)致 Patch 生成失敗。但是 access 方法、getter-setter 方法本身很短,至多也就有一層內(nèi)聯(lián)層級(jí),不會(huì)影響 Patch 的生成,proguard 又無(wú)法配置哪些方法內(nèi)聯(lián),因此我們打算自己來(lái)實(shí)現(xiàn)。
抖音上兩個(gè)短方法內(nèi)聯(lián)減少定義方法數(shù) 7 萬(wàn)+,DEX 文件減少一個(gè),包體積收益達(dá)到了 1.7M。
常量字段消除
上面短方法內(nèi)聯(lián)是將方法內(nèi)容展開(kāi)到調(diào)用處去,我們代碼中的一些常量也類(lèi)似,可以將常量值替換使用處,從而減少字段的聲明,這種優(yōu)化就是常量字段消除的最簡(jiǎn)單表現(xiàn)。
我們知道 javac 會(huì)做一些 final 類(lèi)型變量的常量字段消除優(yōu)化,比如下面的代碼:
public class ConstJava {
public static final int INTEGER = 1024;
public static final String STRING = "this is long str";
public static void constPropagation() {
System.out.println("integer:" + INTEGER);
System.out.println("string:" + STRING);
}
}
在編譯之后 constPropagation 方法就會(huì)變成如下內(nèi)容,常量直接替換成了字面值,這樣相應(yīng)的 final 字段就變成了無(wú)用字段,proguard 就可以將其 shrink 掉。
public static void constPropagation() {
System.out.println("integer:1024");
System.out.println("string:this is long str");
}
但是比如下面的一些一些 kotlin 代碼,編譯之后如下, 并未進(jìn)行傳播優(yōu)化。當(dāng)然這里如果添加 const 關(guān)鍵字修改,對(duì)應(yīng)地會(huì)進(jìn)行優(yōu)化。
class ConstKotlin {
companion object {
val INTEGER = 1024
val STRING = "this is long str"
}
private val b = 6
fun constPropagation(){
println("a:$INTEGER")
println("s:$STRING")
}
}
編譯后代碼:
private static final int INTEGER = 1024;
@NotNull
private static final String STRING = "this is long str";
public final void constPropagation() {
String var1 = "a:" + INTEGER;
System.out.println(var1);
var1 = "s:" + STRING;
System.out.println(var1);
}
因此我們可以針對(duì)這種 case 進(jìn)行優(yōu)化。
另外我們上面說(shuō)常量字段消除優(yōu)化之后,對(duì)應(yīng)的字段聲明就可以被 proguard 刪除,但是項(xiàng)目中有很多 keep 過(guò)度的情況,比如下面的規(guī)則會(huì)導(dǎo)致常量字段聲明被保留,這種情況我們可以將字段刪除。
-keep class com.bytedance.android.demo.ConstJava{*;}
優(yōu)化思路
1.收集 static final 類(lèi)型的變量,并記錄其字面值,這里需要排除一些特殊的字段,然后最終確定能刪除的字段。需要排除的字段主要有下面兩種:
- 用來(lái)表示序列化對(duì)象版本的 serialVersionUID 字段;
- 有反射使用到的字段,一般來(lái)說(shuō)不太會(huì)有反射訪問(wèn) final 類(lèi)型變量的情況,但這里還是會(huì)嘗試分析代碼中對(duì)字段的反射調(diào)用,如果有對(duì)應(yīng)的訪問(wèn)則保留。
2.針對(duì)代碼中 getstatic 指令的訪問(wèn),分析其訪問(wèn)的字段,如果在第一步收集到的字段中,就把對(duì)應(yīng)的指令改為 l 對(duì)應(yīng)的常量入棧指令,并刪除對(duì)應(yīng)的字段。如下為對(duì) INTEGER 的訪 getstatic 指令,其在第一步收集到的 final 類(lèi)型變量中,字面值為 1。
getstatic #48 // Field STRING:Ljava/lang/String;
修改為 ldc 指令:
ldc #25 // String s:this is long str
這里些同學(xué)會(huì)有疑問(wèn),比如一個(gè)大的字符串傳播到多個(gè)類(lèi)里面不是反而會(huì)增大包體積么?
的確存在這種可能,不過(guò)由于一個(gè) Dex 中所有的類(lèi)共用一個(gè)常量池,所以傳播過(guò)去如果兩個(gè)類(lèi)在同一個(gè) Dex 文件中的話是不會(huì)有負(fù)向的,反之則會(huì)有負(fù)向。
常量字段消除優(yōu)化總體帶來(lái) 400KB 左右的包體收益。
R.class 常量?jī)?nèi)聯(lián)
常量字段消除優(yōu)化的是常規(guī)的 final static 類(lèi)型,但在我們的代碼中,還有另一種類(lèi)型的常量也可以內(nèi)聯(lián)優(yōu)化。
在我們 Android 的開(kāi)發(fā)中,常常會(huì)用到 R 這個(gè)類(lèi),它是我們使用資源的最平常的方式。但實(shí)際上,R 文件的生成有著許多不合理的地方,對(duì)我們的性能和包大小都造成了極大的影響。但是要理解這個(gè)問(wèn)題,首先我們需要再理解一次 R 文件是什么。
我們?cè)谄綍r(shí)的代碼開(kāi)發(fā)中,常常會(huì)寫(xiě)出以下平常的代碼:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 此處我們使用R中的id來(lái)獲取MainActivity的layout資源
setContentView(R.layout.activity_main);
}
}
我們?cè)谠摾惺褂肦.layout.activity_main來(lái)獲取了 MainActivity 的 layout 資源,那我們將其轉(zhuǎn)化為字節(jié)碼會(huì)是如何呢?這需要分兩種情況討論:
- 當(dāng) MainActivity 在 application module 下時(shí),其字節(jié)碼為:
protected void onCreate(android.os.Bundle);
Code:
0: aload_0
1: aload_1
2: invokespecial #2 // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
5: aload_0
6: ldc #4 // int 2131296285
8: invokevirtual #5 // Method setContentView:(I)V
11: return
可以看到使用R.layout.activity_main直接被替換成了常量。
- 然而,當(dāng) MainActivity 在 library module 下時(shí),其字節(jié)碼為:
protected void onCreate(android.os.Bundle);
Code:
0: aload_0
1: aload_1
2: invokespecial #2 // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
5: aload_0
6: getstatic #3 // Field com/bytedance/android/R$layout.activity_main:I
9: invokevirtual #4 // Method setContentView:(I)V
12: return
可以看到其從使用 ldc 指令導(dǎo)入常量,變成了使用 getstatic 指令訪問(wèn) R$layout 的 activity_main 域。
為什么會(huì)出現(xiàn)差別
我們知道,library module 在提供給 application module 的時(shí)候一般是通過(guò) aar 的形式提供的,因此為了在 library module 打包時(shí),javac 能夠編譯通過(guò),AGP 默認(rèn)會(huì)給 library module 提供一個(gè)臨時(shí)的 R.java 文件(最終不會(huì)打入 library module 的包中),并且為了防止被 javac 內(nèi)聯(lián),會(huì)將 R 中 field 的修飾符限定為public static,這樣就使得 R 的域都不為常量,最終逃過(guò) javac 內(nèi)聯(lián)保留到了 application module 的編譯中。
為什么 library module 不內(nèi)聯(lián)
在 Android 中,我們每個(gè)資源 id 都是唯一的,因此我們?cè)诖虬臅r(shí)候需要保證不會(huì)出現(xiàn)重復(fù) id 的資源。如果我們?cè)?library module 就已經(jīng)指定了資源 id,那我們就和容易和其他 library module 出現(xiàn)資源 id 的沖突。因此 AGP 提供了一種方案,在 library module 編譯時(shí),使用資源 id 的地方仍然采用訪問(wèn)域的方式,并記錄使用的資源在 R.txt 中。在 application module 編譯時(shí),收集所有 library module 的 R.txt,加上 application module R 文件輸入給 aapt,aapt 在獲得全局的輸入后,按序給每個(gè)資源生成唯一不重復(fù)的資源 id,從而避免這種沖突。但此時(shí),library module 已經(jīng)編譯完成,因此只能生成 R.java 文件,來(lái)滿足 library module 的運(yùn)行時(shí)資源獲取。
為什么 ProGuard 沒(méi)有優(yōu)化
我們?cè)谑褂?ProGuard 的時(shí)候,Google 官方建議我們帶上一些 keep 規(guī)則,這也是新建 application 默認(rèn)會(huì)生成的模版代碼
buildTypes {
release {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
}
}
官方給的 keep 規(guī)則(https://android.googlesource.com/platform/sdk/+/master/files/proguard-android-optimize.txt)中,為了保證運(yùn)行時(shí)正確(如避免程序運(yùn)行時(shí)反射獲取 R class 的字段),所以加了下面這條規(guī)則:
-keepclassmembers class **.R$* {
public static <fields>;
}
該 keep 規(guī)則的作用是,將所有 R 以及 R 內(nèi)部類(lèi)的以 public static 修飾的域保留,使其不被優(yōu)化。因此在我們最終的 APK 中,R.class 仍然存在,這造成了我們包體積的膨脹。
實(shí)際上,造成我們包體積膨脹的原因不止 R 的域的定義和賦值,在 Android 中,一個(gè) DEX 可放置的 field 的數(shù)量上限固定是 65536,超過(guò)這個(gè)限制則我們需要將一個(gè) DEX 拆分為兩個(gè)。多個(gè) DEX 會(huì)導(dǎo)致 DEX 中的復(fù)用數(shù)據(jù)變少,從而進(jìn)一步提升了包體積的膨脹。因此我們對(duì)于 R 的優(yōu)化,在 DEX 層面上也會(huì)有很大的收益。
解決方法
了解問(wèn)題根源后,解決方案也十分簡(jiǎn)單。既然 R.class 中各個(gè)域的值確認(rèn)后就不再改變,那我們完全可以將通過(guò) R 獲取資源 id 的調(diào)用處內(nèi)聯(lián),并刪除對(duì)應(yīng)的域,來(lái)獲取收益。
優(yōu)化思路大概如下:
1.遍歷所有的方法,定位所有的getstatic指令
2.如果該getstatic指令的目標(biāo) Class name 的為**.R 或者**.R$*形式的 Class
- a. 如果getstatic指令的目標(biāo) Field 為public static int類(lèi)型,則使用ldc指令將getstatic替換,直接將 Field 的實(shí)際值導(dǎo)入;
- b. 如果getstatic指令的目標(biāo) Field 為public static int[]類(lèi)型,則使用newarray指令將getstatic替換,將<clinit>中 Field 的數(shù)組賦值導(dǎo)入。
3.遍歷完成后,判斷 R.class 中的是否所有域都被刪除,如果全部被刪除,則將該 R.class 也移除。
我們使用前文的 case 來(lái)說(shuō)明如下:
protected void onCreate(android.os.Bundle);
Code:
0: aload_0
1: aload_1
2: invokespecial #2 // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
5: aload_0
// 判斷是R.class的Field調(diào)用,使用ldc替換
6: getstatic #3 // Field com/bytedance/android/R$layout.activity_main:I
6: ldc #4 // int 2131296285
8: invokevirtual #5 // Method setContentView:(I)V
11: return
實(shí)際上,我們并不是所有 id 都能內(nèi)聯(lián),如果我們運(yùn)行時(shí)通過(guò)反射 R.class 來(lái)獲取某些指定名字的資源時(shí),如果我們將其內(nèi)聯(lián)了,會(huì)導(dǎo)致運(yùn)行時(shí)找不到 id 的異常。為了防止這種情況的發(fā)生,我們可以在方案中增加一個(gè)白名單的概念,在白名單中的域?qū)⒉粫?huì)被內(nèi)聯(lián),對(duì)應(yīng)的,方案中的步驟 2,需要修改為
- 如果該getstatic指令的目標(biāo) Class name 的為**.R 或者**.R$*形式的 Class
a.如果getstatic指令的目標(biāo) Field 在白名單中,則跳過(guò);
b.如果getstatic指令的目標(biāo) Field 為public static int類(lèi)型,則使用ldc指令將getstatic替換,直接將 Field 的實(shí)際值導(dǎo)入;
c.如果getstatic指令的目標(biāo) Field 為public static int[]類(lèi)型,則使用newarray指令將getstatic替換,將<clinit>中 Field 的數(shù)組賦值導(dǎo)入。
抖音上線此優(yōu)化后減少包體積約 30.5M。抖音能產(chǎn)生這么大的收益是因?yàn)槎兑舻?R 十分巨大,包含的 field 非常多,同時(shí)由于單個(gè) DEX 能定義的 field 最多為 65536 個(gè),如果不做精簡(jiǎn)則會(huì)導(dǎo)致 DEX 數(shù)量的劇增,從而出現(xiàn) DEX 總體積暴漲的情況。
小結(jié)
今天我們介紹的這些優(yōu)化可以大幅減少 DEX 包體積,很大地促進(jìn)抖音的用戶增長(zhǎng),同時(shí)也可以優(yōu)化啟動(dòng)時(shí)虛擬機(jī)對(duì) DEX 加載耗時(shí)。不過(guò)這些只是抖音在字節(jié)碼方面所做冰山一角,本文介紹的所有方案的實(shí)現(xiàn)代碼,都在我們之前開(kāi)源的字節(jié)碼修改工具 ByteX 里:
- https://github.com/bytedance/ByteX
當(dāng)然,DEX 相關(guān)的優(yōu)化還有很多。比如我們對(duì) Kotlin 的代碼生成也進(jìn)行了優(yōu)化,在 Kotlin 流行的今天,也拿到了較大的收益;同時(shí)對(duì)于 DEX 本身格式和內(nèi)容的優(yōu)化,在抖音也落地了很多技術(shù)含量較高的方案。這里受限于篇幅就不再詳述。
在本系列后續(xù)的文章中,我們還將繼續(xù)從 DEX、資源、SO、業(yè)務(wù)治理幾個(gè)大方面深入講解抖音上我們包體積相關(guān)的技術(shù)探索,盡情期待。
最后
在這里就還分享一份由大佬親自收錄整理的學(xué)習(xí)PDF+架構(gòu)視頻+面試文檔+源碼筆記,高級(jí)架構(gòu)技術(shù)進(jìn)階腦圖、Android開(kāi)發(fā)面試專(zhuān)題資料,高級(jí)進(jìn)階架構(gòu)資料
這些都是我現(xiàn)在閑暇時(shí)還會(huì)反復(fù)翻閱的精品資料。里面對(duì)近幾年的大廠面試高頻知識(shí)點(diǎn)都有詳細(xì)的講解。相信可以有效地幫助大家掌握知識(shí)、理解原理,幫助大家在未來(lái)取得一份不錯(cuò)的答卷。
當(dāng)然,你也可以拿去查漏補(bǔ)缺,提升自身的競(jìng)爭(zhēng)力。
真心希望可以幫助到大家,Android路漫漫,共勉!