传统发版要经过应用市场审核这一过程,但面对需要紧急修复的bug时无疑会增加时间成本,并且为了应对现在日渐强烈的运营需求,动态化部署应运而生,包括插件化和热修复,当然插件化和热修复充满了黑科技,包括对系统私有api的hook,会存在兼容性问题,但对于我们学习其中原理,深入理解framwork的工作机制大有裨益,所以,我们先从热修复开始探索
我们先看下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的加载细节上会有略微的差别。
一个完整的Java程序是由多个.class文件组成的,在程序运行过程中,需要将这些.class文件加载到JVM中才可以使用。而负责加载这些.class文件的就是类加载器(ClassLoader)。因此ClassLoder的作用简单来说就是加载.class文件,提供给程序运行时使用。那ClassLoader是如何加载.class文件的呢,通过“双亲委派模型”
通过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;
}
所以双亲委派模型的工作过程是: 如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此, 因此所有的加载请求最终都应该传送到最顶层的启动类加载器中, 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类) 时, 子加载器才会尝试自己去完成加载。
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;
在安装时可能会提示INSTALL_FAILED_DEXOPT。产生的原因就是LinearAlloc限制,DVM中的LinearAlloc是一个固定的缓存区,当方法数过多超出了缓存区的大小时会报错。
为了解决65536限制和LinearAlloc限制,从而产生了Dex分包方案。Dex分包方案主要做的是在打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态的加载次Dex,从而缓解了主Dex的65536限制和LinearAlloc限制。而与dex分包相关的就是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只有两个构造函数,方法参数含义
对比只能"加载安装到手机上的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类中只有一个构造方法,其参数
//只适用于api 26以上
File dexOutputDir = context.getCodeCacheDir();
那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;
}
最终水落石出,
那么我们通过反射修改dexElements,添加自定义的dex文件,就可以达到热修复的目的,下面通过demo来实现下这一理论
public class SaySuccess {
public String say() {
return "say original message";
}
}
将其显示到TextVeiw,
SaySuccess saySuccess = new SaySuccess();
mBinding.showTv.setText(saySuccess.say());
然后我们通过修改return返回,动态加载到原apk,达到热修复的目的
修改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等等。
底层替换方案是直接在已经加载类中的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在第一次构建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$override
的access$dispatch
方法,access$dispatch
方法中会根据参数onCreate.(Landroid/os/Bundle;)V
执行MainActivity$override
的onCreate方法,从而实现了onCreate方法的修改。
借鉴Instant Run的原理的热修复框架有Robust和Aceso。
以最具代表性的Sophix和Tinker做对比
热修复可能会存在厂商版本兼容性问题,但是其中的**“读懂源码并加以利用”**的能力值得每个技术学习