前记

传统发版要经过应用市场审核这一过程,但面对需要紧急修复的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) {//如果没有被加载则将加载任务委托给parentc = 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) {...// 保存dexPaththis.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

    @Overrideprotected 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、饿了么的Amigo和Nuwa等等。

  • hotfix demo在github地址,点击查看

底层替换方案

底层替换方案是直接在已经加载类中的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_;//1void* 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;//1if (localIncrementalChange != null) {//2localIncrementalChange.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的原理的热修复框架有Robust和Aceso。

业界方案对比

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

思考

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

参考博客

  • 热修复入门:Android 中的 ClassLoader
  • 美团Android DEX自动拆包及动态加载简介
  • Android热更新方案Robust
    Android 热补丁技术——资源的热修复
  • App运行时字段或者方法数目超过65535原因分析
  • 阿里深入探索Android热修复原理
  • Android热修复原理(一)热修复框架对比和代码修复

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

  1. Android热修复升级探索——代码修复冷启动方案

    前言 前面一篇文档, 我们提到热部署修复方案有诸多特点(有关热部署修复方案实现, Android热修复升级探索--追寻极致的代码热替换).其根本原理是基于native层方法的替换, 所以当类结构变化时 ...

  2. Android工具修复属性,Android 热修复介绍之代码修复

    什么是Android热修复技术 简单来说就是不重新安装apk的情况下,通过补丁,修复bug 正常开发流程 热修复开发流程 目前主流的热修复技术框架 阿里系的: Andfix.Hotfix.Sophix ...

  3. [读书笔记] 深入探索Android热修复技术原理 (手淘技术团队)

    热修复技术介绍 探索之路 最开始,手淘是基于Xposed进行了改进,产生了针对Android Dalvik虚拟机运行时的Java Method Hook技术--Dexposed. 但该方案对于底层Da ...

  4. 【Android 修炼手册】常用技术篇 -- Android 热修复解析

    这是[Android 修炼手册]第 8 篇文章,如果还没有看过前面系列文章,欢迎点击 这里 查看- 预备知识 了解 android 基本开发 了解 ClassLoader 相关知识 看完本文可以达到什 ...

  5. android的热修复,Android热修复原理

    热修复框架技术主要有三类,代码修复,资源修复,动态链接库修复. 资源修复 很多资源修复的框架参考了Instant Run资源修复的原理,所以先了解一下Instant Run Instant Run I ...

  6. 干货满满,Android热修复方案介绍

    摘要:在云栖社区技术直播中,阿里云客户端工程师李亚洲(毕言)从技术原理层面解析和比较了业界几大热修复方案,揭开了Qxxx方案.Instant Run以及阿里Sophix等热修复方案的神秘面纱,帮助大家 ...

  7. 动态加载、插件化、热部署、热修复(更新)知识汇总

    开发中经常能听到动态加载,插件化,热部署等词,动态加载到底是何方神物,它能实现什么功能,实现原理又如何?动态加载和插件化.热部署又有着什么样的联系呢?下面我们一起来学习吧. 1. 基本知识 1.1 动 ...

  8. Android 热修复方案分析

    绝大部分的APP项目其实都需要一个动态化方案,来应对线上紧急bug修复发新版本的高成本.之前有利用加壳,分拆两个dex结合DexClassLoader实现了一套全量更新的热更方案.实现原理在Andro ...

  9. Android 热修复实现原理

    文章目录 1.热修复背景 2.Instant Run 概述 3.类加载 3.热修复 3.1 代码修复 3.1.1 类加载方案 3.1.2 底层替换方案 3.2 资源修复 3.3 so 修复 1.热修复 ...

最新文章

  1. FFmpeg通过摄像头实现对视频流进行解码并显示测试代码(新接口)
  2. Java 读写Properties配置文件(转)
  3. 用新语法写更简洁的ABAP代码
  4. App Engine中的Google Services身份验证,第2部分
  5. 华为手机怎样才算激活了_外观专利到底怎样才算侵权呢?
  6. q87主板支持cpu型号_INTEL的10代和9代的区别,型号和价格都有哪些,入手哪个性价比高...
  7. python创建一个有序链表_Python实现合并两个有序链表的方法示例
  8. 如何选择注塑机动力系统
  9. Ansible自动化采集数据并生成巡检报告
  10. 2019年厦门大学计算机系夏令营经历
  11. 手把手阿里云企业邮箱设置教程三步搞定
  12. python五分制转分数档_五分制绩点换算四分制(5.0绩点计算器在线)
  13. 【Python数据清洗】numpy.take()用法
  14. 关于MATLAB中clear的用法
  15. 音频相概念扫盲——声音处理的过程
  16. r语言显示找不到read_html,R语言中read.table函数不常见的用法-文本中有#注释符号...
  17. windows 技术篇-共享地址里的共享文件显示为灰色叉叉不可用问题原因及解决方法
  18. 软件测试方法和技术实验-佣金问题
  19. 【测控电路】三运放高共模抑制比放大电路
  20. 串口232,485转以太网模块 TCP/IP 串口协议转换模块

热门文章

  1. WindowsXP注册表详解
  2. 在tensorflow下进行pip操作时需要注意的地方
  3. 关于Android短信拦截(二)
  4. 计算机720p进制,历史频道《人类大历史 Big History》第1季全17集 英语中字 720P高清纪录片...
  5. 关于微信小程序异步转同步方法
  6. Oops是什么有什么用
  7. Poco库使用:文件目录操作
  8. Android 仿微信语音聊天,正式加入字节跳动
  9. (数据结构)1.实现顺序栈的各种基本运算 2.实现环形队列的各种基本运算
  10. Android P 正式到来