在之前介绍的3种主流框架中,Tinker是采用动态加载dex文件,依赖类加载机制实现重启生效的热修复技术,通过差分打包的方式,将补丁包放入dexElement数组中,这种技术也是最常用的一种方式,那么首先了解一下Tinker的差分打包
腾讯Tinker的开源框架中,提供了一个差分打包的工具类,大家可以从Tinker的源码中找到
其中BisDiff中的bsdiff方法,用于将两个文件差分打包,打成一个diffFile差分包
public static void bsdiff(File oldFile, File newFile, File diffFile) throws IOException {
InputStream oldInputStream = new BufferedInputStream(new FileInputStream(oldFile));
InputStream newInputStream = new BufferedInputStream(new FileInputStream(newFile));
OutputStream diffOutputStream = new FileOutputStream(diffFile);
try {
byte[] diffBytes = bsdiff(oldInputStream, (int) oldFile.length(), newInputStream, (int) newFile.length());
diffOutputStream.write(diffBytes);
} finally {
diffOutputStream.close();
}
}
我们可以试一下,并使用gradle创建一个插件实现差分打包
首先创建buildSrc文件夹,之前在gradle的文章中介绍过,可自行查看
第一步 创建差分任务 – 执行差分打包的操作
class BSDiffTask extends DefaultTask{
@TaskAction
void action(){
//获取输入
def olfFile = inputs.files.files[0]
def newFile = inputs.files.files[1]
def diffFile = outputs.files.files[0]
BSDiff.bsdiff(olfFile,newFile,diffFile)
}
}
第二步 创建差分插件,提供各个模块使用,分配输入输出
class BsDiffPlugin implements Plugin<Project>{
@Override
void apply(Project target) {
//指定输出输入
target.afterEvaluate {
target.task(type:BSDiffTask,"bsdiff"){
inputs.files "${project.buildDir}/bsdiff/hello.txt","${project.buildDir}/bsdiff/hello_good.txt"
outputs.files "${project.buildDir}/bsdiff/hello_patch"
}
}
}
}
plugins {
id 'com.android.application'
id 'kotlin-android'
}
//依赖这个插件
apply plugin:BsDiffPlugin
在完成这个插件依赖之后,在GradleTask中新增了一个bsdiff任务,可以执行这个差分任务
生成了一个patch包
现在还有一个疑问,像kotlin-android这个插件,为什么可以放在plugins中,如果自己做的插件想要这样使用,其实很简单
在resource/META-INF/gradle-plugins文件夹下,创建属性文件,文件名就是在plugin中的id
implementation-class=com.dd.bsdiff.plugin.bs.BsDiffPlugin
内容就是通过implementation-class导入插件的全类名,这种方式的好处就是易于扩展,只需要改变属性文件内容即可,不需要修改id
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'com.bsdiff.plugin'
}
在BsDiffPlugin插件中,输入和输出是写死的,这在开发中是万万不可行的,所以这是一个优化点
方案1:gradle.properties
# app/gradle.properties
oldFile=build/bsdiff/hello.txt
newFile=build/bsdiff/hello_good.txt
diffFile=build/bsdiff/hello_path2.txt
在gradle.properties中声明的变量,在gradle中是可以直接使用的,在任何任务中也可以随便使用
class BsDiffPlugin implements Plugin<Project>{
@Override
void apply(Project target) {
//指定输出输入
target.afterEvaluate {
target.task(type:BSDiffTask,"bsdiff"){
//
// inputs.files "${project.buildDir}/bsdiff/hello.txt","${project.buildDir}/bsdiff/hello_good.txt"
// outputs.files "${project.buildDir}/bsdiff/hello_patch"
inputs.files project.getProperties().get("oldFile"),project.getProperties().get("newFile")
outputs.files project.getProperties().get("diffFile")
}
}
}
}
project.getProperties获取到的就是在properties中声明的键值对,可以根据key来获取对应的值
方案2:扩展
ext {
diff = [
oldFile : "${project.buildDir}/bsdiff/hello.txt",
newFile : "${project.buildDir}/bsdiff/hello_good.txt",
diffFile: "${project.buildDir}/bsdiff/hello_path3"
]
}
class BsDiffPlugin implements Plugin<Project>{
@Override
void apply(Project target) {
//指定输出输入
target.afterEvaluate {
target.task(type:BSDiffTask,"bsdiff"){
inputs.files project.diff.get("oldFile"),project.diff.get("newFile")
outputs.files project.diff.get("diffFile")
}
}
}
}
project.diff通过获取扩展定义的map也可以实现
class BsPatchPlugin implements Plugin<Project>{
@Override
void apply(Project target) {
target.afterEvaluate {
target.task(type:BSPatchTask,"patch"){
inputs.files project.diff.get("oldFile"),project.diff.get("diffFile")
outputs.files project.diff.get("pathFile")
}
}
}
}
class BSPatchTask extends DefaultTask{
@TaskAction
void patch(){
def oldFile = inputs.files.files[0]
def diffFile = inputs.files.files[1]
def newFile = outputs.files.first()
BSPatch.patchFast(oldFile,newFile,diffFile,0)
}
}
差分合并就是使用Tinker的BSPatch中的patchFast方法实现合并,最终输出的文件就是合并之后的,apk也是同理
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
throw IllegalArgumentException()
}
}
当前App在启动的时候,就会闪退,因为报了一个错误,假设上线版本之后,发现有这个问题,那么就需要热修复
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// throw IllegalArgumentException()
}
}
之后我修复了这个问题,那么需要使用插件来差分然后合并
像Tinker实现的原理,就是依赖类加载机制,通过动态加载dex补丁包实现热修复,如果想要手写一个热修复框架,可以通过Hook的方式动态加载dex文件
在之前的Android热修复技术 – 类加载机制中介绍过,Android的类加载同样遵循双亲委派机制,我们自己写的类都是PathClassLoader来加载的,如果PathClassLoader的父类加载器BootClassLoader没有加载这个类,那么就由PathClassLoader来findClass查找这个类,因此思路就产生了:
1 获取PathClassLoader
2 获取PathClassLoader的DexPathList(其实是BaseDexClassLoader)
3 获取DexPathList中的dexElements,将dex动态插入到数组第0个位置
fun getField(any: Any, name: String): Field? {
//获取当前对象的Class
var objectClass = any.javaClass
while (objectClass != Any::class.java) {
//获取想要的属性
try {
val field = objectClass.getDeclaredField(name)
field.isAccessible = true
return field
} catch (e: Exception) {
//这个可能会抛属性找不到的异常
}
objectClass = objectClass.superclass
}
return null
}
这里关于反射的技术点,需要说一下getDeclaredField和getField的区别
getDeclaredField : 能够反射当前类的全部属性(private、public、protect…),但是不会去父类查找
getField :能够反射当前类的public属性,而且能够向父类查找这个属性
综合来说,getDeclaredField最能被接受,只是需要通过递归的方式向上查找这个属性;例如热修复,因为pathList在BaseDexClassLoader中,如果只从PathClassLoader中查找肯定查不到,需要向父类去查找;获取方法同理
fun getMethod(any: Any,name: String, parameterTypes:Class<*>):Method?{
//获取当前对象的Class
var objectClass = any.javaClass
while (objectClass != Any::class.java){
//获取想要的属性
val field = objectClass.getDeclaredMethod(name,parameterTypes)
if(field != null){
//可访问
field.isAccessible = true
return field
}
objectClass = objectClass.superclass
}
return null
}
//获取PathClassLoader
val classLoader = context.classLoader
//获取PathClassLoader的pathList
val pathList = ReflectUtils.getField(classLoader,"pathList")
Log.e("TAG","$pathList")
//获取pathList的属性值 DexPathList[[zip file "/data/app/com.tal.bsdiff-kly9VUu4YKX77N9S6kiUaw==/base.apk"],nativeLibraryDirectories=[/data/app/com.tal.bsdiff-kly9VUu4YKX77N9S6kiUaw==/lib/x86, /system/lib]]
val dexPathList= pathList?.get(classLoader)
Log.e("TAG","$dexPathList")
if(dexPathList != null){
//获取dexElement
val dexElements = ReflectUtils.getField(dexPathList,"dexElements")
Log.e("TAG","$dexElements")
//获取dexElements的属性值
val dexElementArray = dexElements?.get(dexPathList)
Log.e("TAG","$dexElementArray")
//把dex文件,放在数组的第0个位置
}
从代码中看到,其实获取dexElement数组的值很简单,只是通过反射两个属性pathList以及dexElements就可以拿到,最关键的点就在于,如果把我们的dex文件跟dexElement数组放在一起。
首先 看一下BaseDexClassLoader中的一个方法addDexPath,注意这个方法是被隐藏了,在外部是调用不到的 @UnsupportedAppUsage
## BaseDexClassLoader/addDexPath
/**
* @hide
*/
@UnsupportedAppUsage
@libcore.api.CorePlatformApi
public void addDexPath(String dexPath) {
addDexPath(dexPath, false /*isTrusted*/);
}
/**
* @hide
*/
@UnsupportedAppUsage
public void addDexPath(String dexPath, boolean isTrusted) {
pathList.addDexPath(dexPath, null /*optimizedDirectory*/, isTrusted);
}
最终实现是在DexPathList中实现的这个方法
## DexPathList.java / addDexPath
public void addDexPath(String dexPath, File optimizedDirectory, boolean isTrusted) {
final List<IOException> suppressedExceptionList = new ArrayList<IOException>();
final Element[] newElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptionList, definingContext, isTrusted);
if (newElements != null && newElements.length > 0) {
final Element[] oldElements = dexElements;
dexElements = new Element[oldElements.length + newElements.length];
//新创建的数组是放在后面
System.arraycopy(
oldElements, 0, dexElements, 0, oldElements.length);
System.arraycopy(
newElements, 0, dexElements, oldElements.length, newElements.length);
}
if (suppressedExceptionList.size() > 0) {
final IOException[] newSuppressedExceptions = suppressedExceptionList.toArray(
new IOException[suppressedExceptionList.size()]);
if (dexElementsSuppressedExceptions != null) {
final IOException[] oldSuppressedExceptions = dexElementsSuppressedExceptions;
final int suppressedExceptionsLength = oldSuppressedExceptions.length +
newSuppressedExceptions.length;
dexElementsSuppressedExceptions = new IOException[suppressedExceptionsLength];
System.arraycopy(oldSuppressedExceptions, 0, dexElementsSuppressedExceptions,
0, oldSuppressedExceptions.length);
System.arraycopy(newSuppressedExceptions, 0, dexElementsSuppressedExceptions,
oldSuppressedExceptions.length, newSuppressedExceptions.length);
} else {
dexElementsSuppressedExceptions = newSuppressedExceptions;
}
}
}
在这个方法中,调用了makeDexElements方法,这个方法一共就在2个地方调用,一个是DexPathList的构造方法中,还有一个就是在addDexPath方法中,这个方法的主要作用就是,整合了新的dexElement数组会跟之前的dexElement数组组合在一起,因此我们要做的任务就是
1 将补丁dex文件转换为一个Element数组 – patchElement
2 将patchElement与原先的oldElement组合成一个新的Element数组 – newElement (这里仿照addDexPath来合并数组)
3 将原先的dexElement赋值为新的newElement
接下来按照每一步来实现
第一步:使用makeDexElements将补丁dex抓换为Element数组
private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
List<IOException> suppressedExceptions, ClassLoader loader) {
return makeDexElements(files, optimizedDirectory, suppressedExceptions, loader, false);
}
那么在反射调用这个方法前,需要先配置一些参数
//dexFile 为dex补丁包
val files = mutableListOf<File>(dexFile)
val suppressedExceptions = mutableListOf<IOException>()
//获取DexPathList的makeDexElements方法
val makeDexElements = ReflectUtil.getMethod(
dexPathList, "makeDexElements", List::class.java, File::class.java,
List::class.java, ClassLoader::class.java
)
val pathElement =
//调用DexPathList的makeDexElements方法,返回一个Element数组
makeDexElements.invoke(dexPathList,files,null,suppressedExceptions,classLoader)
Log.e("TAG","$pathElement")
第二步,仿照addDexPath将两个Element数组合并
//创建一个空的数组,长度为dexElementArray.size + pathElement.size
// val newElement = arrayOfNulls(dexElementArray.size + pathElement.size)
//这里要通过反射的方式来创建数据,因为并不知道,要创建的数组是什么类型的
val newElement = Array.newInstance(pathElement::class.java.componentType!!,dexElementArray.size + pathElement.size)
//存放的数组,存放数组的起始位置
System.arraycopy(pathElement,0,newElement,0,pathElement.size)
System.arraycopy(dexElementArray,0,newElement,pathElement.size,dexElementArray.size)
这里需要注意一点,在DexPathList中,dexElement数组的类型是Element数组,但是我们是拿不到这个Element内部类的,在创建新的数组的时候,不能使用常用的手段创建一个数组,而是需要使用反射的方式来创建,类型就是pathElement或者dexElementArray类型,其实就是Element类型,最终拿到了新的数组newElement
第三步,将dexElement重新赋值
dexElements.set(dexPathList,newElement)
最终调用在Application中先初始化,当前应用首页抛出异常,执行热修复后,正常运行
HotFixUtils.execute(this, File("$cacheDir/classes4.dex"))
其实正常的企业级热修复在发版的时候,都会添加热修复框架,只不过在每次启动app的时候,需要判断,服务端是否有新的patch包,如果没有patch包,那么就走正常的处理逻辑;如果存在patch包就需要执行热修复的逻辑,说明有bug了,像patch包就可以通过gradle脚本上传
通过动态加载dex实现热修复,是目前主流的方式,相较于ASM字节码插桩,dex动态加载实现更为简洁高效,当然这也是一部分,其他还需要深挖的点,后续也会继续总结上来
1 混淆后的class如何进行热修复?
2 tinker是如何实现dex文件的差分打包的?
3 如何对资源、so库、application进行热修复?
4 如何选择热修复框架?
5 如何在运行时对安装的应用进行dex合并,而不是每次都动态加载补丁包中的dex
附上热修复的代码,可以自行查看
/**
* 热修复工具类
*/
object HotFixUtils {
fun execute(context: Application, dexFile: File) {
//获取PathClassLoader
val classLoader = context.classLoader
//获取PathClassLoader的pathList
val pathList = ReflectUtils.getField(classLoader, "pathList")
Log.e("TAG", "$pathList")
//获取pathList的属性值 DexPathList[[zip file "/data/app/com.tal.bsdiff-kly9VUu4YKX77N9S6kiUaw==/base.apk"],nativeLibraryDirectories=[/data/app/com.tal.bsdiff-kly9VUu4YKX77N9S6kiUaw==/lib/x86, /system/lib]]
val dexPathList = pathList?.get(classLoader)
Log.e("TAG", "$dexPathList")
if (dexPathList != null) {
//获取dexElement
val dexElements = ReflectUtils.getField(dexPathList, "dexElements")
Log.e("TAG", "$dexElements")
//获取dexElements的属性值
val dexElementArray = dexElements?.get(dexPathList) as Array<*>
Log.e("TAG", "$dexElementArray")
//把dex文件,放在数组的第0个位置
// private static Element[] makeDexElements(List files, File optimizedDirectory,
// List suppressedExceptions, ClassLoader loader) {
// return makeDexElements(files, optimizedDirectory, suppressedExceptions, loader, false);
// }
// 1 将补丁dex文件转换为一个Element数组 – patchElement
val files = mutableListOf<File>(dexFile)
val suppressedExceptions = mutableListOf<IOException>()
val makeDexElements = ReflectUtil.getMethod(
dexPathList, "makeDexElements", List::class.java, File::class.java,
List::class.java, ClassLoader::class.java
)
val pathElement = makeDexElements.invoke(dexPathList,files,null,suppressedExceptions,classLoader) as Array<*>
Log.e("TAG","$pathElement")
// 2 将patchElement与原先的oldElement组合成一个新的Element数组 – newElement (这里就需要调用addDexPath)
//创建一个空的数组,长度为dexElementArray.size + pathElement.size
// val newElement = arrayOfNulls(dexElementArray.size + pathElement.size)
//这里要通过反射的方式来创建数据,因为并不知道,要创建的数组是什么类型的
val newElement = java.lang.reflect.Array.newInstance(pathElement::class.java.componentType!!,dexElementArray.size + pathElement.size)
//存放的数组,存放数组的起始位置
System.arraycopy(pathElement,0,newElement,0,pathElement.size)
System.arraycopy(dexElementArray,0,newElement,pathElement.size,dexElementArray.size)
// 3 将原先的dexElement赋值为新的newElement
dexElements.set(dexPathList,newElement)
}
}
}