Android熱更新方案Robust開源,新增自動化補丁工具
我們在之前的博客文章中介紹了高兼容性、高穩定性的實時熱更新解決方案Robust之后,業內反響強烈,不斷有讀者咨詢我們什么時候開源。今天我們非常高興地宣布,Robust已經開源啦!開源地址: https://github.com/Meituan-Dianping/Robust 。
Robust熱更新系統借鑒Instant Run原理,實現了一個兼容性更強而且實時生效的熱更新方案。其基本思路是,Robust熱更新系統在一個方法的入口處插入一段跳轉代碼,當發現某個方法出現bug就跳轉執行補丁中的代碼,略過原有代碼的執行,否則執行原有方法體邏輯。
Robust憑借著自身的優勢,已經在美團點評各個團隊得到了快速普及。制作補丁的需求也隨之越來越旺盛,人力手動制作補丁明顯跟不上業務方的需求。雖然我們團隊已經早早地開始自動化補丁的相關工作,但無奈自動化之路坑太多,一直都難以針對各種情況制作出可用的補丁。
如何快速、穩定地生成補丁已經成為制約Robust熱更新系統推廣的瓶頸。在Robust推廣的初期,補丁基本是手動生成,一個補丁的制作和測試經常需要一天的時間,大大降低了系統對線上問題的反應速度。如果能自動化補丁,補丁的生成就不再是瓶頸,只需要一次打包的時間就可以生成補丁。為此我們團隊進行了不懈的努力,最終為Robust熱更新系統提供了一個比較成熟的自動化生成補丁工具。最新的開源版本中,已經包含這部分工作。
自動化原理
自動化工具是如何寫補丁中代碼的呢?我們知道,攜帶方法的上下文環境跳轉到補丁方法中,可以在補丁方法中利用這些參數來修復bug。不過由于Java訪問修飾符的限制,很多方法和字段不能在補丁中直接訪問,因此反射就成為了補丁中修復線上bug的最佳選擇。在補丁的制作過程中大量的使用反射來調用出現bug類中的方法和字段,還可以在補丁類新增方法或者類,以期達到修復線上問題的目的。舉個例子來說,原始代碼如下:
public int multiple(int number) {
if(number<0){
return -1;
}
number= changeInputs(number);
return times*number;
}
public int changeInputs(int number) {
return number*2;
}
被Robust熱更新系統插入代碼之后如下:
public static ChangeQuickRedirect changeQuickRedirect;
public int multiple(int number) {
if(changeQuickRedirect != null) {
Object var2 = null;
if(PatchProxy.isSupport(new Object[]{new Integer(number)}, this, changeQuickRedirect, false, 627)) {
return ((Integer)PatchProxy.accessDispatch(new Object[]{new Integer(number)}, this, changeQuickRedirect, false, 627)).intValue();
}
}
if(number < 0) {
return -1;
} else {
number = this.changeInputs(number);
return this.times * number;
}
}
//這個方法沒有被Robust處理
public int changeInputs(int number) {
return number*2;
}
如果我們想把 multiple(int number) 這個方法修改為增加對負數的處理(刪除if的判斷條件),補丁如何生成呢?如果是手動書寫補丁的話, multiple(int number) 這個方法既有字段的訪問又有方法的調用,那就是把按照修改后的邏輯,挨個寫反射代碼咯(這里不需要反射Robust的插樁代碼),這個方法的方法體也比較簡單。自動化補丁做的事情就是逐個掃描方法體的內容,把字段和方法調用的轉換為反射,如下自動化生成的代碼:
SampleClass originClass;
public int multiple(int number) {
boolean var4 = false;
Object var5;
var5 = ((SampleClassPatch)this).originClass;
Object[] var6 = new Object[]{new Integer(number)};
int var8 = ((Integer)EnhancedRobustUtils.invokeReflectMethod("b", var5, var6, new Class[]{Integer.TYPE}, SampleClass.class)).intValue();
Log.d("robust", "invoke method is No: 19 changeInputs");
number = var8;
boolean var3 = false;
Object var9;
var9 = ((SampleClassPatch)this).originClass;
int var7 = ((Integer)EnhancedRobustUtils.getFieldValue("c", var9, SampleClass.class)).intValue();
Log.d("robust", "get value is times No: 20");
return var7 * number;
}
注:
- EnhancedRobustUtils是一個對反射的封裝類,可以反射指定對象的指定字段和方法。比如說((Integer)EnhancedRobustUtils.invokeReflectMethod("b", var5, var6, new Class[]{Integer.TYPE}, SampleClass.class)) 就是反射var5對象的b方法,方法的參數類型是Integer,參數的具體值是var6。
- 為什么反射方法的方法名不是multiple?這里是反射混淆后的代碼,自動化補丁支持ProGuard混淆,下文有進一步的描述。
- originClass是出現bug class的對象。
這是自動化生成補丁代碼的一小部分,實際的補丁文件還包括對補丁的描述以及補丁方法的轉發等。
實現
上面的介紹只是自動化的冰山一角,實際使用時問題就會變得錯綜迷離,市場上各大App包括美團點評的產品基本上都是ProGuard混淆優化后的,代碼變得晦澀難懂,ProGuard大大地增加了自動化補丁的難度,上文的樣例中就是對ProGuard之后的代碼進行反射(注意看反射字段和方法時的方法名和字段名)。當然ProGuard做的優化工作還遠遠不止這些,那我們如何應對ProGuard的優化,才能保證補丁中的混淆關系和線上APK中的混淆關系保持一致呢?基本上有如下三種解決辦法:
1. applymapping
ProGuard提供了使用指定mapping來進行混淆的功能,就是在proguard-rules.pro文件中添加applymapping這個配置型,可以參考這篇博客: 混淆實操——手把手教你用applymapping 。第一次看到ProGuard的這個功能如獲至寶,這可以極大的減少自動化補丁的工作,可惜事與愿違,當筆者把這個參數應用到美團App上的時候,沒有修改任何代碼,僅僅是apply上一次構建的mapping文件,發現映射關系并不一樣。然后在網上搜索了一下,也有不少反饋說applymapping并不能保證映射關系的一致性。查看 官網的詳細介紹 之后,我們發現了這樣的一段話:
大概的意思是說,applyingmapping只能保證部分映射關系一致。這對于我們來說,是完全不可以接受的,我們需要的是絕對可靠的輸出補丁,不能依賴我們無法控制的事物。
下圖是在美團App中使用applymapping指定mapping打包過程中ProGuard輸出的日志:
從日志中可以看出,很多類并沒有按照mapping中的映射關系去映射,而是被rename了,然后就不得不放棄這種做法。
即使applymapping按照預期保證了映射關系的一致性,但是如果出現如下情形:有個函數是 void fun(String s,int t) ,在項目中對fun使用時只有第一個參數是變化的,第二個參數始終是個常量值,那么經過ProGuard后fun函數會被處理為 void fun_xxx(String s) (這種情況屬于ProGuard優化范圍內,當ProGuard力度達到一定的強度后就會出現),如果在生成補丁那次的代碼對fun函數使用時第二個參數不保證是固定值了,那后面那次對fun函數ProGurad的處理,不管如何配置Progurad兩次的結果肯定是不一樣的。如果fun函數在代碼version1時滿足內聯條件則編譯時會做內聯處理但是在生成補丁的version2代碼時卻不符合內聯規則了,那么這次fun函數的處理就不能保證處理一致了。
2. 無為而治
每次打包改動不大的話,是可以保持映射關系一致。也就是說,在同一臺機器上打包兩次,這兩次改動相差不是很大,這樣就可以保證映射關系一致。這個筆者親測是可以的,但是對于這個改動的范圍到底可以有多大,沒有很好的把握,筆者在測試的時候僅僅是增加和刪除方法體內的代碼這是沒啥問題,最終可以保持映射關系一致,但是增加類和方法的時候就發生了一些微妙的改變,部分映射關系發生改變。最怕產生多米諾骨牌效應,一個小小的改動會導致自動化處理混淆出現問題,生成的補丁就不可用的情況,最擔心是測試團隊沒有測試出問題,上線之后一片哀嚎。這種方法也就無疾而終了。
3. Do it yourself
各種捷徑均告吹之后,只剩下DIY這條路,一路走來也是滿坎坷的,自動化補丁并不是一蹴而就,為了解決形形色色的問題,我們分了多個階段來處理生成的補丁,比如說混淆的處理就是在Smali匯編語言級別處理的。其實如果僅僅是處理ProGuard的混淆,問題還沒有那么復雜,問題的難點在于ProGuard做的事情還有很多,優化、壓縮代碼就是很重要的一步。舉些例子來說,ProGuard會把類中的get、set方法作用的字段直接訪問性修改為public,然后刪除get和set方法;刪除無用的方法;以及最令人頭疼的內聯問題等等。總的來說,自動化補丁之路就是一部血淚史。
踩過的坑
自動化做的事情就是根據修改bug后的代碼生成最終可執行的dex,就目前來說,整個補丁制作流程包括:.java ->.class ->.dex ->.smali->.dex 。補丁的生成過程步驟繁雜,與此同時,自動化補丁處理代碼風格迥異,需要對Java的各種語法提供支持,無論是泛型、內部類還是Lambda表達式,同時還需要提供對ProGuard的混淆、優化、內聯支持,這些極大的增加自動化補丁的難度,也讓補丁自動化之路顯得漫長無比。總的來說,補丁的自動化過程中主要有這么兩類問題:
- Java編譯器的優化
- ProGuard的優化
其中第一類問題并沒有增加補丁制作技術難度,但是會具有一些迷惑性,需要去分析這種的語法糖的底層實現,搞明白其實現的原理;第二類問題就是自動化的核心,簡單的來說,就是把修改完的代碼按照線上Apk的混淆規則,ReProGuard一次,這樣才能保證補丁的高可用性。
1. Java編譯器的優化
Java編譯器的優化工作包括Java編譯器會自動生成一些 橋方法 以及移動代碼的位置等,比較典型的就是泛型方法、內部類和Lambda表達式。補丁自動化的過程中使用注解來標注需要補丁的方法,所以當Java編譯器針對泛型移動代碼時,注解也會被移動,直接導致補丁上線后無法修復問題。以Java編譯器對泛型方法的處理為例,Java編譯器會為泛型方法生成一個橋方法(在橋方法里面調用真正的方法,橋方法的參數是object的類型,注意這類橋方法Robust熱更新系統并沒有對其插樁),同時Java編譯器把原方法上的注解移動到橋方法上,針對泛型方法制作補丁時,就變成了針對泛型方法的橋方法制作補丁了。Lambda表達式也與此類似,編譯器把Lambda表達式的內容,移到了一個新的方法(Java編譯器為我們生成的access開頭的方法)里面去,而且我們還無法給Lambda表達式加上注解。
為了解決上述的問題,自動化提供了一個靜態方法( Robust.modify() ),支持在泛型或者Lambda表達式里面調用這個靜態方法,自動化掃描所有的方法調用,檢測到這個靜態方法的調用就就可以找到找到需要制作補丁的方法。這樣就可以避免由于Java編譯器做的一些優化工作導致我們無法修復預期的bug。
與這個問題類似的,還有內部類的問題,這個問題和ProGuard交織在一起。對于構造方法是私有的內部類,Java編譯器也會生成一個包訪問性的構造方法,以便于外部類訪問。
可以參看 官方文檔 的介紹,如下例:
public class Sample{
public int multiple(int number) {
Children pair=new Children("1");
pair.setFirst("asdad");
number= changeInputs(number);
return times*number;
}
class Children{
private String first=null;
private Children(String fir){
this.first=fir;
setFirst("1");
}
public void setFirst(String fir){
this.first=fir;
}
}
}
我們在 multiple(int number) 里面創建了一個內部類的對象,其中內部類的方法是私有的,如果這樣寫,Java編譯是不會報錯的,但是仔細想一下類的私有構造方法外部類怎么可能調用到呢?明顯違反了Java的訪問性規則。我們來看看反編譯的代碼:
public int multiple(int);
Code:
0: new #9 // class com/meituan/sample/SampleClass$Children
3: dup
4: aload_0
5: ldc #39 // String 1
7: aconst_null
8: invokespecial #42 // Method com/meituan/sample/SampleClass$Children."<init>":(Lcom/meituan/sample/SampleClass;Ljava/lang/String;Lcom/meituan/sample/SampleClass$1;)V
11: astore_2
12: aload_2
13: ldc #44 // String asdad
15: invokevirtual #48 // Method com/meituan/sample/SampleClass$Children.setFirst:(Ljava/lang/String;)V
18: aload_0
19: iload_1
20: invokevirtual #51 // Method changeInputs:(I)I
23: istore_1
24: aload_0
25: getfield #28 // Field times:I
28: iload_1
29: imul
30: ireturn
那個init就是構造器的調用(上述截圖中code的第八行,調用的指令是:invokespecial),是不是后面加上了一個小尾巴( Lcom/meituan/sample/SampleClass$1 ,這個 SampleClass$1 不是筆者手動定義的)?讓我們再看看 SampleClass$Children 里面干了啥:
final com.meituan.sample.SampleClass this$0;
private com.meituan.sample.SampleClass$Children(com.meituan.sample.SampleClass, java.lang.String);
Code:
0: aload_0
1: aload_1
2: putfield #20 // Field this$0:Lcom/meituan/sample/SampleClass;
5: aload_0
6: aload_1
7: invokespecial #23 // Method com/meituan/sample/SampleClass$Parent."<init>":(Lcom/meituan/sample/SampleClass;)V
10: aload_0
11: aconst_null
12: putfield #25 // Field first:Ljava/lang/String;
15: aload_0
16: aload_2
17: putfield #25 // Field first:Ljava/lang/String;
20: aload_0
21: ldc #27 // String 1
23: invokevirtual #31 // Method setFirst:(Ljava/lang/String;)V
26: return
com.meituan.sample.SampleClass$Children(com.meituan.sample.SampleClass, java.lang.String, com.meituan.sample.SampleClass$1);
Code:
0: aload_0
1: aload_1
2: aload_2
3: invokespecial #40 // Method "<init>":(Lcom/meituan/sample/SampleClass;Ljava/lang/String;)V
6: return
這里出現了兩個構造方法,編譯器自動生成了一個包訪問性的構造方法,不過傳進來的小尾 com.meituan.sample.SampleClass$1 就是一個空的類,只有類的定義,其他的啥也沒有。
如果事情都是這么簡單就好了,這個問題也不難用反射來解決,但是這邊存在著兩個問題:
- 像這種匿名內部的類名字(數字部分)可能會隨著每次打包發生改變的。
- 當項目中ProGuard力度比較大的時候,內部類的構造方法的訪問性會被修改為public,然后編譯器生成的方法被優化掉。
第一個問題還容易解決,第二個問題就有點棘手,不確定各個業務方ProGuard力度優化到什么地步,為了避免反射的方法找不到,只好采取一種保守的措施,制作補丁的時候把內部類構造方法的訪問性改為public,然后直接反射這個public的構造函數。這樣做就避免了編譯器優化這一步,確保可以反射到正確的構造方法。
2. ProGuard的優化
ProGuard的相關優化工作是這次補丁自動化的難點。在此之前,我們先來簡單了解一下 ProGuard 做了一些什么事。
從ProGuard的工作流來看,ProGuard做的工作基本主要包含:壓縮、優化、混淆以及最后的校驗。體現到代碼層面上做的事情就是:混淆類名、方法名、字段名,修改方法、字段訪問性,刪除方法(上例中內部類的構造方法),方法的內聯,甚至是減少方法的參數(這就改變了方法簽名)等等。大體可以總結為三大問題:混淆、優化、內聯,其中優化相關操作,比如說改變方法簽名和刪除方法,我們可以把這類問題劃歸到內聯,因為在優化后的代碼里面這些方法和內聯的方法一樣,都消失了。
首先來說對于混淆這部分處理思路并不困難,可以在Smali匯編語言那層做字符串的替換,不過需要確保不會引入其他問題,這部分操作需要慎重。而對于內聯問題的處理,就有點麻煩,因為內聯(ProGuard優化工作可以被當做內聯來統一處理)的方法在最終的Apk中是不存在的,所以需要略施小計,把消失的方法給“補”上來。對于這些問題的詳細解決辦法,聽我們一一道來。
對于ProGuard修改訪問性的問題,使用反射的方式可以很好地解決這個問題,但是這樣可能會引入一個問題,由于ProGuard之后,各個方法和字段的名字混淆為簡單字母,比如a、b之類的,子類和父類很大可能行會出現不同的方法或者字段被混淆成一樣簡單字母。如下例:
public class Parent {
private String first=null;
//混淆為c
private void privateMethod(String fir){
System.out.println(fir);
}
//混淆為b
public void setFirst(String fir){
first=fir;
Parent children=new Children();
children.privateMethod("Robust");
}
}
public class Children extends Parent{
private String sencod=null;
//混淆為c
public void setSecond(String fir){
this.sencod=fir;
}
}
設想這樣一種情況,如果我們對Parent的 setFirst 方法制作補丁,自然而然就會對 children.privateMethod 方法反射,此時 privateMethod 被混淆成為c,此時當前的對象實際類型是Children,此時在children實例上反射方法c的話,會反射到哪里呢?反射到了 setSecond ,這和預期是不一致的,我們想要反射的 privateMethod 方法。
這個問題的解決辦法就是在反射的時候,加強對反射條件限制,強制校驗反射的方法或者字段的聲明類,如果在反射的時候就知道方法c是類 Parent 中的方法的話,就可以解決這個問題,在反射的時候就需要多傳遞一個方法的聲明類。
內聯問題的共同點是在ProGuard優化之后的Apk中均找不到這些方法,解決辦法就是把這些消失的內聯方法給“補”上來。我們采取的辦法是,為這些內聯的方法創建對應的內聯類,在內聯類里面僅包含這些內聯的方法,然后在補丁中攜帶這些內聯類,最后再把代碼中調用內聯方法的地方修改為調用補丁中內聯類的對應方法,這個操作分為幾步,最終實現了在補丁中把對應的內聯方法“補”上。比如上例的 privateMethod 被內聯了。則補丁應該如下:
public class Parent {
private String first=null;
//privateMethod被內聯了
// private void privateMethod(String fir){
// System.out.println(fir);
//}
public void setFirst(String fir){
first=fir;
Parent children=new Children();
//children.privateMethod("Robust");
//內聯替換的邏輯
ParentInline inline= new ParentInline(children);
inline.privateMethod("Robust");
}
}
public class ParentInline{
private Parent children ;
public ParentInline(Parent p){
children=p;
}
//混淆為c
public void privateMethod(String fir){
System.out.println(fir);
}
}
當ProGuard力度的不斷增大,可能會出現多級內聯的問題,最擔心會出現循環內聯的問題(筆者目前沒有遇到這種情況),比如說類A的 methodA1 內聯了類B的 methodB1 方法,而與此同時類B的 methodB2 內聯類A的 methodA2 方法,這樣就可能會出現一個循環內聯問題,導致創建內聯類代碼陷入死循環。我們解決這種問題的方法是,首先掃描出所有內聯的類,為它們創建一個包含內聯方法的hook內聯類,這個hook內聯類里面方法的實現不重要,僅僅是為了編譯可以通過,內聯方法的地方修改為調用hook的內聯類,最后再把hook內聯類的方法實現。
總結
上述林林總總的問題只是我們在補丁自動化過程中遇到問題的一瞥,想要針對形形色色的代碼風格以及不同ProGuard力度成功的制作出可用的補丁,并非一件容易的事情,比想象的要復雜的多。一路風雨飄搖的自動化補丁,經過我們團隊的不懈努力之后,最終漸漸地穩定,可以完美的針對多種代碼風格生成補丁。古人云:行百里者半于九十,自動化補丁只是大廈剛成,未來任重而道遠。
重要的事情再說一遍,Robust熱更新系統開源啦!還包括補丁自動化自動化工具喲!Github倉庫地址: https://github.com/Meituan-Dianping/Robust 。歡迎大家共建和反饋。
作者簡介
吳坤,美團點評平臺技術部 Android技術專家。2015年加入美團,先后負責客戶端安全組件、美團App動態化等項目。目前做為美團平臺Android基礎設施組負責人,主導并推廣Robust熱更新系統。
張夢,2015年校招進入美團,前期負責美團App諸多底層SDK的開發和維護工作,目前重點負責Robust熱更新系統的兩大插件:插樁埋點以及補丁自動化相關工作。
定旭,美團點評平臺技術部Android資深工程師,2015年加入原美團,先后負責了設備唯一標識SDK等基礎組件的維護工作,目前主要致力于Robust熱更新系統的開發工作。
美團平臺技術部客戶端技術團隊,負責美團平臺的基礎業務和移動基礎設施的開發工作。基于海量用戶的美團平臺,支撐了美團點評多條業務線的快速發展。同時,我們也在移動開發技術方面做了一些積極的探索,在動態化、質量保障、開發模型等方面有一定積累。客戶端技術團隊積極采用開源技術的同時,也把我們的一些積累回饋給開源社區,希望跟業界一起推動移動開發效率、質量的提升。
來自:http://tech.meituan.com/android_autopatch.html