Android热修复
常用热修复解决方案实现原理
什么是热修复?
定义:在我们应用上线后出现bug需要及时修复时,不用再发新的安装包,只需要发布补丁包,在客户无感知下修复掉bug。
怎么进行热修复?
开发端:生成补丁包 -Gradle插件patch
服务端:补丁包管理
用户端:执行热修复
为什么类替换方案
—不能及时生效?
热修复需要解决的问题
-
开发端:
- 补丁包是什么?
- 如何生成补丁包?
- 开启混淆后呢?
- 对比改动自动生成补丁包(gradle)?
-
用户端
- 什么时候执行热修复?
- 越早越好要在有bug类之前执行,application 里面;
- 如果已经加载过,就修复不了了,因为ClassLoader有缓存了;
- 怎么执行热修复(使用补丁包)?
- Android版本兼容问题?
插桩式热修复落地
热修复方案很多,比较出名的下面四个要记住腾讯Tinker,阿里的AndFix,美团的Robust,以及Qzone
- 什么时候执行热修复?
-
AndFix native动态替换java层方法,native层hook java层代码;
从补丁包加载类,Test.class,通过反射拿到test方法和注解,JNI层替换到补丁包里面方法的属性,
public class Test{
@MethodReplace(class= "xxx.xxx.Test",method= "test")
public void test(){
//...
}
}
- Robus 热修复解决方案:
- 同样提供Gradle插件
- 对每个产品代码在每个函数都在编译打包阶段自动插入一段代码;
//State.java的getIndex函数 public long getIndex() { return 100; } public static ChangeQuickRedirect changeQuickRedirect; public long getIndex() { //相当于插桩了开关,当补丁包通过类加载反射实例化,给这个静态changeQuickRedirect 赋值,就走if语句,否则开关关闭; 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的目的。
- Tinker
bsdiff 作用:增量更新(今日头条/抖音都有存在)
用法:
1.bsdiff 1.txt 2.txt patch
//得到patch差分包
2.bspatch 1.txt 2.txt patch
//下载patch和原包合并
DexDifff
Tinker 通过计算对比指定的Base APK中的dex和修改之后的APK中的Dex区别,补丁包中的内容就是两者差分的描述。运行时将Base APK与补丁包进行合并,重启后合成新的dex文件。
DexPathList.java 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;
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
怎么执行热修复(使用补丁包)?
- 获取到当前应用的PathClassloader;
- 反射获取到DexPathList属性对象pathList;
- 反射修改pathList的dexElements
- 把补丁包patch.dex转化为Element[] (patch)
- 获得pathList的dexElements属性(old)
- patch+dexElements合并,并反射赋值给pathList的dexElements
制作补丁包流程:
1、把Bug修复掉后,先生成类的class文件。
2、执行命令:dx --dex --output=patch.jar com/enjoy/enjoyfix/Utils.class
应用补丁包: patchElment(补丁包生成的) + oldElement(APK原有的) 赋值给oldElement
DexPathList.java
//splitDexPath 多个dex的时候,a.dex:b.dex 拆分
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions, definingContext, isTrusted);
1、获取程序的PathClassLoader对象
2、反射获得PathClassLoader父类BaseDexClassLoader的pathList对象
3、反射获取pathList的dexElements对象 (oldElement)
4、把补丁包变成Element数组:patchElement(反射执行makePathElements)
5、合并patchElement+oldElement = newElement (Array.newInstance)
6、反射把oldElement赋值成newElement
/**
* 1、获取程序的PathClassLoader对象
* 2、反射获得PathClassLoader父类BaseDexClassLoader的pathList对象
* 3、反射获取pathList的dexElements对象 (oldElement)
* 4、把补丁包变成Element数组:patchElement(反射执行makePathElements)
* 5、合并patchElement+oldElement = newElement (Array.newInstance)
* 6、反射把oldElement赋值成newElement
*
* @param application
* @param patch
*/
public static void installPatch(Application application, File patch) {
File hackDex = initHack(application);
List<File> patchs = new ArrayList<>();
patchs.add(hackDex);
if (patch.exists()) {
patchs.add(patch);
}
//1、获取程序的PathClassLoader对象
ClassLoader classLoader = application.getClassLoader();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
try {
ClassLoaderInjector.inject(application, classLoader, patchs);
} catch (Throwable throwable) {
}
return;
}
//2、反射获得PathClassLoader父类BaseDexClassLoader的pathList对象
try {
Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
Object pathList = pathListField.get(classLoader);
//3、反射获取pathList的dexElements对象 (oldElement)
Field dexElementsField = ShareReflectUtil.findField(pathList, "dexElements");
Object[] oldElements = (Object[]) dexElementsField.get(pathList);
//4、把补丁包变成Element数组:patchElement(反射执行makePathElements)
Object[] patchElements = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Method makePathElements = ShareReflectUtil.findMethod(pathList, "makePathElements",
List.class, File.class,
List.class);
ArrayList<IOException> ioExceptions = new ArrayList<>();
patchElements = (Object[])
makePathElements.invoke(pathList, patchs, application.getCacheDir(), ioExceptions);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
Method makePathElements = ShareReflectUtil.findMethod(pathList, "makeDexElements",
ArrayList.class, File.class, ArrayList.class);
ArrayList<IOException> ioExceptions = new ArrayList<>();
patchElements = (Object[])
makePathElements.invoke(pathList, patchs, application.getCacheDir(), ioExceptions);
}
//5、合并patchElement+oldElement = newElement (Array.newInstance)
//创建一个新数组,大小 oldElements+patchElements
// int[].class.getComponentType() ==int.class
Object[] newElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(),
oldElements.length + patchElements.length);
System.arraycopy(patchElements, 0, newElements, 0, patchElements.length);
System.arraycopy(oldElements, 0, newElements, patchElements.length, oldElements.length);
//6、反射把oldElement赋值成newElement
dexElementsField.set(pathList, newElements);
} catch (Exception e) {
e.printStackTrace();
}
}
makePathElements参数:
1、补丁包:List[new File("/sdcard/patch.jar")]
2、optimizedDirectory 传一个私有目录就行比如:context.getCacheDir()
3、ArrayList suppressedExceptions = new ArrayList();
有两个兼容问题:
-
在不同的版本中可能不是makePathElement,或者 参数会不同,需要适配!
-
Android N 混合编译问题:热点代码记录后空闲编译,启动后就会插到ClassLoader;
ART 是在 Android KitKat(Android 4.0)引入并在 Lollipop(Android 5.0)中设为默认运行环境,可以看作Dalvik2.0。 ART模式在Android N(7.0)之前安装APK时会采用AOT(Ahead of time:提前编译、静态编译)预编译为机器码。 而在Android N使用混合模式的运行时。应用在安装时不做编译,而是运行时解释字节码,同时在JIT编译了一 些代码后将这些代码信息记录至Profile文件,等到设备空闲的时候使用AOT(All-Of-the-Time compilation:全 时段编译)编译生成称为app_image的base.art(类对象映像)文件,这个art文件会在apk启动时自动加载(相当 于缓存)。根据类加载原理,类被加载了无法被替换,即无法修复。
混合编译热修复解决方案
运行时替换PathClassLoader方案 :
上面所说app_image文件中的class是插入到PathClassloader中的ClassTable中。假设我们完全废弃掉PathClassloader,而 采用一个新建Classloader来加载后续的所有类,即可达到将cache无用化的效果。
//总共替换三处:LoadedApk.class Resource.class DrawableInflater.class 属性 ClassLoader classloader
private static void doInject(Application app, ClassLoader classLoader) throws Throwable {
Thread.currentThread().setContextClassLoader(classLoader);
Context baseContext = (Context) ShareReflectUtil.findField(app, "mBase").get(app);
if (Build.VERSION.SDK_INT >= 26) {
ShareReflectUtil.findField(baseContext, "mClassLoader").set(baseContext, classLoader);
}
Object basePackageInfo = ShareReflectUtil.findField(baseContext, "mPackageInfo").get(baseContext);
ShareReflectUtil.findField(basePackageInfo, "mClassLoader").set(basePackageInfo, classLoader);
if (Build.VERSION.SDK_INT < 27) {
Resources res = app.getResources();
try {
ShareReflectUtil.findField(res, "mClassLoader").set(res, classLoader);
final Object drawableInflater = ShareReflectUtil.findField(res, "mDrawableInflater").get(res);
if (drawableInflater != null) {
ShareReflectUtil.findField(drawableInflater, "mClassLoader").set(drawableInflater, classLoader);
}
} catch (Throwable ignored) {
// Ignored.
}
}
}
- 假如MainActivity 引用到Uitls都在同一个dex中的话,那么这个MainActivity就会被打上CLASS_ISPREVERIFIED:标明MainActivity不需要跨dex调用,如果此时修复Utils里的bug,编辑单独的dex文件,MainActivity就需要跨dex调用,产生冲突
if (ClassVerifier.PREVENT_VERIFY) {
//AntilazyLoad类会被打包成单独的hack.dex;
//classes.dex内的类都会引用一个在不相同dex中的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志了,只要没被打上这个标志的类都可以进行打补丁操作。
System.out.println(AntilazyLoad.class);
}
这里用到了字节码插桩技术
字节码插桩
插件实现
- Gradle 本身就是java程序;帮我们完成一系列javac、dx、aapt、等命令行;
- AfterEvaluater{} 注册一个监听,等Gradle解析完执行;
字节码插桩要在此时执行,也是编译出class文件,执行dx之前; - ASM:操作Java 字节码的框架,ClassReader读取字节码,ClassVistor解析,ClassWriter生成字节码;
static byte[] referHackWhenInit(InputStream inputStream) throws IOException {
// class的解析器
ClassReader cr = new ClassReader(inputStream)
// class的输出器
ClassWriter cw = new ClassWriter(cr, 0)
// class访问者,相当于回调,解析器解析的结果,回调给访问者
ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, cw) {
//要在构造方法里插桩 init
@Override
public MethodVisitor visitMethod(int access, final String name, String desc,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
mv = new MethodVisitor(Opcodes.ASM5, mv) {
@Override
void visitInsn(int opcode) {
//在构造方法中插入AntilazyLoad引用
if ("<init>".equals(name) && opcode == Opcodes.RETURN) {
//引用类型
//基本数据类型 : I J Z
super.visitLdcInsn(Type.getType("Lcom/enjoy/patch/hack/AntilazyLoad;"));
}
super.visitInsn(opcode);
}
};
return mv;
}
};
//启动分析
cr.accept(cv, 0);
return cw.toByteArray();
}
自动化补丁方案
gradle 插件是启动一个JVM来执行;
apply plugin:‘com.android.application’
apply plugin:‘com.android.library’
方式 | 说明 |
---|---|
Build script脚本 | 把插件写在 build.gradle 文件中,一般用于简单的逻辑,只在该 build.gradle 文件可见 |
buildSrc目录 | 将插件源代码放在 buildSrc/src/main/groovy/ 中,只对该项目中可 见 |
独立项目 | 一个独立的 Java 项目/模块,可以将文件包发布到仓库(Jcenter), 使其他项目方便引入 |
那些类需要打包进补丁包?如何筛选?
.class->MD5
如何用代码打包进补丁包;
Runtime.get Runtime
混淆怎么办?
保障这次混淆和上次混淆出相同的类名