前言
本系列文章主要是匯總了一下大佬們的技術文章,屬於Android基礎部分,作為一名合格的安卓開發工程師,咱們肯定要熟練掌握java和android,本期就來說說這些~
[非商業用途,如有侵權,請告知我,我會刪除]
DD一下: Android進階開發各類文檔,也可關注公眾號<Android苦做舟>獲取。
1.Android高級開發工程師必備基礎技能
2.Android性能優化核心知識筆記
3.Android+音視頻進階開發面試題衝刺合集
4.Android 音視頻開發入門到實戰學習手冊
5.Android Framework精編內核解析
6.Flutter實戰進階技術手冊
7.近百個Android錄播視頻+音視頻視頻dome
.......
Android虛擬機指令
1.指令集解讀
1.1 JVM 跨語言與字節碼
JVM是跨語言的平台,很多語言都可以編譯成為遵守規範的字節碼,這些字節碼都可以在JAVA虛擬機上運行。Java虛擬機不關心這個字節碼是不是來自於Java程序,只需要各個語言提供自己的編譯器,字節碼遵循字節碼規範,比如字節碼的開頭是CAFEBABY。
將各種語言編譯成為字節碼文件的編譯器,稱之為前端編譯器。而Java虛擬機中,也有編譯器,比如即時編譯器,此處稱為後端編譯器。
Java虛擬機要做到跨語言,目前來看應該是當下最強大的虛擬機。但是並非一開始設計要跨語言。
1.1.1 跨語言的平台有利於什麼?
由於有了跨語言平台,多語言混合編程就更加方便了,通過特定領域的語言去解決特定領域的問題。
比如並行處理使用Clojure語言編寫,展示層使用JRuby/Rails,中間層用Java編寫,每一應用層都可以使用不同的語言編寫,接口對於開發者是透明的。不同語言可以相互調用,就像是調用自己語言原生的API一樣。它們都運行在同一個虛擬機上。
1.1.2 何為字節碼?
字節碼狹義上是java語言編譯而成,但是由於JVM是支持多種語言編譯的字節碼的,而字節碼都是一個標準規範,因為我們應該稱其為JVM字節碼。
不同的編譯器,可以編譯出相同的字節碼文件,字節碼文件也可以在不同操作系統上的不同JVM中運行。
因此,Java虛擬機實際上和Java語言並非強制關聯的關係,虛擬機只和二級制文件(Class文件)強關聯。
1.2 class字節碼解讀
1.2.1 Class類文件結構
class文件是一組以8字節為基礎單位的二進制流,各個數據項目嚴格按照順序緊湊的地排列在文件之中,中間沒有添加任何分隔符,這使得整個class文件中存儲的內容幾乎全部都是程序的必要的數據。當遇到需要佔用8字節以上空間的數據項時,則會按照高位在前的方式分割成若干個8字節進行存儲。
Class文件格式只有倆種數據類型:“無符號數”和“表”。
- 無符號數:屬於基本的數據類型,以u1、u2、u4、u8來分別代表1個字節、2個字節、4個字節和8個字節的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照utf-8編碼構成的字符串值。
- 表是由多個無符號數或者其他表作為數據項構成的複合數據類型,為了便於區分,所有表的命名都習慣性的以“_info” 結尾。表用於描述有層次關係的複合結構的數據,整個class文件本質上也可以是一張表,按嚴格順序排列構成。
如下圖,為class類結構:
2.1.1 class文件格式:
- 魔數和class文件的版本:每個class文件的頭4個字節被稱為魔數,它的唯一作用是確定這個文件是否為一個能被虛擬機接受的class文件。緊接着魔數的四個字節存儲的是class文件的版本號:第5和第6個字節是次版本號,第7和第8個字節是主版本號。Java的版本號是從第45開始的。
- 常量池,緊接着主、次版本號之後的是常量池的入口,常量池可以比喻成class文件裡面的源倉庫,它是class文件結構中與其他項目關聯最多的數據,通常也是佔用class文件空間最大的數據項目之一,另外,他還是class文件中第一個出現的表類型的數據項目。常量池的入口需要放置一項u2類型的數據,代表常量池容量計數值。這個容量的計數是從1開始的不是從0開始。常量池中主要存放兩大類常量:字面量和符號引用。字面量比較接近於Java層面的常量概念,如文本字符串、被聲明為final的常量值等。符號引用則包括下面幾類常量:
- 被模塊導出或者開放的包類和接口的全限定名字段的名稱和描述符方法的名稱和描述符方法句柄和方法類型動態調用點和動態常量
常量池中每一項常量都是一個表,截至到jdk13,常量表中分別有17種不同類型的常量。
- 訪問標誌(access_flag):在常量池結束之後,緊接着的2個字節代表訪問標誌,這個表示用於是被一些類或者接口層次的訪問信息,包括:這個class是類還是接口;是否定義為public;是否定義為abstract類型,等等。access_flag一共有16種標誌位可以使用,當前只定義了9個,沒有使用的標誌位一律為0。
- 類索引(this_class)、父類索引(super_class)與接口索引集合(interfaces);類索引和父類索引都是一個u2類型的數據集合,接口索引集合是一組u2類型的數據集合,class文件中由這三項數據來確定該類型的繼承關係。類索引用於確定這個類的全限定名,父類索引用於確定這個類的父類的全限定名。接口索引集合用來描述這個類實現了哪些接口,這些被實現的接口將按implements關鍵字後的接口順序從左到右排列在接口索引集合中。
- 字段表(field_class)用於描述接口或者類中聲明的變量。包括類級別變量和實例級別的變量,但不包括在方法內部申明的局部變量。字段可以包括的修飾符有字段的作用域(public、protect)、實例變量還是類變量(static)、可變性(final)、並發可見性(volatile,是否從主內存讀寫)、可否被序列化(transient)、字段數據類型(基本類型、對象、數組)。上面各個修飾符要麼有,要麼沒有,很適合使用標誌位來表示。而字段和字段類型,只能引用常量池中的常量來描述。跟隨着access_flag的標誌的是兩項索引值:name_index和description_index。它們都是對常量池的引用,分別代表字段的簡單名稱以及字段和方法的描述符。全限定名:類似:org/test/testclass;簡單名稱就是指沒有類型和參數修飾的方法或者字段名稱:類似 inc() inc、字段m m;方法和字段的描述符比較複雜
基本類型以及代表無返回值的void類型都用一個大寫的字符表示,而對象則使用字段L加對象的全限定名來表示。對於數組,每一個維度將使用一個前置的[字符來描述,例如:java.lang.String[](#) -> [[Ljava.lang.String; 用來描述方法時,按照先參數列表後返回值的順序描述,例如:int indexof(char[] source, int first) ->([CI)I。字段表集合不會列出從父類或者父接口繼承而來的字段,但有可能出現Java代碼不存的字段。
- 方法表描述;class文件存儲格式中對方法的描述與對字段的描述採用了幾乎完全一樣的方式,方法表的結構如同字段表一樣,依次包括訪問標誌、名稱索引、描述符索引、屬性表集合。如果父類方法在子類中沒有重寫,方法表集合中就不會出現來自父類的方法信息。有可能出現編譯器自己的方法.
- 屬性表:class文件、字段表、方法表都可以攜帶自己的屬性表集合,以描述某些場景的專用信息。下面為部分屬性表信息。
1.2.2 字節碼與數據類型
Java虛擬機的指令由一個字節長度的、代表着某種特定操作含義的數據(稱為操作碼)以及跟隨其後的零至多個代表此操作所需的參數(稱為操作數)構成。由於Java虛擬機採用面向操作數棧而不是面向寄存器的架構,所以大多數指令都不包括操作數,只有一個操作碼,指令參數都放在操作數棧中。Java虛擬機的操作碼為一個字節(0-255),這意味着指令集的操作碼總數不能超過256條。class文件格式放棄了編譯後代碼的操作數對齊,這就意味着虛擬機在處理那些超過一個字節的數據時,不得不在運行時從字節中重建出具體的數據結構。
如下為Java虛擬機指令集支持的數據類型。
- 加載與存儲指令:用於將數據在棧楨中的局部變量和操作數棧之間來回傳輸。例如:iload(將一個局部變量加載到操作數棧)、istore(將一個數值從操作數棧存到局部變量表)、bipush(將常量加載到操作數棧)
- 運算指令:用於對兩個操作數棧上的值進行某種特定運算,並把結果重新存入操作數棧頂。例如:iadd、isub、imul、idiv、irem、ineg。
- 類型轉換指令:可以將兩種不同的數值類型相互轉換。
- 對象創建與訪問指令:雖然類實例和數組都是對象,但Java虛擬機對類實例和數組的創建與操作使用了不同的字節碼指令。對象創建之後,就可以使用對象訪問指令獲取對象實例的字段或者數組元素
- 創建類指令:new;創建數組指令:newarray,anewarray,multianewarray
- 訪問類字段和實例字段:getfield,putfield,getstatic,putstatic
- 把一個數組元素加載到操作數棧的指令:baload,calod,等等
- 將一個操作數棧的元素存儲到數組元素的指令:bastore,castore等等
- 取數組長度:arraylength;檢查類實例類型的指令:instanceof,checkcast;
- 操作數棧指令:出棧(pop)、互相(swap)
- 控制轉移指令:ifeq、iflt等等
- 方法調用和返回指令;invokevirtual(調用對象實例方法,根據對象的實際類型進行分配)、invokeinterface(調用接口方法,在運行時找一個實現了這個接口方法的對象)、invokespecoal(特殊處理的實例方法,類似私用方法、父類方法、初始化方法)、invokestatic(類靜態方法)、invokedynamic(運行時動態解析出調用點限定符所引用的方法)。其分配邏輯由用戶所設定的引導方法設定。返回指令:ireturn
- 異常處理指令:Java虛擬機中處理異常採用異常表來完成。
- 同步指令:Java虛擬機支持方法級別和方法內部一段指令序列的同步,這倆種都是使用monitro來實現的,同步一段指令序列通常由java語言中的synchronized語句塊來表示,Java虛擬機中的指令有monitorenter和monitorexit來支持synchronized的語義。
1.3 Hotspot dalvik ART關係對比
1.3.1 Dalvik簡介
1、Google自己設計的用於Android平台的虛擬機;
2、支持已轉化為dex格式的java應用程序運行;dex是專為Dalvik設計的一種壓縮格式
3、允許在有限的內存中同時運行多個虛擬機實例,並未每一個Dalvik應用作為一和獨立的linux進程運行;
4、5.0以後,Google直接刪除Dalvik,取而代之的是ART。
1.3.2 Dalvik與JVM區別
1、Dalvik是基於寄存器,JVM基於棧;
2、Dalvik運行dex文件,JVM運行java字節碼;
3、自Android2.2以後,Dalvik支持JIT(即時編譯技術)。
1.3.3 ART(Android Runtime)
1、在Dalvik下,應用每次運行,字節碼都需要通過即時編譯器轉化為機器碼,這樣會拖慢應用的運行效率;
2、在ART下,應用第一次安裝時,字節碼就會預先變異成機器碼,使其真正成為本地應用。這個過程叫做預編譯(AOT),這樣,每次啟動和執行的時候都會更快。
Dalvik與ART區別最大的不同就是:Dalvik是即時編譯,每次運行前都先編譯;而ART採用預編譯。
ART優缺點
優點:
1、系統性能顯著提升;
2、應用啟動更快,運行更快,體驗更流暢;
3、更長的電池續航能力;
4、支持更低的硬件。
缺點:
1、機器碼佔用存儲空間更大;
2、應用安裝時間變長。
1.3.4 Dex
Dex文件是Dalvik的可執行文件,Dalvik是針對嵌入式設備設計的java虛擬機,所以Dex文件和Class文件的結構上有很大區別。為了更好的利用嵌入式你設備的資源,Dalvik在java程序編譯後,還需要用dx工具將編譯產生的數個Class文件整合成一個Dex文件。這樣其中的各個類就可以共享數據,減少冗餘,使文件結構更加緊湊。
一個設備在執行Dex文件之前,需要優化該Dex文件並生成對應的Odex文件,然後該Odex文件被Dalvik執行。Odex文件本質是個Dex文件,只是針對目標平台做了相關優化,包括對內部字節碼進行一系列處理,主要為字節碼驗證,替換優化及空方法消除。
1.3.5 Dalvik和Art區別
安卓可以運行多個app,對應運行了多個dalvik實例,每一個應用都有一個獨立的linux進程,獨立的進程可以防止虛擬機崩潰造成所有程序都關閉。就像一條電燈泡上的電燈都是並聯關係的,一個燈泡壞了其他燈泡不受影響,一個程序崩潰了其他程序也不受影響。
- Art一次編譯,終身受用,提高app加載速度,運行速度,省電;不過安裝時間略長,佔Rom體積略大
- Dalvik佔用Rom體積小,安裝略快,不過加載app時間長,運行慢,更加耗電。
1.4 棧的存儲結構和運行原理
1.4.1 棧中存儲的是什麼?
1.每個線程都有自己的棧,棧中存儲的是棧幀。 2.在這個線程上正在執行的每個方法都各自對應一個棧幀。方法與棧幀是一對一的關係。 3.棧幀是一個內存區塊,是一個數據集,維繫着方法執行過程中的各種數據信息。
1.4.2 棧的運行原理
1.JVM直接對java棧的操作只有兩個,就是對棧幀的壓棧和出棧。 2.在一條活動線程中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的。這個棧幀被稱為當前棧幀(Current Frame),與當前棧幀相對應的方法就是當前方法(Current Method),定義這個方法的類就是當前類(Current Class)。 3.執行引擎運行的字節碼只對當前棧幀進行操作。 4.如果該方法調用的其他的方法,對應的新的棧幀會被創建出來,放在棧的頂端,成為新的當前幀。
棧的運行原理圖: 如下圖所示,有四個方法,方法1調用方法2,2調用3,3調用4。 這時棧中會有4個棧幀。當前棧幀是方法4對應的棧幀,位於棧頂。 方法執行完成,將依次出棧。出棧順序為4,3,2,1。
5.棧幀是線程私有的,其它的線程不能引用另外一個線程的棧幀。
6.當前方法返回之際,當前棧幀會傳回此方法的執行結果給前一個棧幀,接着虛擬機會丟棄當前棧幀,使得前一個棧幀重新成為當前棧幀。
7.Java函數返回方式有兩種,使用return或者拋出異常。不管哪種方式,都會導致棧幀被彈出。
1.5 棧幀的內部結構
1.每個棧幀中存儲着局部變量表
2.操作數棧
3.動態鏈接(指向運行時常量池的方法引用)
4.方法返回地址(或方法正常退出或者異常推出的意義)
5.一些附加信息
在JAVA虛擬機中以方法作為最基本的執行單元,“棧幀”則是用於支持虛擬機方法調用和執行的數據結構。它也是虛擬機運行時數據區中的棧中的棧元素。
從JAVA程序的角度來看,同一時刻,同一條線程裡面,在調用堆棧的所有方法都同時處於執行狀態。但對於執行引擎來講,在活動線程中,只有棧頂的方法才是在運行的,即只有棧頂的方法是生效的,其被稱為“當前棧幀”,與這個棧幀所關聯的方法被稱為"當前方法",執行引擎運行的所有字節碼指令都只針對當前棧幀進行操作。
棧幀中存儲着方法的局部變量表,操作數棧,動態連接和方法返回地址。下面對這幾個部分進行一一介紹。
1.5.1 局部變量表
局部變量表示一組變量值的存儲空間,用於存放方法參數和方法內部定義的局部變量。局部變量表的容量以變量槽為最小單位,一個變量槽佔用32位長度的內存空間,即棧中8個類型數據中除double和long需要佔用兩個變量槽之外,其餘均佔用一個變量槽。
需要注意的是,局部變量表是建立在線程的堆棧中的,即線程私有的數據,即對於變量槽的讀寫是線程安全的。
另外局部變量表中變量槽0通常存着this對象引用,其他數據從變量槽1開始存儲,通過字節碼指令store存入局部變量表,需要調用時,可通過load指令取出。同時為了節省棧幀佔用的內存空間,局部變量表的變量槽是可以重用的,其作用域不一定會覆蓋整個方法體,如果當前字節碼的PC計數器已經超出某個變量的作用域,那麼這個變量槽就可以交給其他變量來重用。
可以參照下面這段代碼:
public void method1(){
int a = 0;
int b = 2;
int c = a+b;
}
public void method2(){
int d = 0;
int e = 2;
int f = d+e;
}
public void method1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: iconst_0
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
LineNumberTable:
line 9: 0
line 10: 2
line 11: 4
line 12: 8
public void method2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: iconst_0
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
LineNumberTable:
line 14: 0
line 15: 2
line 16: 4
line 17: 8
可以看到在兩個不同的方法中,method2的d,e,f變量復用了method1中的a,b,c對應的變量槽。
這樣雖然可以節省開銷,卻也會帶來一定的問題,參考下面的代碼:
public static void main(String[] args) {
{
byte[] b = new byte[64*1024*1024];
}
System.gc();
}
[GC (System.gc()) 68813K->66384K(123904K), 0.0017888 secs]
[Full GC (System.gc()) 66384K->66225K(123904K), 0.0074844 secs]
可以看到,本來應該被回收的數組b卻並沒有被回收,這主要是由於局部變量表的變量槽中依然還保存着對b的引用(雖然已經出了作用域,但該變量槽並沒有被複用,因此引用關係依然保持),使得其無法被垃圾回收。可通過在代碼塊下方插入int a =0來複用相應的變量槽,打破引用關係,或者將b置為null,這兩種方法均可以實現對b的回收。
另外局部變量表中的對象必須要進行賦值,不可以像類變量那樣由系統賦予默認值
public class A{
int a;//系統賦值a = 0
public void method(){
int b;//錯誤,必須要賦值
}
}
1.5.2 操作數棧
操作數佔主要用於方法中變量之間的運算,其主要原理是遇到運算相關的字節碼指令(如iadd)時,將最接近棧頂的兩個元素彈出進行運算。操作數棧的具體工作流程可參照下面以這段代碼:
public void method1(){
int a = 0;
int b = 2;
int c = a+b;
}
此外在虛擬機棧中,兩個棧幀會重疊一部分,即讓下面棧幀的部分操作數與上面棧幀的局部變量表的一部分重疊在一起,這樣不僅可以節省空間,亦可以在調用方法時,直接共用一部分數據,無需進行額外參數的複製傳遞。
1.5.3 動態連接
每個棧幀都包含一個指向運行時常量池中該棧幀所屬的方法的引用,持有這個引用是為了支持方法調用過程中的動態連接,即每一次運行期間都要動態地將常量池中方法的符號引用轉換為直接引用。
1.5.4 方法返回地址
方法在執行完畢後,有兩種方式退出這個方法。一是執行引擎遇到任意一個方法返回的字節碼指令(return)。二是方法執行過程中出現了異常,並且在方法的異常表中沒有找到對應的異常處理器,在方法退出後,必須返回最初方法被調用的位置,程序才能繼續執行。而主調方法的PC計數器的值就可以作為返回地址,,棧幀中會保存着這個計數器的值。
1.6 Jclasslib與HSDB工具應用分析
1.6.1 jclasslib應用分析
下面要隆重介紹的是一款可視化的字節碼查看插件:jclasslib。
大家可以直接在 Idea 插件管理中安裝(安裝步驟略)。
使用方法:
- 在 IDEA 打開想研究的類。
- 編譯該類或者直接編譯整個項目( 如果想研究的類在 jar 包中,此步可略過)。
- 打開“view” 菜單,選擇“Show Bytecode With jclasslib” 選項。
- 選擇上述菜單項後 IDEA 中會彈出 jclasslib 工具窗口。
那麼有自帶的強大的反彙編工具 javap 還有必要用這個插件嗎?
這個插件的強大之處在於:
- 不需要敲命令,簡單直接,在右側方便和源代碼進行對比學習。
- 字節碼命令支持超鏈接,點擊其中的虛擬機指令即可跳轉到 jvms 相關章節,超級方便。
該插件對我們學習虛擬機指令有極大的幫助。
1.6.2HSDB的使用
HSDB全稱是HotSpotDebugger, HotSpot虛擬機的調試工具,在使用的時候,需要程序處在暫停的狀態,可以直接使用Idea的debug工具. 使用HSDB可以看到堆棧裡面相關的內容,
啟動HSDB
無論哪種方式啟動,都需要先知道當前java程序的進程號,我們使用jps命令,如下圖所示:
然後我們使用命令 jhsdb hsdb --pid=87854 來啟動HSDB,如下圖所示:
使用HSDB查看JVM虛擬機棧信息
我們知道,在創建一個線程時,都會有一個為之分配一個jvm棧,如上圖我們可以看到在java Threads中有5個線程,我們選中main線程,然後點擊上面的查看棧信息的圖標,如下圖所示:
1:在原java Threads面板上,點第二個按鈕,可召喚出Stack Memory for main 這個面板.
Stack Memory for main 面板主體有三大部分,如上圖所述
2:最左側是棧的內存地址
3:中間一列是該地址上存的值(大多是別的對象的地址),
4:最右側是HotSpot的說明
5:在右側的說明中, 我們可以此時棧中有兩個棧幀(Frame)
大家看到 Young com/platform/tools/jvm/Main$TestObject 這個我們定義的對象,記住這個地址0x00000001161d11e0 代表這個對象是在棧中被引用
使用HSDB查看堆信息
我們的對象大都是在堆裡面,我們可以藉助HSDB看堆中有多少個實例對象,如下圖所示
1:點擊 Tools->Object Histogram ,打開右邊的Object Histogram面板
2:在2處輸入我們的類全名,然後點3望遠鏡搜索,在下面會顯示 我們的類,有三個實例
4:可以雙擊選中我們的類, 也可以放大鏡,可以打開Show Objects of Type 面板 看到三個實例的詳情
其中第三個,就是我們在棧中看到的方法內的成員變量.
對於另外兩個,需要通過反向指針查詢 ,看哪個類引用了這個實例,來看是哪個變量
HSDB使用revptrs 看實例引用
對於上面還有兩個地址, 我們不能確定是什麼對象,所以我們可以通過指針反查來看他們被什麼所引用,如下圖所示:
如上圖,我們可以看到,一個被Class對象所引用, 是類靜態變量,一個被jvm/Main , 也就是我們Main類引用, 是類成員變量. 通過這個我們也可以總結, 靜態變量,其實也是存在堆裡面.
Class,static及Klass的關係
這個版本的hsdb 有些指令不支持,如mem , whatis等,所以要深入學習的小夥伴可以用jdk1.8的hsdb試下上述兩個命令
多個Java對象(Java Object,在堆中)對應同一個Klass(在MetaSpace中)對應同一個Class對象(在堆中), 類的靜態變量地址都存在Class對象的後面(所以也在堆中).
2.深入Android內存管理
Android Runtime(ART)虛擬機和Dalvik虛擬機都使用分頁(Paging)和** 內存映射(Memory-mapped file)來 管理內存。這意味着應用修改的任何內存,無論修改的方式是分配新對象還是輕觸內存映射的頁面,都會一直駐留在RAM中,並且無法換出。要從應用中釋放內存,只能釋放應用保留的對象引用,使內存可供垃圾回收器回收。這種情況有一個例外:對於任何未被修改的內存映射文件(例如:代碼)** ,如果系統想要在其他位置使用其內存,可將其從RAM中換出。
1.1 Android虛擬機與JVM底層區別
虛擬機: JVM的作用是把平台無關的.class裡面的字節碼翻譯成平台相關的機器碼,來實現跨平台。Dalvik和Art(安卓5.0之後使用的虛擬機)就是安卓中使用的虛擬機。
虛擬機是什麼,Jvm,Dalvik(DVM)與Art三者之間的區別
1.1.1 JVM和Android虛擬機的區別
區別一: dvm執行的是.dex格式文件 jvm執行的是.class文件 android程序編譯完之後生產.class文件,然後,dex工具會把 .class文件處理成 .dex文件,然後把資源文件和.dex文件等打包成.apk文件。apk就是android package的意思。 jvm執行的是.class文件。 區別二: dvm是基於寄存器的虛擬機 而jvm執行是基於虛擬棧的虛擬機。寄存器存取速度比棧快的多,dvm可以根據硬件實現最大的優化,比較適合移動設備 區別三: .class文件存在很多的冗餘信息,dex工具會去除冗餘信息,並把所有的.class文件整合到.dex文件中。減少了I/O操作,提高了類的查找速度
總結: JVM以Class為執行單元,Android虛擬機以Dex執行單元,編譯流程JVM直接通過Javac即可加載。Android 虛擬機需要先編譯成dex,然後編譯成apk。最後執行 Android Art虛擬機在安裝的時候講dex緩存本地機器碼,安裝比較慢,耗存儲空間 Android Dalvik虛擬機在程序運行過程中進行翻譯。節省空間,耗cpu時間。以空間換時間的典型
1.1.2 dex和class到底在結構上有什麼區別?
dex 將文件劃分為了 三個區域,這三個區域存儲了整個工程中所有的java 文件的信息,所以 dex 在類越來越多的時候優勢就提現出來了。他只要一個dex文件,很多區域都是可以進行復用的,減少了dex 文件的大小。
本質上他們是一樣的,dex 是從 class 文件演變而來的,但是 calss 中存在了許多沉余信息,dex 去掉了沉余信息,並進行了整合
1.1.3 棧和寄存器的概念,你之前有深入理解過嗎?
總結: Java虛擬機都是基於棧的結構,而Dalvik虛擬機則是基於寄存器。基於棧的指令很緊湊, Java虛擬機使用的指令只佔一個字節,因而稱為字節碼。 基於寄存器的指令由於需要指定源地址和目標地址,因此需要佔用更多的指令空間。 Dalvik虛擬機的某些指令需要佔用兩個字節。 基於棧和基於寄存器的指令集各有優劣,一般而言,執行同樣的功能, 基於棧的需要更多的指令(主要是load和store指令),而基於寄存器需要更多的指令空間。 棧需要更多指令意味着要多佔用CPU時間,寄存器需要更多指令空間意味着數據緩衝(d-cache)更易失效。
1.2 垃圾回收
Android Runtime(ART)虛擬機或者Dalvik虛擬機的受管內存環境會跟蹤每次內存分配。一旦確定程序不再使用某塊內存,它就會將該內存重新釋放在堆中,無需程序員進行任何干預,這種回收受管內存環境中的未使用內存的機制稱為垃圾回收。垃圾回收有兩個目標:在程序中查找將來無法訪問的數據對象,並且回收這些對象使用的資源。
Android的堆是分代的,這意味着它會根據分配對象的預期壽命和大小跟蹤不同的分配存儲分區,例如:最近分配的對象屬於新生代,當某個對象保持活動狀態達足夠長的時間,可將其提升為較老代,然後是永久代。
堆的每一代對相應對象可佔用的內存量都有其自身的專用上限。每當一代開始填滿時,系統便會執行垃圾回收事件以釋放內存。垃圾回收的持續時間取決於它回收的是哪一代對象以及每一代有多少個活動對象。
儘管垃圾回收速度非常快,但是仍然會影響應用的性能。通常情況下,我們無法從代碼中控制何時發生垃圾回收事件,系統有一套專門確定何時執行垃圾回收的標準,當滿足條件時,系統會停止執行進程並開始垃圾回收。如果在動畫或者音樂播放等密集型處理循環過程中發生垃圾回收,則可能會增加處理時間,進而可能會導致應用中的代碼執行超出建議的16ms閾值,無法實現高效、流暢的幀渲染。
此外,我們的代碼流執行的各種工作可能迫使垃圾回收事件發生得更頻繁或者導致其持續時間超過正常範圍,例如:我們在Alpha混合動畫的每一幀期間,在for循環的最內層分配多個對象,則可能在堆中創建大量的對象,在這種情況下,垃圾回收器會執行多個垃圾回收事件,並可能降低應用的性能。
1.3 內存問題
1.3.1 共享內存
為了在RAM中容納所需的一切,Android會嘗試跨進程共享RAM頁面,它可以通過以下方式實現:
- 每個應用進程都從一個名為Zygote的現有進程分叉(fork) 。系統啟動並加載通用框架(Framework)代碼和資源(例如:Activity主題背景)時,Zygote進程隨之啟動。為啟動** 新的應用進程,系統會分叉(fork)Zygote進程,然後在新進程中加載並運行應用代碼,這種方法可以讓框架(Framework)代碼和資源分配的大多數RAM頁面在所有應用進程之間共享**。
- 大多數靜態數據會內存映射到一個進程中,這種方法使得數據不僅可以在進程之間共享,還可以在需要時換出。靜態數據示例包括:Dalvik代碼(通過將其放入預先鏈接的.odex文件中進行直接內存映射) 、應用資源(通過將資源表格設計為可內存映射的結構以及通過對齊APK的zip條目)和** 傳統項目元素(例如:.so文件中的原生代碼)** 。
- 在很多地方,Android使用明確分配的共享內存區域(通過ashmem或者gralloc)在進程間共享同一動態RAM。例如:窗口surface使用在應用和屏幕合成器之間共享的內存,而光標緩衝區則使用在內容提供器和客戶端之間共享的內存。
1.3.2 分配與回收應用內存
Dalvik堆局限於每個應用進程的單個虛擬內存範圍。這定義了邏輯堆大小,該大小可以根據需要增長,但不能超過系統為每個應用定義的上限。
堆的邏輯大小與堆使用的物理內存量不同。在檢查應用堆時,Android會計算按比例分攤的內存大小(PSS)值,該值同時考慮與其他進程共享的臟頁和乾淨頁,但其數量與共享該RAM的應用數量成正比。此(PSS)總量是系統認為的物理內存佔用量。
Dalvik堆不壓縮堆的邏輯大小,這意味着Android不會對堆進行碎片整理來縮減空間。只有當堆末尾存在未使用的空間時,Android才能縮減邏輯堆大小,但是系統仍然可以減少堆使用的物理內存。垃圾回收之後,Dalvik遍歷堆並查找未使用的頁面,然後使用madvise將這些頁面返回給內核,因此大數據塊的配對分配和解除分配應該使所有(或者幾乎所有)使用的物理內存被回收,但是從較小分配量中回收內存的效率要低很多,因為用於較小分配量的頁面可能仍在與其他尚未釋放的數據塊共享。
1.3.3 限制應用內存
為了維持多任務環境的正常運行,Android會為每個應用的堆大小設置硬性上限。不同設備的確切堆大小上限取決於設備的總體RAM大小。如果應用在達到堆容量上限後嘗試分配更多內存,則可能會收到OutOfMemory異常。
在某些情況下,例如:為了確定在緩存中保存多少數據比較安全,我們可以通過調用getMemoryClass()方法** 查詢系統以確定當前設備上確切可用的堆空間大小,這個方法返回一個整數,表示應用堆的可用兆字節數**。
1.3.4 切換應用
當用戶在應用之間切換時,Android會將非前台應用保留在緩存中。非前台應用就是指用戶看不到或者未運行的前台服務(例如:音樂播放)的** 應用。例如:當用戶首次啟動某個應用時,系統會為其創建一個進程,但是當用戶離開此應用時,該進程不會退出,系統會將該進程保留在緩存中,如果用戶稍後返回該應用,系統就會重複使用該進程,從而加快應用切換速度**。
如果應用具有緩存的進程並且保留了目前不需要的資源,那麼即使用戶未使用應用,它也會影響系統的整體性能,當系統資源(例如:內存)不足時,它就會終止緩存中的進程,系統還會考慮終止佔用最多內存的進程以釋放RAM。
要注意的是,當應用處於緩存中時,所佔用的內存越少,就越有可能免於被終止並得以快速恢復,但是系統也可能根據當下的需求不考慮緩存進程的資源使用情況而隨時將其終止。
1.3.5 進程間的內存分配
Android平台在運行時不會浪費可用的內存,它會一直嘗試利用所有可用的內存。例如:系統會在應用關閉後將其保留在內存中,以便用戶快速切回到這些應用,因此,通常情況下,Android設備在運行時幾乎沒有可用的內存,所以要在重要系統進程和許多用戶應用之間正確分配內存,內存管理至關重要。
下面會講解Android是如何為系統和用戶應用分配內存的基礎知識和操作系統如何應對低內存情況。
1.3.6 內存類型
Android設備包含三種不同類型的內存:RAM、zRAM和存儲器,如下圖所示:
要注意的是,CPU和GPU訪問同一個RAM。
- RAM是最快的內存類型,但其大小通常有限。高端設備通常具有最大的RAM容量。
- zRAM是用於交換空間的RAM分區。所有數據在放入zRAM時會進行壓縮,然後在從zRAM向外複製時進行解壓縮。這部分RAM會隨着頁面進出zRAM而增大或者縮小。設備製造商可以設置zRAM大小上限。
- 存儲器中包含所有持久性數據(例如:文件系統等)和** 為所有應用、庫和平台添加的對象代碼。存儲器比另外兩種內存的容量大得多。在Android上,存儲器不像在其他Linux實現上那樣用於交換空間,因為頻繁寫入會導致這種內存出現損壞,並縮短存儲媒介的使用壽命**。
1.3.7 內存頁面
隨機存取存儲器(RAM)分為** 多個頁面。通常,每個頁面為4KB的內存**。
系統會將頁面視為可用或者已使用。可用的頁面是未使用的RAM,已使用的頁面是系統目前正在使用的RAM,可以分為以下類別:
- 緩存頁:
- 有存儲器中的文件(例如:代碼或者內存映射文件)支持的內存。緩存內存有兩種類型:
- 私有頁:由一個進程擁有且未共享。
- 乾淨頁:存儲器中未經修改的文件副本,可由內核交換守護進程(kswapd)刪除以增加** 可用內存**。
- 臟頁:存儲器中經過修改的文件副本,可由內核交換守護進程(kswapd)移動到** zRAM或者在zRAM中進行壓縮以增加可用內存**。
- 共享頁:由多個進程使用。
- 乾淨頁:存儲器未經修改的文件副本,可由內核交換守護進程(kswapd)刪除以增加** 可用內存**。
- 臟頁:存儲器中經過修改的文件副本,允許通過內核交換守護進程(kswapd)或者通過明確使用** msync()或 munmap()將更改寫回 存儲器中的文件,以增加內存空間**。
- 匿名頁:沒有存儲器中的文件支持的內存(例如:由設置了MAP_ANONYMOUS標記的mmap()進行分配)。
- 臟頁:可由內核交換守護進程(kswapd)移動到** zRAM或者在zRAM中進行壓縮以增加可用內存**。
要注意的是,乾淨頁包含存在於存儲器中文件(或者文件一部分)的** 精確副本。如果乾淨頁不再包含文件的精確副本(例如:因應用操作所致),則會變成臟頁。乾淨頁可以刪除,因為始終可以使用存儲器中的數據重新生成它們;臟頁不可以刪除,否則數據將會丟失**。
內存不足管理
Android有兩種處理內存不足情況的主要機制:內核交換守護進程和低內存終止守護進程。
內核交換守護進程(kswapd)
內核交換守護進程(kswapd)是** Linux內核的一部分,用於將已使用內存轉換為可用內存。當設備上的可用內存不足時,該守護進程將變為活動狀態。Linux內核設有可用內存上下限閾值。當可用內存降至下限閾值以下時,kswapd開始回收內存;當可用內存達到上限閾值時,kswapd停止回收內存**。
kswapd可以刪除乾淨頁來回收它們,因為這些頁面受到存儲器的支持且未經修改。如果某個進程嘗試處理已刪除的乾淨頁,則系統會將該頁面從存儲器複製到RAM,這個操作成為請求分頁。
下圖展示的是由存儲器支持的乾淨頁已刪除:
kswapd可以將緩存的私有臟頁和匿名臟頁移動到zRAM進行壓縮,這樣可以釋放RAM中的可用內存(可用頁面) 。如果某個進程嘗試處理zRAM中的臟頁,該頁面將被解壓縮並移回到RAM。如果與壓縮頁面關聯的進程被終止,則該頁面將從zRAM中刪除。如果可用內存量低於特定閾值,系統會開始終止進程。
下圖展示的是臟頁被移至zRAM並進行壓縮:
1.3.8 低內存終止守護進程(LMK)
很多時候,內核交換守護進程(kswapd)不能為系統釋放足夠多的內存。在這種情況下,系統會使用onTrimMemory()方法** 通知應用內存不足,應該減少其分配量。如果這還不夠,Linux內核會開始終止進程以釋放內存,它會使用低內存終止守護進程(LMK)** 來執行此操作。
LMK使用一個名為oom_adj_score的內存不足分值來確定正在運行的進程的優先級,以此決定要終止的進程。最高得分的進程最先被終止。後台應用最先被終止,系統進程最後被終止。
下圖列出了從高到低的LMK評分類別,評分最高的類別,即第一行中的項目將最先被終止:
- 後台應用(Background apps) :之前運行過且當前不處於活動狀態的應用。LMK將首先從具有最高oom_adj_score的應用開始終止後台進程。
- 上一個應用(Previous app) :最近用過的後台應用。上一個應用比後台應用具有更高的優先級(得分更低) ,因為相比某個後台應用,用戶更有可能切換到上一個應用。
- 主屏幕應用(Home app) :這是啟動器應用。終止該應用會使壁紙消失。
- 服務(Services) :服務由應用啟動,例如:同步或者上傳到雲端。
- 可覺察的應用(Perceptible apps) :用戶可通過某種方式察覺到的非前台應用,例如:運行一個顯示小界面的搜索或者聽音樂。
- 前台應用(Foreground app) :當前正在使用的應用。終止前台應用看起來就像是應用崩潰了,可能會向用戶提示設備出了問題。
- 持久性(服務)(Persisient) :這些是設備的核心服務,例如:電話和WLAN。
- 系統(System) :系統進程。這些進程被終止後,手機可能看起來即將重新啟動。
- 原生(Native) :系統使用的極低級別的進程,例如:內核交互終止守護線程(kswapd) 。
要注意的是,設備製造商可以更改LMK的行為。
1.3.9 計算內存佔用量
內核會跟蹤系統中的所有內存頁面。
下圖展示的是不同進程使用的頁面:
在確定應用使用的內存量時,系統必須考慮共享的頁面。訪問相同服務或者庫的應用將共享內存頁面,例如:Google Play服務和某個遊戲應用可能會共享位置信息服務,這樣便很難確定屬於整個服務和每個應用的內存量分別是多少。下圖展示的是由兩個應用共享的頁面(中間) :
如果需要確定應用的內存佔用量,可以使用以下任一指標:
- 常駐內存大小(RSS) :應用使用的共享和非共享頁面的數量。
- 按比例分攤的內存大小(PSS) :應用使用的非共享頁面的數量加上共享頁面的均勻分攤數量(例如:如果三個進程共享3MB,則每個進程的PSS為1MB) 。
- 獨佔內存大小(USS) :應用使用的非共享頁面數量(不包括共享頁面) 。
如果操作系統想要知道所有進程使用了多少內存,那麼按比例分攤的內存大小(PSS)非常有用,因為** 頁面只統計一次,不過計算需要花很長時間,因為系統需要確定共享的頁面以及共享頁面的進程數量。常駐內存大小(RSS)不區分 共享和非共享頁面,因此計算起來更快,更適合跟蹤內存分配量的變化**。
1.3.10 管理應用內存
隨機存取存儲器(RAM)在任何軟件開發環境中都是一項** 寶貴資源,尤其是在移動操作系統中,由於物理內存通常都有限,因此RAM就更加寶貴了。雖然Android Runtime(ART)虛擬機和Dalvik虛擬機都執行例行的垃圾回收任務,但這並不意味着我們可以忽略應用分配和釋放內存的位置和時間。我們仍然需要避免引入內存泄漏問題 (通常因為在靜態成員變量中保留對象引用而引起) ,並且在適當時間(例如:生命周期回調)** 釋放所有Reference對象。
1.3.11 監控可用內存和內存使用量
我們需要先找到應用中內存使用問題,然後才能修復問題。可以使用Android Studio中的內存性能剖析器(Memory Profiler)來幫助我們** 查找和診斷內存問題**:
- 了解我們的應用在一段時間內如何分配內存。Memory Profiler可以顯示實時圖表,包括:應用的內存使用量、分配的Java對象數量和垃圾回收事件發生的時間。
- 發起垃圾回收事件,並在應用運行時拍攝Java堆的快照。
- 記錄應用的內存分配情況,然後檢查有分配的對象、查看每個分配的堆棧軌跡,並在Android Studio編輯器中跳轉到對應的代碼。
1.3.12 釋放內存以響應事件
如上面所述,Android可以通過多種方式從應用中回收內存或者在必要時完全終止應用,從而釋放內存以執行關鍵任務。為了進一步幫助平衡系統內存並避免系統需要終止我們的應用進程,我們可以在Activity類中實現ComponentCallback2接口並且重寫onTrimMemory()方法,就可以在處於** 前台或者後台時監聽與內存相關的事件,然後釋放對象以響應指示系統需要回收內存的應用生命周期事件或者系統事件**,示例代碼如下所示:
/**
* Created by TanJiaJun on 2020/7/7.
*/
class MainActivity : AppCompatActivity(), ComponentCallbacks2 {
/**
* 當UI隱藏或者系統資源不足時釋放內存。
* @param level 引發的與內存相關的事件
*/
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
when (level) {
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
/**
* 釋放當前持有內存的所有UI對象。
*
* 用戶界面已經移動到後台。
*/
}
ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
/**
* 釋放應用程序不需要運行的內存。
*
* 應用程序運行時,設備內存不足。
* 引發的事件表示與內存相關的事件的嚴重程度。
* 如果事件是TRIM_MEMORY_RUNNING_CRITICAL,那麼系統將開始殺死後台進程。
*/
}
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
ComponentCallbacks2.TRIM_MEMORY_MODERATE,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> {
/**
* 釋放進程能釋放的儘可能多的內存。
*
* 該應用程序在LRU列表中,同時系統內存不足。
* 引發的事件表明該應用程序在LRU列表中的位置。
* 如果事件是TRIM_MEMORY_COMPLETE,則該進程將是第一個被終止的。
*/
}
else -> {
/**
* 發布任何非關鍵的數據結構。
*
* 應用程序從系統接收到一個無法識別的內存級別值,我們可以將此消息視為普通的低內存消息。
*/
}
}
}
}
要注意的是,onTrimMemory()方法是** Android4.0才添加的,對於早期版本,我們可以使用onLowMemory()方法,這個 回調方法大致相當於TRIM_MEMORY_COMPLETE**事件。
1.3.13 查看應該使用多少內存
為了允許多個進程同時運行,Android針對為每個應用分配的堆大小設置了硬性限制,這個限制會因設備總體可用的RAM多少而異。如果我們的應用已達到堆容量上限並嘗試分配更多內存,系統會拋出OutOfMemory異常。
為了避免用盡內存,我們可以查詢系統以確定當前設備上可用的堆空間,可以通過調用getMemoryInfo()方法向系統查詢此數值,這個方法會返回** ActivityManager.MemoryInfo對象,這個對象會提供與設備當前的內存狀態有關的信息,例如:可用內存、總內存和內存閾值(如果達到此內存級別,系統就會開始終止進程)** 。ActivityManager.MemoryInfo對象還會提供一個布爾值lowMemory,我們可以根據這個值確定設備是否內存不足。示例代碼如下所示:
fun doSomethingMemoryIntensive() {
// 在執行需要大量內存的邏輯之前,檢查設備是否處於低內存狀態
if (!getAvailableMemory().lowMemory) {
// 執行需要大量內存的邏輯
}
}
// 獲取設備當前內存狀態的MemoryInfo對象
private fun getAvailableMemory(): ActivityManager.MemoryInfo =
ActivityManager.MemoryInfo().also {
(getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(it)
}
1.3.14 使用內存效率更高的代碼結構
我們可以在代碼中選擇效率更高的方案,以儘可能降低應用的內存使用量。
1.3.15 謹慎使用服務(Service)
如果我們的應用需要某項服務(Service)在** 後台執行工作,請不要讓其保持運行狀態,除非它真的需要運行作業,在服務完成任務後應該使其停止運行,否則可能會導致內存泄漏**。
在我們啟動某項服務後,系統更傾向於讓此服務的進程始終保持運行狀態,這種行為會導致服務進程代價十分高昂,因為一旦服務使用了某部分RAM,那麼這部分RAM就不再供其他進程使用,這樣會減少系統可以在LRU緩存中保留的緩存進程數量,從而降低應用切換效率。當內存緊張,並且系統無法維護足夠的進程以託管當前運行的服務時,就可能導致內存抖動。
通常應該避免使用持久性服務,因為它們會對可用內存提出持續性的要求,我們可以使用JobScheduler調度後台進程。
如果我們必須使用某項服務,則限制此服務的生命周期的最佳方式是使用IntentService,它會在處理完啟動它的intent後立即自行結束。
1.3.16 使用經過優化的數據容器
編程語言所提供的部分類並未針對移動設備做出優化,例如:常規HashMap實現的內存效率可能十分低下,因為每個映射都需要分別對應一個單獨的條目對象。
Android框架包含幾個經過優化的數據容器,例如:SparseArray、SparseBooleanArray和LongSparseArray,以SparseArray為例,它的效率更高,因為它可以避免系統需要對鍵(有時還對值)進行自動裝箱(這會為每個條目分別創建1~2個對象) 。
根據業務需要,儘可能使用精簡的數據結構,例如:數組。
1.3.17 謹慎對待代碼抽象
開發者往往會將抽象簡單地當做一種良好的編程做法,因為抽象可以提高代碼靈活性和維護性,不過抽象的代價很高,通常它們需要更多的代碼才能執行,需要更多的時間和更多的RAM才能將代碼映射到內存中,因此,如果抽象沒有帶來顯著的好處時,我們就應該避免使用抽象。
1.3.18 針對序列化數據使用精簡版Protobuf
協議緩衝區(Protocol Buffers)是** Google設計的一種無關語言和平台並且可擴展的機制,用於對結構化數據進行序列化,它與XML類似,但是更小、更快也更簡單。在移動端中使用精簡版的Protobuf,因為常規Protobuf會生成極其冗長的代碼,這會導致應用出現各種問題:例如:RAM使用量增多、APK大小顯著增加和執行速度變慢**。
1.4 避免內存抖動
如前面所述,垃圾回收事件通常不會影響應用的性能,不過如果在短時間內發生許多垃圾回收事件,就可能會快速耗盡幀時間,系統花在垃圾回收上的時間越多,能夠花在呈現界面或者流式傳輸音頻等其他任務上的時間就越少。
通常,內存抖動可能會導致出現大量的垃圾回收事件,實際上,內存抖動可以說明在給定時間內出現的已分配臨時對象的數量,例如:我們在for循環中分配多個臨時對象或者在View的onDraw()方法中創建** Paint對象或者Bitmap對象,在這兩種情況下,應用都會快速創建大量對象,這些操作可以快速消耗新生代(young generation)區域中的所有可用內存,從而迫使垃圾回收事件發生**。
我們可以藉助Android Studio中內存性能剖析器(Memory Profiler)找到** 內存抖動較高的位置,確定代碼中問題區域後,嘗試減少對性能至關重要的區域中的分配數量,可以考慮將某些代碼邏輯從內部循環中移出或者使用工廠方法模式**。
移除會佔用大量內存的資源和庫
代碼中的某些資源和庫可能會在我們不知情的情況下吞噬內存,APK的總體大小(包括第三方庫或者嵌入式資源)可能會影響應用的** 內存消耗量,我們可以通過從代碼中移除任何冗餘、不必要或者臃腫的組件、資源或者庫,降低應用的內存消耗量**。
縮減總體APK大小
我們可以通過縮減應用的總體大小來顯著降低應用的內存使用量。位圖(bitmap)大小、資源、動畫幀數和第三方庫都會影響APK的大小。Android Studio和Android SDK提供了幫助我們縮減資源和外部依賴項大小的多種工具,這些工具可以縮減代碼,例如:R8編譯。
當我們使用Android Gradle插件3.4.0版本及更高版本構建項目時,這個插件不再使用ProGuard來執行編譯時代碼優化,而是與R8編譯器協同工作來處理以下編譯時任務:
- 代碼縮減(即搖樹優化(Tree Shaking)) :從應用及其庫依賴項中檢測並安全地移除未使用的類、字段、方法和屬性(這使其成為了一個對於規避64K引用限制非常有用的工具) 。例如:如果我們僅使用某個庫依賴項的少數幾個API,縮減功能可以識別應用未使用的庫代碼,並且從應用中移除這部分代碼。
- 資源縮減:從封裝應用中移除不使用的資源,包括應用庫依賴項中不使用的資源,這個功能可以與代碼縮減功能結合使用,這樣一來,移除不使用的代碼後,也可以安全地移除不再引用的所有資源。
- 混淆處理:縮短類和成員的名稱,從而減少DEX文件的大小。
- 優化:檢查並重寫代碼,以進一步減少應用的DEX文件的大小。例如:如果R8檢測到從未使用過某段if/else語句的else分支的代碼,則會移除else分支的代碼。
使用Android App Bundle上傳應用(僅限於Google Play)
要在發布到Google Play時立即縮減應用大小,最簡單的方法就是將應用發布為Android App Bundle,這是一種全新的上傳格式,包含應用的所有編譯好的代碼和資源,Google Play負責處理APK生成和簽名工作。
Google Play的新應用服務模式Dynamic Delivery會使用我們提供的App Bundle針對每位用戶的設備配置生成並提供經過優化的APK,因此他們只需下載運行我們的應用所需的代碼和資源,我們不需要再編譯、簽署和管理多個APK以支持不同的設備,而用戶也可以獲得更小、更優化的下載文件包。
要注意的是,Google Play規定我們上傳的簽名APK的壓縮下載大小限制為不超過100MB,而對使用App Bundle發布的應用壓縮下載大小限制為150MB。
使用Android Size Analyzer
Android Size Analyzer工具可讓我們輕鬆地發現和實施多種縮減應用大小的策略,它可以作為Android Studio插件或者獨立JAR使用。
在Android Studio中使用Android Size Analyzer
我們可以使用Android Studio中的插件市場下載Android Size Analyzer插件,可以按着以下步驟操作:
- 依次選擇Android Studio>Preferences,如果是Windows的話,依次選擇File>Settings。
- 選擇左側面板中的Plugins部分。
- 點擊Marketplace標籤。
- 搜索Android Size Analyzer插件。
- 點擊分析器插件的Install按鈕。
如下圖所示:
安裝插件後,從菜單欄依次選擇Analyze>Analyze App Size,對當前項目運行應用大小分析,分析了項目後,系統會顯示一個工具窗口,其中包含有關如何縮減應用大小的建議,如下圖所示:
通過命令行使用分析器
我們可以從GitHub以TAR或者ZIP文件形式下載最新版本的Android Size Analyer,解壓縮文件後,使用以下某個命令對Android項目或者Android App Bundle運行size-analyzer腳本(在Linux或者MacOS上)或者** size-analyzer.bat腳本(在Windows上)** :
./size-analyzer check-bundle <path-to-aab>
./size-analyzer check-project <path-to-project-directory>
1.4.1 了解APK結構
在討論如何縮減應用的大小之前,有必要了解下APK的結構。APK文件由一個Zip壓縮文件組成,其中包含構成應用的所有文件,這些文件包括Java類文件、資源文件和包含已編譯資源的文件。
APK包含以下文件夾:
- META-INF/ :包含CERT.SF和CERT.RSA簽名文件,以及MANIFEST.MF清單文件。
- assets/ :包含應用的資源,可以使用AssetManager對象檢索這些資源。
- res/ :包含未編譯到resources.arsc中的資源。
- lib/ :包含特定於處理器軟件層的已編譯代碼。這個目錄包含每種平台類型的子目錄,例如:armeabi、armeabi-v7a、arm64-v8a、x86、x86_64和mips。
APK還包含以下文件,在這些文件中,只有AndroidManifest.xml是必需的:
- resources.arsc:包含已編譯的資源,這個文件包含res/values/文件夾的所有配置中的** XML內容。打包工具會提取此XML內容,將其編譯成二進制文件形式,並壓縮內容,這些內容包括語言字符串和樣式,以及未直接包含在resources.arsc文件中的內容(例如:布局文件和圖片)的路徑**。
- classes.dex:包含以Android Runtime(ART)虛擬機和Dalvik虛擬機可理解的DEX文件格式編譯的類。
- AndroidManifest.xml:包含Android清單文件,這個文件列出了應用的名稱、版本、訪問權限和引用的庫文件,它使用了Android的二進制XML格式。
1.4.2 縮減資源數量和大小
APK的大小會影響應用加載速度、使用的內存量和消耗的電量。縮減APK大小的一種簡單方法是縮減其包含的資源數量和大小,具體來說,我們可以移除應用不再使用的資源,並且可以用可伸縮的Drawable對象取代圖片文件。
1.4.3 移除未使用的資源
lint工具是Android Studio中附帶的靜態代碼分析器,可以檢測到res/文件夾中** 未被代碼引用的資源,當lint工具發現項目中有可能未使用的資源時,會顯示一條消息**,消息如下所示:
res/layout/preferences.xml: Warning: The resource R.layout.preferences appears
to be unused [UnusedResources]
要注意的是,lint工具不會掃描assets/文件夾、通過反射引用的資源和已鏈接至應用的庫文件,此外,它不會移除資源,只會提醒我們它們的存在。
如果我們在應用的build.gradle文件中啟用了shrinkResource,那麼Gradle可以幫我們自動移除未使用的資源,示例代碼如下:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
要使用shrinkResource,我們必須啟用代碼縮減功能,在編譯過程中,R8首先會移除未使用的代碼,然後Android Gradle插件會移除未使用的資源。
在Android Gradle插件0.7版本及更高版本中,我們可以聲明應用支持的配置。Gradle會使用resConfig和resConfigs變體以及defaultConfig選項將這些信息傳遞給編譯系統,隨後,編譯系統會阻止來自其他不受支持配置的資源出現在APK中,從而縮減APK的大小。
要注意的是,代碼縮減可以清理庫的一些不必要代碼,但可能無法移除大型內部依賴項。
1.4.4 盡量減少庫中的資源使用量
在開發Android應用時,我們通常需要使用外部庫來提高應用的可用性和多功能性,例如:我們可以使用Glide來實現圖片加載功能。
如果庫是為服務器或者桌面設備設計的,則它可能包含應用不需要的許多對象和方法,如果庫許可允許我們修改庫,我們可以編輯庫的文件來移除不需要的部分,我們還可以使用適合移動設備的庫。
1.4.5 僅支持特定密度
Android支持多種設備,涵蓋了各種屏幕密度。在Android 4.4(API級別19)及更高版本中,框架支持各種密度:ldpi、mdpi、tvdpi、hdpi、xhdpi、xxhdpi和xxxhdpi。儘管Android支持所有這些密度,但是我們無需將光柵化資源導出為每個密度。
如果我們不添加用於特定屏幕密度的資源,Android會自動縮放為其他屏幕密度設計的資源,建議每個應用至少包含一個xxhdpi圖片變體。
1.4.6 使用可繪製對象
某些圖片不需要靜態圖片資源,框架可以在運行時動態繪製圖片。我們可以使用Drawable對象(XML中的shape元素)來** 動態繪製圖片,它只會佔用APK中的少量空間,此外,XML的Drawable對象可以生成符合Material Design準則的單色圖片**。
1.4.7 重複使用資源
我們可以為圖片的變體添加單獨的資源,例如:同一圖片經過色調調整、陰影設置或者旋轉的版本。建議重複使用同一組資源,並在運行時根據需要對其進行自定義。
在Android5.0(API級別21)及更高版本上,使用android:tint和android:tintMode屬性可以更改資源的顏色,對於較低版本的平台,則使用ColorFilter類。
我們可以省略僅是另一個資源的旋轉等效項的資源,下面例子展示了通過繞圖片中心位置旋轉180度,將拇指向上變成拇指向下,示例代碼如下所示:
<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_thumb_up"
android:fromDegrees="180"
android:pivotX="50%"
android:pivotY="50%" />
1.4.8 從代碼進行渲染
我們可以通過按一定程序渲染圖片來縮減APK大小,這樣可以釋放不少空間,因為不需要在APK中存儲圖片文件。
1.4.9 壓縮PNG文件
aapt工具可以在編譯過程中通過無損壓縮來優化放置在res/drawable/中的圖片資源,例如:aapt工具可以通過調色板將不需要超過256種顏色的真彩色PNG轉換為8位PNG,這樣做會生成質量相同但內存佔用量更小的圖片。
要注意的是,aapt工具具有以下限制:
- aapt工具不會縮減asset/文件夾中包含的PNG文件。
- 圖片文件需要使用256種或更少的顏色才可供aapt工具進行優化。
- aapt工具可能會擴充已壓縮的PNG文件,為防止出現這種情況,我們可以在Gradle中使用cruncherEnabled標記為PNG文件停用此過程,示例代碼如下:
- aaptOptions { cruncherEnabled = false }
壓縮PNG和JPEG文件
我們可以使用pngcrush、pngquant或者zopflipng等工具縮減PNG文件的大小,同時不損失畫質。所有這些工具都可以縮減PNG文件的大小,同時保持肉眼感知的畫質不變。
pngcrush工具是最有效的:該工具會迭代PNG過濾器和zlib(Deflate)參數,使用過濾器和參數的每個組合來壓縮圖片,然後它會選擇可產生最小壓縮輸出的配置。
要壓縮JPEG文件,我們可以使用packJPG和guetzli等工具。
使用WebP文件格式
如果以Android3.2(API級別13)及更高版本為目標(target) ,我們可以使用WebP文件格式的圖片代替PNG文件或者JPEG文件。WebP格式提供有損壓縮(例如:JPEG)和** 透明度(例如:PNG)** ,不過與PNG或者JPEG相比,這種格式可以提供更好的壓縮效果。
我們可以使用Android Studio將現有的BMP、JPG、PNG或者靜態GIF圖片轉換成WebP格式。
要注意的是,Google Play只接受PNG格式的啟動器圖標。
使用矢量圖形
我們可以使用矢量圖形創建與分辨率無關的圖標和其他可伸縮媒體,它可以極大地減少APK佔用的空間。矢量圖片在Android中以VectorDrawable對象的形式表示,100字節的文件可以生成與屏幕大小相同的清晰圖片。
要注意的是,系統渲染每個VectorDrawable對象需要花費大量時間,使用VectorDrawable對象渲染較大的圖片需要更長的時間才能顯示在屏幕上,因此建議在顯示小圖片時才使用VectorDrawable對象。
將矢量圖形用於動畫圖片
請勿使用AnimationDrawable創建逐幀動畫,因為這樣做需要為動畫的每個幀添加單獨的位圖(bitmap)文件,而這樣做就會大大增加APK的大小,應該改為使用AnimatedVectorDrawableCompat創建動畫矢量可繪製資源。
1.5 減少原生(Native)和Java代碼
我們可以使用多種方法來縮減應用中的原生(Native)和** Java代碼庫的大小**。
1.5.1 移除不必要的生成代碼
確保了解自動生成任何代碼所佔用的空間,例如:許多協議緩衝區工具會生成過多的類和方法,這可能會使應用的大小增加一倍或者兩倍。
1.5.2 避免使用枚舉
單個枚舉會使應用的classes.dex文件增加大約1.0到1.4KB的大小,這些增加的大小會快速累積,產生複雜的系統或者共享庫,如果可能,請考慮使用@IntDef註解和代碼縮減移除枚舉並將它們轉換為整數,此類型轉換可保留枚舉的各種安全優勢。
1.5.3 縮減原生二進制文件的大小
如果我們的應用使用原生代碼和Android NDK,我們還可以通過優化代碼來縮減發布版應用的大小,移除調試符號和避免解壓縮原生庫是兩項很實用的技術。
移除調試符號
如果應用正在開發中且仍需要調試,則使用調試符號非常合適,我們可以使用Android NDK中提供的arm-eabi-strip工具從原生庫中移除不必要的調試符號,之後,我們就可以編譯發布版本。
避免解壓縮原生庫
在構建應用的發布版本時,我們可以通過在應用清單的application元素中設置android:extractNativeLibs="false" ,將未壓縮的.so文件打包在APK中。停用此標記可防止PackageManager在安裝過程中將 .so文件從APK複製到文件系統,並具有減少應用更新的額外好處。使用Android Gradle插件3.6.0版本及更高版本構建應用時,插件會默認將此屬性設為false。
1.6 維護多個精簡APK
APK可能包含用戶下載但從不使用的內容,例如:其他語言或者針對特定屏幕密度的資源。要確保為用戶提供最小的下載文件,我們應該使用Android App Bundle將應用上傳到Google Play。通過上傳App Bundle,Google Play能夠針對每位用戶的設備配置生成並提供經過優化的APK,因此用戶只需下載運行我們的應用所需的代碼和資源,我們無需再編譯、簽署和管理多個APK以支持不同的設備,而用戶也可以獲得更小、更優化的下載文件包。
如果我們不打算將應用發布到Google Play,則可以將應用細分為多個APK,並按屏幕尺寸或者GPU紋理支持等因素進行區分。
當用戶下載我們的應用時,我們的設備會根據設備的功能和設置接收正確的APK,這樣的話設備就不會接收設備所不具備的功能和資源,例如:如果用戶具有hdpi設備,則不需要為更高密度顯示器提供的xxxhdpi資源。
1.7 使用Dagger2實現依賴注入
依賴注入框架可以簡化我們編寫的代碼,並提供一個可供我們進行測試及其他配置更改的自適應環境。
如果我們打算在應用中使用依賴注入框架,請考慮使用Dagger2。Dagger2不使用反射來掃描應用的代碼,它的靜態編譯時實現意味着它可以在Android應用中使用,而不會帶來不必要的運行時代價或者內存消耗量。
其他使用反射的依賴注入框架傾向於通過掃描代碼中的注釋來初始化進程,這個過程可能需要更多的CPU周期和RAM,並可能在應用啟動時導致出現明顯的延遲。
1.8 謹慎使用外部庫
外部庫代碼通常不是針對移動環境編寫的,在移動客戶端上運行可能效率低下。如果我們決定使用外部庫,則可能需要針對移動設備優化該庫,在決定使用該庫之前,請提前規劃,並在代碼大小和RAM消耗量方面對庫進行分析。
即使是一些針對移動設備進行優化的庫,也可能因實現方式不同而導致問題,例如:一個庫可能使用的是精簡版Protobuf,而另一個庫使用的是Micro Protobuf,導致我們的應用出現兩種不同的Protobuf實現。日誌記錄、分析、圖片加載框架以及許多我們意外之外的其他功能的不同實現都可能導致這種情況。
雖然ProGuard可以使用適當的標記移除API和資源,但是無法移除庫的大型內部依賴項。我們所需要的這些庫中的功能可能需要較低級別的依賴項。如果存在以下情況,這就特別容易導致出現問題:我們使用某個庫中的Activity子類(往往會有大量的依賴項) 、庫使用反射(這很常見,意味着我們需要花費大量的時間手動調整ProGuard以使其運行) 等。
此外,請避免針對數十個功能中的一兩個功能使用共享庫,這樣會產生大量我們甚至根本用不到的代碼和開銷,在考慮是否使用這個庫時,請查找與我們的需求十分契合的實現,否則,我們可以決定自己去創建實現。
3.類加載機制
3.1 類的生命周期
3.1.1 加載階段
加載階段可以細分如下
- 加載類的二進制流
- 數據結構轉換,將二進制流所代表的靜態存儲結構轉化成方法區的運行時的數據結構
- 生成java.lang.Class對象,作為方法區這個類的各種數據的訪問入口
加載類的二進制流的方法
- 從zip包中讀取。我們常見的JAR、AAR依賴
- 運行時動態生成。我們常見的動態代理技術,在java.reflect.Proxy中就是用ProxyGenerateProxyClass來為特定的接口生成代理的二進制流
3.1.2 驗證
驗證是連接階段的第一步,這一階段的目的是為了確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
- 文件格式驗證:如是否以魔數 0xCAFEBABE 開頭、主、次版本號是否在當前虛擬機處理範圍之內、常量合理性驗證等。 此階段保證輸入的字節流能正確地解析並存儲於方法區之內,格式上符合描述一個 Java類型信息的要求。
- 元數據驗證:是否存在父類,父類的繼承鏈是否正確,抽象類是否實現了其父類或接口之中要求實現的所有方法,字段、方法是否與父類產生矛盾等。 第二階段,保證不存在不符合 Java 語言規範的元數據信息。
- 字節碼驗證:通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。例如保證跳轉指令不會跳轉到方法體以外的字節碼指令上。
- 符號引用驗證:在解析階段中發生,保證可以將符號引用轉化為直接引用。
可以考慮使用 -Xverify:none 參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
3.1.3 準備
為類變量分配內存並設置類變量初始值,這些變量所使用的內存都將在方法區中進行分配。
3.1.4 解析
虛擬機將常量池內的符號引用替換為直接引用的過程。 解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符 7 類符號引用進行
3.1.5 初始化
到初始化階段,才真正開始執行類中定義的 Java 程序代碼,此階段是執行 <clinit>() 方法的過程。
3.1.6 類加載的時機
虛擬機規範規定了有且只有 5 種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始)
- 遇到new、getstatic 和 putstatic 或 invokestatic 這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。對應場景是:使用 new 實例化對象、讀取或設置一個類的靜態字段(被 final 修飾、已在編譯期把結果放入常量池的靜態字段除外)、以及調用一個類的靜態方法。
- 對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
- 當初始化類的父類還沒有進行過初始化,則需要先觸發其父類的初始化。(而一個接口在初始化時,並不要求其父接口全部都完成了初始化)
- 虛擬機啟動時,用戶需要指定一個要執行的主類(包含 main() 方法的那個類), 虛擬機會先初始化這個主類。
- 當使用 JDK 1.7 的動態語言支持時,如果一個 java.lang.invoke.MethodHandle 實例最後的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。
注意:
- 通過子類引用父類的靜態字段,不會導致子類初始化。
- 通過數組定義來引用類,不會觸發此類的初始化。MyClass[] cs = new MyClass[10];
- 常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
3.2 類加載器
把實現類加載階段中的“通過一個類的全限定名來獲取描述此類的二進制字節流”這個動作的代碼模塊稱為“類加載器”。
將 class 文件二進制數據放入方法區內,然後在堆內(heap)創建一個 java.lang.Class 對象,Class 對象封裝了類在方法區內的數據結構,並且向開發者提供了訪問方法區內的數據結構的接口。
3.3 類的唯一性
對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性。
即使兩個類來源於同一個 Class 文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那這兩個類也不相等。 這裡所指的“相等”,包括代表類的 Class 對象的 equals() 方法、 isAssignableFrom() 方法、isInstance() 方法的返回結果,也包括使用 instanceof 關鍵字做對象所屬關係判定等情況
3.4 雙親委託機制
如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// First, check if the class has already been loaded
//先從緩存中加沒加載這個類
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//從parent中加載
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
//加載不到,就自己加載
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
return c;
}
好處
- 避免重複加載,當父加載器已經加載了該類的時候,就沒有必要子ClassLoader再加載一次。
- 安全性考慮,防止核心API庫被隨意篡改。
3.5 Android中ClassLoader
- ClassLoader是一個抽象類,定義了ClassLoader的主要功能
- BootClassLoader是ClassLoader的子類(注意不是內部類,有些材料上說是內部類,是不對的),用於加載一些系統Framework層級需要的類,是Android平台上所有的ClassLoader的最終parent
- SecureClassLoader擴展了ClassLoader類,加入了權限方面的功能,加強了安全性
- URLClassLoader繼承SecureClassLoader,用來通過URI路徑從jar文件和文件夾中加載類和資源,在Android中基本無法使用
- BaseDexClassLoader是實現了Android ClassLoader的大部分功能
- PathClassLoader加載應用程序的類,會加載/data/app目錄下的dex文件以及包含dex的apk文件或者java文件(有些材料上說他也會加載系統類,我沒有找到,這裡存疑)
- DexClassLoader可以加載自定義dex文件以及包含dex的apk文件或jar文件,支持從SD卡進行加載。我們使用插件化技術的時候會用到
- InMemoryDexClassLoader用於加載內存中的dex文件
3.6 ClassLoader的加載流程源碼分析
-> ClassLoader.java 類
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized(this.getClassLoadingLock(name)) {
//先查找class是否已經加載過,如果加載過直接返回
Class<?> c = this.findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (this.parent != null) {
//委託給parent加載器進行加載 ClassLoader parent;
c = this.parent.loadClass(name, false);
} else {
//當執行到頂層的類加載器時,parent = null
c = this.findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException var10) {
}
if (c == null) {
long t1 = System.nanoTime();
c = this.findClass(name);
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
//如果parent加載器中沒有找到,
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
this.resolveClass(c);
}
return c;
}
}
由子類實現
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
BaseDexClassLoader類中findClass方法
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
// pathList是DexPathList,是具體存放代碼的地方。
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException(
"Didn't find class "" + name + "" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
public Class<?> findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
Class<?> clazz = element.findClass(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
public Class<?> findClass(String name, ClassLoader definingContext,
List<Throwable> suppressed) {
return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed) : null;
}
public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
return defineClass(name, loader, mCookie, this, suppressed);
}
private static Class defineClass(String name, ClassLoader loader, Object cookie,
DexFile dexFile, List<Throwable> suppressed) {
Class result = null;
try {
result = defineClassNative(name, loader, cookie, dexFile);
} catch (NoClassDefFoundError e) {
if (suppressed != null) {
suppressed.add(e);
}
} catch (ClassNotFoundException e) {
if (suppressed != null) {
suppressed.add(e);
}
}
return result;
}
// 調用 Native 層代碼
private static native Class defineClassNative(String name, ClassLoader loader, Object cookie, DexFile dexFile)
3.7 熱修復技術
3.7.1 熱修復技術介紹
- 重新發布版本代價大,成本高,不及時,用戶體驗差,對此有幾種解決方案:
- Hybird:原生+H5混合開發,缺點是人工成本搞,用戶體驗不如純原生方案好;
- 插件化:移植成本高,對老代碼的改造費時費力,而且無法動態修改;
- 熱修復技術,將補丁上傳到雲端,app可以直接從雲端下來補丁直接應用;
- 熱修復技術對於國內開發者來說是一個比較實用的功能,可以解決如下問題:
- 發布新版本代價較大,用戶下載安裝成本高;
- 版本更新的效率問題,需要較長時間來完成版本覆蓋;
- 版本更新的升級率問題,不升級版本的用戶得不到修復,強更又比較暴力。
- 小而重要的功能,需要短時間內完成版本覆蓋,比如節日活動。
- 熱修復的優勢:無需發版,用戶無感知,修復成功率高,用時短;
百家爭鳴的熱修復框架
- 手淘的Dexposed: 開源,底層替換方案, 基於Xposed,針對Dalvik運行時的Java Method Hook技術,但對於Dalvik底層過於依賴,無法繼續兼容Android5.0之後的ART,因此作罷;
- 支付寶的Andfix:開源,底層替換方案,藉助Dexposed思想,做到了Dalvik和ART環境的全版本兼容,但其底層固定結構的替換方案穩定性不好,使用範圍也存在着諸多限制,而且對於資源和so修復未能實現
- 阿里百川的Hotfix:開源,底層替換方案,依賴於Andfix並對業務邏輯解耦,安全性和易用性較好,但還是存在Andfix的缺點;
- Qzone超級補丁: 未開源,類加載方案,會侵入打包流程
- 美團的Robust:開源,Instant Run方案,
- 大眾點評的Nuwa: 開源,類加載方案,
- 餓了么的Amigo:開源,類加載方案
- 微信的Tinker:開源,類加載方案
- 手淘的Sophix:未開源
3.7.2 熱修復技術原理
- 熱修復框架的核心技術主要有三類,分別是代碼修復、資源修復和動態鏈接庫修復
代碼修復:
- 代碼修復主要有三個方案,分別是底層替換方案、類加載方案和Instant Run方案
1. 類加載方案
- 類加載方案需要重啟App後讓ClassLoader重新加載新的類,因為類是無法被卸載的,要想重新加載新的類就需要重啟App,因此採用類加載方案的熱修復框架是不能即時生效的。
優點:
- 不需要太多的適配;
- 實現簡單,沒有諸多限制;
缺點
- 需要APP重啟才能生效(冷啟動修復);
- dex插樁:Dalvik平台存在插樁導致的性能損耗,Art平台由於地址偏移問題導致補丁包可能過大的問題;
- dex替換:Dex合併內存消耗在vm head上,可能OOM,導致合併失敗
- 虛擬機在安裝期間為類打上CLASS_ISPREVERIFIED標誌是為了提高性能的,強制防止類被打上標誌會影響性能;
Dex分包
- 類加載方案基於Dex分包方案,而Dex分包方案主要是為了解決65536限制和LinearAlloc限制:
- 65536限制:DVM指令集的方法調用指令invoke-kind索引為16bits,最多能引用 65535個方法;
- LinearAlloc限制:DVM中的LinearAlloc是一個固定的緩存區,當方法數過多超出了緩存區的大小,安裝時提示INSTALL_FAILED_DEXOPT;
- Dex分包方案: 打包時將應用代碼分成多個Dex,將應用啟動時必須用到的類和這些類的直接引用類放到主Dex中,其他代碼放到次Dex中。當應用啟動時先加載主Dex,等到應用啟動後再動態的加載次Dex。主要有兩種方案,分別是Google官方方案、Dex自動拆包和動態加載方案。
幾種不同的實現:
- 將補丁包放在Element數組的第一個元素得到優先加載(QQ空間的超級補丁和Nuwa)
- 將補丁包中每個dex 對應的Element取出來,之後組成新的Element數組,在運行時通過反射用新的Element數組替換掉現有的Element 數組(餓了么的Amigo);
- 將新舊apk做了diff,得到patch.dex,然後將patch.dex與手機中apk的classes.dex做合併,生成新的classes.dex,然後在運行時通過反射將classes.dex放在Element數組的第一個元素(微信Tinker)
- Sophix:dex的比較粒度在類的維度,並且 重新編排了包中dex的順序,classes.dex,classes2.dex..,可以看作是 dex文件級別的類插樁方案,對舊包中的dex順序進行打破重組
2. 底層替換方案
- 其思想來源於Xposed框架,完美詮釋了AOP編程,直接在Native層修改原有類(不需要重啟APP),由於是在原有類進行修改限制會比較多,不能夠增減原有類的方法和字段,因為這破壞原有類的結構(引起索引變化), 雖然限制多,但時效性好,加載輕快,立即見效;
優點
- 實時生效,不需要重新啟動,加載輕快
缺點
- 兼容性差,由於 Android 系統每個版本的實現都有差別,所以需要做很多的兼容。
- 開發需要掌握 jni 相關知識, 而且native異常排查難度更高
- 由於無法新增方法和字段,無法做到功能發布級別
幾種不同的實現:
- 採用替換ArtMethod結構體中的字段,這樣會有兼容問題,因為手機廠商的修改 以及 android版本的迭代可能會導致底層ArtMethod結構的差異,導致方法替換失敗;(AndFix)
- 同時使用類加載和底層替換方案,針對小修改,在底層替換方案限制范 圍內,還會再判斷所運行的機型是否支持底層替換方案,是就採用底層替換(替換整個ArtMethod結構體,這樣不會存在兼容問題),否則使用類加載替換;(Sophix)
3. Instant Run方案
Instant Run新特性的原理就是當進行代碼改動之後,會進行增量構建,也就是僅僅構建這部分改變的代碼,並將這部分代碼以補丁的形式增量地部署到設備上,然後進行代碼的熱替換,從而觀察到代碼替換所帶來的效果。其實從某種意義上講,Instant Run和熱修復在本質上是一樣的。
Instant Run打包邏輯
- 接入Instant Run之後,與傳統方式相比,在進行打包的時候會存在以下四個不同點
- manifest注入:InstantRun會生成一個自己的application,然後將這個application註冊到manifest配置文件裡面,這樣就可以在其中做一系列準備工作,然後再運行業務代碼;
- nstant Run代碼放入主dex:manifest注入之後,會將Instant Run的代碼放入到Android虛擬機第一個加載的dex文件中,包括classes.dex和classes2.dex,這兩個dex文件存放的都是Instant Run本身框架的代碼,而沒有任何業務層的代碼。
- 工程代碼插樁——IncretmentalChange;這個插裝裡面會涉及到具體的IncretmentalChange類。
- 工程代碼放入instantrun.zip;這裡的邏輯是當整個App運行起來之後才回去解壓這個包裡面的具體工程代碼,運行整個業務邏輯。
- Instant Run在第一次構建apk時,使用ASM在每一個方法中注入了類似如下的代碼 (ASM 是一個 Java 字節碼操控框架。它能被用來動態生成類或者增強既有類的功能)
//$change實現了IncrementalChange這個抽象接口。
//當點擊InstantRun時,如果方法沒有變化則$change為null,就調用return,不做任何處理。
//如果方法有變化,就生成替換類,假設MainActivity的onCreate方法做了修改,就會生成替換類MainActivity$override,
//這個類實現了IncrementalChange接口,同時也會生成一個AppPatchesLoaderImpl類,這個類的getPatchedClasses方法
//會返回被修改的類的列表(裡面包含了MainActivity),根據列表會將MainActivity的$change設置為MainActivity$override
//因此滿足了localIncrementalChange != null,會執行MainActivity$override的access$dispatch方法,
//access$dispatch方法中會根據參數”onCreate.(Landroid/os/Bundle;)V”執行MainActivity$override的onCreate方法,
//從而實現了onCreate方法的修改。
IncrementalChange localIncrementalChange = $change;
if (localIncrementalChange != null) {//2
localIncrementalChange.access$dispatch(
"onCreate.(Landroid/os/Bundle;)V", new Object[] { this,
paramBundle });
return;
}
被廢棄的Instant Run
Android Studio 3.5 中一個顯著變化是引入了 Apply Changes,它取代了舊的 Instant Run。Instant Run 是為了更容易地對應用程序進行小的更改並測試它們,但它會產生一些問題。為了解決這一問題,谷歌已經徹底刪除了 Instant Run,並從根本上構建了 Apply Changes ,不再在構建過程中修改 APK,而是使用運行時工具動態地重新定義類,它應該比立刻運行更可靠和更快。
優點
- 實時生效,不需要重新啟動
- 支持增加方法和類
- 支持方法級別的修復,包括靜態方法
- 對每個產品代碼的每個函數都在編譯打包階段自動的插入了一段代碼,插入過程對業務開發是完全透明
缺點
- 代碼是侵入式的,會在原有的類中加入相關代碼
- 會增大apk的體積
4. 資源修復
- 目前市面上大部分資源熱修復方案基本都參考了Instant Run的實現, 其主要分兩步:
- 創建新的AssetManager,並通過反射調用addAssetPath加載完整的新資源包;
- 找到所有之前引用到原有AssetManager的地方,通過反射,把引用處 替換為新AssetManager;
- 這裡的具體原理可以參考章探索Android開源框架 - 10. 插件化原理中的資源加載部分;
- Sophix: 構造了一個package id為0x66的資源包(原有資源包為 0x7f),此包只包含改變了的資源項,然後直接在原有的AssetManager中 addAssetPath這個包就可以了,不修改AssetManager的引用處,替換更快更安全
5. so庫修復
- 主要是更新so,也就是重新加載so,主要用到了System的load和loadLibrary方法
- System.load(""): 傳入so在磁盤的完整路徑,用於加載指定路徑的so
@CallerSensitive
public static void load(String filename) {
Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
}
- System.loadLibrary(""):傳入so名稱,用於加載app安裝後自動從apk包中複製到/data/data/packagename/lib下的so
@CallerSensitive
public static void loadLibrary(String libname) {
Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname);
}
- 最終都會調用到LoadNativeLibrary(),其主要做了如下工作:
- 判斷so文件是否已經加載,若已經加載判斷與class_Loader是否一樣,避免so重複加載;
- 如果so文件沒有被加載,打開so並得到so句柄,如果so句柄獲取失敗,就返回false,常見新的SharedLibrary,如果傳入path對應的library為空指針,就將創建的SharedLibrary賦值給library,並將library存儲到libraries_中;
- 查找JNI_OnLoad的函數指針,根據不同情況設置was_successful的值,最終返回該was_successful;
兩種方案:
- 將so補丁插入到NativeLibraryElement數組的前部,讓so補丁的路徑先被返回和加載;
- 調用System.load方法來接管so的加載入口;