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 = 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 > 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

时间: 2024-11-03 03:45:35

Android 热修复方案分析的相关文章

Android热修复技术专题:来自微信、淘宝、支付宝、QQ空间的热修复方案

最近好多人都讨论关于热更新的话题,所以查询了一些资料看看 当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App.测试.向各个应用市场和渠道换包.提示用户升级.用户下载.覆盖安装.有时候仅仅是为了修改了一行代码,也要付出巨大的成本进行换包和重新发布. 这时候就提出一个问题:有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装?答案当然是有的,那就是最近涌现出来得热补丁方案,主要包括淘宝的Dexpo

Android热修复:Andfix和Hotfix,两种方案的比较与实现

Andfix和hotfix是两种android热修复框架. android的热修复技术我看的最早的应该是QQ空间团队的解决方案,后来真正需要了,才仔细调查,现在的方案中,阿里有两种Dexposed和Andfix框架,由于前一种不支持5.0以上android系统,所以阿里系的方案我们就看Andfix就好.Hotfix框架算是对上文提到的QQ空间团队理论实现.本文旨在写实现方案,捎带原理. Andfix 引入 框架官网:https://github.com/alibaba/AndFix 介绍是用英文

Android 热修复 Tinker 源码分析之DexDiff / DexPatch

在上一篇文章中,我们介绍了Android 热修复 Tinker接入及源码浅析,里面包含了热修的一些背景知识,从tinker对dex文件的处理来看,源码大体上可以分为3部分阅读: 在应用中对patch的合并与加载,已经在上篇文章中详细介绍过了Android 热修复 Tinker接入及源码浅析 详细的dex patch,dex diff算法 tinker gradle plugin相关知识 tinker有个非常大的亮点就是自研发了一套dex diff.patch相关算法.本篇文章主要目的就是分析该算

Android热修复原理普及

Android热修复原理普及 这段时间比较难闲,就抽空研究一下Android热修复的原理.自从Android热修复这项技术出现之后,随之而现的是多种热修复方案的出现.前两天又看到一篇文章分析了几种热修复方案的比较. 原文地址是:[Android热修复] 技术方案的选型与验证 看完这篇文章,有点汗颜.有这么多的热修复方案,并且他们之间的实现原理也不一样,各有优缺点. 然后在尼古拉斯_赵四的博客中看到几篇关于热修复的文章,对着这几篇文章撸了一番.大概的了解了热修复一种原理,其思路和QQ空间提出的安卓

Android热修复学习之旅——HotFix完全解析

在上一篇博客Android热修复学习之旅开篇--热修复概述中,简单介绍了各个热修复框架的原理,本篇博客我将详细分析QQ空间热修复方案. Android dex分包原理介绍 QQ空间热修复方案基于Android dex分包基础之上,简单概述android dex分包的原理就是:就是把多个dex文件塞入到app的classloader之中,但是android dex拆包方案中的类是没有重复的,如果classes.dex和classes1.dex中有重复的类,当classes.dex和classes1

Android 热修复Nuwa的原理及Gradle插件源码解析

现在,热修复的具体实现方案开源的也有很多,原理也大同小异,本篇文章以Nuwa为例,深入剖析. Nuwa的github地址 https://github.com/jasonross/Nuwa 以及用于hotpatch生成的gradle插件地址 https://github.com/jasonross/NuwaGradle 而Nuwa的具体实现是根据QQ空间的热修复方案来实现的.安卓App热补丁动态修复技术介绍.在阅读本篇文章之前,请先阅读该文章. 从QQ空间终端开发团队的文章中可以总结出要进行热更

Android热修复技术选型

2015年以来,Android开发领域里对热修复技术的讨论和分享越来越多,同时也出现了一些不同的解决方案,如QQ空间补丁方案.阿里AndFix以及微信Tinker,它们在原理各有不同,适用场景各异,到底采用哪种方案,是开发者比较头疼的问题.本文希望通过介绍QQ空间补丁.Tinker以及基于AndFix的阿里百川HotFix技术的原理分析和横向比较,帮助开发者更深入了解热修复方案. 技术背景 一.正常开发流程 从流程来看,传统的开发流程存在很多弊端: 重新发布版本代价太大 用户下载安装成本太高 B

全面了解Android热修复技术

WeTest 导读 本文探讨了Android热修复技术的发展脉络,现状及其未来. 热修复技术概述 热修复技术在近年来飞速发展,尤其是在InstantRun方案推出之后,各种热修复技术竞相涌现.国内大部分成熟的主流APP都拥有自己的热修复技术,像手淘.支付宝.QQ.饿了么.美团等等. 目前能搜集到的资料,大多简单罗列每个方案的特点并进行横向比较,而其中技术发展的脉络往往被掩盖了.热修复技术从何而来,又将往何处去?在这些资料中都找不到答案. 我认为,走马观花地看一遍各家的热修复方案并不能找到答案,所

Android热修复技术选型——三大流派解析

2015年以来,Android开发领域里对热修复技术的讨论和分享越来越多,同时也出现了一些不同的解决方案,如QQ空间补丁方案.阿里AndFix以及微信Tinker,它们在原理各有不同,适用场景各异,到底采用哪种方案,是开发者比较头疼的问题.本文希望通过介绍QQ空间补丁.Tinker以及基于AndFix的阿里百川HotFix技术的原理分析和横向比较,帮助开发者更深入了解热修复方案. 技术背景 -----------------------------------------------------