动态化部署:Android热修复之代码修复(一)

1,970 阅读13分钟

前记

传统发版要经过应用市场审核这一过程,但面对需要紧急修复的bug时无疑会增加时间成本,并且为了应对现在日渐强烈的运营需求,动态化部署应运而生,包括插件化和热修复,当然插件化和热修复充满了黑科技,包括对系统私有api的hook,会存在兼容性问题,但对于我们学习其中原理,深入理解framwork的工作机制大有裨益,所以,我们先从热修复开始探索

Android的.java文件如何被加载的

我们先看下Android中.java文件的编译过程,java文件会先通过javac编译成.class文件,然后通过dx/d8将这些.class文件打包成dex,但是不是通过JVM加载,而是通过Android 自身的Dalvik/ART虚拟机加载。在程序第一次被加载的时候,为了提高以后的启动速度和执行效率,Android系统会对这个class.dex文件做一定程序的优化,系统会运行一个名为DexOpt的程序为该应用在当前机型中运行做准备。DexOpt 是在第一次加载 Dex 文件的时候执行的,并生成一个ODEX文件(Optimised Dex)存放在/data/dalvik-cache目录下。以后再运行这个程序的时候,就只要直接加载这个优化过的ODEX文件就行了,省去了每次都要优化的时间。不过,这个优化过程会根据不同设备上Dalvik虚拟机的版本、Framework库的不同等因素而不同。在一台设备上被优化过的ODEX文件,拷贝到另一台设备上不一定能够运行。 Android的Dalvik/ART虚拟机如同标准JVM虚拟机一样,也是同样需要加载class文件到内存中来使用,但是在ClassLoader的加载细节上会有略微的差别。

ClassLoader

什么是ClassLoader

一个完整的Java程序是由多个.class文件组成的,在程序运行过程中,需要将这些.class文件加载到JVM中才可以使用。而负责加载这些.class文件的就是类加载器(ClassLoader)。因此ClassLoder的作用简单来说就是加载.class文件,提供给程序运行时使用。那ClassLoader是如何加载.class文件的呢,通过“双亲委派模型”

双亲委派模型(Parents Delegation Model)

通过ClassLoader#loadClass

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            //首先检查该class是否已经被加载,如果已经被加载,则直接返回
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        //如果没有被加载则将加载任务委托给parent
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    //如果仍然没加载成功,则调用当前的ClassLoader的findClass方法继续尝试加载
                    c = findClass(name);
                }
            }
            return c;
    }

所以双亲委派模型的工作过程是: 如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此, 因此所有的加载请求最终都应该传送到最顶层的启动类加载器中, 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类) 时, 子加载器才会尝试自己去完成加载。

Dex分包

65535问题

Android为什么会产生分dex方案呢?因为单个dex如果方法数超过65535,会报错

com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536

主要原因是Dalvik Bytecode中,方法索引是采用使用原生类型short来索引文件中的方法,16bit标识,也就是4个字节共计最多表达65536个method,field/class的个数也均有此限制。对于DEX文件,则是将工程所需全部class文件合并且压缩到一个DEX文件期间,也就是Android打包的DEX过程中, 单个DEX文件可被引用的方法总数(自己开发的代码以及所引用的Android框架、类库的代码)被限制为65536;

LinearAlloc限制

在安装时可能会提示INSTALL_FAILED_DEXOPT。产生的原因就是LinearAlloc限制,DVM中的LinearAlloc是一个固定的缓存区,当方法数过多超出了缓存区的大小时会报错。

为了解决65536限制和LinearAlloc限制,从而产生了Dex分包方案。Dex分包方案主要做的是在打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态的加载次Dex,从而缓解了主Dex的65536限制和LinearAlloc限制。而与dex分包相关的就是Android中的ClassLoader

Android中的ClassLoader

通过

ClassLoader classLoader = getClassLoader();
while (classLoader != null) {
    System.out.println("getClassLoader:" + classLoader);
  classLoader = classLoader.getParent();
}
System.out.println("getClassLoader:" + classLoader);</pre>

打印结果

System.out: getClassLoader:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.zqg.hotfix-lleC6h14ozRn8Z3sPjWelw==/base.apk"],nativeLibraryDirectories=[/data/app/com.zqg.hotfix-lleC6h14ozRn8Z3sPjWelw==/lib/arm64, /system/lib64, /system/product/lib64]]]
System.out: getClassLoader:java.lang.BootClassLoader@1cfa35f
System.out: getClassLoader:null

