3个你未必了解的Android Hot Fix技术

3个你未必了解的Android Hot Fix技术

2017-12-28  Mob开发者平台  安卓巴士Android开发者门户

一、技术背景


1.传统开发流程



从流程来看,传统的开发流程存在很多弊端:


  • 重新发布版本代价太大

  • 用户下载安装成本太高

  • BUG修复不及时,用户体验太差


2.热修复开发流程



而热修复的开发流程显得更加灵活,优势很多:


  • 无需重新发版,实时高效热修复

  • 用户无感知修复,无需下载新的应用,代价小

  • 修复成功率高,把损失降到最低


二、业界热门的热修复技术


热修复作为当下热门的技术,在业界内比较著名的有腾讯QQ空间的超级补丁、微信的Tinker以及阿里百川的HotFix。


1.QQ空间超级补丁


超级补丁技术基于DEX分包方案,使用了多DEX加载的原理,大致的过程就是:把BUG方法修复以后,放到一个单独的DEX里,插入到dexElements数组的最前面,让虚拟机去加载修复完后的方法。


2.微信Tinker


微信针对QQ空间超级补丁技术的不足提出了一个提供DEX差量包,整体替换DEX的方案。主要的原理是与QQ空间超级补丁技术基本相同,区别在于不再将 patch.dex增加到elements数组中,而是差量的方式(DexDiff)给出patch.dex,然后将patch.dex与应用的 classes.dex合并,然后整体替换掉旧的DEX文件,以达到修复的目的。


3.阿里百川的HotFix


阿里百川推出的热修复HotFix服务,通过增加或替换整个DEX的方案,提供了一种运行时在Native修改Filed指针的方式,实现方法的替换,达到即时生效无需重启,对应用无性能消耗的目的。


以上3种技术各有优缺点,关于这3种技术实现原理的简要分析可以参考网络。


下面详细介绍QQ空间超级补丁的实现原理。


三、QQ空间超级补丁的原理


超级补丁方案是基于android MultiDex方案的,关于dex分包方案,网上有几篇解释,这里不再赘述。简单概括一下,就是把多个dex文件塞入到app的ClassLoader 中,但是android dex拆包方案中的类是没有重复的,如果classes.dex和classes1.dex中有重复的类,当用到这个重复的类时,系统会选择哪个类进行加载呢?


要搞明白这个问题,我们需要从Android的ClassLoader体系谈起。


Android中加载类一般使用的是PathClassLoader和DexClassLoader,首先看下这两个类的区别:


对于PathClassLoader,从文档的注释上来看:


PathClassLoader

Provides a simple {@link ClassLoader} implementation that operates
on a list of files and directories in the local file system, but
does not attempt to load classes from the network. Android uses
this class for its system class loader and for its application
class loader(s).


可以看出,Android是使用这个类作为其系统类和应用类的加载器。并且对于这个类呢,只能去加载已经安装到Android系统中的apk文件。


再来看一下DexClassLoader的文档注释:


DexClassLoader

A class loader that loads classes from {@code .jar} and
{@code .apk} files containing a {@code classes.dex} entry.
This can be used to execute code not installed as part of an application.


可以看出,该类呢,可以用来从.jar和.apk类型的文件内部加载classes.dex文件。可以用来执行非安装的程序代码。


对于加载类,无非就是给个className,然后去findClass。PathClassLoader和DexClassLoader都继承自BaseDexClassLoader。在BaseDexClassLoader中有如下源码:


#BaseDexClassLoader
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
   Class clazz = pathList.findClass(name);

   if (clazz == null) {
       throw new ClassNotFoundException(name);
   }
   return clazz;
}

#DexPathList
public Class findClass(String name) {
   for (Element element : dexElements) { // 每个element就是一个Dex文件
       DexFile dex = element.dexFile;

       if (dex != null) {
           Class clazz = dex.loadClassBinaryName(name, definingContext);
           if (clazz != null) {
               return clazz;
           }
       }
   }
   return null;
}

