1. 程式人生 > >Android熱更新方案Robust

Android熱更新方案Robust

美團•大眾點評是中國最大的O2O交易平臺,目前已擁有近6億使用者,合作各類商戶達432萬,訂單峰值突破1150萬單。美團App是平臺主要的入口之一,O2O交易場景的複雜性決定了App穩定性要達到近乎苛刻的要求。使用者到店消費買優惠券時死活下不了單,定外賣一個明顯可用的紅包怎麼點也選不中,上了一個新活動使用者一點就Crash……過去發生過的這些畫面太美不敢想象。客戶端相對Web版最大的短板就是有發版的概念,對線上事故很難有即時生效的解決方式,每次發版都如臨深淵如履薄冰,畢竟就算再完善的開發測試流程也無法保證不會將Bug帶到線上。
從去年開始,Android平臺出現了一些優秀的熱更新方案,主要可以分為兩類:一類是基於multidex的熱更新框架,包括Nuwa、Tinker等;另一類就是native hook方案,如阿里開源的Andfix和Dexposed。這樣客戶端也有了實時修復線上問題的可能。但經過調研之後,我們發現上述方案或多或少都有一些問題,基於native hook的方案:需要針對dalvik虛擬機器和art虛擬機器做適配,需要考慮指令集的相容問題,需要native程式碼支援,相容性上會有一定的影響;基於Multidex的方案,需要反射更改DexElements,改變Dex的載入順序,這使得patch需要在下次啟動時才能生效,實時性就受到了影響,同時這種方案在android N [speed-profile]編譯模式下可能會有問題,可以參考

Android N混合編譯與對熱補丁影響解析。考慮到美團Android使用者機型分佈的碎片化,很難有一個方案能覆蓋所有機型。
去年底的Android Dev Summit上,Google高調發布了Android Studio 2.0,其中最重要的新特性Instant Run,實現了對程式碼修改的實時生效(熱插拔)。我們在瞭解Instant Run原理之後,實現了一個相容性更強的熱更新方案,這就是產品化的hotpatch框架--Robust。

原理

Robust外掛對每個產品程式碼的每個函式都在編譯打包階段自動的插入了一段程式碼,插入過程對業務開發是完全透明。如State.java的getIndex函式:

public long getIndex() {
        return 100;
    }

被處理成如下的實現:

public static ChangeQuickRedirect changeQuickRedirect;
    public long getIndex() {
        if(changeQuickRedirect != null) {
            //PatchProxy中封裝了獲取當前className和methodName的邏輯,並在其內部最終呼叫了changeQuickRedirect的對應函式
            if(PatchProxy.isSupport(new
Object[0], this, changeQuickRedirect, false)) { return ((Long)PatchProxy.accessDispatch(new Object[0], this, changeQuickRedirect, false)).longValue(); } } return 100L; }

可以看到Robust為每個class增加了個型別為ChangeQuickRedirect的靜態成員,而在每個方法前都插入了使用changeQuickRedirect相關的邏輯,當 changeQuickRedirect不為null時,可能會執行到accessDispatch從而替換掉之前老的邏輯,達到fix的目的。
如果需將getIndex函式的返回值改為return 106,那麼對應生成的patch,主要包含兩個class:PatchesInfoImpl.java和StatePatch.java。
PatchesInfoImpl.java:

public class PatchesInfoImpl implements PatchesInfo {
    public List<PatchedClassInfo> getPatchedClassesInfo() {
        List<PatchedClassInfo> patchedClassesInfos = new ArrayList<PatchedClassInfo>();
        PatchedClassInfo patchedClass = new PatchedClassInfo("com.meituan.sample.d", StatePatch.class.getCanonicalName());
        patchedClassesInfos.add(patchedClass);
        return patchedClassesInfos;
    }
}

StatePatch.java:

