天天看點

Android 熱修複方案分析

絕大部分的APP項目其實都需要一個動态化方案,來應對線上緊急bug修複發新版本的高成本.之前有利用加殼,分拆兩個dex結合DexClassLoader實作了一套全量更新的熱更方案.實作原理在Android 基于Proxy/Delegate 實作bug熱修複這篇部落格中有分解.因為這套方案是在Java端實作,并且是全量更新是以相容性較好,成功率較高.但是線上上跑了幾個月之後就碰到了瓶頸,因為随着業務的增長分拆過之後的dex檔案方法數也超過65535個,更換拆包方案的話維護成本太高.同時由于沒有做差異diff,就帶來了patch包過大,備援多等缺點.正好微信的動态化方案Tinker也開源了,就趁這個機會先把市面上主流的熱更方案彙總分析下,再選一個方向深入研究一個盡量兼并相容性擴充性及時性的方案.

Github 相關資料分析

先統計下github上幾個star比較多的開源熱更方案,資料為2016年11月3号采集的,僅供參考.從非技術的角度來分析下表的資料,根據開源時間到最近commit時間、commit數量、issues的關閉率和Release版本數都可以看出這幾個項目目前的維護情況.還有Wiki相關文檔的支援.怎麼看Tinker現在都是一副很生猛的架勢.而阿裡百川的商業化Hotfix現在還在公測,方式用的是Andfix,把熱更做成一個商業化的功能,就不清楚Andfix以後在github上的維護情況了,但是同時也證明了Andfix的價值.而Dexposed一直沒有相容ART,這裡就先不詳細分析了.

2016/11/11 Andfix Dexposed Nuwa Tinker
來源 支付寶 淘寶 微信
開源時間 2015/9/5 2015/3/16 2015/11/3 2016/9/21
star數 4560 3245 2429 5515
commit數 49 77 14 72
最近送出時間 2016/10/28 2015/10/21 2015/11/14 2016/11/1
issues(open/closed) 171/104 32/37 61/31 8/142
Release版本數 1 8
文檔支援

實作原理

  • Andfix

Andfix實作熱更的核心方法是在JNI中動态hook替換目标方法,來達到即時修複bug的目的.而替換的方法則是由源apk檔案和修改過的apk檔案的dex做diff,反編譯更新檔包工具apkpatch可以看到兩個dex周遊做diff的過程.

public DiffInfo diff(File newFile, File oldFile) throws IOException {
    DexBackedDexFile newDexFile = DexFileFactory.loadDexFile(newFile, , true);
    DexBackedDexFile oldDexFile = DexFileFactory.loadDexFile(oldFile, , true);
    DiffInfo info = DiffInfo.getInstance();
    boolean contains = false;
    for(Iterator iterator = newDexFile.getClasses().iterator(); iterator.hasNext();)
    {
        DexBackedClassDef newClazz = (DexBackedClassDef)iterator.next();
        Set oldclasses = oldDexFile.getClasses();
        for(Iterator iterator1 = oldclasses.iterator(); iterator1.hasNext();)
        {
            DexBackedClassDef oldClazz = (DexBackedClassDef)iterator1.next();
            if(newClazz.equals(oldClazz))
            {
                compareField(newClazz, oldClazz, info);
                compareMethod(newClazz, oldClazz, info);
                contains = true;
                break;
            }
        }

        if(!contains)
            info.addAddedClasses(newClazz);
    }

    return info;
}
           

周遊出修改過的方法加上一個MethodReplace的注解(包含要替換的目标類和目标方法),生成一個diff dex,再簽上名更名為.apatch的更新檔包通過更新的方式分發的各個終端處.通過反編譯中間diff dex可以看到更新檔檔案中對fix method的描述.

@MethodReplace(clazz="com.networkbench.agent.impl.NBSAgent", method="getBuildId")
public static String getBuildId() {
    return "6f3d1afc-d890-47c2-8ebe-76dc6c53050c";
}
           

終端在效驗過更新檔包的合法性後,則把更新檔包中帶有

MethodReplace

注解的方法周遊出來,根據注解中的目标方法配置,将old method利用classloader加載進記憶體,然後交給JNI去替換old method.