#DexFile
public Class loadClassBinaryName(String name, ClassLoader loader) {
   return defineClass(name, loader, mCookie);
}
private native static Class defineClass(String name, ClassLoader loader, int cookie);


一个CLassLoader 可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历 dexElements,然后从当前的dex文件中找类,如果找到则返回,如果找不到则从下一个dex文件中继续查找。


理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件中的类:



热补丁方案便是在此基础上产生的,把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到dexElements的最前面:



关于拆分dex的原理,如果没有相关项目的话,可以参考一下谷歌的multidex方案实现。然后在插入数组的时候,把补丁包插入到最前面去。


OK,好像问题很简单,轻松的搞定了,但是当你按照上面的思路准备好了patch.dex,当加载类的时候出现了java.lang.IllegaAccessError:Class ref in pre-verified class resoved to unexpected implementation异常。


为什么会出现以上问题呢?


让我们来搜索一下抛出错误的代码所在,定位到Android源码中的Resolve.cpp中的dvmResolveClass方法,可以看到只要满足最外层(!fromUnverifiedConstant && IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED))的条件,就会抛出以上异常。Qzone就是从CLASS_ISPREVERIFIED标记入手,想办法让Class不打上该标签。


Resolve.cpp

/*
* Find the class corresponding to "classIdx", which maps to a class name
* string.  It might be in the same DEX file as "referrer", in a different
* DEX file, generated by a class loader, or generated by the VM (e.g.
* array classes).
*
* Because the DexTypeId is associated with the referring class' DEX file,
* we may have to resolve the same class more than once if it's referred
* to from classes in multiple DEX files.  This is a necessary property for
* DEX files associated with different class loaders.
*
* We cache a copy of the lookup in the DexFile's "resolved class" table,
* so future references to "classIdx" are faster.
*
* Note that "referrer" may be in the process of being linked.
*
* Traditional VMs might do access checks here, but in Dalvik the class
* "constant pool" is shared between all classes in the DEX file.  We rely
* on the verifier to do the checks for us.
*
* Does not initialize the class.
*
* "fromUnverifiedConstant" should only be set if this call is the direct
* result of executing a "const-class" or "instance-of" instruction, which
* use class constants not resolved by the bytecode verifier.
*
* Returns NULL with an exception raised on failure.
*/
// referrer:引用者,即ModuleManager
// classIdx:QzoneActivityManager类的id,根据这个id来找类
ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx,
   bool fromUnverifiedConstant)
{
   // ModuleManager所在的dex=classes.dex
   DvmDex* pDvmDex = referrer->pDvmDex;
   ClassObject* resClass;
   const char* className;

   /*
    * Check the table first -- this gets called from the other "resolve"
    * methods.
    */
   resClass = dvmDexGetResolvedClass(pDvmDex, classIdx);
   // 首次加载,所以==NULL
   if (resClass != NULL)
       return resClass;

   LOGVV("--- resolving class %u (referrer=%s cl=%p)",
       classIdx, referrer->descriptor, referrer->classLoader);

   /*
    * Class hasn't been loaded yet, or is in the process of being loaded
    * and initialized now.  Try to get a copy.  If we find one, put the
    * pointer in the DexTypeId.  There isn't a race condition here --
    * 32-bit writes are guaranteed atomic on all target platforms.  Worst
    * case we have two threads storing the same value.
    *
    * If this is an array class, we'll generate it here.
    */
   // 在ModuleManager所在的dex中查找QzoneActivityManager,因为原先
   // ModuleManager和QzoneAtivityManager在classes.dex中,所以这里的
   // className返回的是“QzoneActivityManager”
   className = dexStringByTypeIdx(pDvmDex->pDexFile, classIdx);
   if (className[0] != '\0' && className[1] == '\0') {
       /* primitive type */
       resClass = dvmFindPrimitiveClass(className[0]);
   } else {
       // 从ClassLoader中查找,也就是elements遍历查找,这里返回的
       // resClass是在patch.dex中了
       resClass = dvmFindClassNoInit(className, referrer->classLoader);
   }

   if (resClass != NULL) {
       /*
        * If the referrer was pre-verified, the resolved class must come
        * from the same DEX or from a bootstrap class.  The pre-verifier
        * makes assumptions that could be invalidated by a wacky class
        * loader.  (See the notes at the top of oo/Class.c.)
        *
        * The verifier does *not* fail a class for using a const-class
        * or instance-of instruction referring to an unresolveable class,
        * because the result of the instruction is simply a Class object
        * or boolean -- there's no need to resolve the class object during
        * verification.  Instance field and virtual method accesses can
        * break dangerously if we get the wrong class, but const-class and
        * instance-of are only interesting at execution time.  So, if we
        * we got here as part of executing one of the "unverified class"
        * instructions, we skip the additional check.
        *
        * Ditto for class references from annotations and exception
        * handler lists.
        */
       // Qzone就是从这里下手,阻止dex校验
       if (!fromUnverifiedConstant &&
           IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED))
       {
           ClassObject* resClassCheck = resClass;
           if (dvmIsArrayClass(resClassCheck))
               resClassCheck = resClassCheck->elementClass;

           // 校验引用者和被引用者的dex是否相同,不相同则报错
           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;
           }
       }

       LOGVV("##### +ResolveClass(%s): referrer=%s dex=%p ldr=%p ref=%d",
           resClass->descriptor, referrer->descriptor, referrer->pDvmDex,
           referrer->classLoader, classIdx);

       /*
        * Add what we found to the list so we can skip the class search
        * next time through.
        *
        * TODO: should we be doing this when fromUnverifiedConstant==true?
        * (see comments at top of oo/Class.c)
        */
       dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);
   } else {
       /* not found, exception should be raised */
       LOGVV("Class not found: %s",
           dexStringByTypeIdx(pDvmDex->pDexFile, classIdx));
       assert(dvmCheckException(dvmThreadSelf()));
   }

   return resClass;
}