通过日志看出,加载该类的是PathClassLoader,而加载PathClassLoader的是BootClassLoader,但是BootClassLoader的加载器为null 查看PathClassLoader的源码,

public PathClassLoader(String dexPath, ClassLoader parent) {       super(dexPath, null, null, parent);
} 
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) { 
  super(dexPath, null, librarySearchPath, parent); 
}

PathClassLoader只有两个构造函数,方法参数含义

  • dexPath : 包含 dex 的 jar 文件或 apk 文件的路径集,多个以文件分隔符分隔,默认是“:”
  • libraryPath : 包含 C/C++ 库的路径集,多个同样以文件分隔符分隔,可以为空 ,只能用来加载安装在手机上的dex 具体的实现都在BaseDexClassLoader里面,BaseDexClassLoader 的子类是 PathClassLoader和 DexClassLoader,接下来介绍下DexClassLoader

DexClassLoader

对比只能"加载安装到手机上的dex"PathClassLoader,DexClassLoader可以加载未安装过的dex文件,此处不严谨,其实PathClassLoader和DexClassLoader都可以加载未安装过的dex文件,这也是热修复和插件化的理论基础之一

public DexClassLoader(String dexPath, String optimizedDirectory,
        String libraryPath, ClassLoader parent) {
    super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}

DexClassLoader类中只有一个构造方法,其参数

  • String dexPath: 包含 class.dex 的 apk、jar 文件路径 ,多个用文件分隔符(默认是 :)分隔
  • String optimizedDirectory : 用来缓存优化的 dex 文件的路径,即从 apk 或 jar 文件中提取出来的 dex 文件。该路径不可以为空,且应该是应用私有的,有读写权限的路径(实际上也可以使用外部存储空间,但是这样的话就存在代码注入的风险),可以通过以下方式来创建一个这样的路径:
//只适用于api 26以上
File dexOutputDir = context.getCodeCacheDir();
  • String libraryPath: 存储 C/C++ 库文件的路径集
  • ClassLoader parent : 父类加载器,遵从双亲委托模型

那BaseDexClassLoader是如何加载dex的呢 接下来查看BaseDexClassLoader的源码, 在构造方法中

DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
       ...
        // 保存dexPath
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                           suppressedExceptions, definingContext, isTrusted);

        
    }

看下makeDexElements

private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
            List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
      Element[] elements = new Element[files.size()];
      int elementsPos = 0;
      /*
       * Open all files and load the (direct or contained) dex files up front.
       */
      for (File file : files) {
          if (file.isDirectory()) {
              // We support directories for looking up resources. Looking up resources in
              // directories is useful for running libcore tests.
              elements[elementsPos++] = new Element(file);
          } else if (file.isFile()) {
              String name = file.getName();

              DexFile dex = null;
              if (name.endsWith(DEX_SUFFIX)) {
                  // Raw dex file (not inside a zip/jar).
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                      if (dex != null) {
                          elements[elementsPos++] = new Element(dex, null);
                      }
                  } catch (IOException suppressed) {
                      System.logE("Unable to load dex file: " + file, suppressed);
                      suppressedExceptions.add(suppressed);
                  }
              } else {
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                  } catch (IOException suppressed) {
                      /*
                       * IOException might get thrown "legitimately" by the DexFile constructor if
                       * the zip file turns out to be resource-only (that is, no classes.dex file
                       * in it).
                       * Let dex == null and hang on to the exception to add to the tea-leaves for
                       * when findClass returns null.
                       */
                      suppressedExceptions.add(suppressed);
                  }

                  if (dex == null) {
                      elements[elementsPos++] = new Element(file);
                  } else {
                      elements[elementsPos++] = new Element(dex, file);
                  }
              }
              if (dex != null && isTrusted) {
                dex.setTrusted();
              }
          } else {
              System.logW("ClassLoader referenced unknown path: " + file);
          }
      }
      if (elementsPos != elements.length) {
          elements = Arrays.copyOf(elements, elementsPos);
      }
      return elements;
    }

作用就是将包含dex的文件夹或者文件复制到Element数组中

BaseDexClassLoader#findClass

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        ...
        Class c = pathList.findClass(name, suppressedExceptions);
        ...
        return c;
    }

其中是调用DexPathList#findClass,

  public Class<?> findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            Class<?> clazz = element.findClass(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }
        ...
        return null;
    }

遍历dexElements,如果找到该类则返回,找不到则继续找下一个

其中调用Element#findClass,

public Class<?> findClass(String name, ClassLoader definingContext,
                List<Throwable> suppressed) {
            return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed)
                    : null;
        }