private void fixClass(Class<?> clazz, ClassLoader classLoader) {
      Method[] methods = clazz.getDeclaredMethods();
      MethodReplace methodReplace;
      String clz;
      String meth;
      for (Method method : methods) {
           methodReplace = method.getAnnotation(MethodReplace.class);
           if (methodReplace == null)
                continue;
           clz = methodReplace.clazz();
           meth = methodReplace.method();
           if (!isEmpty(clz) && !isEmpty(meth)) {
                replaceMethod(classLoader, clz, meth, method);
           }
      }
 }

 private void replaceMethod(ClassLoader classLoader, String clz,
           String meth, Method method) {
      try {
           String key = clz + "@" + classLoader.toString();
           Class<?> clazz = mFixedClass.get(key);
           if (clazz == null) {// class not load
                Class<?> clzz = classLoader.loadClass(clz);
                // initialize target class
                clazz = AndFix.initTargetClass(clzz);
           }
           if (clazz != null) {// initialize class OK
                mFixedClass.put(key, clazz);
                Method src = clazz.getDeclaredMethod(meth,
                          method.getParameterTypes());
                AndFix.addReplaceMethod(src, method);
           }
      } catch (Exception e) {
           Log.e(TAG, "replaceMethod", e);
      }
 }
           

在Andfix.app中可以看到JNI中

replaceMethod

方法,由于從Lolipop開始Android放棄使用dalvik轉向android runtime,是以Andfix也要區分不同的平台進行替換.像Dexposed到目前為止都沒有做ART的相容.

static void replaceMethod(JNIEnv* env, jclass clazz, jobject src,
          jobject dest) {
     if (isArt) {
          art_replaceMethod(env, src, dest);
     } else {
          dalvik_replaceMethod(env, src, dest);
     }
}
           
extern void __attribute__ ((visibility ("hidden"))) dalvik_replaceMethod(
          JNIEnv* env, jobject src, jobject dest) {
     jobject clazz = env->CallObjectMethod(dest, jClassMethod);
     ClassObject* clz = (ClassObject*) dvmDecodeIndirectRef_fnPtr(
               dvmThreadSelf_fnPtr(), clazz);
     clz->status = CLASS_INITIALIZED;

     Method* meth = (Method*) env->FromReflectedMethod(src);
     Method* target = (Method*) env->FromReflectedMethod(dest);
     LOGD("dalvikMethod: %s", meth->name);

     meth->accessFlags |= ACC_PUBLIC;
     meth->methodIndex = target->methodIndex;
     meth->jniArgInfo = target->jniArgInfo;
     meth->registersSize = target->registersSize;
     meth->outsSize = target->outsSize;
     meth->insSize = target->insSize;

     meth->prototype = target->prototype;
     meth->insns = target->insns;
     meth->nativeFunc = target->nativeFunc;
}
           

由于相容問題在ART的

replaceMethod

方法中對每一個不同的系統版本進行區分,分别實作.

extern void __attribute__ ((visibility ("hidden"))) art_replaceMethod(
          JNIEnv* env, jobject src, jobject dest) {
    if (apilevel > ) {
        replace_7_0(env, src, dest);
    } else if (apilevel > ) {
          replace_6_0(env, src, dest);
     } else if (apilevel > ) {
          replace_5_1(env, src, dest);
     } else if (apilevel > ) {
          replace_5_0(env, src, dest);
    }else{
        replace_4_4(env, src, dest);
    }
}
           

因為Andfix的方案是在native替換方法,是以穩定性和相容性就是差一些.就Andfix開源項目來說在實際接入的過程中發現對multi dex支援不友好,還需要修改更新檔包生成工具apkpatch,并且apkpatch開源得也不友好,修複靜态方法有問題.

  • Nuwa

由于Qzone隻是分享了實作原理,并沒有開源出來.而Nuwa是參考Qzone的實作方式開源的一套方案,這裡就主要分析Nuwa了.Nuwa的修複流程并不複雜,不像Andfix需要在JNI中進行方法替換.在Application中的

attachBaseContext

方法中對Nuwa進行初始化,先将asset路徑下的hack.apk複制到指定位置,然後以加載更新檔的方式加載hack.apk至于這個hack.apk的作用下面會講.

public static void init(Context context) {
    File dexDir = new File(context.getFilesDir(), DEX_DIR);
    dexDir.mkdir();

    String dexPath = null;
    try {
        dexPath = AssetUtils.copyAsset(context, HACK_DEX, dexDir);
    } catch (IOException e) {
        Log.e(TAG, "copy " + HACK_DEX + " failed");
        e.printStackTrace();
    }

    loadPatch(context, dexPath);
}
           

加載更新檔的方法主要的作用是把更新檔dex通過反射加載到