从代码上来看,如果两个相关联的类在不同的dex中就会报错,但是拆分dex没有报错这是为什么,原来这个校验的前提是:


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


如果引用者(ModuleManager)这个类被打上了CLASS_ISPREVERIFIED标志,那么就会进行dex的校验。那么这个标志是什么时候被打上去的?继续搜索一下代码,在DexPrepare.cpp的verifyAndOptimizeClass方法中找到了以下代码:


DexPrepare.cpp

/*
* 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);
   }
}


这段代码是dex转化成odex(dexopt)的代码中的一段,我们知道当一个apk在安装的时候,apk中的classes.dex会被虚拟机(dexopt)优化成odex文件,然后才会拿去执行。


虚拟机在启动的时候,会有许多的启动参数,其中一项就是verify选项,当verify选项被打开的时候,上面doVerify变量为true,那么就会 执行dvmVerifyClass进行类的校验,如果dvmVerifyClass校验类成功,那么这个类会被打上 CLASS_ISPREVERIFIED的标志,而只要dvmVerifyClass返回false,则类不会被打上标志,那么具体的校验过程又是什么样子的呢?


在DexVerify.cpp中找到了dvmVerifyClass方法的实现:


DexVerify.cpp

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;
}


首先验证clazz->directMethods方法(包括static方法、private方法、构造方法 等),然后验证clazz->virtualMethods方法,只要以上方法中任意一个方法的verifyMethod返回 false,dvmVerifyClass最终就返回false。


verifyMethod方法的实现我们就不再贴代码了,概括一下就是只要以上方法中直 接引用到的类(第一层级关系,不会进行递归搜索)和这些方法所在的clazz都在同一个dex中的话,verifyMethod就返回true,如果所有 方法都通过了验证,这个类就会被打上CLASS_ISPREVERIFIED标志。



反过来:只要类中有一个方法所直接引用到的类和该类不处于同一个dex中,该类就不会被打上CLASS_ISPREVERIFIED标志!


最终Qzone的方案就是在gradle插件中对除了Application子类之外的所有类的构造函数中插入一段代码,代码如下:


if (ClassVerifier.PREVENT_VERIFY) {   
   System.out.println(AntilazyLoad.class);
}


其中:


class ClassVerifier {    
   public static final boolean PREVENT_VERIFY = false; // false防止代码被执行,提高性能
}    


这样这些类就不会被打上CLASS_ISPREVERIFIED标志,就可以对任意一个类进行热修复。



关于CLASS_ISPREVERIFIED标志,这里再提醒一下,是阻止引用者被打上该标签, 也就是说,假设你的app里面有个类叫做LoadBugClass,在其内部引用了BugClass。发布过程中发现BugClass有编写错误,那么想 要发布一个新的BugClass类,那么你就要阻止LoadBugClass这个类打上CLASS_ISPREVERIFIED标志。


还有 一点要注意的是,在应用启动的时候AntilazyLoad类所在的dex包必须被先加载进来,不然AntilazyLoad类会被标记为不存在,即使后 续加载了hack.dex包,那么他也是不存在的,这样屏幕就会出现茫茫多的类AntilazyLoad找不到的log。


所以 Application作为应用的入口不能插入这段代码。(因为载入hack.dex的代码是在Application的 attachBaseContext中执行的,如果在Application的构造函数里面插入了这段代码,那么就是在hack.dex加载之前就使用该 类,该类一次找不到,会被永远的打上找不到的标志)。


之所以选择构造函数是因为他不增加方法数,一个类即使没有显式的构造函数,也会有一个隐式的默认构造函数。

 

以上我们讲了Qzone热修复方案的原理,接下来我们看看如何实现整套方案。


四、几个基于Qzone方案的开源热修复框架


  • https://github.com/dodola/HotFix

  • https://github.com/jasonross/Nuwa

  • https://github.com/bunnyblue/DroidFix

  • https://github.com/dodola/RocooFix


HotFix 项目比较简单,适合于了解Qzone热修复的原理,自动化做的不是很好。Nuwa在自动制作补丁包等方面做的很好,但是不支持1.2.3以上的 gralde版本,而且原作者也已经不再维护该项目。


于是HotFix的作者又构建了一套RocooFix,实现了通过gradle插件来制作补丁包,无 需关注hash.txt和mapping.txt文件的生成,制作更加方便。并且支持gradle plugin 1.3.0到2.1.2(实际上可以支持到最新的gradle-3.3以及gradle plugin 2.2.0)。


所以我们就以RocooFix为研究对象,进行源码分析。


为了更好的理解源码,我们首先来总结一下Qzone方案的核心原理都需要做哪些事情:



除了以上核心功能外,我们还需要做一些辅助的事情,才能实现一个完整的热修复流程,比如说“将有bug的类单独打成patch.jar”。


五、阻止相关类打上CLASS_ISPREVERIFIED标志


前面提到了要阻止类被打上CLASS_ISPREVERIFIED标签,首先要准备一个类com.dodola.rocoo.Hack.java,它被单独打包到一个rocoo.dex中。让我们看看rocoo.dex的内容:



如何生成rocoo.dex呢?


dx --dex --output=D:\ProjectDemo\bin\classes.dex D:\ProjectDemo\bin 
dx:android SDK/build-tools/目录下的工具
--output=<要生成的classes.dex路径> <要处理的class文件的路径>


然后要在我们自己的类的构造方法中加入如下代码:


if (Boolean.FALSE.booleanValue()) {
   System.out.println(Hack.class);
}


这一步可以在打包生成apk前直接修改class文件,插入以上代码,常用的操作java字节码文件的工具有javassist和asm框架。关于这两个工具的具体用法,有兴趣的同学可以参考以下链接:


javassist:

https://www.ibm.com/developerworks/cn/java/j-dyn0916/


asm官方文档:

http://asm.ow2.org/index.html


RocooFix框架使用的是asm,让我们看看RocooFix如何往class文件中打桩:


com.dodola.rocoofix.utils.NuwaProcessor.groovy

/**
* hack class
* @param v
*/
public static void hackProcess(MethodVisitor v) {
   Label l1 = new Label();
   v.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/Boolean", "FALSE", "Ljava/lang/Boolean;");
   v.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Boolean", "booleanValue", "()Z", false);
   v.visitJumpInsn(Opcodes.IFEQ, l1);
   v.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
   v.visitLdcInsn(Type.getType("Lcom/dodola/rocoo/Hack;"));
   v.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/Object;)V", false);
   v.visitLabel(l1);
}


