抖音 Android 包體積優化探索:從 Class 位元組碼入手精簡 DEX 體積
theme: juejin highlight: an-old-hope
前言
眾所周知,應用安裝包的體積會十分影響使用者的應用下載速度和安裝速度。據 GooglePlay 平臺對外發布相關的包大小對轉化率影響的資料,我們可以看到隨著包大小的增加,安裝轉化率總體呈下降的趨勢。
因此對於我們的應用來說,為了提升我們使用者下載的轉化率(即下載安裝啟用使用者與潛在使用者的比例),我們對包體積必須給予一定的優化和管控。
我們應用商店中提供給使用者下載的安裝包,是 Android 定義的 APK 格式,其實質則是一個包含應用所有所需資源的 zip 包,它包含了如下所示的幾個組成部分:
這其中最主要的組成部分便是 DEX 檔案,它們都是由 Java/Kotlin 程式碼編譯而成。過去的兩年中,抖音的 DEX 的個數從 8 個漲到了 21 個,DEX 的總大小從 26M 漲到了 48M,增長十分迅猛。誠然,隨著抖音的快速發展,業務複雜度的提高,程式碼量級一定是在增加的,但如何在業務無感的情況下,對程式碼進行通用優化,也是我們一個很重要的優化方向。
在介紹具體優化手段之前,我們首先需要了解下針對 DEX 整體上的優化思路。
DEX 通用優化思路
在 AGP 的構建過程中,Java 或 Kotlin 原始碼在經過編譯之後會生成 Class 位元組碼檔案,在這個階段 AGP 提供了 Transform 來做位元組碼的處理,我們非常熟悉的 Proguard 就是在這個階段工作的,之後 Class 檔案經由 dexBuilder 生成一堆較小的 DEX 檔案,再經由 mergeDex 合併成最終的 DEX 檔案,然後打入 APK 中。具體過程如下圖所示:
因此,我們針對 DEX 檔案的優化時機可以從分別從三個階段切入,分別是.kt 或.java 原始檔、class 檔案、DEX 檔案:
- 在原始檔進行處理也就是手動改造程式碼,這種方式對程式設計本身有侵入,並且有較強的侷限性;
- 在 class 位元組碼階段對開發者無感知,而且基本上能完成大多數的優化,但對於像跨 DEX 引用優化這樣涉及 DEX 格式本身的優化無法完成;
- 在 DEX 檔案階段進行優化是最理想的,在這個階段我們除了能對 DEX 位元組碼本身進行優化,也可對 DEX 檔案格式進行操作。
優化的手段總體上來說也就是冗餘去除、內容精簡、格式優化等方式。
由於早期抖音 class 位元組碼修改工具建設比較成熟,我們很多包體積的優化都是通過修改 class 位元組碼完成的,隨著優化的深入,後期也有很多優化是在 DEX 檔案階段處理的。關於 DEX 階段相關的優化我們後續會有相關文章介紹,這裡主要介紹 Class 位元組碼階段進行的相關優化,主要分為兩大類:
- 單純去除無用的程式碼指令,包括去除冗餘賦值,無副作用程式碼刪除等
- 除了能減少程式碼指令數量外,同時減少方法和欄位的數量,從而有效減少 DEX 的數量。我們知道 DEX 中引用方法數、引用欄位數等不能超過 65535,超過之後就需要新開一個 DEX 檔案,因此減少 DEX 中方法數、欄位數可以減少 DEX 檔案數量,像短方法內聯、常量欄位消除、R 常量內聯就屬於這類優化。
接下來我們會針對每一項優化的背景、優化思路和收益進行詳細介紹。
去除冗餘賦值
在我們平時的程式碼開發中,我們可能會寫出以下的程式碼:
``` 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!"); } } } ```
我們常常為了保證一個 Class 的成員變數的初始滿足我們期望的值,手動對其進行一次賦值,如上述程式碼裡的 aBoolean 和 aBooleanStatic。這是一種邏輯上非常安全的做法,但這真是必須的嗎?
其實 Java 官方在虛擬機器規範(https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-2.html#jvms-2.3 ) 中定義了,Class物件在虛擬機器中載入時,所有的靜態欄位(也就是靜態成員變數,下面統稱為Field)都會首先載入一個預設值。
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 zeroshort
... whose default value is zeroint
... whose default value is zerolong
... whose default value is zerochar
... whose default value is the null code point ('\u0000'
)The floating-point types are:
float
... whose default value is positive zerodouble
... whose default value is positive zero2.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 areference
type isnull
.
總結來說,在 Java 中的基本型別和引用型別的 Field 都會在 Class 被載入的同時賦予一個預設值,byte
、short
、int
、long
、float
、double
型別都會被賦為 0, char 型別會被賦為'\u0000'
,引用型別會被賦為 null。
我們將開頭那段程式碼通過命令列java -p -v
轉化為位元組碼:
```
public com.bytedance.android.dexoptimizer.MyClass();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."
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 ```
通過上述位元組碼發現,雖然 JVM 會在執行時將 aBoolean 賦值為 0,但是我們在位元組碼中仍然會再賦值一次 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
以上標紅部分出現了重複賦值,去除了不影響執行時邏輯。因此,我們考慮在 Class 位元組碼處理階段,將這種冗餘的位元組碼移除來獲取包大小收益。
優化思路
理解了問題產生的原因後,就很容易得到對應的解決方案。首先,能夠被優化的 Field 賦值,需要滿足這三個條件:
- Field 是屬於其直接定義的 Class 的,而非在父類定義過的;
- Field 賦值是在 Class 的
clinit
、init
方法中,這樣做很大程度是為了降低複雜度(因為只在這兩個方法中呼叫的 private 方法也是能做這樣的優化,但分析這樣的方法複雜度很高); - Field 賦值是預設值,當出現多個賦值時,在非預設賦值後的賦值都無法被優化。
我們結合下面的程式碼,具體說明一下各種情況是否可以被優化:
``` Class MyClass { // 可以優化,直接定義的,且是預設值 private boolean aBoolean = false; // 不可優化,因為賦值為非預設值 private boolean bBoolean = true; // 可以優化,直接定義的,且是預設值 private static boolean aBooleanStatic = false;
static { // 可以優化,第一處出現,且是預設值 aBooleanStatic = false;
// 其他程式碼 ...
// 可以優化,前面沒有非預設值賦值,且是預設值 aBooleanStatic = false;
// 其他程式碼 ...
// 不可優化,因為賦值為非預設值 aBooleanStatic = true;
// 其他程式碼 ...
// 不可優化,因為之前出現了非預設值的賦值 aBooleanStatic = false; }
private void boo() { // 不可優化,因為函式為非clinit或init aBoolean = false; } } ```
具體實現上,我們的優化思路是這樣的:
- 遍歷 Class 所有方法,找到<clinit>
和<init>
方法,從上往下進行位元組碼指令遍歷
- 遍歷這兩種方法的所有位元組碼指令,找到所有的 putfield 指令,將 putfield 指令的目標 ClassName 和 FieldName 使用-
連線,構建一個唯一的 Key,如果
- putfield 目標 Class 不是當前 Class,跳過
- putfield 前的 load 指令不為iconst_0
,fconst_0
,dconst_0
,lconst_0
,aconst_null
,並將該 putfield 所關聯的唯一的 Key 放入已經遍歷過的 Key 的集合中
- putfield 前的 load 指令為iconst_0
,fconst_0
,dconst_0
,lconst_0
,aconst_null
,且該 putfield 所關聯的唯一的 Key 沒有在遍歷過的 Key 的集合出現過,則標記為可清除的位元組碼指令
- 遍歷完成後,刪除所有被標記為可清除的位元組碼指令
我們用一個簡單的例子來說明下我們的思路:
public com.bytedance.android.dexoptimizer.MyClass(); // 1. 判斷是<init>方法,進入優化邏輯
Code: // 2. 從上往下進行程式碼遍歷
0: aload_0
1: invokespecial #Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #Field MyClass.aBoolean:Z. // 3.發現是該Class的域,且賦值為iconst_0,標記往上三個指令可以刪除
7: aload_0
8: iconst_1
9: putfield #Field MyClass.aBoolean:Z // 4.發現是該Class的域,且賦值不為iconst_0,則在遍歷過的Key的集合中新增MyClass-aBoolean,繼續往下
10: aload_0
11: iconst_0
12: putfield #Field MyClass.aBoolean:Z // 5.發現是該Class的域,但在遍歷過的Key的集合中發現存在MyClass-aBoolean,繼續往下
15: return
最終發現上述位元組碼中,標紅的部分可以刪除,刪除對應的位元組碼指令,優化完成。
使用抖音之前開源的位元組碼處理框架 ByteX,可以比較方便地獲取 Field 的 Class,遍歷 Class 的所有方法,以及所有方法的位元組碼。我們也已經將此方案進行了開源,有興趣的同學可以前往檢視詳細程式碼:
- https://github.com/bytedance/ByteX/blob/master/field-assign-opt-plugin
刪除無副作用程式碼
冗餘賦值是利用了虛擬機器在類載入時為欄位預設賦值的特性,從而刪除多餘的的賦值指令,而我們程式碼中本身也有一些對線上包是沒有作用的,最常見的就是日誌列印,除了佔用包體積之外,還會造成效能問題以及安全風險,因此一般都會將其移除掉,接下來我們以 Log.i 呼叫為例來介紹如何刪除程式碼中的無用函式呼叫。比如下面程式碼中的日誌列印語句:
```
public static void click() { clickSelf(); Log.i("Logger", "click time:" + System.currentTimeMillis()); } ```
一開始我們嘗試了 proguard 的 -assumenosideeffects,這個指令需要我們假定要刪除的方法呼叫沒有任何的副作用,並且從程式分析的角度來說這個方法是不會修改堆上某個物件或者棧上方法引數的值。使用如下配置,proguard 就會在 optimize 階段幫我們刪除 Log 相關的方法呼叫。
-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(...);
}
但是這種刪除並不徹底,它只會刪除方法呼叫指令本身,比如上面的程式碼中刪除 Log.i 方法呼叫之後,會遺留一個 StringBuilder 物件的建立:
public static void click() {
clickSelf();
new StringBuilder("click time:")).append(System.currentTimeMillis();
}
這個物件的建立我們人為判斷的話也是無用的,但是僅從簡單的靜態程式指令分析的角度並不能判定其是無用的,因此 proguard 並沒有將其刪除。
既然 assumenosideeffects 刪除不乾淨,我們就自己來實現更加徹底的優化方案。
優化思路
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());
在編譯完成之後會生成多條指令(從 ldc 到 pop),除了目標方法 Log.i 呼叫 invokestatic 指令外,還有很多引數建立和入棧指令。
我們要刪除相關方法的呼叫的話,主要是就是找到這行程式碼所產生的起始指令和終止指令,然後起始到終止位置之間的指令就是我們要刪除的全部指令。 1. 查詢終止指令位置
終止指令的查詢相對簡單,主要就是找到要刪除的目標方法呼叫指令,再根據方法的返回值型別確定是否要包含其後的 pop 或 pop2 指令。
比如上述程式碼我們通過遍歷就能找到目標方法呼叫invokestatic #2
的位置,因為 Log.i 的返回值型別是 int,終止指令就是下一條的 pop。
注意 pop 指令的作用是主動讓 int 型別的值出棧,也就是不會使用該方法的返回值,只有這種情況下我們才能安全刪除目標方法,否則不能刪除。當然如果方法的返回值型別是 void,就不會有 pop 指令。
2. 查詢起始指令位置
起始指令的查詢則需要我們對於 java 字碼指令設計有基本的認識: java 位元組碼指令是基於堆疊設計的,每一條位元組碼指令會對應運算元棧的若干引數的入棧和出棧,並且一個完整獨立程式碼/程式碼塊執行前和執行後運算元棧應該是一樣的。
因此我們找到終止指令後,倒序遍歷指令,根據指令的作用進行反向的入棧和出棧操作,當我們的棧中 size 減為 0 時,就找到了起始指令的位置。注意在入棧時候要記錄引數的型別,並在出棧時候做型別匹配校驗。如上面的示例:
- pop 指令效果是單 slot 引數(像 int,float)出棧 ,那我們就在棧存入一個 slot 型別的引數
- invokestatic 要看方法的引數和返回值,正常效果是對應方法的引數從右至左依次出棧,方返回值 int 入棧。我們就根據方法返回值出棧一個 int 型別的引數,發現棧頂目前是 slot,型別匹配。然後按照方法引數從左至右依次入棧兩個 String 型別的引數。
- invokevirtual 指令正常方法呼叫引數依次從右至左依次出棧,然後 this 物件出棧,最後方法返回值 String 入棧。我們彈出棧頂一個引數,發現其和 String 匹配,然後依次入棧 this 對應的型別 StringBuilder,這裡呼叫的是 toString 方法沒有引數就不用再入棧。
- 中間其他的指令類似,直到 ldc 指令,本身是向棧中放入一個 int,float 或 String 常量,我們這裡彈出一個引數,發現其是 String 匹配,並且此時棧的大小變為 0,也就找到了起始指令的位置。
方案缺陷
不過上述方案存在兩個缺陷:
- 因為分析只在單個方法內分析,針對 Log 方法封裝的情況,必須需要配置封裝方法作為目標方法,才能刪除完全刪除,比如下面的方法需要配置 AccountLog.d 才能刪除其呼叫處的 StringBuilder 建立。
object AccountLog {
@JvmStatic
fun d(tag: String, msg: String) = Log.d(tag, msg)
}
- 可能會誤刪除一些有用的指令,因為無法認為 Log.i 的兩個引數的構建指令都是沒有用的,我們只能確定 StringBuilder 的建立是沒用的,但是一些其他的方法呼叫可能會改變一些物件的狀態,因此存在一定風險。
Proguard 方案
在我們上述方案在線上執行一年之後,嘗試針對上述弊端進行優化,然後發現 proguard 還提供了 assumenoexternalsideeffects 指令,它可以讓我們指定沒有任何外部副作用的方法。
指定了以後,它只會修改呼叫這個方法的例項本身,但不會修改其他的物件。通過如下的配置可以刪除無用的 StringBuilder 建立。
-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);
}
不過,這個配置只適用於 Log 裡只傳入 String 的情況。如果是int Log.w (String tag, Throwable tr)
這種情況,就無法把Throwable
引數也一起去掉。那還是應該採用我們自己實現的外掛才能優化乾淨。
此優化對抖音包體積收益,約為 520KB。
短方法內聯
上面介紹的兩個優化是從去除無用的指令的角度出發,開篇 DEX 優化思路中我們有講過,減少定義方法或者欄位數從而減少 DEX 數量也是我們常用優化思路之一,短方法內聯就是精簡程式碼指令的情況下,同時減少定義方法數。
在和海外競品的對比過程中,我們發現單個 DEX 檔案中的定義方法數遠比競品要多,進一步對 DEX 進行分析,發現抖音的 DEX 中有大量的 access,getter-setter 方法,而競品中幾乎沒有。因此我們打算針對短方法做一些內聯優化,減少定義方法數。
在介紹優化方案前,先來了解下內聯的基礎知識,內聯作為最常見的程式碼優化手段,被稱為優化之母。一些語言像 C++、Kotlin 提供了 inline 關鍵字給程式設計師做函式的內聯,而 Java 語言本身並沒有給程式設計師提供控制或建議 inline 的機會,甚至 javac 編譯過程中也沒有做方法內聯。為了便於理解,我們通過一個簡單的例子來看內聯是如何工作的,如下程式碼中 callMethod 呼叫 print 函式:
``` 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); } } ```
在內聯之後 inlineMethod 的內容直接被展開到 callMethod 中, 從位元組碼的角度看變化如下:
內聯前:
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
內聯後:
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
從執行時間的角度看,減少了一次函式呼叫,從而提升了執行效能。從空間佔用角度看,減少了一處函式宣告,從而減少了程式碼體積。
那是不是所有的方法都適合內聯呢?
顯然不是的,對於單次呼叫的方法說內聯能同時取得時間和空間的收益;對於多次呼叫的的方法則需要考慮方法本身的長短,比如上面的 print 方法展開之後的指令是比 invokestatic 指令本身要長很多的,但是像 access、getter-setter 方法本身比較短就很適合內聯。
access 方法內聯
``` 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 可以在內部類 Foo$Inner 中直接訪問外部類 Foo 的私有成員,但是 JVM 並沒有什麼內部類外部類的概念,認為一個類直接訪問另一個類的私有成員是非法的。編譯器為了能實現這種語法糖,會在編譯期生成以下靜態方法:
static int Foo.access$100(Foo foo) {
return foo.mValue;
}
static void Foo.access$200(Foo foo, int value) {
foo.doStuff(value);
}
內部類物件建立時候會傳入外部類的引用,這樣當內部類需要訪問外部類的mValue
或呼叫doStuff()
方法時,會通過呼叫這些靜態方法來實現。這裡需要生成靜態的方法的原因,是因為被訪問的成員是私有的,而私有訪問控制更多地是在原始碼層面去約束,防止破壞程式的設計。在位元組碼層面只要不破壞語法邏輯,因此我們完全可以將這些私有成員改成 public 的,直接刪除掉編譯器生成的橋接靜態方法。
優化思路
具體的優化分為分為以下幾步:
- 收集位元組碼中的 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 ```
如上面的位元組碼所示,它的特徵非常明顯,因為是編譯生成的方法,它有 synthetic 標記,並且是靜態方法,方法名字以"access$"開頭,通過這些特徵在 ClassVisitor visitMethod 時就很容易匹配到相關方法。
- 分析並記錄 access 方法呼叫處要替換的目標指令。
access 橋接的訪問只有欄位和方法兩種,相對應的指令是方法訪問指令(invokvirtual, invokspecial 等)和欄位訪問指令(getfield, putfield 等) ,只需遍歷方法找到相應的指令,同時解析出指令訪問的欄位或方法資訊,然後再將對應的 private 成員改為 public。比如 access$000 方法會找到如下指令,訪問的欄位是類 Foo 的 mValue。
getfield #2 // Field mValue:I
- 替換 access 方法呼叫處的 invokestatic 為對應的目標指令,並刪除 access 方法的定義。
遍歷查詢所有對 access 方法的呼叫點,如下面的 invokestatic 指令,其呼叫方法在我們第一步收集的 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 內聯
封裝是面向物件程式設計(OOP)的基本特性之一,使用 getter 和 setter 方法是在程式設計中常見的封裝方法之一。在日常開發中,我們常常會為一些類寫一些 getter-setter 方法,如下程式碼所示:
public class People {
private int age;
public int getAge() {
return this.age;
}
public void setAge(int age) {
this.age = age;
}
}
這些方法完全就是短方法內聯的最佳 case。
優化思路
getter-setter 內聯整體實現和 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 ```
- 收集程式碼中要內聯的 getter-setter 方法資訊。參考上面的位元組碼指令,主要是找出只有引數入棧(LOAD 類指令)、欄位訪問(GETFIELD, PUTFIELD)、RETURN 指令 的方法。這裡需要注意的是要過濾被 proguard 規則 keep 的方法,這些刪除風險很大,因為可能會有外掛內呼叫或者反射呼叫。
- 記錄每個方法訪問欄位的指令以及目標欄位,如果欄位訪問許可權是非 public 的話,修改成 public 的。
- 針對呼叫 getter-setter 的方法的地方,直接替換為相應的欄位訪問指令,並刪除 getter-setter 的方法的定義。
為什麼不用 Proguard
Proguard 除了混淆、shrink 無用程式碼之外,也會對程式碼進行諸多的優化,其中就包括短方法內聯,唯一方法內聯等。那我們的 App 為什麼沒有直接使用呢?主要還是因為使用了 robust 熱修,auto-patch 對內聯層級過高以及像 builder 方法這種情況支援的不好,會導致 Patch 生成失敗。但是 access 方法、getter-setter 方法本身很短,至多也就有一層內聯層級,不會影響 Patch 的生成,proguard 又無法配置哪些方法內聯,因此我們打算自己來實現。
抖音上兩個短方法內聯減少定義方法數 7 萬+,DEX 檔案減少一個,包體積收益達到了 1.7M。
常量欄位消除
上面短方法內聯是將方法內容展開到呼叫處去,我們程式碼中的一些常量也類似,可以將常量值替換使用處,從而減少欄位的宣告,這種優化就是常量欄位消除的最簡單表現。
我們知道 javac 會做一些 final 型別變數的常量欄位消除優化,比如下面的程式碼:
``` 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 方法就會變成如下內容,常量直接替換成了字面值,這樣相應的 final 欄位就變成了無用欄位,proguard 就可以將其 shrink 掉。
public static void constPropagation() {
System.out.println("integer:1024");
System.out.println("string:this is long str");
}
但是比如下面的一些一些 kotlin 程式碼,編譯之後如下, 並未進行傳播優化。當然這裡如果新增 const 關鍵字修改,對應地會進行優化。
``` 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); } ```
因此我們可以針對這種 case 進行優化。
另外我們上面說常量欄位消除優化之後,對應的欄位宣告就可以被 proguard 刪除,但是專案中有很多 keep 過度的情況,比如下面的規則會導致常量欄位宣告被保留,這種情況我們可以將欄位刪除。
-keep class com.bytedance.android.demo.ConstJava{*;}
優化思路
-
收集 static final 型別的變數,並記錄其字面值,這裡需要排除一些特殊的欄位,然後最終確定能刪除的欄位。需要排除的欄位主要有下面兩種:
-
用來表示序列化物件版本的 serialVersionUID 欄位;
-
有反射使用到的欄位,一般來說不太會有反射訪問 final 型別變數的情況,但這裡還是會嘗試分析程式碼中對欄位的反射呼叫,如果有對應的訪問則保留。
-
針對程式碼中 getstatic 指令的訪問,分析其訪問的欄位,如果在第一步收集到的欄位中,就把對應的指令改為 l 對應的常量入棧指令,並刪除對應的欄位。如下為對 INTEGER 的訪 getstatic 指令,其在第一步收集到的 final 型別變數中,字面值為 1。
getstatic #48 // Field STRING:Ljava/lang/String;
修改為 ldc 指令:
ldc #25 // String s:this is long str
這裡些同學會有疑問,比如一個大的字串傳播到多個類裡面不是反而會增大包體積麼?
的確存在這種可能,不過由於一個 Dex 中所有的類共用一個常量池,所以傳播過去如果兩個類在同一個 Dex 檔案中的話是不會有負向的,反之則會有負向。
常量欄位消除優化總體帶來 400KB 左右的包體收益。
R.class 常量內聯
常量欄位消除優化的是常規的 final static 型別,但在我們的程式碼中,還有另一種型別的常量也可以內聯優化。
在我們 Android 的開發中,常常會用到 R 這個類,它是我們使用資源的最平常的方式。但實際上,R 檔案的生成有著許多不合理的地方,對我們的效能和包大小都造成了極大的影響。但是要理解這個問題,首先我們需要再理解一次 R 檔案是什麼。
我們在平時的程式碼開發中,常常會寫出以下平常的程式碼:
``` public class MainActivity extends AppCompatActivity {
@Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState);
// 此處我們使用R中的id來獲取MainActivity的layout資源 setContentView(R.layout.activity_main); } } ```
我們在該例中使用R.layout.activity_main
來獲取了 MainActivity 的 layout 資源,那我們將其轉化為位元組碼會是如何呢?這需要分兩種情況討論:
- 當 MainActivity 在 application module 下時,其位元組碼為:
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
直接被替換成了常量。
- 然而,當 MainActivity 在 library module 下時,其位元組碼為:
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 指令匯入常量,變成了使用 getstatic 指令訪問 R$layout 的 activity_main 域。
為什麼會出現差別
我們知道,library module 在提供給 application module 的時候一般是通過 aar 的形式提供的,因此為了在 library module 打包時,javac 能夠編譯通過,AGP 預設會給 library module 提供一個臨時的 R.java 檔案(最終不會打入 library module 的包中),並且為了防止被 javac 內聯,會將 R 中 field 的修飾符限定為public static
,這樣就使得 R 的域都不為常量,最終逃過 javac 內聯保留到了 application module 的編譯中。
為什麼 library module 不內聯
在 Android 中,我們每個資源 id 都是唯一的,因此我們在打包的時候需要保證不會出現重複 id 的資源。如果我們在 library module 就已經指定了資源 id,那我們就和容易和其他 library module 出現資源 id 的衝突。因此 AGP 提供了一種方案,在 library module 編譯時,使用資源 id 的地方仍然採用訪問域的方式,並記錄使用的資源在 R.txt 中。在 application module 編譯時,收集所有 library module 的 R.txt,加上 application module R 檔案輸入給 aapt,aapt 在獲得全域性的輸入後,按序給每個資源生成唯一不重複的資源 id,從而避免這種衝突。但此時,library module 已經編譯完成,因此只能生成 R.java 檔案,來滿足 library module 的執行時資源獲取。
為什麼 ProGuard 沒有優化
我們在使用 ProGuard 的時候,Google 官方建議我們帶上一些 keep 規則,這也是新建 application 預設會生成的模版程式碼
buildTypes {
release {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
}
}
官方給的 keep 規則(https://android.googlesource.com/platform/sdk/+/master/files/proguard-android-optimize.txt)中,為了保證執行時正確(如避免程式執行時反射獲取 R class 的欄位),所以加了下面這條規則:
-keepclassmembers class **.R$* {
public static <fields>;
}
該 keep 規則的作用是,將所有 R 以及 R 內部類的以 public static 修飾的域保留,使其不被優化。因此在我們最終的 APK 中,R.class 仍然存在,這造成了我們包體積的膨脹。
實際上,造成我們包體積膨脹的原因不止 R 的域的定義和賦值,在 Android 中,一個 DEX 可放置的 field 的數量上限固定是 65536,超過這個限制則我們需要將一個 DEX 拆分為兩個。多個 DEX 會導致 DEX 中的複用資料變少,從而進一步提升了包體積的膨脹。因此我們對於 R 的優化,在 DEX 層面上也會有很大的收益。
解決方法
瞭解問題根源後,解決方案也十分簡單。既然 R.class 中各個域的值確認後就不再改變,那我們完全可以將通過 R 獲取資源 id 的呼叫處內聯,並刪除對應的域,來獲取收益。
優化思路大概如下:
- 遍歷所有的方法,定位所有的
getstatic
指令 -
如果該
getstatic
指令的目標 Class name 的為.R 或者.R$* 形式的 Classa. 如果
getstatic
指令的目標 Field 為public static int
型別,則使用ldc
指令將getstatic
替換,直接將 Field 的實際值匯入;b. 如果
getstatic
指令的目標 Field 為public static int[]
型別,則使用newarray
指令將getstatic
替換,將<clinit>
中 Field 的陣列賦值匯入。 3. 遍歷完成後,判斷 R.class 中的是否所有域都被刪除,如果全部被刪除,則將該 R.class 也移除。
我們使用前文的 case 來說明如下:
``` 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呼叫,使用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 ```
實際上,我們並不是所有 id 都能內聯,如果我們執行時通過反射 R.class 來獲取某些指定名字的資源時,如果我們將其內聯了,會導致執行時找不到 id 的異常。為了防止這種情況的發生,我們可以在方案中增加一個白名單的概念,在白名單中的域將不會被內聯,對應的,方案中的步驟 2,需要修改為
-
如果該
getstatic
指令的目標 Class name 的為.R 或者.R$* 形式的 Classa. 如果
getstatic
指令的目標 Field 在白名單中,則跳過;b. 如果
getstatic
指令的目標 Field 為public static int
型別,則使用ldc
指令將getstatic
替換,直接將 Field 的實際值匯入;c. 如果
getstatic
指令的目標 Field 為public static int[]
型別,則使用newarray
指令將getstatic
替換,將<clinit>
中 Field 的陣列賦值匯入。
抖音上線此優化後減少包體積約 30.5M。抖音能產生這麼大的收益是因為抖音的 R 十分巨大,包含的 field 非常多,同時由於單個 DEX 能定義的 field 最多為 65536 個,如果不做精簡則會導致 DEX 數量的劇增,從而出現 DEX 總體積暴漲的情況。
小結
今天我們介紹的這些優化可以大幅減少 DEX 包體積,很大地促進抖音的使用者增長,同時也可以優化啟動時虛擬機器對 DEX 載入耗時。不過這些只是抖音在位元組碼方面所做冰山一角,本文介紹的所有方案的實現程式碼,都在我們之前開源的位元組碼修改工具 ByteX 裡:
- https://github.com/bytedance/ByteX
當然,DEX 相關的優化還有很多。比如我們對 Kotlin 的程式碼生成也進行了優化,在 Kotlin 流行的今天,也拿到了較大的收益;同時對於 DEX 本身格式和內容的優化,在抖音也落地了很多技術含量較高的方案。這裡受限於篇幅就不再詳述。
在本系列後續的文章中,我們還將繼續從 DEX、資源、SO、業務治理幾個大方面深入講解抖音上我們包體積相關的技術探索,盡情期待。
加入我們
抖音Android基礎技術團隊是一個深度追求極致的團隊,我們專注於效能、架構、包大小、穩定性、基礎庫、編譯構建等方向的深耕,保障超大規模團隊的研發效率和數億使用者的使用體驗。目前北京、上海、杭州、深圳都有大量人才需要,歡迎有志之士與我們共同建設億級使用者全球化APP!
可以點選以下連結,進入位元組跳動招聘官網查詢「抖音基礎技術 Android」相關職位:
【北京、上海】 https://job.toutiao.com/referral/mobile/position/share/?token=MjsxNjQxNTMzNjIwNzY1OzY2ODgyMTcyMDQzMDUzODA4Njg7NzA1MDMzMTQ5OTAwNjM4MDMwMg
【杭州】 https://job.toutiao.com/s/8V74RjJ
也可以郵件聯絡:[email protected] 或者 [email protected] 諮詢相關資訊或者直接傳送簡歷內推!
- Abase2:位元組跳動新一代高可用 NoSQL 資料庫
- 火山引擎 A/B 測試私有化實踐
- 大型系統儲存層遷移實踐
- 位元組跳動自研高效能微服務框架 Kitex 的演進之旅
- 因果推斷在遊戲個性化數值中的實踐及應用
- 位元組跳動自研高效能微服務框架 Kitex 的演進之旅
- OOP 思想在 TCC/APIX/GORM 原始碼中的應用
- 一文了解位元組跳動如何解決資料 SLA 治理難題
- LL-DASH CMAF 低延遲直播
- 深入理解 OC/C 閉包
- 廣告素材優選演算法在內容營銷中的應用實踐
- ByteDoc 3.0:MongoDB 雲原生實踐
- 深入剖析 split locks,i 可能導致的災難
- 抖音 Android 包體積優化探索:資源二進位制格式的極致精簡
- 分析 Android 耗電原理後,飛書是這樣做耗電治理的
- 抖音 Android 效能優化系列:Java OOM 優化之 NativeBitmap 方案
- iOS StoreKit 2 新特性解析
- 抖音 Android 包體積優化探索:資源二進位制格式的極致精簡
- iOS StoreKit 2 新特性解析
- ByteDoc 3.0:MongoDB 雲原生實踐