• 安卓热修复比较介绍


    概述:

    热修复其实很简单,通俗理解就找到有bug的apk和无bug的apk的差异生成一个.apatch(按照AndFix使用)结尾的文件,通过预先固定的通道从网上下载无bug的代码替换有bug的代码,从而实现bug的修复,最关键的是用户体验好,如果按照正常的流程操作的话需要开发人员修复完bug后打包经过测试人员测试后,上传到多个应用市场;通过热修复的方法就省去了很大的人力物力成本。

    热修复框架分析

    • 底层替换方案:阿里的AndFix、HotFix
    • 类加载方案:QQ空间补丁技术、微信的Tinker方案、饿了么的Amigo
    • 二者结合:Sophix

    目前最主要有三种方案:

    Native Hook 进行底层替换---java虚拟机的热修复(hotfix)

     基于类加载与 Dex 分包方案,进行 Dex 插桩/替换(基于Classloader的热修复(tinker)) ;
    Install Run 进行类的注入 ;由于国内手机厂商定制系统的多样,Dex 插桩/替换是我认为最适合的方案。
    当然还有其他的,目前阿里的Sophix据说是非侵入式,但是不是今天研究重点(没有开源,我也不知道原理)

    1.1 . 基于虚拟机的 热修复:

    这个 的原理需要了解一点java虚拟机的类加载机制。我们都知道java虚拟机内存模型中有方法区,堆区,栈区。

    我们写好的类的class字节码就在方法区中, 方法区中为每个类生成一个方法表,hotfix会用到这张表;new出来的对象就保存在

    堆区中; 对象调用方法 会从方法区中的方法表找到方法 压倒方法栈中成为占帧。

    那么修复用的原理就是把方法区中的 方法字节码替换成已经修改好的,然后去执行。

    原理很简单实现起来还是很麻烦的,需要在c层去处理。  在底层会有一个叫 ArtMethod 的对象保存方法的描述 ,我们要做的就是替换这个对象,具体实现后续补充吧。

    2、热修复:Andfix为例子


    2、热修复的原理
    我们知道Java虚拟机 —— JVM 是加载类的class文件的,而Android虚拟机——Dalvik/ART VM 是加载类的dex文件,
    而他们加载类的时候都需要ClassLoader,ClassLoader有一个子类BaseDexClassLoader,而BaseDexClassLoader下有一个
    数组——DexPathList,是用来存放dex文件,当BaseDexClassLoader通过调用findClass方法时,实际上就是遍历数组,
    找到相应的dex文件,找到,则直接将它return。而热修复的解决方法就是将新的dex添加到该集合中,并且是在旧的dex的前面,
    所以就会优先被取出来并且return返回。

    Dex插桩原理:
    ClassLoader 是通过调用 findClass 方法,在 pathList 对象中的 dexElements[] 中遍历dex文件寻找相关的类。由于靠前的dex会优先被系统调用,所以就有了插桩的概念。将修复好的 dex 插入到 dexElements[] 的最前方,这样系统就会调用修复好的插入类而不是靠后的 bug 类。

    上图中,patch.dex 是插入的 dex ,classes2.dex 是原有的 bug dex。ClassLoader 在遍历时优先获取了 patch.dex 中的 D.class ,所以 classes2.dex 中的 D.class 就不会被调用,这样就完成了对 D.class 的替换,修复了bug。

    本文简单介绍了代码修复的技术原理,下篇文章将从系统源码入手,结合我自己封装的代码修复开源框架Fettler,详细解读代码修复的每一个过程。  

    1  热修复的原理

    Android的类加载器有两种:  PathClassLoader和DexClassLoader,两者的父类是BaseDexClassLoader,  BaseDexClassLoader的父类是ClassLoader

    其中PathDexLoader用来加载系统类和应用类;

    DexClassLoader用来加载一些jar、apk、dex文件,其实jar和apk文件实际上加载的都是dex文件。

    热修复原理: ClassLoader 会遍历一个由dex文件组成的数组,然后加载其中的dex文件,

    我们会把正确的dex(修复过的类所在的dex)文件  插入数组的前面, 当加载器 加载到好的类文件时候就不会加载有bug的类了,就实现了热修复

    1,基于ClassLoad的 修复实现

    原理:在android中有两个常用ClassLoader,PathClassLoader加载已安装apk中class,DexClassLoader加载未安装apk或者aar中class.两个有一个共同的父类,BaseDexClassLoader,在BaseDexClassLoader->DexPathList->Element[] dexElements

    中存储着apk或者aar中所有dex的集合。class加载类是从头遍历这个集合找到class就返回不会再往下找,这样我们就可以把修改好的dex查在数组的前边,让类加载器选择我们修改好的class(不知道算不算是一个bug)。

    ================== 

    热修复介绍
    1.开发流程

    当项目出现紧急bug时,传统的开发流程是发布新版本,引导用户覆盖安装。抛开平台审核上线的时间不说,一天重复下载安装至少两次的用户体验是很差的。

    而热修复的出现完美解决了这个问题,用户在收到服务器推送过来的修复包后,在项目运行时进行修复。

    整个过程是在用户无感知状态下完成,也无需下载相对来说较大的安装包,代价小。

    总结为两个优点:

    无需重新发版,修复效率高; 用户无感知,代价小

    2.都能修复什么

    资源修复
    代码修复
    so库修复

    =================

    一、代码修复

     热修复框架分析

    • 底层替换方案:阿里的AndFix、HotFix
    • 类加载方案:QQ空间补丁技术、微信的Tinker方案、饿了么的Amigo
    • 二者结合:Sophix

    1、类加载方案

    (1)Dex分包原理

       单个Dex文件里面方法数不能超过65536个方法。

    (1)原因:
    因为android会把每一个类的方法id检索起来,存在一个链表结构里面。但是这个链表的长度是用一个short类型来保存的, short占两个字节(保存-2的15次方到2的15次方-1,即-32768~32767),最大保存的数量就是65536。

    (2)解决方案:

    • 精简方法数量,删除没用到的类、方法、第三方库。
    • 使用ProGuard去掉一些未使用的代码
    • 对部分模块采用本地插件化的方式。
    • 分割Dex

    Dex分包方案主要做的是在打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。

    当应用启动时先加载主Dex,等到应用启动后再动态的加载次Dex。

    (2)类加载修复方案

    如果Key.Class文件中存在异常,将该Class文件修复后,将其打入Patch.dex的补丁包
    (1) 方案一:
    通过反射获取到PathClassLoader中的DexPathList,然后再拿到 DexPathList中的Element数组,将Patch.dex放在Element数组dexElements的第一个元素,最后将数组进行合并后并重新设置回去。在进行类加载的时候,由于ClassLoader的双亲委托机制,该类只被加载一次,也就是说Patch.dex中的Key.Class会被加载。

     (2)方案二:
    提供dex差量包patch.dex,将patch.dex与应用的classes.dex合并成一个完整的dex,完整dex加载后得到dexFile对象,作为参数构建一个Element对象,然后整体替换掉旧的dex-Elements数组。(Tinker) 

    (3)类加载方案的限制

    方案一:

    • 由于类是无法进行卸载,所以类如果需要重新加载,则需要重启App,所以类加载修复方案不是即时生效的。
    • 在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到patch.dex中,导致补丁包大,耗时严重。

    方案二:

    • 下次启动修复
    • dex合并内存消耗可能导致OOM,最终dex合并失败

    2、底层替换方案

    (1)基本方案

     主要是在Native层替换原有方法, ArtMethod结构体中包含了Java方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等。

    替换ArtMethod结构体中的字段或者替换整个ArtMethod结构体,就是底层替换方案。  由于直接替换了方法,可以立即生效不需要重启。

    (2)优缺点

    (1)缺点

    • 不能够增减原有类的方法和字段,如果我们增加了方法数,那么方法索引数也会增加,这样访问方法时会无法通过索引找到正确的方法。
    • 平台兼容性问题,如果厂商对ArtMethod结构体进行了修改,替换机制就有问题。

    (2)优点

    • Bug修复的即时性
    • 生成的PATCH体积小,性能影响低

    二、资源修复

    1、Instant Run

    • 反射构建新的AssetManager,并反射调用addAssertPath加载sdcard中的新资源包,这样就得到一个含有所有新资源的AssetManager
    • 将原来引用到AssetManager的地方,通过反射把引用处 替换为新的AssetManager

    核心代码:runtime/MonkeyPatcher.java

    1.  
      #MonkeyPatcher
    2.  
      public static void monkeyPatchExistingResources(@Nullable Context context,
    3.  
      @Nullable String externalResourceFile,
    4.  
      @Nullable Collection<Activity> activities) {
    5.  
      ......
    6.  
      try {
    7.  
      // Create a new AssetManager instance and point it to the resources installed under
    8.  
      // (1)通过反射创建了一个newAssetManager,调用addAssetPath添加了sdcard上的资源包
    9.  
      AssetManager newAssetManager = AssetManager.class.getConstructor().newInstance();
    10.  
      Method mAddAssetPath = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
    11.  
      mAddAssetPath.setAccessible(true);
    12.  
      if (((Integer) mAddAssetPath.invoke(newAssetManager, externalResourceFile)) == 0) {
    13.  
      throw new IllegalStateException("Could not create new AssetManager");
    14.  
      }
    15.  
      // Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
    16.  
      // in L, so we do it unconditionally.
    17.  
      Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod("ensureStringBlocks");
    18.  
      mEnsureStringBlocks.setAccessible(true);
    19.  
      mEnsureStringBlocks.invoke(newAssetManager);
    20.  
      if (activities != null) {
    21.  
      //(2)反射获取Activity中AssetManager的引用,替换成新创建的newAssetManager
    22.  
      for (Activity activity : activities) {
    23.  
      Resources resources = activity.getResources();
    24.  
      try {
    25.  
      Field mAssets = Resources.class.getDeclaredField("mAssets");
    26.  
      mAssets.setAccessible(true);
    27.  
      mAssets.set(resources, newAssetManager);
    28.  
      } catch (Throwable ignore) {
    29.  
      Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
    30.  
      mResourcesImpl.setAccessible(true);
    31.  
      Object resourceImpl = mResourcesImpl.get(resources);
    32.  
      Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
    33.  
      implAssets.setAccessible(true);
    34.  
      implAssets.set(resourceImpl, newAssetManager);
    35.  
      }
    36.  
      Resources.Theme theme = activity.getTheme();
    37.  
      try {
    38.  
      try {
    39.  
      Field ma = Resources.Theme.class.getDeclaredField("mAssets");
    40.  
      ma.setAccessible(true);
    41.  
      ma.set(theme, newAssetManager);
    42.  
      } catch (NoSuchFieldException ignore) {
    43.  
      Field themeField = Resources.Theme.class.getDeclaredField("mThemeImpl");
    44.  
      themeField.setAccessible(true);
    45.  
      Object impl = themeField.get(theme);
    46.  
      Field ma = impl.getClass().getDeclaredField("mAssets");
    47.  
      ma.setAccessible(true);
    48.  
      ma.set(impl, newAssetManager);
    49.  
      }
    50.  
      ......
    51.  
      }
    52.  
      //(3)遍历Resource弱引用的集合,将AssetManager替换成newAssetManager
    53.  
      for (WeakReference<Resources> wr : references) {
    54.  
      Resources resources = wr.get();
    55.  
      if (resources != null) {
    56.  
      // Set the AssetManager of the Resources instance to our brand new one
    57.  
      try {
    58.  
      Field mAssets = Resources.class.getDeclaredField("mAssets");
    59.  
      mAssets.setAccessible(true);
    60.  
      mAssets.set(resources, newAssetManager);
    61.  
      } catch (Throwable ignore) {
    62.  
      Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
    63.  
      mResourcesImpl.setAccessible(true);
    64.  
      Object resourceImpl = mResourcesImpl.get(resources);
    65.  
      Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
    66.  
      implAssets.setAccessible(true);
    67.  
      implAssets.set(resourceImpl, newAssetManager);
    68.  
      }
    69.  
      resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
    70.  
      }
    71.  
      }
    72.  
      } catch (Throwable e) {
    73.  
      throw new IllegalStateException(e);
    74.  
      }
    75.  
      }
    76.  
       

    2、资源包替换(Sophix)

    默认由Android SDK编译出来的apk,其资源包的package id为0x7f。framework-res.jar的资源package id为0x01

    • 构造一个package id为0x66的资源包(非0x7f和0x01),只包含已经改变的资源项。
    • 由于不与已经加载的Ox7f冲突,所以可以通过原有的AssetManager的addAssetPath加载这个包。

    三、SO库修复

    本质是对native方法的修复和替换

    2、SO修复方案

    (1)接口替换

        提供方法替代System.loadLibrary方法:

    • 如果存在补丁so,则加载补丁so库,不去加载apk安装目录下的so库
    • 如果不存在补丁so,调用System.loadLibrary去加载安装apk目录下的so库

    (2)反射注入

         因为加载so库会遍历nativeLibraryDirectories

    • 通过反射将补丁so库的路径插入到nativeLibraryDirectories数组的最前面
    • 遍历nativeLibraryDirectories时,就会将补丁so库进行返回并加载,从而达到修复目的

    1、so库加载

    (1)通过以下方法加载so库.

    类似于类加载的findClass方法,在数组中每一个元素对应一个so库,最终返回了so的路径。

    如果将so补丁添加到数组的最前面,在调用方法加载so库时,会先将补丁so的路径返回。

    1.  
      #System
    2.  
      public static void loadLibrary(String libname) {
    3.  
      Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);
    4.  
      }
    5.  
      参数为so库名称,位于apk的lib目录下
    6.  
       
    7.  
      public static void load(String filename) {
    8.  
      Runtime.getRuntime().load0(VMStack.getStackClass1(), filename);
    9.  
      }
    10.  
      加载外部自定义so库文件,参数为so库在磁盘中的完整路径
    11.  
      复制代码
    1.  
      private static native String nativeLoad(String filename, ClassLoader loader, String librarySearchPath);
    2.  
      复制代码

    最终都是调用了native方法nativeLoad,参数fileName为so在磁盘中的完整路径名

    (2)遍历nativeLibraryDirectories目录

    1.  
      #DexPathList
    2.  
      public String findLibrary(String libraryName) {
    3.  
      String fileName = System.mapLibraryName(libraryName);
    4.  
      for (File directory : nativeLibraryDirectories) {
    5.  
      File file = new File(directory, fileName);
    6.  
      if (file.exists() && file.isFile() && file.canRead()) {
    7.  
      return file.getPath();
    8.  
      }
    9.  
      }
    10.  
      return null;
    11.  
      }

    参考资料:

    ======

     具体实现:

          if (context == null) {
                return;
            }
            File filesDir = context.getDir("odex", Context.MODE_PRIVATE);
            File[]  listFiles=filesDir.listFiles();
            for (File file : listFiles) {
                if(file.getName().startsWith("classes")||file.getName().endsWith(".dex")){
                    Log.i("INFO", "dexName:"+file.getName());
                    loadedDex.add(file);
                }
            }
            String optimizeDir = filesDir.getAbsolutePath() + File.separator + "opt_dex";
            File fopt = new File(optimizeDir);
            if (!fopt.exists()) {
                fopt.mkdirs();
            }
            for (File dex : loadedDex) {
                DexClassLoader classLoader = new DexClassLoader(dex.getAbsolutePath(), fopt.getAbsolutePath(), null, context.getClassLoader());
                PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();

                try {
    //                -----------------------系统的ClassLoader------------------------------------
                    Class baseDexClazzLoader=Class.forName("dalvik.system.BaseDexClassLoader");
                    Field  pathListFiled=baseDexClazzLoader.getDeclaredField("pathList");
                    pathListFiled.setAccessible(true);
                    Object pathListObject = pathListFiled.get(pathClassLoader);


                    Class  systemPathClazz=pathListObject.getClass();
                    Field  systemElementsField = systemPathClazz.getDeclaredField("dexElements");
                    systemElementsField.setAccessible(true);
                    Object systemElements=systemElementsField.get(pathListObject);

    //                ------------------自己的ClassLoader--------------------------
                    Class myDexClazzLoader=Class.forName("dalvik.system.BaseDexClassLoader");
                    Field  myPathListFiled=myDexClazzLoader.getDeclaredField("pathList");
                    myPathListFiled.setAccessible(true);
                    Object myPathListObject =myPathListFiled.get(classLoader);


                    Class  myPathClazz=myPathListObject.getClass();
                    Field  myElementsField = myPathClazz.getDeclaredField("dexElements");
                    myElementsField.setAccessible(true);
                    Object myElements=myElementsField.get(myPathListObject);


    //                ------------------------融合-----------------------------
                    Class<?> sigleElementClazz = systemElements.getClass().getComponentType();
                    int systemLength = Array.getLength(systemElements);
                    int myLength = Array.getLength(myElements);
                    int newSystenLength = systemLength + myLength;
    //                生成一个新的 数组   类型为Element类型
                    Object newElementsArray = Array.newInstance(sigleElementClazz, newSystenLength);
                    for (int i = 0; i < newSystenLength; i++) {
                        if (i < myLength) {
                            Array.set(newElementsArray, i, Array.get(myElements, i));
                        }else {
                            Array.set(newElementsArray, i, Array.get(systemElements, i - myLength));
                        }
                    }
    //      ---------------------------融合完毕   将新数组  放到系统的PathLoad内部---------------------------------
                    Field  elementsField=pathListObject.getClass().getDeclaredField("dexElements");;
                    elementsField.setAccessible(true);


                    elementsField.set(pathListObject,newElementsArray);

    这样就完成了dex的插入替换。

    比较以上两种方案比较喜欢第二种,应为第一种只能修改方法,  第二种可以整个类替换,切适配性很好(只要java不修改这个bug在哪都能用)。
     

  • 相关阅读:
    iOS -- @try
    javaScript学习
    iOS -- js与原生交互
    iOS -- WKWebView
    iOS -- UIWindow的使用
    iOS -- app生命周期中代理方法的应用场景
    iOS -- keyChain
    UISegmentedControl
    明天你好
    编程:是一门艺术(转)
  • 原文地址:https://www.cnblogs.com/awkflf11/p/12555425.html
Copyright © 2020-2023  润新知