dexElements

數組的最前端。因為Classloader在findClass的時候是按順序周遊dexElements(dex數組),隻要dexElement中有該class就加載并停止周遊.是以利用Classloader的這種特性把更新檔包插入dexElements的首位,系統在findClass的時候就優先拿到更新檔包中的class,達到修複bug的目的.

public static void loadPatch(Context context, String dexPath) {

    if (context == null) {
        Log.e(TAG, "context is null");
        return;
    }
    if (!new File(dexPath).exists()) {
        Log.e(TAG, dexPath + " is null");
        return;
    }
    File dexOptDir = new File(context.getFilesDir(), DEX_OPT_DIR);
    dexOptDir.mkdir();
    try {
        DexUtils.injectDexAtFirst(dexPath, dexOptDir.getAbsolutePath());
    } catch (Exception e) {
        Log.e(TAG, "inject " + dexPath + " failed");
        e.printStackTrace();
    }
}

   public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
    DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
    Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
    Object newDexElements = getDexElements(getPathList(dexClassLoader));
    Object allDexElements = combineArray(newDexElements, baseDexElements);
    Object pathList = getPathList(getPathClassLoader());
    ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", allDexElements);
}
           

如果隻是把更新檔包插入dexElements的首位然後運作就會有一個異常 java.lang.IllegaAccessError:Class ref in pre-verified class resoved to unexpected implementation 造成這個異常的原因是因為更新檔包中的類和與其有關聯的類不在同一個dex檔案中.跟蹤這個異常,定位到Android源碼中的Resolve.cpp 中的

dvmResolveClass

方法,可以看到隻要滿足最外層

(!fromUnverifiedConstant && IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED))

的條件就會抛出pre-verified的異常.Qzone就是從

CLASS_ISPREVERIFIED

标記入手, 想辦法讓Class不打上

CLASS_ISPREVERIFIED

标簽.

ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx,
    bool fromUnverifiedConstant)
{
    ...
    ...
        if (!fromUnverifiedConstant &&
            IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED))
        {
            ClassObject* resClassCheck = resClass;
            if (dvmIsArrayClass(resClassCheck))
                resClassCheck = resClassCheck->elementClass;

            if (referrer->pDvmDex != resClassCheck->pDvmDex &&
                resClassCheck->classLoader != NULL)
            {
                ALOGW("Class resolved by unexpected DEX:"
                     " %s(%p):%p ref [%s] %s(%p):%p",
                    referrer->descriptor, referrer->classLoader,
                    referrer->pDvmDex,
                    resClass->descriptor, resClassCheck->descriptor,
                    resClassCheck->classLoader, resClassCheck->pDvmDex);
                ALOGW("(%s had used a different %s during pre-verification)",
                    referrer->descriptor, resClass->descriptor);
                dvmThrowIllegalAccessError(
                    "Class ref in pre-verified class resolved to unexpected "
                    "implementation");
                return NULL;
            }
        }
    ...
    ...
    return resClass;
}
           

Qzone根據dexopt的過程中(DexPrepare.cpp -> verifyAndOptimizeClass)如果

dvmVerifyClass

傳回true了,就會給class标記上

CLASS_ISPREVERIFIED

.是以我們要確定dvmVerifyClass傳回false, 隻要不被打上

CLASS_ISPREVERIFIED

标記,就不會觸發上述的異常.

