1 热修复技术出现的背景
要说到热修复的出现,主要还是因为在中国Android应用没有一个统一的应用商店,每个手机厂商基本都会定制自己的一款手机应用市场,不像国外的统一都使用Google Play。
中国用户下载app都在不同的应用市场下载,没有统一的应用市场导致的问题就是,如果应用出现bug需要紧急修复,按正常的发布流程,需要从bug的修改、发布应用到各个应用市场、提示用户下载安装修复这几个流程。
在流程上看,有一些弊端:
-
需要重新发布版本代价太大(可能只是修改小bug)
-
用户下载安装覆盖成本太高
-
bug修复不及时导致给用户的体验差
出现了以上问题,所以在中国热修复技术就产生了。
2 热修复技术简介
什么是热修复?热修复简单来说就是一种补丁方案,我们只需要通过将补丁文件打包为 .dex
推送给用户,结合类加载机制在应用重启的时候就可以修复好出现的bug。
优势上是显而易见的:
-
无需重新发布版本,实时高效修复
-
用户无感知,无需下载安装覆盖应用,代价小
-
修复成功率比较高
目前在市面上热修复技术有阿里巴巴的 AndFix
、Dexposed
,腾讯QQ空间的超级补丁和微信的 Tinker
等。
这些热修复技术框架的使用我不会在这里讲,而是要了解热修复技术的底层实现原理。
3 插件化
在知道热修复之前,你或许有听说过插件化。那什么是插件化?
3.1 什么是插件化
如果你有关注一些比较大型的app,比如支付宝、美团等,可以发现app的一些入口是一个完全不同的功能,这些功能可能是主项目发布后在后期动态加入进来的,而这种将其他apk集成到另一个apk的技术就是插件化。插件化的核心就是动态部署。
要注意的是,下面说的插件化方式也不是官方提供也不提倡的,和热修复一样在中国比较合适,要和Android提供的 Android App Bundles
区分。
3.2 插件化例子
现在我们实现一个功能:在我们的apk中通过插件化集成另一个apk,应用启动的时候让集成进来的apk生效(因为是demo,所以我们直接就在项目中创建另一个apk)。
- 创建一个插件名为
plugin
(注意这里我们是选择Phone & Tablet Module
模拟一个插件apk),apk里面写一个类:
package com.example.plugin;
public class PluginClass {
public String plugin() {
return "I'm a plugin!";
}
}
- 生成apk,将这个apk放到主项目的
assets
目录下(当然,实际的项目可能会从网络下载或其他方式获取插件apk):
- 将apk加载进来:
那么你可能会有疑问了:外部apk我们怎么将它作为插件加入到我们主项目中来呢?我们知道android的apk打包后都有一个或多个dex文件,而dex文件里面是我们java文件的 .class
字节码文件:
既然dex文件是我们一些 .class
字节码文件,那么同样的也需要类加载器来加载,就是 DexClassLoader
,我们可以通过它来获取加载我们的apk中的那个 PluginClass
类。
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
File pluginApk = new File(getCacheDir() + "/plugin.apk");
if (!pluginApk.exists()) {
try(Source source = Okio.source(getAssets().open("plugin-debug.apk"));
BufferedSink sink = Okio.buffer(Okio.sink(pluginApk))) {
sink.writeAll(source);
} catch (IOException e) {
e.printStackTrace();
}
}
DexClassLoader dexClassLoader = new DexClassLoader(pluginApk.getPath(), getCacheDir().getPath(), null, null);
String pluginPrint = "";
try {
Class<?> clazz = dexClassLoader.loadClass("com.example.plugin.PluginClass");
Constructor<?> constructor = clazz.getDeclaredConstructor();
Object pluginObj = constructor.newInstance();
Method pluginMethod = clazz.getDeclaredMethod("plugin");
pluginPrint = (String) pluginMethod.invoke(pluginObj);
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
((TextView) findViewById(R.id.tv_plugin_test)).setText(pluginPrint);
}
}
上面的代码非常简单,就是读取我们 assets
目录的apk文件到本地,然后使用 DexClassLoader
将apk加载后使用反射调用。
3.3 新增界面、资源的插件
既然上面是通过反射才能够拿到数据展示,那么如果我的插件apk可能有界面Activity的怎么办?直接使用 Intent
跳转?肯定是行不通的,运行时会提示清单文件没有注册Activity。那要怎么做?可以提供代理Activity让它转发处理到插件apk的界面:
// 伪代码
public class ProxyActivity extends AppCompatActivity {
Object pluginActivity = DexClassLoader.loadClass("xxx");
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
pluginActivity.onCreate(savedInstanceState);
}
@Override
protected void onStart() {
super.onStart();
pluginActivity.onStart();
}
...
}
不过上面的方式也会导致要写很多的代理类。
还有另外一种方式就是通过欺骗系统,在系统检查完清单文件发现Activity注册后,在启动清单文件注册的Activity之前替换为我们要启动的Activity。不过这种方式说不准往后会被官方屏蔽也说不定,只使用于当前。
而新增在插件apk的资源又要怎么获取?需要重写 getResources()
扩展:
@Override
public Resources getResources() {
return new Resources(createAssetManager("插件apk本地目录"),
super.getResources().getDisplayMetrics(),
super.getResources().getConfiguration());
}
private AssetManager createAssetManager(String dexPath) {
try {
// addAssetPath()是AssetManager的方法
AssetManager am = AssetManager.class.newInstance();
Method addAssetPath = am.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(am, dexPath);
return sm;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
4 热修复技术
热修复技术主要是处理我们上线发布的版本需要紧急对bug进行修复,可能只是一些小改动,如果使用上面说的插件化的方式就不行了,插件化会替换掉所有的内容,这显然不符合我们预期,我们只想要替换掉修复bug更改的一个或几个java文件。
4.1 类加载机制
因为热修复技术需要了解类加载机制和反射相关原理和使用,所以首先还是需要有一个基本了解。在之前我写了一篇文章可以作为参考:
4.2 PathClassLoader和DexClassLoader
在Android中主要涉及有几个类加载器:
-
PathClassLoader
-
DexClassLoader
-
BaseDexClassLoader
PathClassLoader
和 DexClassLoader
都是 BaseDexClassLoader
的子类:
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
}
}
根据上面的源码可以发现,PathClassLoader
和 DexClassLoader
的区别在于,DexClassLoader
多了一个 optimizedDirectory
参数,optimizedDirectory
可以是apk外部的文件目录路径。
这产生的具体区别就是,DexClassLoader
可用于加载指定路径下的 .dex
文件,能支持动态插件化加载、热修复;PathClassLoader
就只能用于加载已经安装到系统中的apk文件中的 .dex
文件,不能从外部加载,也就不支持动态插件化加载、热修复。
4.3 热修复技术的原理
4.3.1 findClass()
上面分析了 PathClassLoader
和 DexClassLoader
的区别,发现它们都是 BaseDexClassLoader
的子类,具体的热修复原理分析也是在这里说明。
热修复干预类加载机制是在 ClassLoader.findClass()
处理:
BaseDexClassLoader.java
public class BaseDexClassLoader {
private DexPathList pathList;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
...
Class c = pathList.findClass(name, suppressedExceptions);
...
}
}
DexPathList.java
public class DexPathList {
private Element[] dexElements;
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;
}
}
}
Element.java
public class Element {
public Class<?> findClass(String name, ClassLoader definingContext, List<Throwable> suppressed) {
return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed) : null;
}
}
DexFile.java
public final class DexFile {
public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
return defineClass(name, loader, mCookie, this, suppressed);
}
private static class defineClass(String name, ClassLoader loader, Object cookie, DexFile dexFile, List<Throwable> suppressed) {
Class result = null;
try {
// native method
result = defineClassNative(name, loader, cookie, dexFile);
} catch (NoClassDefFoundError e) {
...
} catch (ClassNotFoundException e) {
...
}
return result;
}
}
上面的流程先简单梳理一下:
BaseDexClassLoader.findClass()
-> DexPathList.findClass()
-> Element.findClass()
-> DexFile.loadClassBinaryName()
-> DexFile.defineClass()
但是有几个点我们没有搞明白:DexPathList
是什么?dexElements
是什么?dexFile
又是什么?接下来一步步分析。
4.3.2 DexPathList、dexElement、dexFile
首先看下 DexPathList
是什么时候被初始化的:
public class BaseDexClassLoader {
private DexPathList pathList;
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) {
this(dexPath, librarySearchPath, parent, null, false);
}
public BaseDexClassLoader(String dexPath, String librarySearchPath, ClassLoader parent, ClassLoader[] sharedLibraryLoaders, boolean isTrusted) {
super(parent);
...
// DexPathList在ClassLoader创建的时候被初始化
this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);
...
}
}
可以发现,DexPathList
是在 BaseDexClassLoader
创建的时候初始化,进去 DexPathList
:
public class DexPathList {
private Element[] dexElements;
DexPathList(ClassLoader definingContext, String dexPath, String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
...
this.definingContext = definingContext;
// dexElements在DexPathList初始化的时候完成加载dex文件
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedException, definingContext, isTrusted);
}
private static List<File> splitDexPath(String path) {
return splitPaths(path, false);
}
private static List<File> splitPaths(String searchPath, boolean directoryOnly) {
List<File> result = new ArrayList<>();
if (searchPath != null) {
// File.pathSeparator在不同的系统是不一样的
// Windows系统:File.pathSeparator=";"
// Unix、Linux系统:File.pathSeparator=":"
// 这是系统环境变量Path分割拼接的路径
// 这里的操作就是将我们的apk的dex文件目录做拆分
// new DexClassLoader(path, ...)
// 也就是path可以是单个文件,也可以是一个目录,根据不同的操作系统对目录路径拆分
for (String path : searchPath.split(File.pathSeparator)) {
if (directoryOnly) {
try {
StructStat sb = Libcore.os.stat(path);
if (!S_ISDIR(sb.st_mode)) {
continue;
}
} catch (ErrnoException ignored) {
continue;
}
}
result.add(new File(path));
}
}
return result;
}
// 加载dex文件存储到dexElements数组中
private static Element[] makeDexElements(List<File> fiels, File optimizedDirectory, List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
Element[] elements = new Element[files.size()];
int elementsPos = 0;
for (File file : files) {
if (file.isDirectory()) {
elements[elementsPos++] = new Element(file);
} else if (file.isFile()) {
String name = file.getName();
DexFile dex = null;
// DEX_SUFFIX = ".dex"
// 如果是dex文件就加载,并且装进Element存到dexElements
if (name.endsWith(DEX_SUFFIX)) {
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
if (dex != null) {
elements[elementsPos++] = new Element(dex, null);
}
} catch (IOException suppressed) {
...
}
} else {
try {
// 不是dex文件同样也处理加载
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
...
}
// 无论是否为dex文件都装载进Element存到dexElements
if (dex == null) {
elements[elementsPos++] = new Element(file);
} else {
elements[elementsPos++] = new Element(dex, file);
}
}
}
}
...
}
private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader, Element[] elements) throws IOException {
if (optimizedDirectory == null) {
return new DexFile(file, loader, elements);
} else {
String optimizedPath = optimizedPathFor(file, optimizedDirectory);
return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
}
}
// 这个方法就是处理,如果不是dex文件,就帮你的文件加上后缀.dex
private static String optimizedPathFor(File path, File optimizedDirectory) {
String fileName = path.getName();
if (!fileName.endsWith(DEX_SUFFIX)) {
int lastDot = fileName.lastIndexOf(".");
if (lastDot < 0) {
fileName += DEX_SUFFIX;
} else {
StringBuilder sb = new StringBuilder(lastDot + 4);
sb.append(fileName, 0, lastDot);
sb.append(DEX_SUFFIX);
fileName = sb.toString();
}
}
return fileName;
}
}
通过上面的源码分析,dexElements
其实就是我们存放的 .dex
文件的数组,只不过将 .dex
封装进Element。它同样在ClassLoader创建出来的时候,将我们的文件通过 dexFile
加载出来。
4.4 分析干预类加载
经过上面的分析我们知道,既然主要的 .dex
加载完成的文件是存放在 dexElements
数组中,那么它就是一个切入点。
有两种方式可以干预:
-
全量替换
dexElements
数组 -
将我们修改的类文件先转成dex文件,然后自己封装放进Element,再将这个Element插入到
dexElements
数组前面
我们用一个例子来干预类加载达到热修复:提供两个按钮,点击 hotfix
按钮替换 dexElements
,点击 show text
按钮显示热修复后的内容。
4.4.1 全量替换dexElement数组
// 未热修复前的类
public class HotfixClass {
public String hotfix() {
return "I'm a original!";
}
}
// 热修复后的类
public class HotfixClass {
public String hotfix() {
return "I'm a hotfix!";
}
}
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final TextView tvText = findViewById(R.id.tv_text);
Button btnShowText = findViewById(R.id.btn_show_text);
Button btnHotFix = findViewById(R.id.btn_hotfix);
btnShowText.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
HotfixClass hotfixClass = new HotfixClass();
tvText.setText(hotfixClass.hotfix());
}
});
btnHotFix.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
File hotfixFile = new File(getCacheDir() + "/hotfix.apk");
try(Source source = Okio.source(getAssets().open("app-debug.apk"));
BufferedSink sink = Okio.buffer(Okio.sink(hotfixFile))) {
sink.writeAll(source);
} catch (IOException e) {
e.printStackTrace();
}
try {
ClassLoader classLoader = getClassLoader();
Class<BaseDexClassLoader> loaderClass = BaseDexClassLoader.class;
// 获取ClassLoader的DexPathList pathList成员变量
Field pathListFiled = loaderClass.getDeclaredField("pathList");
pathListFiled.setAccessible(true);
Object pathList = pathListFiled.get(classLoader);
// 获取DexPathList的Element[] dexElements成员变量
Class<?> pathListClass = pathList.getClass();
Field dexElementsField = pathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
// 创建我们的类加载器,加载出我们热修复需要的dexElements数组,替换旧的ClassLoader的dexElements
DexClassLoader hotfixClassLoader = new DexClassLoader(hotfixFile.getPath(), getCacheDir().getPath(), null, null);
Object newPathList = pathListFiled.get(hotfixClassLoader);
Object newDexElements = dexElementsField.get(newPathList);
dexElementsField.set(pathList, newDexElements);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
});
}
}
上面代码操作步骤如下:
-
通过反射获取
BaseDexClassLoader
里面的pathList
成员变量 -
再通过
pathList
反射获取dexElements
成员变量 -
自己创建一个ClassLoader加载我们自己的补丁文件生成
dexElements
-
替换旧的
dexElements
4.4.2 存在的问题
看起来没问题也运行正常,但实际上有一些弊端:
-
如果把程序kill掉,重新启动apk后直接点击
show text
热更新失效了(因为类加载机制这时候加载的是你没修改的那个类) -
我为了修复这个bug,替换了整个apk而不是只替换要修复的
HotfixClass
类 -
热更新要生效,需要重启apk(要让热更新生效只能如此)
4.4.2.1 解决程序kill掉后热修复失效问题
第二个问题是因为没有及时的将我们热修复的补丁文件加到 dexElements
中,可以将它提前到 Application.attachBaseContext()
执行:
public class HotfixApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
File hotfixFile = new File(getCacheDir() + "/hotfix.apk");
if (!hotfixFile.exists()) {
return;
}
try {
ClassLoader classLoader = getClassLoader();
Class<BaseDexClassLoader> loaderClass = BaseDexClassLoader.class;
// 获取ClassLoader的DexPathList pathList成员变量
Field pathListFiled = loaderClass.getDeclaredField("pathList");
pathListFiled.setAccessible(true);
Object pathList = pathListFiled.get(classLoader);
// 获取DexPathList的Element[] dexElements成员变量
Class<?> pathListClass = pathList.getClass();
Field dexElementsField = pathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
// 创建我们的类加载器,加载出我们热修复需要的dexElements数组,替换旧的ClassLoader的dexElements
DexClassLoader hotfixClassLoader = new DexClassLoader(hotfixFile.getPath(), getCacheDir().getPath(), null, null);
Object newPathList = pathListFiled.get(hotfixClassLoader);
Object newDexElements = dexElementsField.get(newPathList);
dexElementsField.set(pathList, newDexElements);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
4.4.2.2 解决替换整个apk而不是替换修复的类文件问题
第一个问题的解决方案就是我们只将我们要修改的类编译为 .dex
文件,然后插入到 dexElements
前面。
将 .class
文件编译为 .dex
需要使用到 d8
工具,这个工具存放在我们的sdk build-tools/xxx版本/d8
:
-
将
HotfixClass.java
使用命令javac HotfixClass.java
编译为HotfixClass.class
-
将
HotfixClass.class
转成.dex
文件,执行命令:
// Windows
d8.bat HotfixClass.class
// Unix、Linux
./d8 HotfixClass.class
输出:classes.dex
- 使用
classes.dex
作为热修复的补丁文件
因为修改的是一个 .dex
文件,如果还是使用上面的方案去全量替换 dexElements
将会导致应用崩溃,因为你是把整个apk替换称这个只有一个补丁 .dex
文件。
所以也就需要第二种热修复方案。
4.4.3 修改文件转成dex文件封装为Element插入到dexElement数组前面
public class HotfixApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
File hotfixFile = new File(getCacheDir() + "/hotfix.dex");
if (!hotfixFile.exists()) {
return;
}
try {
ClassLoader classLoader = getClassLoader();
Class<BaseDexClassLoader> loaderClass = BaseDexClassLoader.class;
// 获取ClassLoader的DexPathList pathList成员变量
Field pathListFiled = loaderClass.getDeclaredField("pathList");
pathListFiled.setAccessible(true);
Object pathList = pathListFiled.get(classLoader);
// 获取DexPathList的Element[] dexElements成员变量
Class<?> pathListClass = pathList.getClass();
Field dexElementsField = pathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
Object dexElements = dexElementsField.get(pathList);
// 创建我们的类加载器,加载出我们热修复需要的dexElements数组
DexClassLoader hotfixClassLoader = new DexClassLoader(hotfixFile.getPath(), getCacheDir().getPath(), null, null);
Object newPathList = pathListFiled.get(hotfixClassLoader);
Object newDexElements = dexElementsField.get(newPathList);
int oldLength = Array.getLength(dexElements);
int newLength = Array.getLength(newDexElements);
Object concatDexElements = Array.newInstance(dexElements.getClass().getComponentType(), oldLength + newLength);
for (int i = 0; i < newLength; i++) {
Array.set(concatDexElements, i, Array.get(newDexElements, i));
}
for (int i = 0; i < oldLength; i++) {
Array.set(concatDexElements, newLength + i, Array.get(dexElements, i));
}
dexElementsField.set(pathList, concatDexElements);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
通过将一个或多个需要修复的类文件打包成 .dex
文件的方式,在实际的项目开发当中要使用到热修复,我们就可以将我们要修改的补丁文件打成 .dex
文件,通过网络的方式推给apk,让apk在下次启动的时候生效。
需要注意的是,我们自己热修复的 .dex
文件封装的Element要插入 dexElements
数组前面而不是后面。
首先第一个原因就是 dexElements
是顺序遍历循环的:
public class DexPathList {
private Element[] dexElements;
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;
}
}
}
第二个原因是因为类加载机制在首次加载到这个类后,下一次获取会去查找缓存那个之前已加载过的类:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 第二次及往后查找缓存获取c != null,直接就会返回
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
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) {
// 第一次加载会遍历到dexElements
c = findClass(name);
}
}
return c;
}
5 总结
5.1 热修复原理的简单说明
简单来说,热修复的原理就是:
-
ClassLoader的dex文件替换
-
直接修改字节码
5.2 初始化流程和从dex文件查找类流程
初始化流程:
创建 BaseDexClassLoader
-> 创建 DexPathList
-> DexPathList.splitDexPath()
获取dex文件 -> DexPathList.makeDexElements()
加载dex文件封装为Element存储到 dexElements
数组
从dex文件查找类流程:
BaseDexClassLoader.findClass()
-> DexPathList.findClass()
-> 遍历 dexElements
调用 Element.findClass()
-> DexFile.loadClassBinaryName()
-> DexFile.defineClass()
5.3 插件化和热修复的区别
区别有两点:
-
插件化的内容在原App中没有,而热修复是在原App中的内容做改动
-
插件化在代码中有固定的入口,而热修复则可能改变任何一个位置的代码