源码分析微信热修复框架Tinker的类加载过程

最近在设计一个安卓热修复的完整方案, 这两天终于有零零散散的时间可以考虑下如何选型了.之前项目中用过阿里的基于"安卓神器"Xposed框架的Dexposed,非常惊艳.但毕竟也有一年没更新了,很多东西都被后起之秀比如AndFix超越了~ 而且由于之前项目的特殊性,应用只安装在4.1和4.4系统上,这一点神奇的避开了了Dexposed的硬伤不支持5.0+.但做普通APP就绕不开这个硬伤了.

正好微信开源了Tinker, 赶着在休假前一天的半夜翻一翻源码, 做点分析,应该算是"全网首发"Tinker源码解析了.大笑大笑

先简单介绍下目前的两种实现热修复的流派, 以Dexposed和AndFix为首的Native流, 以Nuwa, ClassLoader(QZONE)为首的Dex(也叫Java)流.

Native流核心是替换函数,将Java方法的属性设为native转到JNI层处理,在JNI中又把方法指针指向了Java Hook,在hook中回调其他Java方法,Java->Native Hook->Java Fix,最终回调到任意的目标方法.

Dex流核心是替换dex,有点像插件动态加载,原理是虚拟机在加载类--即从类名映射到class文件的过程--时顺序遍历系统中dexElements(记住这个成员名)数组,dexElements持有应用所有dex,一旦其中element能够成功加载立即返回目标类对应的class.这就给了聪明的人们启发: 如果能将自己的"私货"dex插入dexElements数组并保证它的顺序在最前,岂不是可以完美实现将class替换成"私货"? 接下来就顺理成章了,java中夹带私货的标准流程都是利用反射机制,这次也不例外.通过反射层层获取各种成员各种变量,最后获取到dexElements这个成员,将这个数组arrayCopy一份,顺便在复制出来的数组第0个位置放上自己的dex,最后将复制体set回dexElements,走私完成.

顺便说一下走私的时机问题,和虚拟机有关的、和context有关的,一般都在Application的attachBaseContext()函数中做入口,onCreate()也没问题.

这样一来系统加载任何类时都会先去私货dex中找有没有相关的,就达到了替换类的目标.

这里有个问题很关键,也是Tinker的最大亮点,dvm有一条规则,一个类如果引用了另一个类,一般是要求他们由同一个dex加载.刚才的流程显然犯规了,私货肯定不和原来的类是同一个dex.但为什么MultiDex这类分包方案不犯规呢?是因为判断犯规有个条件,即如果类没有被打上IS_PREVERIFIED标记则不会触发判定.如果类在静态代码块或构造函数中引用到了不在同一个dex的文件则不会有IS_PREVERIFIED标记.因此最直接的办法就是手动在所有类的构造函数或static函数中加上一行引用其他dex的方法,这个dex出于性能考虑只有一个空的类比如class A {}.这个dex叫做hack dex, 给所有类加引用的步骤叫做"插桩".当然了,手动插桩是不现实的,一般会用JavaAssist做字节码层面的修改,其实我觉得用AspectJ也可以~好处是源码级的改动,不需要做字节码的操作,但不知道为什么目前为止没见人这么用过.

---------------------我是分割线--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

铺垫完毕,接下来开始剖析Tinker源码.

dev分支上是最新的Tinker1.6.1版本,从类名可以知道Tinker处理了类的加载,资源的加载以及so库的加载.我们的关注点在类加载上,根据经验判断,TinkerLoader类是类加载模块的入口,因此从该类开始:

[java]  view plain  copy
  1. public Intent tryLoad(TinkerApplication app, int tinkerFlag, boolean tinkerLoadVerifyFlag) {  
  2.        Intent resultIntent = new Intent();  
  3.   
  4.        long begin = SystemClock.elapsedRealtime();  
  5.        tryLoadPatchFilesInternal(app, tinkerFlag, tinkerLoadVerifyFlag, resultIntent);  
  6.        long cost = SystemClock.elapsedRealtime() - begin;  
  7.        ShareIntentUtil.setIntentPatchCostTime(resultIntent, cost);  
  8.        return resultIntent;  
  9.    }  

TinkerLoader.tryLoad()很明显就是加载dex的入口函数,这里微信统计了加载时间,并进入tryLoadPatchFilesInternal()方法.这个方法较长,主要是对新旧两个dex做合并,这里截取其中关键的步骤:

[java]  view plain  copy
  1. if (isEnabledForDex) {  
  2.             boolean loadTinkerJars = TinkerDexLoader.loadTinkerJars(app, tinkerLoadVerifyFlag, patchVersionDirectory, resultIntent);  
  3.             if (!loadTinkerJars) {  
  4.                 Log.w(TAG, "tryLoadPatchFiles:onPatchLoadDexesFail");  
  5.                 return;  
  6.             }  
  7. }  

做了很多安全校验的机制以保证dex可用后,调用TinkerDexLoader.loadTinkerJars()方法.

[java]  view plain  copy
  1.  public static boolean loadTinkerJars(Application application, boolean tinkerLoadVerifyFlag, String directory, Intent intentResult) {  
  2.         if (dexList.isEmpty()) {  
  3.             Log.w(TAG, "there is no dex to load");  
  4.             return true;  
  5.         }  
  6.   
  7.         PathClassLoader classLoader = (PathClassLoader) TinkerDexLoader.class.getClassLoader();  
  8.         if (classLoader != null) {  
  9.             Log.i(TAG, "classloader: " + classLoader.toString());  
  10.         } else {  
  11.             Log.e(TAG, "classloader is null");  
  12.             ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_DEX_CLASSLOADER_NULL);  
  13.             return false;  
  14.         }  
  15.         String dexPath = directory + "/" + DEX_PATH + "/";  
  16.         File optimizeDir = new File(directory + "/" + DEX_OPTIMIZE_PATH);  
  17.   
  18.         ....  
  19.   
  20. }  

loadTinkerJars()获取PathClassLoader并读取dex与dvm优化后的odex地址,

[java]  view plain  copy
  1. ArrayList<File> legalFiles = new ArrayList<>();  
  2.   
  3.         final boolean isArtPlatForm = ShareTinkerInternals.isVmArt();  
  4.         for (ShareDexDiffPatchInfo info : dexList) {  
  5.             //for dalvik, ignore art support dex  
  6.             if (isJustArtSupportDex(info)) {  
  7.                 continue;  
  8.             }  
  9.             String path = dexPath + info.realName;  
  10.             File file = new File(path);  
  11.   
  12.             if (tinkerLoadVerifyFlag) {  
  13.                 long start = System.currentTimeMillis();  
  14.                 String checkMd5 = isArtPlatForm ? info.destMd5InArt : info.destMd5InDvm;  
  15.                 if (!SharePatchFileUtil.verifyDexFileMd5(file, checkMd5)) {  
  16.                     //it is good to delete the mismatch file  
  17.                     ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_DEX_MD5_MISMATCH);  
  18.                     intentResult.putExtra(ShareIntentUtil.INTENT_PATCH_MISMATCH_DEX_PATH,  
  19.                         file.getAbsolutePath());  
  20.                     return false;  
  21.                 }  
  22.                 Log.i(TAG, "verify dex file:" + file.getPath() + ", md5 use time: " + (System.currentTimeMillis() - start));  
  23.             }  
  24.             legalFiles.add(file);  
  25.         }  
  26.         try {  
  27.             SystemClassLoaderAdder.installDexes(application, classLoader, optimizeDir, legalFiles);  
  28.         } catch (Throwable e) {  
  29.             Log.e(TAG, "install dexes failed");  
  30. //            e.printStackTrace();  
  31.             intentResult.putExtra(ShareIntentUtil.INTENT_PATCH_EXCEPTION, e);  
  32.             ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_DEX_LOAD_EXCEPTION);  
  33.             return false;  
  34.         }  
接着遍历dexList,过滤md5不符校验不通过的,调用SystemClassLoaderAdder的 installDexs()方法.

[java]  view plain  copy
  1. public static void installDexes(Application application, PathClassLoader loader, File dexOptDir, List<File> files)  
  2.         throws Throwable {  
  3.   
  4.         if (!files.isEmpty()) {  
  5.             ClassLoader classLoader = loader;  
  6.             if (Build.VERSION.SDK_INT >= 24) {  
  7.                 classLoader = AndroidNClassLoader.inject(loader, application);  
  8.             }  
  9.             //because in dalvik, if inner class is not the same classloader with it wrapper class.  
  10.             //it won't fail at dex2opt  
  11.             if (Build.VERSION.SDK_INT >= 23) {  
  12.                 V23.install(classLoader, files, dexOptDir);  
  13.             } else if (Build.VERSION.SDK_INT >= 19) {  
  14.                 V19.install(classLoader, files, dexOptDir);  
  15.             } else if (Build.VERSION.SDK_INT >= 14) {  
  16.                 V14.install(classLoader, files, dexOptDir);  
  17.             } else {  
  18.                 V4.install(classLoader, files, dexOptDir);  
  19.             }  
  20.   
  21.             if (!checkDexInstall()) {  
  22.                 throw new TinkerRuntimeException(ShareConstants.CHECK_DEX_INSTALL_FAIL);  
  23.             }  
  24.         }  
  25.     }  

