绝大部分的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 loadClass<?> clzz = classLoader.loadClass(clz);// initialize target classclazz = AndFix.initTargetClass(clzz);}if (clazz != null) {// initialize class OKmFixedClass.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 modeALOGV("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

Android 热修复方案分析相关推荐

  1. Android 热修复方案Tinker(五) SO补丁加载

    基于Tinker V1.7.5 Android 热修复方案Tinker(一) Application改造 Android 热修复方案Tinker(二) 补丁加载流程 Android 热修复方案Tink ...

  2. 干货满满,Android热修复方案介绍

    摘要:在云栖社区技术直播中,阿里云客户端工程师李亚洲(毕言)从技术原理层面解析和比较了业界几大热修复方案,揭开了Qxxx方案.Instant Run以及阿里Sophix等热修复方案的神秘面纱,帮助大家 ...

  3. android热修复方案

    热补丁方案有很多,其中比较出名的有腾讯Tinker.阿里的AndFix.美团的Robust以及QZone的超级补丁方案.他们的优劣如下: 一.Tinker 热修复 Tinker通过 Dexdiff 算 ...

  4. android热修复技术tinker,Android热修复方案第一弹——Tinker篇

    背景 一款App的正常开发流程应该是这样的:新版本上线-->用户安装-->发现Bug-->紧急修复-->重新发布新版本-->提示用户安装更新,从表面上看这样的开发流程顺理 ...

  5. Android 热修复方案Tinker(三) Dex补丁加载

    转载来源:http://blog.csdn.net/l2show/article/details/53307523 之前有说到Tinker的修复原理是跟Qzone类似,这里就详细分析一下为什么这样做可 ...

  6. Android热更新五:四大热修复方案对比分析

    很早之前就想深入的研究和学习一下热修复,由于时间的原因一直拖着,现在才执笔弄起来. Android而更新系列: Android热更新一:JAVA的类加载机制 Android热更新二:理解Java反射 ...

  7. [读书笔记] 深入探索Android热修复技术原理 (手淘技术团队)

    热修复技术介绍 探索之路 最开始,手淘是基于Xposed进行了改进,产生了针对Android Dalvik虚拟机运行时的Java Method Hook技术--Dexposed. 但该方案对于底层Da ...

  8. Android热更新十:自己写一个Android热修复

    很早之前就想深入的研究和学习一下热修复,由于时间的原因一直拖着,现在才执笔弄起来. Android而更新系列: Android热更新一:JAVA的类加载机制 Android热更新二:理解Java反射 ...

  9. Android 热修复 Tinker Gradle Plugin解析

    本文已在我的公众号hongyangAndroid原创首发. 转载请标明出处: http://blog.csdn.net/lmj623565791/article/details/72667669 本文 ...

最新文章

  1. Docker学习(三)-----Docker镜像常用命令
  2. 【每日一算法】二叉树中所有距离为 K 的结点
  3. 8.ubuntu下设置自定义快捷键
  4. Sqlite数据库中索引的使用、索引的优缺点
  5. STRUTS模拟试题
  6. Generative Adversarial Nets[BEGAN]
  7. 【Python】可视化神经网络训练过程时处理报错 train_acc=hist.history[‘acc’] KeyError: ‘acc’
  8. 安装redisclient、redis-cluster,使用redis desktop manager和java(eclipse)连接redis过程遇到问题汇总
  9. html怎么把excel表格合并单元格,Excel中如何快速合并多个单元格
  10. python命令行参数有什么用_一文看懂命令行参数的用法——Python中的getopt神器
  11. nginx重定向规则入门
  12. mysql内连接去重复_MYSQL 内连接查询重复
  13. vnc支持用计算机民远程吗,VNC远程控制软件是什么?有没有更好的远程桌面控制解决方案?...
  14. Vivado FIFO IP核设置
  15. java求解二元二次方程_二元二次方程的解法
  16. 一家椰子鸡店,凭什么让客户去了一趟又一趟?到底有什么样的魔力?
  17. Notepad++编译Verilog代码(精简)
  18. 计算机如何理解图像?
  19. MATLAB画矩形和圆
  20. 流氓软件和骚扰电话是时候该清理下了

热门文章

  1. 各种Android UI开源框架
  2. runtime从入门到精通(一)—— 初识runtime
  3. Kernel crash:总结
  4. 财富2019年世界500强企业爬虫(爬虫学习实践项目)
  5. Golang精编100题-搞定golang面试
  6. 为什么现在玩港美股的比玩A股的多?
  7. GitHub 上六大爆款AI学习项目,Star超过5.6w
  8. Android修改设置文字转语音输出,默认语速
  9. BZOJ3926: [Zjoi20150]诸神眷顾的幻想乡
  10. php id 生产器,Laravel 分布式唯一 ID 生成器运用_PHP开发框架教程