热修复技术

 

一、热修复技术是什么,怎么出现的呢,为什么需要?

当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App、测试、向各个应用市场和渠道换包、提示用户升级、用户下载、覆盖安装。有时候仅仅是为了修改了一行代码,也要付出巨大的成本进行换包和重新发布。不仅大大增加开发成本也会影响到产品的口碑,造成用户流失。

这时候就提出一个问题:有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装?

于是涌现出来很多热补丁方案。

能够让应用能够在无需重新安装的情况实现更新,帮助应用快速建立动态修复能力。

二、局限性与适用场景

  • 补丁只能针对单一客户端版本,随着版本差异变大补丁体积也会增大;
  • 补丁不能支持所有的修改,例如AndroidManifest;
  • 补丁无论对代码还是资源的更新成功率都无法达到100%。

既然补丁技术无法完全代替升级,那它适合使用在哪些场景呢?

1. 轻量而快速的升级

热补丁技术也可以理解为一个动态修改代码与资源的通道,它适合于修改量较少的情况。一般在300k以内,以Android用户的升级习惯,即使是相对活跃的微信也需要10天以上的时间去覆盖50%的用户。使用补丁技术,我们能做到1天覆盖70%以上。这也是基于补丁体积较小,可以直接使用移动网络下载更新。

2.远端调试

一入Android深似海,Android开发的另外一个痛是机型的碎片化。我们也许都会遇到"本地不复现","日志查不出","联系用户不鸟你"的烦恼。所以补丁机制非常适合使用在远端调试上。即我们需要具备只特定用户发送补丁的能力,这对我们查找问题非常有帮助。

3.数据统计

数据统计在微信中也占据着非常重要的位置,我们也非常希望将热补丁与数据统计结合的更好。事实上,热补丁无论在普通的数据统计还是ABTest都有着非常大的优势。例如若我想对同一批用户做两种test, 传统方式无法让这批用户去安装两个版本。使用补丁技术,我们可以方便的对同一批用户不停的更换补丁。

扫描二维码关注公众号,回复: 2485796 查看本文章

4.其他

事实上,Android官方也使用热补丁技术实现Instant Run。它分为Hot Swap、Warm Swap与Cold Swap三种方式,大家可以参考英文介绍,也可以看参考文章中的翻译稿。最新的Instant App应该也是采用类似的原理,但是Google Play是不允许下发代码的,这个海外App需要注意一下。

 

 

三、热修复的原理

 

1、通过更改dex加载顺序实现热修复

其核心原理就是通过更改含有bug的dex文件的加载顺序。在dex的加载中,若以找到方法则不会继续查找,所以如果能让修复之后的方法在含有bug的方法之前加载就能达到修复bug的目的。

lassLoader

原腾讯空间Android工程师,陈钟老师发明的热补丁方案,是他在看源码的时候偶然发现的切入点。

我们知道,multidex方案的实现,其实就是把多个dex放进app的classloader之中,从而使得所有dex的类都能被找到。而实际上findClass的过程中,如果出现了重复的类是会使用第一个找到的类的。

public Class findClass(String name, List suppressed) {  

 

    for (Element element : dexElements) {  

        DexFile dex = element.dexFile;

        if (dex != null) {

            Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);

            if (clazz != null) {

              return clazz;

            }

        }

    }

    if (dexElementsSuppressedExceptions != null) {  

        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));

    }

    return null;

}

该热补丁方案就是从这一点出发,只要把有问题的类修复后,放到一个单独的dex,通过反射插入到dexElements数组的最前面,可以让虚拟机加载到打完补丁的class

在实践中,会发现运行加载类的时候报preverified错误,原来在DexPrepare.cpp,将dex转化成odex的过程中,会在DexVerify.cpp进行校验,验证如果直接引用到的类和clazz是否在同一个dex,如果是,则会打上CLASS_ISPREVERIFIED标志。通过在所有类(Application除外,当时还没加载自定义类的代码)的构造函数插入一个对在单独的dex的类的引用,就可以解决这个问题。空间使用了javaassist进行编译时字节码插入。

开源实现有Nuwa, HotFix, DroidFix。

2、通过Native替换方法指针的方式实现热修复

这里主要是阿里开源的两个热修复框架:Dexpost   AndFix都是通过Native层使用指针替换的方法替换bug,达到修复bug的目的的

Dexposed

基于Xposed的AOP框架,方法级粒度,可以进行AOP编程、插桩、热补丁、SDK hook等功能。

Xposed需要Root权限,是因为它要修改其他应用、系统的行为,而对单个应用来说,其实不需要root。 Xposed通过修改Android Dalvik运行时的Zygote进程,并使用Xposed Bridge来hook方法并注入自己的代码,实现非侵入式的runtime修改。比如蜻蜓fm和喜马拉雅做的事情,其实就很适合这种场景,别人反编译市场下载的代码是看不到patch的行为的。小米(onVmCreated里面还未小米做了资源的处理)也重用了dexposed,去做了很多自定义主题的功能,还有沉浸式状态栏等。

应用启动的时候,都会fork zygote进程,装载class和invoke各种初始化方法,Xposed就是在这个过程中,替换了app_process,hook了各种入口级方法(比如handleBindApplication、ServerThread、ActivityThread、ApplicationPackageManager的getResourcesForApplication等),加载XposedBridge.jar提供动态hook基础。