最终水落石出,

  • dex文件路径中可能有多个通过“:”拼接的路径,先split路径
  • 通过makeDexElements将dex文件复制到Element数组中
  • 然后通过findClass遍历Element数组,逐个加载element中的dex,其中如果两个dex包含相同的class,只会加载之前的

那么我们通过反射修改dexElements,添加自定义的dex文件,就可以达到热修复的目的,下面通过demo来实现下这一理论

手动实现ClassLoader类加载方案

创建SaySuccess.java

public class SaySuccess {
    public String say() {
        return "say original message";
  }
}

将其显示到TextVeiw,

SaySuccess saySuccess = new SaySuccess();
mBinding.showTv.setText(saySuccess.say());

然后我们通过修改return返回,动态加载到原apk,达到热修复的目的

修改SaySuccess return,打新包,通过PathClassLoader#PathList#Element加载

修改SaySuccess返回值

public class SaySuccess {
    public String say() {
        return "say new fix message";
  }
}

重新打包后放到assets的apk文件夹下,然后读取路径

                File newFile = new File(getCacheDir() + "/newfix.apk");
                try {
                    InputStream is = getAssets().open("apk/newfix.apk");
                    FileOutputStream fos = new FileOutputStream(newFile);
                    byte[] buffer = new byte[1024];
                    int byteCount = 0;
                    while ((byteCount = is.read(buffer)) != -1) {//循环从输入流读取 buffer字节
                        fos.write(buffer, 0, byteCount);//将读取的输入流写入到输出流
                    }
                    fos.flush();//刷新缓冲区
                    is.close();
                    fos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }

然后通过反射BaseDexClassLoader#pathList#dexElements,加包含修改文件dex的apk,加载进来

                try {
                    Field pathListField = BaseDexClassLoader.class.getDeclaredField("pathList");
                    pathListField.setAccessible(true);
                    Object pathListObj = pathListField.get(getClassLoader());
                    Class<?> dexPathListClass = pathListObj.getClass();
                    Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
                    dexElementsField.setAccessible(true);
                    Object dexElementsObj = dexElementsField.get(pathListObj);
                    PathClassLoader pathClassLoader = new PathClassLoader(newFile.getPath(), null);
                    Object newPathListObj = pathListField.get(pathClassLoader);
                    Object newDexElementsObj = dexElementsField.get(newPathListObj);

                    dexElementsField.set(pathListObj, newDexElementsObj);
                    SaySuccess saySuccessHot = new SaySuccess();
                    mBinding.showTv.setText(saySuccessHot.say());
                } catch (NoSuchFieldException
                        | IllegalAccessException e) {
                    e.printStackTrace();
                }
                break;

如图,

还在路上,稍等...

这是通过将新apk全量加载,会增加新包的体积和方法数,能不能加载只包含修改java文件的dex呢,可以,我们切到C:\Users\qiguang.zhu\AppData\Local\Android\Sdk\build-tools\29.0.2目录下,

通过

javac SaySuccess.java

生成SaySuccess.class文件,然后通过

d8 SaySuccess.Class

生成newfix.dex文件,这就是只包含修改文件的dex 然后修改反射逻辑,

int oldLength = Array.getLength(dexElementsObj);
                    int newLength = Array.getLength(newDexElementsObj);
                    Object concatDexElementsObject = Array.newInstance(dexElementsObj.getClass().getComponentType(), oldLength + newLength);
                    for (int i = 0; i < newLength; i++) {
                        Array.set(concatDexElementsObject, i, Array.get(newDexElementsObj, i));
                    }
                    for (int i = 0; i < oldLength; i++) {
                        Array.set(concatDexElementsObject, newLength + i, Array.get(dexElementsObj, i));
                    }

将新加的dex添加到dexElement数组的前面,因为包含两个相同class的dex,数组前面的先生效 ClassLoader的类加载方案不足的地方时,app必须重启才能使修复生效,如果不重启,原有的类还在虚拟机中,就无法加载新类。因此,只有在下次App重启的时候,在还没运行到业务逻辑之前抢先加载补丁中的新类,这样在后续访问这个类时,就会解析为新的类。 采用类加载方案的主要是以腾讯系为主,包括微信的Tinker、QQ空间的超级补丁、手机QQ的QFix、饿了么的AmigoNuwa等等。

底层替换方案

底层替换方案是直接在已经加载类中的native层替换掉原有方法,是在原有类的基础上进行修改的。底层替换原理和反射的原理有些关联,假设我们要反射调用OtherBean#testLog方法