接下来的问题是,如何在dx之前去执行上述操作?


RocooFix实现了一个gradle插件com.dodola.rocoofix,在dx之前插件会执行自定义的preDexTask,通过上述方法帮我们完成打桩工作(即防止类被打上CLASS_ISPREVERIFIED标签)。


以短信Demo为例,我们模拟一个点击“通讯录好友”按钮时,报错的场景:


MainActivity.java

public void onClick(View v) {
       switch (v.getId()) {
           case R.id.btn_bind_phone: {
               ...
           } break;
           case R.id.rl_contact: {
               tvNum.setVisibility(View.GONE);
               Toast.makeText(this, "Oops, something wrong with your code!", Toast.LENGTH_SHORT).show();
               // 打开通信录好友列表页面
//              ContactsPage contactsPage = new ContactsPage();
//              contactsPage.show(this);
           } break;
       }
   }


运行项目,当点击“通讯录好友”时,会弹一个toast:



然后我们再反编译生成的apk,来确认一下是否已经打桩成功:


EventHandler:



MainActivity:



六、动态改变BaseDexClassLoader对象间接引用的dexElements


原本MainActivity的“通讯录好友”按钮的点击事件是弹一个toast,现在我们发现这是一个bug,修改代码让其正确的显示通讯录好友页面:


