首页 > 代码库 > Android 热修复方案分析

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版本数 0 1 0 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, 19, true);
        DexBackedDexFile oldDexFile = DexFileFactory.loadDexFile(oldFile, 19, 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 = http://www.mamicode.com/clazz.getDeclaredMethod(meth,"hljs-keyword">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 > 23) {
        replace_7_0(env, src, dest);
    } else if (apilevel > 22) {
          replace_6_0(env, src, dest);
     } else if (apilevel > 21) {
          replace_5_1(env, src, dest);
     } else if (apilevel > 19) {
          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文件的情况就可以让dvmVerifyClass返回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 = 0; i < clazz->directMethodCount; i++) {
        if (!verifyMethod(&clazz->directMethods[i])) {
            LOG_VFY("Verifier rejected class %s", clazz->descriptor);
            return false;
        }
    }
    for (i = 0; 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

<script type="text/javascript"> $(function () { $(‘pre.prettyprint code‘).each(function () { var lines = $(this).text().split(‘\n‘).length; var $numbering = $(‘
    ‘).addClass(‘pre-numbering‘).hide(); $(this).addClass(‘has-numbering‘).parent().append($numbering); for (i = 1; i <= lines; i++) { $numbering.append($(‘
  • ‘).text(i)); }; $numbering.fadeIn(1700); }); }); </script>

    Android 热修复方案分析