其具体native实现则在Xposed的libxposed_common.cpp里面有注册,根据系统版本分发到libxposed_dalvik和libxposed_art里面,以dalvik为例大致来说就是记录下原来的方法信息,并把方法指针指向我们的hookedMethodCallback,从而实现拦截的目的。

方法级的替换是指,可以在方法前、方法后插入代码,或者直接替换方法。只能针对java方法做拦截,不支持C的方法。

缺点:不支持art。

AndFix

同样是方法的hook,AndFix不像Dexposed从Method入手,而是以Field为切入点。

先看Java入口,AndFixManager.fix:

/**

 * fix

 *

 * @param file        patch file

 * @param classLoader classloader of class that will be fixed

 * @param classes     classes will be fixed

 */

public synchronized void fix(File file, ClassLoader classLoader, List classes) {

        

 

        ClassLoader patchClassLoader = new ClassLoader(classLoader) {

            @Override

            protected Class findClass(String className) throws ClassNotFoundException {

                Class clazz = dexFile.loadClass(className, this);

                if (clazz == null && className.startsWith("com.alipay.euler.andfix")) {

                    return Class.forName(className);

                }

                if (clazz == null) {

                    throw new ClassNotFoundException(className);

                }

                return clazz;

            }

        };

        Enumeration entrys = dexFile.entries();

        Class clazz = null;

        while (entrys.hasMoreElements()) {

            String entry = entrys.nextElement();

            if (classes != null && !classes.contains(entry)) {

                continue;

            }

      

            clazz = dexFile.loadClass(entry, patchClassLoader);

            if (clazz != null) {

                fixClass(clazz, classLoader);

            }

        }

    } catch (IOException e) {

        Log.e(TAG, "pacth", e);

    }

}

看来最终fix是在fixClass方法:

 

 

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 clzz = classLoader.loadClass(clz);

      

      clazz = AndFix.initTargetClass(clzz);

    }

    if (clazz != null) {

      mFixedClass.put(key, clazz);

      

      Method src = clazz.getDeclaredMethod(meth, method.getParameterTypes());

      

      AndFix.addReplaceMethod(src, method);

    }

  } catch (Exception e) {

    Log.e(TAG, "replaceMethod", e);

  }

}

在dalvik和art上,系统的调用不同,但是原理类似,这里我们尝个鲜,以6.0为例art_method_replace_6_0:

 

 

 

void replace_6_0(JNIEnv* env, jobject src, jobject dest) {

    art::mirror::ArtMethod* smeth = (art::mirror::ArtMethod*) env->FromReflectedMethod(src);

    art::mirror::ArtMethod* dmeth = (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);

 

    dmeth->declaring_class_->class_loader_ =

            smeth->declaring_class_->class_loader_;

    dmeth->declaring_class_->clinit_thread_id_ =

            smeth->declaring_class_->clinit_thread_id_;

    dmeth->declaring_class_->status_ = smeth->declaring_class_->status_-1;

 

  

    smeth->declaring_class_ = dmeth->declaring_class_;

    smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;

    smeth->access_flags_ = dmeth->access_flags_;

    smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;

    smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;

    smeth->method_index_ = dmeth->method_index_;

    smeth->dex_method_index_ = dmeth->dex_method_index_;

 

  

    smeth->ptr_sized_fields_.entry_point_from_interpreter_ =

            dmeth->ptr_sized_fields_.entry_point_from_interpreter_;

    smeth->ptr_sized_fields_.entry_point_from_jni_ =

            dmeth->ptr_sized_fields_.entry_point_from_jni_;

    smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =

            dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;

 

    LOGD("replace_6_0: %d , %d",

            smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,

            dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);

}

 

 

void setFieldFlag_6_0(JNIEnv* env, jobject field) {

    art::mirror::ArtField* artField =

            (art::mirror::ArtField*) env->FromReflectedField(field);

    artField->access_flags_ = artField->access_flags_ & (~0x0002) | 0x0001;

    LOGD("setFieldFlag_6_0: %d ", artField->access_flags_);

}

在dalvik上的实现略有不同,是通过jni bridge来指向补丁的方法。

使用上,直接写一个新的类,会由补丁工具会生成注解,描述其与要打补丁的类和方法的对应关系。

四、实际案例

QQ空间:

空间Android独立版5.2发布后,收到用户反馈,结合版无法跳转到独立版的访客界面,每天都较大的反馈。在以前只能紧急换包,重新发布。成本非常高,也影响用户的口碑。最终决定使用热补丁动态修复技术,向用户下发Patch,在用户无感知的情况下,修复了外网问题,取得非常好的效果。

隐患: 
虚拟机在安装期间为类打上CLASS_ISPREVERIFIED标志是为了提高性能的,我们强制防止类被打上标志是否会影响性能?这里我们会做一下更加详细的性能测试.但是在大项目中拆分dex的问题已经比较严重,很多类都没有被打上这个标志。 
如何打包补丁包: 
1. 空间在正式版本发布的时候,会生成一份缓存文件,里面记录了所有class文件的md5,还有一份mapping混淆文件。 
2. 在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不相同的class文件打包成补丁包。 
备注:该方案现在也应用到我们的编译过程当中,编译不需要重新打包dex,只需要把修改过的类的class文件打包成patch dex,然后放到sdcard下,那么就会让改变的代码生效。

 

猜你喜欢

转载自blog.csdn.net/weixin_41835113/article/details/81228009