热更新原理
热更新 / 热修复
不安装新版本的软件,直接从网络下载新功能模块来对软件进行局部更新
热更新和插件化的区别
区别有两点
- 插件化的内容在原App中没有,而热更新是原App中的内容做了改动
- 插件化在代码中有固定的入口,而热更新则可能改变任何一个位置的代码
热更新的原理
ClassLoader 的 dex 文件替换 直接修改字节码
前置知识:loadClass() 的类加载过程
- 宏观上:是一个带缓存的、从上到下的加载过程(即网上所说的「双亲委托机 制」)
- 对于具体的一个 ClassLoader:
- 先从自己的缓存中取
- 自己没有缓存,就找父 ClassLoader 要(parent.loadClass())
- 父 View 也没有,就自己加载(findClass())
- BaseDexClassLoader 或者它的子类(DexClassLoader、PathClassLoader 等)的 findClass():
- 通过它的 pathList.findClass()
- 它的 pathList.loadClass() 通过 DexPathList 的 dexElements 的 findClass()
- 所以热更新的关键在于,把补丁 dex 文件加载放进一个 Element,并且插 入到 dexElements 这个数组的前面(插入到后面的话会被忽略掉)
手写热更细
- 因为无法在更新之前就指定要更新谁;所以不能定义新的 ClassLoader,而只能 选择对 ClassLoader 进行修改,让它能够加载补丁里面的类
- 因为补丁的类在原先的 App 中已经存在,所以应该把补丁的 Element 对象插入 到 dexElements 的前面才行,插入到后面会被忽略掉。
- 具体的做法:反射
- 自己用补丁创建一个PathClassLoader
- 把补丁PathClassLoader里面的elements替换到旧的里面去
- 注意:
- 尽早加载热更新(通用手段是把加载过程放在 Application.attachBaseContext())
- 热更新下载完成后在需要时先杀死程序才能让补丁生效
- 优化:热更新没必要把所有内容都打过来,只要把改变的类拿过来就行了用 d8 把指定的 class 打包进 dex
- 完整化:从网上加载
- 再优化:把打包过程写一个task
整包替换(全量替换)
- 首先我们我们写一个页面,显示的文字是Plugin类提供的「我要热更新」
public class Plugin {
public static String getTitle(){
return "我要热更新";
}
}
- 然后我们修改这个类,热更细后显示的文字是「更新了」
return "更新了";
- 然后我们要将修改后的工程进行打包为hotfix.apk,将这个修改后的整包apk复制到assets/apk目录下,方便我们的复制
- 然后我们要将文字修改为更新前的「我要热更新」
- 下面编写热更新的代码,通过点击热更新按钮,完成热更新,主要包含以下两个步骤
- 1.复制apk文件
- 2.替换ClassLoader,加载新的dex文件
- 下面是代码,在点击热更新的时候调用它,然后强杀应用重启就有效果了
private void loadHotFix() { //1.复制文件 File apk = new File(getCacheDir() + "hotfix.apk"); if (!apk.exists()) { try (Source source = Okio.source(getAssets().open("apk/hotfix.apk")); BufferedSink sink = Okio.buffer(Okio.sink(apk));) { sink.writeAll(source); } catch (IOException e) { e.printStackTrace(); } } //2.替换classLoader,里面的dex文件 try { ClassLoader originalLoader = getClassLoader(); DexClassLoader classLoader = new DexClassLoader(apk.getPath(),getCacheDir().getPath(),null,null); Class loaderClass = BaseDexClassLoader.class; Field pathListField = loaderClass.getDeclaredField("pathList"); pathListField.setAccessible(true); Object pathListObject = pathListField.get(classLoader); Class pathListClass = pathListObject.getClass(); Field dexElementsField = pathListClass.getDeclaredField("dexElements"); dexElementsField.setAccessible(true); Object dexElementsObject = dexElementsField.get(pathListObject); Object originalPathListObject = pathListField.get(originalLoader); dexElementsField.set(originalPathListObject,dexElementsObject); //originalLoader.pathList.dexElements = classLoader.pathList.dexElement } catch (NoSuchFieldException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } }
以下是效果图
将热更新代码挂载到Application中
上面热更新功能每次都需要点击按钮才能触发,这显然不是我们需要的,我们需要的是App启动时就尽早加载了热更新,下面要移植代码到Application中
public class HotfixApp extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
//加载热更新插件
loadHotFix();
}
}
增量替换
在我们的开发中,热更新往往只是更新某几个类或者资源文件,上面的全量替换的方式显得很笨拙,那么增量替换就是很好的选择
上面的代码我们只需要替换Plugin类就可以了,我么可以只给Plugin编译打包就可以了
- 首先修改Plugin.java
public class Plugin { public static String getTitle(){ return "我是dex热修复后的标题"; } } - 然后将我们的Plugin.java编译为class文件
javac Plugin.java - 然后将class文件编译为dex文件
d8 Plugin.class - 最后将生成的classes.dex文件放在app的assets/apk目录下
- 再后修改热修复挂载代码,值得注意的是要把最新的补丁dex文件插入到最前面
public class HotfixApp extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
//加载热更新插件
//全量替换
//loadHotFix();
//增量替换
loadDexFix();
}
/**
* 增量替换,替换dex
*/
private void loadDexFix() {
//1.复制文件
File apk = new File(getCacheDir() + "hotfix.dex");
if (!apk.exists()) {
try (Source source = Okio.source(getAssets().open("apk/hotfix.dex"));
BufferedSink sink = Okio.buffer(Okio.sink(apk));) {
sink.writeAll(source);
} catch (IOException e) {
e.printStackTrace();
}
}
//2.替换classLoader,里面的dex文件
try {
ClassLoader originalLoader = getClassLoader();
DexClassLoader
classLoader = new DexClassLoader(apk.getPath(),getCacheDir().getPath(),null,null);
Class loaderClass = BaseDexClassLoader.class;
Field pathListField = loaderClass.getDeclaredField("pathList");
pathListField.setAccessible(true);
Object pathListObject = pathListField.get(classLoader);
Class pathListClass = pathListObject.getClass();
Field dexElementsField = pathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
Object dexElementsObject = dexElementsField.get(pathListObject);
Object originalPathListObject = pathListField.get(originalLoader);
Object originalDexElementsObject = dexElementsField.get(originalPathListObject);
//数组操作,把最新的补丁dex文件插入到最前面
int oldLength = Array.getLength(originalDexElementsObject);
int newLength = Array.getLength(dexElementsObject);
Object concatDexElementsObject = Array.newInstance(dexElementsObject.getClass().getComponentType(), oldLength + newLength);
for (int i = 0; i < newLength; i++) {
Array.set(concatDexElementsObject, i, Array.get(dexElementsObject, i));
}
for (int i = 0; i < oldLength; i++) {
Array.set(concatDexElementsObject, newLength + i, Array.get(originalDexElementsObject, i));
}
dexElementsField.set(originalPathListObject, concatDexElementsObject);
//全量替换伪代码 -> originalLoader.pathList.dexElements = classLoader.pathList.dexElement
//增量替换伪代码 -> originalLoader.pathList.dexElements += classLoader.pathList.dexElement
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
下面是效果图
流程完善
下面为我们的热更新功能增加一个「移除按钮」和「杀掉应用」的按钮
onClick{
...
//加载文字
case R.id.showText:
tv_hotfix.setText(Plugin.getTitle());
break;
//加载热更新dex文件
case R.id.hotFix:
loadHotFix();
break;
//移除热更新
case R.id.removehotFix:
File apk = new File(getCacheDir() + "/hotfix.dex");
if (apk.exists()){
apk.delete();
}
break;
//强杀进程
case R.id.killSelf:
android.os.Process.killProcess(android.os.Process.myPid());
break;
...
}
现在操作这些按钮
- 【显示文字】->“我要热更新”
- 【热更新】(加载dex修复文件)
- 【杀掉应用】(重启应用)
- 【显示文字】->“我是dex热修复后的标题”
- 【移除热更新】(移除热更新dex)
- 【杀掉应用】(重启应用)
- 【显示文字】->“我要热更新”
- ...
由此完成了一个完整的热更新演示过程
从网络下载补丁
热更新的补丁在实际开发中不可能存放在本地,一般都会存在服务器上 下面使用okhttp模拟下载过程
//从网络上下载补丁
case R.id.hotFix:
OkHttpClient client = new OkHttpClient();
final Request request = new Request.Builder()
.url("https://api.dsh.com/patch/upload/hotfix.dex")
.build();
client.newCall(request)
.enqueue(new Callback() {
@Override
public void onFailure(@NotNull Call call, @NotNull IOException e) {
v.post(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, "出错了", Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void onResponse(@NotNull Call call, @NotNull Response response) {
try (BufferedSink sink = Okio.buffer(Okio.sink(apk))) {
sink.write(response.body().bytes());
} catch (IOException e) {
e.printStackTrace();
}
v.post(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, "补丁加载成功", Toast.LENGTH_SHORT).show();
}
});
}
});
break;
补丁自动打包
在build.gradle中添加如下代码,并执行打包命令,这样就可以输出补丁的dex文件了,上传下载,搞定
def patchPath = 'com.dsh.txlessons.plugin.utils/Plugin'
task hotfix {
doLast {
exec {
commandLine 'rm', '-r', './build/patch'
}
exec {
commandLine 'mkdir', './build/patch'
}
exec {
commandLine 'javac', "./src/main/java/${patchPath}.java", '-d', './build/patch'
}
exec {
commandLine '/Users/dsh/Library/Android/sdk/build-tools/29.0.2/d8', "./build/patch/${patchPath}.class", '--output', './build/patch'
}
exec {
commandLine 'mv', "./build/patch/classes.dex", './build/patch/hotfix.dex'
}
}
}