Android热修复技术 --- Gradle插件实现差分打包 + 手写热修复框架

在之前介绍的3种主流框架中,Tinker是采用动态加载dex文件,依赖类加载机制实现重启生效的热修复技术,通过差分打包的方式,将补丁包放入dexElement数组中,这种技术也是最常用的一种方式,那么首先了解一下Tinker的差分打包

1 Tinker bisdiff

腾讯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创建一个插件实现差分打包

1.1 使用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'
}

1.2 Gradle差分打包优化

在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也可以实现

1.3 Gradle插件实现差分合并(apk)


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()
    }
}

之后我修复了这个问题,那么需要使用插件来差分然后合并

2 热修复框架实现

像Tinker实现的原理,就是依赖类加载机制,通过动态加载dex补丁包实现热修复,如果想要手写一个热修复框架,可以通过Hook的方式动态加载dex文件

在之前的Android热修复技术 – 类加载机制中介绍过,Android的类加载同样遵循双亲委派机制,我们自己写的类都是PathClassLoader来加载的,如果PathClassLoader的父类加载器BootClassLoader没有加载这个类,那么就由PathClassLoader来findClass查找这个类,因此思路就产生了:

1 获取PathClassLoader
2 获取PathClassLoader的DexPathList(其实是BaseDexClassLoader)
3 获取DexPathList中的dexElements,将dex动态插入到数组第0个位置

2.1 反射的基础工具

1 反射获取属性Field

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
}

2.2 反射实现dex动态加载

//获取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<Any>(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脚本上传

3 结束语

通过动态加载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<File> files, File optimizedDirectory,
//            List<IOException> 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<Any>(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)

        }
    }
}
  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Awesome_lay

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值