可以看到Tinker对不同系统版本分开做了处理,这里我们就看使用最广泛的Android4.4到Android5.1.

[java]  view plain  copy
  1. /** 
  2.  * Installer for platform versions 19. 
  3.  */  
  4. private static final class V19 {  
  5.   
  6.         private static void install(ClassLoader loader, List<File> additionalClassPathEntries,  
  7.                                     File optimizedDirectory)  
  8.             throws IllegalArgumentException, IllegalAccessException,  
  9.             NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {  
  10.             /* The patched class loader is expected to be a descendant of 
  11.              * dalvik.system.BaseDexClassLoader. We modify its 
  12.              * dalvik.system.DexPathList pathList field to append additional DEX 
  13.              * file entries. 
  14.              */  
  15.             Field pathListField = ShareReflectUtil.findField(loader, "pathList");  
  16.             Object dexPathList = pathListField.get(loader);  
  17.             ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();  
  18.             ShareReflectUtil.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,  
  19.                 new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,  
  20.                 suppressedExceptions));  
  21.             if (suppressedExceptions.size() > 0) {  
  22.                 for (IOException e : suppressedExceptions) {  
  23.                     Log.w(TAG, "Exception in makeDexElement", e);  
  24.                     throw e;  
  25.                 }  
  26.             }  
  27.         }  
  28.   
  29. <span style="white-space:pre">    </span>...  
  30. }  

V19.install()中先通过反射获取BaseDexClassLoader中的dexPathList,然后调用了ShareReflectUtil.expandFieldArray().值得一提的是微信对异常的处理很细致,用List<IOException>接收dexElements数组中每一个dex加载抛出的异常而不是笼统的抛出一个大异常.

接着跟到shareutil包下的ShareReflectUtil类,

重点来了~~

[java]  view plain  copy
  1. /** 
  2.      * Replace the value of a field containing a non null array, by a new array containing the 
  3.      * elements of the original array plus the elements of extraElements. 
  4.      * 
  5.      * @param instance      the instance whose field is to be modified. 
  6.      * @param fieldName     the field to modify. 
  7.      * @param extraElements elements to append at the end of the array. 
  8.      */  
  9.     public static void expandFieldArray(Object instance, String fieldName, Object[] extraElements)  
  10.         throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {  
  11.         Field jlrField = findField(instance, fieldName);  
  12.   
  13.         Object[] original = (Object[]) jlrField.get(instance);  
  14.         Object[] combined = (Object[]) Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length);  
  15.   
  16.         // NOTE: changed to copy extraElements first, for patch load first  
  17.   
  18.         System.arraycopy(extraElements, 0, combined, 0, extraElements.length);  
  19.         System.arraycopy(original, 0, combined, extraElements.length, original.length);  
  20.   
  21.         jlrField.set(instance, combined);  
  22.     }  

不要被它的注释误导了,这里不是替换普通的Field,调用这个方法的入参fieldName正是上一步中的”dexElements”,在这么不起眼的一个工具类中终于找到了Dex流派的核心方法:


和开头说的Dex流的实现几乎一模一样,我们可以看到Tinker本质仍然是用dexElements中位置靠前的Dex优先加载类来实现热修复: )

Tinker虽然原理不变,但它也有拿得出手的重大优化:传统的插桩步骤会导致第一次加载类时耗时变长.应用启动时通常会加载大量类,所以对启动时间的影响很可观.Tinker的亮点是通过全量替换dex的方式避免unexpectedDEX,这样做所有的类自然都在同一个dex中.但这会带来补丁包dex过大的问题,由此微信自研了DexDiff算法来取代传统的BsDiff,极大降低了补丁包大小,又规避了运行性能问题又减小了补丁包大小,可以说是Dex流派的一大进步.

Tinker源码的解析到此结束了,以后有机会再研究下resource,so库等是如何热修复的~

另外完整的热修复是要包括很多辅助模块的,比如安全机制,分发机制,回退机制等,目前还没有类似的开源.或许以后等这部分完成并稳定后安卓团队也可以开源这个大的方案?

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值