/*
* Verify and/or optimize a specific class.
*/
static void verifyAndOptimizeClass(DexFile* pDexFile, ClassObject* clazz,
    const DexClassDef* pClassDef, bool doVerify, bool doOpt)
{
    ...
    ...

    /*
     * First, try to verify it.
     */
    if (doVerify) {
        if (dvmVerifyClass(clazz)) {
            /*
             * Set the "is preverified" flag in the DexClassDef.  We
             * do it here, rather than in the ClassObject structure,
             * because the DexClassDef is part of the odex file.
             */
            assert((clazz->accessFlags & JAVA_FLAGS_MASK) ==
                pClassDef->accessFlags);
            ((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISPREVERIFIED;
            verified = true;
        } else {
            // TODO: log when in verbose mode
            ALOGV("DexOpt: '%s' failed verification", classDescriptor);
        }
    }
    ...
    ...
}
           

為了能讓

dvmVerifyClass

傳回false,我們繼續跟蹤這個方法(DexVerify.app -> dvmVerifyClass).首先是過濾重複驗證,由于更新檔包加載之前是沒有做過驗證的,是以這個條件可以直接忽略.接下來是周遊clazz的

directMethods

(包含構造,靜态,私有方法)和

virtualMethods

,隻要這兩個數組中的方法存在有關聯的對象跨dex檔案的情況就可以讓

dvmVerifyClas

s傳回false.

/*
* Verify a class.
*
* By the time we get here, the value of gDvm.classVerifyMode should already
* have been factored in.  If you want to call into the verifier even
* though verification is disabled, that's your business.
*
* Returns "true" on success.
*/
bool dvmVerifyClass(ClassObject* clazz)
{
    int i;

    if (dvmIsClassVerified(clazz)) {
        ALOGD("Ignoring duplicate verify attempt on %s", clazz->descriptor);
        return true;
    }

    for (i = ; i < clazz->directMethodCount; i++) {
        if (!verifyMethod(&clazz->directMethods[i])) {
            LOG_VFY("Verifier rejected class %s", clazz->descriptor);
            return false;
        }
    }
    for (i = ; i < clazz->virtualMethodCount; i++) {
        if (!verifyMethod(&clazz->virtualMethods[i])) {
            LOG_VFY("Verifier rejected class %s", clazz->descriptor);
            return false;
        }
    }

    return true;
}
           

Qzone給出的方案是在gradle插件中對除了Application子類之外的所有類(包含Jar包中的)的構造方法裡面通過ASM動态注入一個獨立dex中Class的引用,這樣這些類就不會被打上

CLASS_ISPREVERIFIED

,就可以對其進行熱更.把Application排除之外是因為這套方案是在Application中加載dex,Application啟動的時候是找不到這個dex中的clazz的.

同時gradle插件周遊目标class檔案,計算出hash值,再與要修複版本的hash.text中的hash值進行比對,發生變化的hash就是這次更新檔修改的檔案,把這些class彙總起來一起打包為dex,再簽名打包為jar包分發到終端上.

在dalvik中因為把除了Application子類之外所有的類都消除了pre-verify,導緻在加載Class之後會做一次verify和opt帶來一定的性能損耗,騰訊團隊做過測試加載700個50行的Class,加載速度Qzone方案是正常方案的8倍(685, 84ms),啟動速度是1.5倍(7.2, 4.9s).在ART中雖然沒有性能影響,但是由于記憶體位址錯亂的問題需要把修改部分相關的Class,父類以及引用該Class的所有相關Class都要打進更新檔包中,造成更新檔包體積大量增加的問題.

目前Nuwa比較大的坑有兩點,一點是不支援1.2.3以上的gralde版本,一點是混淆之後位元組碼注入失敗.聊聊Android 熱修複Nuwa有哪些坑這篇文章就Nuwa的坑給出了解決思路和方案.

  • Tinker

Tinker是微信在今年九月下旬開源出來的Android熱更新檔方案.Tinker開源之後的熱度,維護程度,文檔等狀态都是比較良心的,目前已經release八個版本出來了.并且支援代碼,so和資源更新,在熱修複這種坑比較多的技術方案中,開源作者能活躍在第一線會給開發者帶來很大的幫助.

Tinker的實作原理其實跟Qzone的思路是類似的,是以這裡就簡單介紹一下Tinker和Qzone方案的差别,後續會詳細分析Tinker.

核心的差別是

  1. Tinker使用全量更新,避免了擦除

    CLASS_ISPREVERIFIED

    标記帶來的性能損耗.
  2. Dexdiff基于Dex檔案結構下手做差分包,來減少更新檔dex的體積.再全平台合成.
  3. 支援so和資源的更新.

總結

摘抄Tinker對幾種方案的彙總

Tinker QZone AndFix
類替換 yes yes
So替換 yes no
資源替換 yes yes
全平台支援 yes yes
即時生效 no no
性能損耗 較小 較大
更新檔包大小 較小 較大
開發透明 yes yes
複雜度 較低 較低
gradle支援 yes no
Rom體積 Dalvik較大 較小
成功率 較高 較高
  1. AndFix作為native解決方案,首先面臨的是穩定性與相容性問題,更重要的是它無法實作類替換,它是需要大量額外的開發成本的;
  2. Qzone方案可以做到釋出産品功能,但是它主要問題是插樁帶來Dalvik的性能問題,以及為了解決Art下記憶體位址問題而導緻更新檔包急速增大的。

轉載請注明出處:http://blog.csdn.net/l2show/article/details/53129564