public class StatePatch implements ChangeQuickRedirect {
    @Override
    public Object accessDispatch(String methodSignature, Object[] paramArrayOfObject) {
        String[] signature = methodSignature.split(":");
        if (TextUtils.equals(signature[1], "a")) {//long getIndex() -> a
            return 106;
        }
        return null;
    }

    @Override
    public boolean isSupport(String methodSignature, Object[] paramArrayOfObject) {
        String[] signature = methodSignature.split(":");
        if (TextUtils.equals(signature[1], "a")) {//long getIndex() -> a
            return true;
        }
        return false;
    }
}

客戶端拿到含有PatchesInfoImpl.java和StatePatch.java的patch.dex後,用DexClassLoader載入patch.dex,反射拿到PatchesInfoImpl.java這個class。拿到後,建立這個class的一個物件。然後通過這個物件的getPatchedClassesInfo函式,知道需要patch的class為com.meituan.sample.d(com.meituan.sample.State混淆後的名字),再反射得到當前執行環境中的com.meituan.sample.d class,將其中的changeQuickRedirect欄位賦值為用patch.dex中的StatePatch.java這個class new出來的物件。這就是打patch的主要過程。通過原理分析,其實Robust只是在正常的使用DexClassLoader,所以可以說這套框架是沒有相容性問題的。

大體流程如下:

外掛的問題

OK,到這裡Robust原理就介紹完了。很簡單是不是?而且sample這個例子中也驗證成功了。難道一切這麼順利?其實現實並不是這樣,我們將這套實現用到美團的主App時,問題出現了:

Conversion to Dalvik format failed:Unable to execute dex: method ID not in [0, 0xffff]: 65536

居然不能打出包來了!從原理上分析,除了引入的patch過程aar外,我們這套實現是不會增加別的方法的,而且引入的那個aar的方法才100個左右,怎麼會造成美團的mainDex超過65536呢?進一步分析,我們一共處理7萬多個函式,導致最後方法數總共增加7661個。這是為什麼呢?

看下patch前後的dex對比:

針對com.meituan.android.order.adapter.OrderCenterListAdapter.java分析一下,發現進行hotpatch之後增加了如下6個方法:

public boolean isEditMode() {
        return isEditMode;
    }
private int incrementDelCount() {
        return delCount.incrementAndGet();
    }
private boolean isNeedDisplayRemainingTime(OrderData orderData) {
        return null != orderData.remindtime && getRemainingTimeMillis(orderData.remindtime) > 0;
    }
private boolean isNeedDisplayUnclickableButton(OrderData orderData) {
        return null != orderData.remindtime && getRemainingTimeMillis(orderData.remindtime) <= 0;
    }
private boolean isNeedDisplayExpiring(boolean expiring) {
        return expiring && isNeedDisplayExpiring;
    }
private View getViewByTemplate(int template, View convertView, ViewGroup parent) {
        View view = null;
        switch (template) {
            case TEMPLATE_DEFALUT:
            default:
                view = mInflater.inflate(R.layout.order_center_list_item, null);
        }
        return view;
    }

但是這些多出來的函式其實就在原來的產品程式碼中,為什麼沒有Robust的情況下不見了,而使用了外掛後又出現在最終的class中了呢?只有一個可能,就是ProGuard的內聯受到了影響。使用了Robust外掛後,原來能被ProGuard內聯的函式不能被內聯了。看了下ProGuard的Optimizer.java的相關片段:

if (methodInliningUnique) {
    // Inline methods that are only invoked once.
    programClassPool.classesAccept(
        new AllMethodVisitor(
        new AllAttributeVisitor(
        new MethodInliner(configuration.microEdition,
                          configuration.allowAccessModification,
                          true,
                          methodInliningUniqueCounter))));
}
if (methodInliningShort) {
    // Inline short methods.
    programClassPool.classesAccept(
        new AllMethodVisitor(
        new AllAttributeVisitor(
        new MethodInliner(configuration.microEdition,
                          configuration.allowAccessModification,
                          false,
                          methodInliningShortCounter))));
}