public class OtherBean {
    public OtherBean(){}
    public void testLog() {
        Log.d("OtherBean", "test log show");
  }
}

反射如下

OtherBean.class.getDeclaredMethod("testLog").invoke(OtherBean.class.newInstance());

invoke方法如下

>@FastNative
public native Object invoke(Object obj, Object... args)
        throws IllegalAccessException, IllegalArgumentException, InvocationTargetException;</pre>

可见invoke方法是一个native方法,对应的jni层的代码为: art/runtime/native/java_lang_reflect_Method.cc

static jobject Method_invoke(JNIEnv* env, jobject javaMethod, jobject javaReceiver,
 jobject javaArgs) {
 ScopedFastNativeObjectAccess soa(env);
 return InvokeMethod(soa, javaMethod, javaReceiver, javaArgs);

查看InvokeMethod方法: art/runtime/reflection.cc

jobject InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod,
 jobject javaReceiver, jobject javaArgs, size_t num_frames) {

...
 ObjPtr<mirror::Executable> executable = soa.Decode<mirror::Executable>(javaMethod);
 const bool accessible = executable->IsAccessible();
 ArtMethod* m = executable->GetArtMethod();//1
...
}

注释1获取传入的javaMethod(OtherBean的testLog方法)在ART虚拟机中对应一个ArtMethod指针,ArtMethod结构体中包含了Java方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等等,ArtMethod结构如下所示。 art/runtime/art_method.h

class ArtMethod FINAL {
...
 protected:
 GcRoot<mirror::Class> declaring_class_;
 std::atomic<std::uint32_t> access_flags_;
 uint32_t dex_code_item_offset_;
 uint32_t dex_method_index_;
 uint16_t method_index_;
 uint16_t hotness_count_;
 struct PtrSizedFields {
 ArtMethod** dex_cache_resolved_methods_;//1
 void* data_;
 void* entry_point_from_quick_compiled_code_;//2
 } ptr_sized_fields_;
}

ArtMethod结构中最重要的字段就是注释1处的dex_cache_resolved_methods_和注释2处的entry_point_from_quick_compiled_code_,它们是方法的执行入口,当我们调用某一个方法时,比如OtherBean的testLog方法,就会取得testLog方法的执行入口,通过执行入口再进入方法体内执行,替换ArtMethod结构体中的字段或者替换整个ArtMethod结构体,这就是底层替换方案。 阿里早期的AndFix采用的就是替换ArtMethod结构体中的字段,这样就会有兼容性问题,因为厂商可能会修改ArtMethod结构体,导致方法替换失败。而后来的Sophix采用的是替换整个ArtMethod结构体,这样不会存在兼容问题。 底层替换方案直接替换了方法,可立即生效不需要重启。采用底层替换方案主要是阿里系为主,包括AndFix、Dexposed、Sophix(同时也采用的类加载方案,自动设别方法做到冷启动和热启动切换)。

instant run方案

Instant Run的原理除了资源修复,同样也可用于代码修复, 可以说Instant Run的出现推动了热修复框架的发展。 Instant Run在第一次构建apk时,使用ASM在每一个方法中注入了类似如下的代码:

IncrementalChange localIncrementalChange = $change;//1
		if (localIncrementalChange != null) {//2
			localIncrementalChange.access$dispatch(
					"onCreate.(Landroid/os/Bundle;)V", new Object[] { this,
							paramBundle });
			return;
		}

其中注释1处是一个成员变量localIncrementalChange ,它的值为$change$change实现了IncrementalChange这个抽象接口。当我们点击InstantRun时,如果方法没有变化则$change为null,就调用return,不做任何处理。如果方法有变化,就生成替换类,这里我们假设MainActivity的onCreate方法做了修改,就会生成替换类MainActivity$override,这个类实现了IncrementalChange接口,同时也会生成一个AppPatchesLoaderImpl类,这个类的getPatchedClasses方法会返回被修改的类的列表(里面包含了MainActivity),根据列表会将MainActivity的$change设置为MainActivity$override,因此满足了注释2的条件,会执行MainActivity$overrideaccess$dispatch方法,access$dispatch方法中会根据参数onCreate.(Landroid/os/Bundle;)V执行MainActivity$override的onCreate方法,从而实现了onCreate方法的修改。 借鉴Instant Run的原理的热修复框架有RobustAceso

业界方案对比

以最具代表性的Sophix和Tinker做对比

思考

热修复可能会存在厂商版本兼容性问题,但是其中的**“读懂源码并加以利用”**的能力值得每个技术学习

参考博客