MainActivity.java

public void onClick(View v) {
       switch (v.getId()) {
           case R.id.btn_bind_phone: {
               ...
           } break;
           case R.id.rl_contact: {
               tvNum.setVisibility(View.GONE);
//              Toast.makeText(this, "Oops, something wrong with your code!", Toast.LENGTH_SHORT).show();
               // 打开通信录好友列表页面
               ContactsPage contactsPage = new ContactsPage();
               contactsPage.show(this);
           } break;
       }
   }


然后需要生成补丁包patch.jar,当然rocoofix插件会帮我们完成这一步,其实patch.jar中就一个classes.dex:



而该dex中其实就是我们改过的MainActivity,之所以会有BuildConfig,是因为升级了VersionCode:



将生成的patch.jar放到assets目录下,模拟用户下载了我们的补丁包的场景,将MainActivity的代码恢复到之前弹toast的逻辑,以确保本次安装运行的apk是有bug的老版本,重新运行程序,点击“通讯录好友”已经可以正确的打开好友页面了:



说明我们的hotfix生效了,接下来我们分析一下RocooFix是如何实现这个过程的。


前面我们在每一个class中都引用了Hack.java,而Hack.java被单独打包在rocoo.dex中,所以我们必须在应用启动的时候,将这个 rocoo.dex插入到dexElements中,否则肯定会出问题。Application的attachBaseContext就很适合做这件事情。


来看看短信Demo自定义的RocooApplication类:


RocooApplication

package cn.smssdk.demo;
import android.content.Context;
import com.dodola.rocoofix.RocooFix;
import com.mob.MobApplication;
 
/**
* Created by weishj on 2017/6/9.
*/
public class RocooApplication extends MobApplication {
   @Override
   protected void attachBaseContext(Context base) {
       super.attachBaseContext(base);
       //初始化
       RocooFix.init(this);
       //打补丁
       RocooFix.initPathFromAssets(this, "patch.jar");
   }
}

RocooFix.java

public static void init(Context context) {
   // 加载rocoo.dex,用于防止android系统为我们的class文件打上CLASS_ISPREVERIFIED的标记
   initPathFromAssets(context, "rocoo.dex");
}