通過註釋可以看出,如果只被呼叫一次或者足夠小的函式,都可能被內聯。深入分析程式碼,我們發現確實如此,只被呼叫了一次的私有函式、只有一行函式體的函式(比如get、set函式等)都極可能內聯。前面com.meituan.android.order.adapter.OrderCenterListAdapter.java多出的那6個函式也證明了這一點。知道原因了就能有解決問題的思路。
其實仔細思考下,那些可能被內聯的只有一行函式體的函式,真的有被外掛處理的必要嗎?別說一行程式碼的函數出問題的可能性小,就算出問題了也可以通過patch內聯它的那個函式來解決問題,或者patch這一行程式碼呼叫的那個函式。只調用了一次的函式其實是一樣的。所以通過分析,這樣的函式其實是可以不被外掛處理的。那麼有了這個認識,我們對外掛做了處理函式的判斷,跳過被ProGuard內聯可能性比較大的函式。重新在團購試了一次,這次apk順利的打包出來了。通過對打出來apk中的dex做分析,發現優化後的外掛還是影響了內聯效果,不過只導致方法數增加了不到1000個,所以算是臨時簡單的解決了這個問題。

影響

原理上,Robust是為每個函式都插入了一段邏輯,為每個class插入了ChangeQuickRedirect的欄位,所以最終肯定會增加apk的體積。以美團主App為例,平均一個函式會比原來增加17.47個位元組,整個App中我們一共處理了6萬多個函式,導致包大小由原來的19.71M增加到了20.73M。有些class沒有必要新增ChangeQuickRedirect欄位,以後可以通過將這些class過濾掉的方式來做優化。
Robust在每個方法前都加上了額外的邏輯,那對效能上有什麼影響呢?

從圖中可以看到,對一個只有記憶體運算的函式,處理前後分別執行10萬次的時間增加了128ms。這是在華為4A上的測試結果。
對啟動速度上的影響:

在同一個機器上的結果,處理前後的啟動時間相差了5ms。

補丁的問題

再來看看補丁本身。要製作出補丁,我們可能會面臨如下兩個問題:

1. 如何解決混淆問題?
2. 被補的函式中使用了super相關的呼叫怎麼辦?

其實混淆的問題比較好處理。先針對混淆前的程式碼生成patch.class,然後利用生成release包時對應的mapping檔案中的class的對映關係,對patch.class做字串上的處理,讓它使用線上執行環境中混淆的class。
被補的函式中使用了super相關的呼叫怎麼辦?比如某個Activity的onCreate方法中需要呼叫super.onCreate,而現在這個bad.Class的badMethod就是這個Activity的onCreate方法,那麼在patched.class的patchedMethod中如何通過這個Activity的物件,呼叫它父類的onCreate方法呢?通過分析Instant Run對這個問題的處理,發現它是在每個class中都添加了一個代理函式,專門來處理super的問題的。為每個class都增加一個函式無疑會增加總的方法數,這樣做肯定會遇到65536這個問題。所以直接使用Instant Run的做法顯然是不可取的。
在Java中super是個關鍵字,也無法通過別的物件來訪問到。看來,想直接在patched.java程式碼中通過Activity的物件呼叫到它父類的onCreate方法有點不太可能了。不過通過對class檔案做分析,發現普通的函式呼叫是使用JVM指令集的invokevirtual指令,而super.onCreate的呼叫使用的是invokesuper指令。那是不是將class檔案中這個呼叫的指令改為invokesuper就好了?看如下的例子:
產品程式碼SuperClass.java:

public class SuperClass {
    String uuid;
    public void setUuid(String id) {
        uuid = id;
    }
    public void thisIsSuper() {
        Log.d("SuperClass", "thisIsSuper "+uuid);
    }
}

產品程式碼TestSuperClass.java:

public class TestSuperClass extends SuperClass{
    String subUuid;
    public void setSubUuid(String id) {
        subUuid = id;
    }

