动态化部署:Android热修复之代码修复(一)
前记
传统发版要经过应用市场审核这一过程,但面对需要紧急修复的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$override
的access$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热修复之代码修复(一)相关推荐
- Android热修复升级探索——代码修复冷启动方案
前言 前面一篇文档, 我们提到热部署修复方案有诸多特点(有关热部署修复方案实现, Android热修复升级探索--追寻极致的代码热替换).其根本原理是基于native层方法的替换, 所以当类结构变化时 ...
- Android工具修复属性,Android 热修复介绍之代码修复
什么是Android热修复技术 简单来说就是不重新安装apk的情况下,通过补丁,修复bug 正常开发流程 热修复开发流程 目前主流的热修复技术框架 阿里系的: Andfix.Hotfix.Sophix ...
- [读书笔记] 深入探索Android热修复技术原理 (手淘技术团队)
热修复技术介绍 探索之路 最开始,手淘是基于Xposed进行了改进,产生了针对Android Dalvik虚拟机运行时的Java Method Hook技术--Dexposed. 但该方案对于底层Da ...
- 【Android 修炼手册】常用技术篇 -- Android 热修复解析
这是[Android 修炼手册]第 8 篇文章,如果还没有看过前面系列文章,欢迎点击 这里 查看- 预备知识 了解 android 基本开发 了解 ClassLoader 相关知识 看完本文可以达到什 ...
- android的热修复,Android热修复原理
热修复框架技术主要有三类,代码修复,资源修复,动态链接库修复. 资源修复 很多资源修复的框架参考了Instant Run资源修复的原理,所以先了解一下Instant Run Instant Run I ...
- 干货满满,Android热修复方案介绍
摘要:在云栖社区技术直播中,阿里云客户端工程师李亚洲(毕言)从技术原理层面解析和比较了业界几大热修复方案,揭开了Qxxx方案.Instant Run以及阿里Sophix等热修复方案的神秘面纱,帮助大家 ...
- 动态加载、插件化、热部署、热修复(更新)知识汇总
开发中经常能听到动态加载,插件化,热部署等词,动态加载到底是何方神物,它能实现什么功能,实现原理又如何?动态加载和插件化.热部署又有着什么样的联系呢?下面我们一起来学习吧. 1. 基本知识 1.1 动 ...
- Android 热修复方案分析
绝大部分的APP项目其实都需要一个动态化方案,来应对线上紧急bug修复发新版本的高成本.之前有利用加壳,分拆两个dex结合DexClassLoader实现了一套全量更新的热更方案.实现原理在Andro ...
- Android 热修复实现原理
文章目录 1.热修复背景 2.Instant Run 概述 3.类加载 3.热修复 3.1 代码修复 3.1.1 类加载方案 3.1.2 底层替换方案 3.2 资源修复 3.3 so 修复 1.热修复 ...
最新文章
- FFmpeg通过摄像头实现对视频流进行解码并显示测试代码(新接口)
- Java 读写Properties配置文件(转)
- 用新语法写更简洁的ABAP代码
- App Engine中的Google Services身份验证,第2部分
- 华为手机怎样才算激活了_外观专利到底怎样才算侵权呢?
- q87主板支持cpu型号_INTEL的10代和9代的区别,型号和价格都有哪些,入手哪个性价比高...
- python创建一个有序链表_Python实现合并两个有序链表的方法示例
- 如何选择注塑机动力系统
- Ansible自动化采集数据并生成巡检报告
- 2019年厦门大学计算机系夏令营经历
- 手把手阿里云企业邮箱设置教程三步搞定
- python五分制转分数档_五分制绩点换算四分制(5.0绩点计算器在线)
- 【Python数据清洗】numpy.take()用法
- 关于MATLAB中clear的用法
- 音频相概念扫盲——声音处理的过程
- r语言显示找不到read_html,R语言中read.table函数不常见的用法-文本中有#注释符号...
- windows 技术篇-共享地址里的共享文件显示为灰色叉叉不可用问题原因及解决方法
- 软件测试方法和技术实验-佣金问题
- 【测控电路】三运放高共模抑制比放大电路
- 串口232,485转以太网模块 TCP/IP 串口协议转换模块