可见RocooFix.init()方法就是加载rocoo.dex,而RocooFix.initPathFromAssets(this, "patch.jar")则是加载我们的补丁包。RocooFix首先将assets目录下的patch.jar写入到应用的私有目录下,然后再加载到 PathClassLoader中:


RocooFix.java

private static void installDexes(ClassLoader loader, File dexDir, List<File> files)
       throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException,
       InvocationTargetException, NoSuchMethodException, IOException, InstantiationException, ClassNotFoundException {
   if (!files.isEmpty()) {
       if (Build.VERSION.SDK_INT >= 24) {
           V24.install(loader, files, dexDir);
       } else if (Build.VERSION.SDK_INT >= 23) {
           V23.install(loader, files, dexDir);
       } else if (Build.VERSION.SDK_INT >= 19) {
           V19.install(loader, files, dexDir);
       } else if (Build.VERSION.SDK_INT >= 14) {
           V14.install(loader, files, dexDir);
       } else {
           V4.install(loader, files);
       }
   }
}


installDex根据系统版本选用不同的install方法加载dex,不同的系统版本在细节上会有一些出入,但原理都是反射,这里选择V24的install看看:


RocooFix.java

private static final class V24 {

   private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
                               File optimizedDirectory)
           throws IllegalArgumentException, IllegalAccessException,
           NoSuchFieldException, InvocationTargetException, NoSuchMethodException, InstantiationException, ClassNotFoundException {


       // 获取ClassLoader的成员变量pathList
       Field pathListField = RocooUtils.findField(loader, "pathList");
       Object dexPathList = pathListField.get(loader);
       // 获取pathList对象的成员变量dexElements
       Field dexElement = RocooUtils.findField(dexPathList, "dexElements");
       // 得到既存的dexElements数组的类型
       Class<?> elementType = dexElement.getType().getComponentType();
       // 获取pathList对象的loadDexFile方法
       Method loadDex = RocooUtils.findMethod(dexPathList, "loadDexFile", File.class, File.class, ClassLoader.class, dexElement.getType());
       loadDex.setAccessible(true);
       // 调用loadDexFile方法加载我们的dex
       Object dex = loadDex.invoke(null, additionalClassPathEntries.get(0), optimizedDirectory, loader, dexElement.get(dexPathList));
       // 将我们的dex构造成符合dexElements数组的元素类型的对象
       Constructor<?> constructor = elementType.getConstructor(File.class, boolean.class, File.class, DexFile.class);
       constructor.setAccessible(true);
       Object element = constructor.newInstance(new File(""), false, additionalClassPathEntries.get(0), dex);

       Object[] newEles = new Object[1];
       newEles[0] = element;
       // 将newEles插到原始的dexElements数组的最前面,完成patch的安装过程
       RocooUtils.expandFieldArray(dexPathList, "dexElements", newEles);
   }
}


反射的实现被封装在RocooUtils中,就是一般的实现方法,这里不再贴出。再次强调一点,补丁dex要插到dexElements数组的最前面。


至此,整个hotfix的核心过程就分析完了,但是有人可能还有疑问,这个补丁包究竟是如何生成的?


七、如何打包patch.jar


rocooFix 的插件在preDexTask工作之前,会执行rocooPatch的工作,自动帮我们生成patch.jar,这部分的原理是在正式版本发布的时候,生 成一份缓存文件,记录所有class文件的md5(用于判断哪个类有更新),还有一份mapping混淆文件(保证混淆过后的类名始终是一致的)。在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不 相同的class文件单独打包成patch.dex,再签名打包为patch.jar(RocooUtils.groovy)。


实际上这个patch.jar我们自己也可以生成,需要注意的是这个patch.jar并不是普通的jar,而是经过dx工具转化过的jar:


(1)进入class文件的存放路径:


cd /Users/jackie/Documents/work/youzu/smssdk3.0_rocoofix/Projects/SMSSDKSample/build/intermediates/classes/debug


(2)将有问题的class文件打成jar,这里是MainActivity.class:


jar cvf patch_origin.jar cn/smssdk/demo/MainActivity.class