    @Override
    public void thisIsSuper() {
        Log.d("TestSuperClass", "thisIsSuper no call");
    }
}

TestSuperPatch.java是DexClassLoader將要載入的程式碼:

public class TestSuperPatch {
    public static void testSuperCall() {
        TestSuperClass testSuperClass = new TestSuperClass();
        String t = UUID.randomUUID().toString();
        Log.d("TestSuperPatch", "UUID " + t);
        testSuperClass.setUuid(t);
        testSuperClass.thisIsSuper();
    }
}

對TestSuperPatch.class的testSuperClass.thisIsSuper()呼叫做invokesuper的替換,並且將invokesuper的呼叫作用在testSuperClass這個物件上,然後載入執行:

Caused by: java.lang.NoSuchMethodError: No super method thisIsSuper()V in class Lcom/meituan/sample/TestSuperClass; or its super classes (declaration of 'com.meituan.sample.TestSuperClass' appears in /data/app/com.meituan.robust.sample-3/base.apk)

報錯資訊說在TestSuperClass和TestSuperClass的父類中沒有找到thisIsSuper()V函式!但是實際上TestSuperClass和父類中是存在thisIsSuper()V函式的,而且通過apk反編譯看也確實存在的,那怎麼就找不到呢?分析invokesuper指令的實現,發現系統會在執行指令所在的class的父類中去找需要呼叫的方法,所以要將TestSuperPatch跟TestSuperClass一樣作為SuperClass的子類。修改如下:

public class TestSuperPatch extends SuperClass {
    ...
}

然後再做一次嘗試:

08-11 09:12:03.012 1787-1787/? D/TestSuperPatch: UUID c5216480-5c3a-4990-896d-58c3696170c5
08-11 09:12:03.012 1787-1787/? D/SuperClass: thisIsSuper c5216480-5c3a-4990-896d-58c3696170c5

看一下testSuperCall的實現,將UUID.randomUUID().toString()的結果,通過setUuid賦值給了testSuperClass這個物件的父類的uuid欄位。從日誌可以看出,對testSuperClass.thisIsSuper處理後,確實是呼叫到了testSuperClass這個物件的super的thisIsSuper函式。OK,super的問題看來解決了,而且這種方式不會增加方法數。

上線後的效果

Robust 靠譜嗎?

嘗試修個線上的問題,我們是在07.14下午17:00多的時候上線的補丁,我們可以看到接下來的幾天一直到07.17號將補丁下線,這個線上問題得到了明顯的修復,補丁下線後看到07.18號這個問題又明顯上升了。直到07.18號下班前又重新上線補丁。

補丁的相容性和成功率如何?通過以上的理論分析,可以看到這套實現基本沒有相容性問題,實際上線的資料如下:

先簡單解釋下這幾個指標:
補丁列表拉取成功率=拉取補丁列表成功的使用者/嘗試拉取補丁列表的使用者
補丁下載成功率=下載補丁成功的使用者/補丁列表拉取成功的使用者
patch應用成功率=patch成功的使用者/補丁下載成功的使用者

通過這個表能夠看出,我們的patch資訊拉取的成功最低,平均97%多,這是因為實際的網路原因,而下載成功後的patch成功率是一直在99.8%以上。而且我們做的是無差別下發,服務端沒有做任何針對機型版本的過濾,線上的結果再次證明了Robust的高相容性。

總結

目前業界已有的Android App熱更新方案,包括Multidesk和native hook兩類,都存在一些相容性問題。為此我們借鑑Instant Run原理,實現了一個相容性更強的熱更新方案--Robust。Robust除了高相容性之外,還有實時生效的優勢。so和資源的替換目前暫時未做實現,但是從框架上來說未來是完全有能力支援的。當然,這套方案雖然對開發者是透明的,但畢竟在編譯階段有外掛侵入了產品程式碼,對執行效率、方法數、包體積還是產生了一些副作用。這也是我們下一步努力的方向。

參考文獻


https://tech.meituan.com/android_robust.html