热修复方案——Robust
问题提出
随着互联网技术的发展,Android手机客户端平台的APP产品也愈来愈多,但是Android手机应用不像web端一样可以实时更新,APP产品是有发版的概念的,对于线上的Bug很那有着即时生效的解决方式,每次发版都如履薄冰一般,毕竟再完善的开发测试流程也是没办法保证没有Bug到到线上的。有了线上Bug再打个包临时审核上线,匆忙之际不免会带来更多的Bug,审核周期也是会产生延误的,所以就产生了各类的热修复技术,本文就是讲述的美团的热更新技术Robust。
问题一:
APP平时都是在应用商店中发版,平时手机APP上有线上BUG的时候,还得匆忙修改打个包,再交由应用商店发布,对于一个初期创业团队,这样的问题是覆灭性的,因为BUG就会让人感觉体验不好,APP产品也就面临着被卸载的危险,千辛万苦推广得到的用户就因为这个原因离开了你。
问题二:
现如今对APP产品的稳定性要求很大,总不能因为BUG就去发布新版,这样给用户的体验也不太好,没多少人没事就去更新版本,一些电商类的应用也许就是因为发布的流程延误了一天乃至几天的流水量。所以说要想办法让用户打开APP就能够自动更新了,不需要再让人看着进度条慢吞吞的推进,看不到的情况下就已经好了。
解决方案
现在市面上有很多解决这样的问题的方案,热修复、热更新是他们的代名词,阿里、腾讯是做的最大的,当然稳定性也是最好的,不过这些都是需要使用他们的平台把补丁给推送出去,BUG对自己的产品都是要命的,所以把要命的东西交给别人管理未免有些不放心,所以就挑选了一个不用第三方平台的技术,让自己的后台把握住,自己也放心了很多。依托于自己的服务器推送或者让客户端自行下载,下载完成后自动加载并把下载好的补丁包删除,安全也有了一些保障。
robust热修复方案原理。
Robust插件对每个产品代码的每个函数都在编译打包阶段自动的插入了一段代码,插入过程对业务开发是完全透明。如State.java的getIndex函数:
public long getIndex() {
return 100;
}
被处理成如下的实现:
public static ChangeQuickRedirect changeQuickRedirect;
public long getIndex() {
if(changeQuickRedirect != null) {
//PatchProxy中封装了获取当前className和methodName的逻辑,并在其内部最终调用了changeQuickRedirect的对应函数
if(PatchProxy.isSupport(new Object[0], this, changeQuickRedirect, false)) {
return ((Long)PatchProxy.accessDispatch(new Object[0], this, changeQuickRedirect, false)).longValue();
}
}
return 100L;
}
可以看到Robust为每个class增加了个类型为ChangeQuickRedirect的静态成员,而在每个方法前都插入了使用changeQuickRedirect相关的逻辑,当 changeQuickRedirect不为null时,可能会执行到accessDispatch从而替换掉之前老的逻辑,达到fix的目的。
如果需将getIndex函数的返回值改为return 106,那么对应生成的patch,主要包含两个class:PatchesInfoImpl.java和StatePatch.java。
PatchesInfoImpl.java:
public class PatchesInfoImpl implements PatchesInfo {
public List<PatchedClassInfo> getPatchedClassesInfo() {
List<PatchedClassInfo> patchedClassesInfos = new ArrayList<PatchedClassInfo>();
PatchedClassInfo patchedClass = new PatchedClassInfo("com.meituan.sample.d", StatePatch.class.getCanonicalName());
patchedClassesInfos.add(patchedClass);
return patchedClassesInfos;
}
}
StatePatch.java:
public class StatePatch implements ChangeQuickRedirect {
@Override
public Object accessDispatch(String methodSignature, Object[] paramArrayOfObject) {
String[] signature = methodSignature.split(":");
if (TextUtils.equals(signature[1], "a")) {//long getIndex() -> a
return 106;
}
return null;
}
@Override
public boolean isSupport(String methodSignature, Object[] paramArrayOfObject) {
String[] signature = methodSignature.split(":");
if (TextUtils.equals(signature[1], "a")) {//long getIndex() -> a
return true;
}
return false;
}
}
客户端拿到含有PatchesInfoImpl.java和StatePatch.java的patch.dex后,用DexClassLoader加载patch.dex,反射拿到PatchesInfoImpl.java这个class。拿到后,创建这个class的一个对象。然后通过这个对象的getPatchedClassesInfo函数,知道需要patch的class为com.meituan.sample.d(com.meituan.sample.State混淆后的名字),再反射得到当前运行环境中的com.meituan.sample.d class,将其中的changeQuickRedirect字段赋值为用patch.dex中的StatePatch.java这个class new出来的对象。这就是打patch的主要过程。通过原理分析,其实Robust只是在正常的使用DexClassLoader,所以可以说这套框架是没有兼容性问题的。
大体流程如下:
客户端集成流程:
工具:Android studio
环境:gradle 2.10+,include 3.0,java 1.7+
官方demo地址:https://github.com/Meituan-Dianping/Robust
1、这里是使用了美团提供的两组插件,所以要在整个项目的最外层的build.gradle文件中dependencies中添加
classpath 'com.meituan.robust:gradle-plugin:0.4.82'
classpath 'com.meituan.robust:auto-patch-plugin:0.4.82'
2、然后是在app目录下的build.gradle中apply plugin: 'com.android.application'
下方加入
//编译插件时打开 //apply plugin: 'auto-patch-plugin' apply plugin: 'robust'
Dependencies中添加依赖
implementation 'com.meitaun.robust::robust:0.4.82'
3、并在清单文件AndroidManifest中注册所需要的权限,并在手机中加入动态申请权限的过程,没有文件读取权限将无法加载补丁包。
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE/>
4、然后创建一份叫做robust的xml文件,可以直接就在官方demo中copy一份放在app目录之下,官方demo下的该文件配置注释很清楚,若无特殊需求,不需要做大型变动,只需要注意三个地方
<packname name="hotfixPackage">
<exceptPackname name="exceptPackage">
<patchPackname name="patchPackname">
这三个地方都是在下方有标注的,可以根据自己的需求修改
<?xml version="1.0" encoding="utf-8"?>
<resources>
<switch>
<!--true代表打开Robust,请注意即使这个值为true,Robust也默认只在Release模式下开启-->
<!--false代表关闭Robust,无论是Debug还是Release模式都不会运行robust-->
<turnOnRobust>true</turnOnRobust>
<!--<turnOnRobust>false</turnOnRobust>-->
<!--是否开启手动模式,手动模式会去寻找配置项patchPackname包名下的所有类,自动的处理混淆,然后把patchPackname包名下的所有类制作成补丁-->
<!--这个开关只是把配置项patchPackname包名下的所有类制作成补丁,适用于特殊情况,一般不会遇到-->
<!--<manual>true</manual>-->
<manual>false</manual>
<!--是否强制插入插入代码,Robust默认在debug模式下是关闭的,开启这个选项为true会在debug下插入代码-->
<!--但是当配置项turnOnRobust是false时,这个配置项不会生效-->
<!--<forceInsert>true</forceInsert>-->
<forceInsert>false</forceInsert>
<!--是否捕获补丁中所有异常,建议上线的时候这个开关的值为true,测试的时候为false-->
<catchReflectException>true</catchReflectException>
<!--<catchReflectException>false</catchReflectException>-->
<!--是否在补丁加上log,建议上线的时候这个开关的值为false,测试的时候为true-->
<!--<patchLog>true</patchLog>-->
<patchLog>false</patchLog>
<!--项目是否支持progaurd-->
<proguard>true</proguard>
<!--<proguard>false</proguard>-->
<!--项目是否支持ASM进行插桩,默认使用ASM,推荐使用ASM,Javaassist在容易和其他字节码工具相互干扰-->
<useAsm>true</useAsm>
<!--<useAsm>false</useAsm>-->
</switch>
<!--需要热补的包名或者类名,这些包名下的所有类都被会插入代码-->
<!--这个配置项是各个APP需要自行配置,就是你们App里面你们自己代码的包名,
这些包名下的类会被Robust插入代码,没有被Robust插入代码的类Robust是无法修复的-->
<packname name="hotfixPackage">
<name> test.cloundcore.com.robusttest</name>
</packname>
<!--不需要Robust插入代码的包名,Robust库不需要插入代码,如下的配置项请保留,还可以根据各个APP的情况执行添加-->
<exceptPackname name="exceptPackage">
<name>com.meituan.robust</name>
<name>com.meituan.sample.extension</name>
</exceptPackname>
<!--补丁的包名,请保持和类PatchManipulateImp中fetchPatchList方法中设置的补丁类名保持一致( setPatchesInfoImplClassFullName("com.meituan.robust.patch.PatchesInfoImpl")),
各个App可以独立定制,需要确保的是setPatchesInfoImplClassFullName设置的包名是如下的配置项,类名必须是:PatchesInfoImpl-->
<patchPackname name="patchPackname">
<name> test.cloundcore.com.robusttest</name>
</patchPackname>
<!--自动化补丁中,不需要反射处理的类,这个配置项慎重选择-->
<noNeedReflectClass name="classes no need to reflect">
</noNeedReflectClass>
</resources>
5、然后声明一个类并继承PatchManipulate,实现里面的三个方法,这个类就是一个线程,用来做获取补丁的耗时操作。
package test.cloundcore.com.robusttest;
import android.content.Context;
import android.os.Environment;
import android.util.Log;
import com.meituan.robust.Patch;
import com.meituan.robust.PatchManipulate;
import com.meituan.robust.RobustApkHashUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
/**
* Created by Administrator on 2018/11/27.
*
* 我们推荐继承PatchManipulate实现你们App独特的A补丁加载策略,其中setLocalPath设置补丁的原始路径,这个路径存储的补丁是加密过得,setTempPath存储解密之后的补丁,是可以执行的jar文件
* setTempPath设置的补丁加载完毕即刻删除,如果不需要加密和解密补丁,两者没有啥区别
*/
public class PatchManipulateImp extends PatchManipulate {
//联网获取最新的补丁
@Override
protected List<Patch> fetchPatchList(Context context) {
//将app自己的robustApkHash上报给服务端,服务端根据robustApkHash来区分每一次apk build来给app下发补丁
//apkhash is the unique identifier for apk,so you cannnot patch wrong apk.
String robustApkHash = RobustApkHashUtils.readRobustApkHash(context);
Log.w("robust","robustApkHash :" + robustApkHash);
//connect to network to get patch list on servers
//在这里去联网获取补丁列表
Patch patch = new Patch();
patch.setName("123");
//LocalPath是存储原始的补丁文件,这个文件应该是加密过的,TempPath是加密之后的,TempPath下的补丁加载完毕就删除,保证安全性
//这里面需要设置一些补丁的信息,主要是联网的获取的补丁信息。重要的如MD5,进行原始补丁文件的简单校验,以及补丁存储的位置,这边推荐把补丁的储存位置放置到应用的私有目录下,保证安全性
patch.setLocalPath(Environment.getExternalStorageDirectory().getPath()+ File.separator+"robust"+File.separator + "patch");
//setPatchesInfoImplClassFullName 设置项各个App可以独立定制,需要确保的是setPatchesInfoImplClassFullName设置的包名是和xml配置项patchPackname保持一致,而且类名必须是:PatchesInfoImpl
//请注意这里的设置
patch.setPatchesInfoImplClassFullName("test.cloundcore.com.robusttest.PatchesInfoImpl
");
List patches = new ArrayList<Patch>();
patches.add(patch);
return patches;
}
//可以再次做patch.jar的验证
@Override
protected boolean verifyPatch(Context context, Patch patch) {
//放到app的私有目录
patch.setTempPath(context.getCacheDir()+ File.separator+"robust"+File.separator + "patch");
try {
copy(patch.getLocalPath(), patch.getTempPath());
}catch (Exception e){
e.printStackTrace();
throw new RuntimeException("copy source patch to local patch error, no patch execute in path "+patch.getTempPath());
}
return true;
}
//普通IO流操作
public void copy(String srcPath,String dstPath) throws IOException {
File src=new File(srcPath);
if(!src.exists()){
throw new RuntimeException("source patch does not exist ");
}
File dst=new File(dstPath);
if(!dst.getParentFile().exists()){
dst.getParentFile().mkdirs();
}
InputStream in = new FileInputStream(src);
try {
OutputStream out = new FileOutputStream(dst);
try {
// Transfer bytes from in to out
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
} finally {
out.close();
}
} finally {
in.close();
}
}
//在这里检查patch是否存在手机里
@Override
protected boolean ensurePatchExist(Patch patch) {
return true;
}
}
6、在需要加载补丁的位置调用PatchManipulateImp,如下:
new PatchExecutor(getApplicationContext(), new PatchManipulateImp(), new RobustCallBack() {
//获取补丁列表后,调此方法
@Override
public void onPatchListFetched(boolean result, boolean isNet, List<Patch> patches) {
}
//在获取补丁后,回调此方法
@Override
public void onPatchFetched(boolean result, boolean isNet, Patch patch) {
}
//在补丁打入应用后,调此方法
@Override
public void onPatchApplied(boolean result, Patch patch) {
}
@Override
public void logNotify(String log, String where) {
}
@Override
public void exceptionNotify(Throwable throwable, String where) {
}
}).start();
7、现在我们可以生成release版本的APK安装包了,记得把混淆开启V1V2都要点击对勾,因为我们需要混淆后的出来的maping文件
先解释一下,在生成 apk 的时候使用 apply plugin:'robust',该插件会生成打补丁时需要的方法记录文件 methodMap.robust,该文件在打补丁的时候用来区别到底哪些方法需要被修复,所以有它才能打补丁。而上文所说的还有 mapping.txt 文件,该文件列出了原始的类,方法和字段名与混淆后代码间的映射。这个文件很重要,可以用它来翻译被混淆的代码。但也不是必须的,如果不需要混淆,可以不保留。这两个文件在生成apk后,分别在 build/outputs/robust/methodsMap.robust,build/outputs/mapping/mapping.txt(需要开启混淆后才会出现),我们需要自己分别拷贝到 app/robust 下,在 app 目录新建个叫 robust 的文件夹,把这两个文件放进去就 ok 了。
8、然后就是修改代码,生成补丁包
修改前:
修改后:
修改的方法前面加上@Modify,新增加的方法前面加上@add注解,对于存有Lambada表达式就在修改的方法里面调用RobustModify.modify(),修改完成之后,再把之前app里面的build.gradle注释掉的打开
apply plugin: 'auto-patch-plugin'
9、还是之前生成apk的流程,会产生报错auto path end successfully
然后你会发现app/build/outputs/robust/中多了一个叫做patch.jar的文件,这就是我们生成的补丁包了。然后可以放在服务器上可以通过下载或者推送的形式让手机客户端得到并放在之前定好的文件夹中,我设置好的手机文件夹是robust,这里我是通过adb命令push进入手机中的文件夹中的。
画面展示修改前:
画面展示加载补丁后:
至此热修复已经讲解完毕,有其他需求,请根据项目修改。