(3)通过dx工具处理jar包:


~/env/Android/sdk/build-tools/25.0.3/dx --dex --output patch.jar patch_origin.jar    



这个patch.jar才是最终我们要发布的补丁包,将其放到assets目录下就可以使用了。


八、QZone超级补丁的局限性


QZone方案为了解决unexpected DEX problem异常而采用插桩的方式,从而规避问题的出现。事实上,Android系统的这些检查规则是非常有意义的,这会导致Qzone方案在Dalvik与Art都会产生一些问题。


Dalvik:在应用安装时会触发dexopt过程,若class verify通过会写入pre-verify标志,在经过optimize之后再写入odex文件。之后每次应用启动都不再需要进行verify和optimize。



若采用插桩导致所有类都非preverify,这导致verify与optimize操作会在加载类时触发。这会有一定的性能损耗,微信分别采用插桩与不插桩两种方式做过两种测试,一是连续加载700个50行左右的类,一是统计微信整个启动完成的耗时。



平均每个类verify+optimize(跟类的大小有关系)的耗时并不长,而且这个耗时每个类只有一次。但由于启动时会加载大量的类,在这个情况影响还是比较大。


Art:Art 采用了新的方式,插桩对代码的执行效率并没有什么影响。但是若补丁中的类出现修改类变量或者方法,可能会导致出现内存地址错乱的问题。为了解决这个问题我 们需要将修改了变量、方法以及接口的类的父类以及调用这个类的所有类(递归查找整个调用链)都加入到补丁包中。这势必会带来补丁包大小的急剧增加。 RocooFix在其gradle插件中做了这部分的处理(NuwaProcessor.groovy)。



这里是因为在dex2oat时fast*已经将类能确定的各个地址写死。如果运行时补丁包的地址出现改变,原始类去调用时就会出现地址错乱。要搞清这个问 题,就需要明白ART的流程,事实上微信当时为了查清这个问题,也花费了一定的时间将Dalvik跟Art的流程基本搞透。


关于ART对Android热修复方案的影响,可以参考以下文章:

http://blog.csdn.net/wangbaochu/article/details/53463314


总的来说,Qzone方案好处在于开发透明,简单,这一套方案目前的应用成功率也是最高的,但在补丁包大小与性能损耗上有一定的局限性。特别是无论我们是否真正应用补丁,都会因为插桩导致对程序运行时的性能产生影响。


九、微信热补丁方案Tinker


正是由于以上两个局限性,微信最终并没有使用QZone的方案,而是开发了自己的热更新方案Tinker


Tinker的设计灵感来自于Instant Run的冷插拔与buck的exopackage,它们的思想都是全量替换新的Dex。即我们完全使用了新的Dex,那样既不出现Art地址错乱的问题,在Dalvik也无须插桩。



简单来说,在编译时通过新旧两个Dex生成差异patch.dex。在运行时,将差异patch.dex重新跟原始安装包的旧Dex还原为新的Dex。这个 过程可能比较耗费时间与内存,所以微信是单独放在一个后台进程:patch中。为了补丁包尽量的小,微信自研了DexDiff算法,它深度利用Dex的格 式来减少差异的大小。它的粒度是Dex格式的每一项,可以充分利用原本Dex的信息,而BsDiff的粒度是文件,AndFix/Qzone的粒度为 class。



但是这套方案并非没有缺点,它带来的问题有两个:


1.占用Rom体积:这边大约是你所修改Dex大小的1.5倍(Dex压缩成jar的大小加上生成的dexopt文件大小)。


2.一个额外的合成过程:虽然微信单独放在一个进程:patch上处理,但是合成时间的长短与内存消耗也会影响最终的成功率(与修改Dex大小、Dex数量、补丁大小相关)。


若不care性能损耗与补丁包大小,Qzone方案是最简单且成功率最高的方案(没有单独的合成过程)。相对Tinker来说,它的占用Rom体积也更小。根据微信团队的统计,Qzone与Tinker的成功率当前大约相差3%左右。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值