今天看啥  ›  专栏  ›  想什么昵称呢

基于 Robust 的SDK 热修复

想什么昵称呢  · 掘金  ·  · 2021-02-22 16:42
阅读 26

基于 Robust 的SDK 热修复

背景

市面上的热修复方案大厂开源的有微信的tinker、美团的Robust、Qzone 的超级补丁、阿里系的Andfix、Sofix; 实现的套路套路基本就两种

  • 底层替换方案,限制多,时效好
  • 类加载方案,时效性差,需要重启才生效,但是限制少,修复范围比较广

但是这些开源的修复框架都是针对App做修复的,没有基于SDK的解决方案,公司的产品是提供SDK给第三方集成,是含有代码文件以及资源文件的aar包,因此也需要一套可以针对aar 的热修复解决方案。

Robust 的原理

以下是官方原理图: 加载patch.dex ,替换掉要修复的类中的changeRedirect 类,每次调用调用方法的时候都会调用changeQuickRedirect 的isSupport 方法,如果该方法返回的是false,则执行原先的旧的方法,如果返回为true,则会走patch类中的patchedMethod。

配置文件中声明了需要插桩的包名,Robust 在编译的时候会对声明的这个包名中的所有的类中增加一个ChangeRedirect类,每个方法的前面增加一段代码

PatchProxyResult var3 = PatchProxy.proxy(new Object[]{postcard, callback}, this, changeQuickRedirect, false, 5447, new Class[]{Postcard.class, InterceptorCallback.class}, Void.TYPE);
      if (!var3.isSupported) {
      }
      
复制代码

接着看下这个PatchProxy做了什么

public static PatchProxyResult proxy(Object[] paramsArray, Object current, ChangeQuickRedirect changeQuickRedirect, boolean isStatic, int methodNumber, Class[] paramsClassTypes, Class returnType) {
        PatchProxyResult patchProxyResult = new PatchProxyResult();
        // 主要的逻辑在于这,判断这个方法的是否需要执行patchMethod,如果需要则调accessDispatch方法执行patchMethod
        if (PatchProxy.isSupport(paramsArray, current, changeQuickRedirect, isStatic, methodNumber, paramsClassTypes, returnType)) {
            patchProxyResult.isSupported = true;
            patchProxyResult.result = PatchProxy.accessDispatch(paramsArray, current, changeQuickRedirect, isStatic, methodNumber, paramsClassTypes, returnType);
        }
        return patchProxyResult;
    }
复制代码

这个PatchProxy.proxy 方法最后返回了个patchProxyResult,Patch.proxy 里面主要调了两个方法,PatchProxy.isSupport和PatchProxy.accessDispatch 方法,再往里看下isSupport 的判断逻辑是什么

public static boolean isSupport(Object[] paramsArray, Object current, ChangeQuickRedirect changeQuickRedirect, boolean isStatic, int methodNumber, Class[] paramsClassTypes, Class returnType) {
        //Robust补丁优先执行,其他功能靠后
        if (changeQuickRedirect == null) {
            //不执行补丁,轮询其他监听者
            if (registerExtensionList == null || registerExtensionList.isEmpty()) {
                return false;
            }
            for (RobustExtension robustExtension : registerExtensionList) {
                if (robustExtension.isSupport(new RobustArguments(paramsArray, current, isStatic, methodNumber, paramsClassTypes, returnType))) {
                    robustExtensionThreadLocal.set(robustExtension);
                    return true;
                }
            }
            return false;
        }
        // 获取方法名,拼接成这样的形式 classMethod = className + ":" + methodName + ":" + isStatic + ":" + methodNumber
        String classMethod = getClassMethod(isStatic, methodNumber);
        if (TextUtils.isEmpty(classMethod)) {
            return false;
        }
        // 获取执行方法所需要的参数,current 为当前类的实例
        Object[] objects = getObjects(paramsArray, current, isStatic);
        try {
            return changeQuickRedirect.isSupport(classMethod, objects);
        } catch (Throwable t) {
            return false;
        }
    }

复制代码

这个方法里面主要是处理方法名和方法所需要的参数,最后再传给changeRedirect类去判断,再看下changeRedirect的实现类里面的判断逻辑

public boolean isSupport(String methodName, Object[] paramArrayOfObject) {
        String str = methodName.split(":")[3];
        this.methodsId = ":7:";
        this.methodLongName = "com.feelschaotic.samplesdk.manager.SdkManager.callBug();";
        if (RollbackManager.getInstance().getRollback(":7:")) {
            return false;
        }
        return ":7:".contains(new StringBuffer().append(":").append(str).append(":").toString());
    }
复制代码

Robust 会给每个方法生成一个methodId,getRollBack 是调用方传进去的rollBackListener,判断该方法的回滚状态。

public Object accessDispatch(String methodName, Object[] paramArrayOfObject) {
        SdkManagerPatch sdkManagerPatch;
        try {
            if (!methodName.split(":")[2].equals("false")) {
                sdkManagerPatch = new SdkManagerPatch(null);
            } else if (keyToValueRelation.get(paramArrayOfObject[paramArrayOfObject.length - 1]) == null) {
                sdkManagerPatch = new SdkManagerPatch(paramArrayOfObject[paramArrayOfObject.length - 1]);
                keyToValueRelation.put(paramArrayOfObject[paramArrayOfObject.length - 1], null);
            } else {
                sdkManagerPatch = (SdkManagerPatch) keyToValueRelation.get(paramArrayOfObject[paramArrayOfObject.length - 1]);
            }
            if ("7".equals(methodName.split(":")[3])) {
                sdkManagerPatch.callBug();
            }
        } catch (Throwable th) {
            RollbackManager.getInstance().notifyOnException(this.methodsId, this.methodLongName, th);
            th.printStackTrace();
        }
        return null;
    }


复制代码

里面主要做了一个static method和 非static method 的区分,后面的修复方法的执行就交给了sdkManagerpatch,SdkManager 是demo 中需要修复的类的名称,Robust 中生成的patch.jar 都以修复的类名做前缀。

基于Robust的SDK解决方案

这个方案是 @FeelsChaotic 大佬提出的,感谢大佬,大佬原文链接:juejin.cn/post/684490…

Robust 只有在application 模式下才能进行插桩以及生成补丁,apk的文件内容和aar 包的文件内容,因此可以在需要进行热更新的sdk模块在打包的时候设置成application 模式,之后在hook编译的过程,把application 模式下生成的文件打包成aar文件输出。

之后再hook processReleaseResources Task修改资源ID的修饰符

OK,改完一跑没东西输出,原来的资源整合的plugin 中对文件的路径只适配了一般的debug和release的情况,项目中如果设置了多个渠道的话编译输出的文件路径会在对应的release 文件夹前面多一级渠道名的目录。 因此需要在packplugin 中增加对多渠道版本打包的适配。 最后修改之后的packplugin 如下

import java.util.regex.Matcher
import java.util.regex.Pattern
final String SDK_PACKAGE_DIR = sdkPackageName.replace('.', File.separator)

final String PACK_PREFIX = 'sdk_hotfix'
String JAR_TASK_NAME = 'jar_' + PACK_PREFIX
String AAR_TASK_NAME = 'aar_' + PACK_PREFIX


String PATH = projectDir.toString() + File.separator + 'robustjar' + File.separator + 'release'

hookBuild(SDK_PACKAGE_DIR)
hookAssembleAndCopyRes(PATH, JAR_TASK_NAME, AAR_TASK_NAME)
hookBundle(PATH)


private void hookBuild(sdkPackageDir) {

    tasks.whenTaskAdded { task ->
        if (!isAppModule.toBoolean()) {
            // 不是Application模式不处理
            return
        }

        Pattern p = Pattern.compile("^process(.*)ReleaseResources\$")
        Matcher m = p.matcher(task.name)
        if (!m.find()) {
            return
        }
        String flavorName = task.name.minus("process").minus("ReleaseResources")

        println '-- hookBuild 监听task:' + task.name + "flavorName: " + flavorName
        task.doLast {
            // hook所需资源所在的父目录, \sdk\build\generated
            String generatedPath = buildDir.toString() + File.separator + "generated"

            // R资源文件的路径,\sdk\build\generated\r\release\包名\R.java
            String rPath = generatedPath + File.separator + "source" + File.separator + "r" + File.separator + flavorName + File.separator + "release" + File.separator + sdkPackageDir + File.separator + "R.java"

            println '--R文件路径:' + rPath

            File file = new File(rPath)
            if (file.exists()) {
                println '--R文件存在,开始修改修饰符'
                ant.replace(
                        file: rPath,
                        token: 'public static final int',
                        value: 'public static int'
                ) {
                    fileset(file: rPath)
                }
                println '--R文件修改完成!'
            } else {
                println '--【告警】R文件不存在!'
            }
        }
    }
}

private void hookAssembleAndCopyRes(path, jarTaskName, aarTaskName) {
// 项目打release版本apk包的话,必然会调用到assemble(渠道)Release的命令,于是我们可以用正则匹配来匹配所有渠道的打Release包过程
    Pattern p = Pattern.compile("^assemble(.*)Release\$")

    // 在task添加到列表的时候,进行打包task的匹配
    tasks.whenTaskAdded { task ->
        if (!isAppModule.toBoolean()) {
            // 不是Application模式不处理
            return
        }
        // 在任务执行的时候,匹配执行assemble(渠道)Release的打APK任务

        Matcher m = p.matcher(task.name)
        if (!m.find()) {
            return
        }

        // 打release包task完成之后进行资源的整合以及jar包去指定class文件,并且生成aar包
        task.doLast {

            String flavorName = task.name.minus("assemble").minus("Release").toLowerCase()
            if (flavorName.isEmpty())
                return
            path = projectDir.toString() + File.separator + 'robustjar'+ File.separator + flavorName + File.separator + 'release'
            println '-- hookAssembleAndCopyRes 监听task:' + task.name + "  flavorName: " + flavorName

            delete {
                // 删除上次生成的文件目录,目录为 ${path}
                delete projectDir.toString() + File.separator + 'robustjar' + File.separator + flavorName + File.separator + "release"
            }

            // 打包所需资源所在的父目录, \sdk\build\intermediates
            String intermediatesPath = buildDir.toString() + File.separator + "intermediates"

            // gradle-3.0.0 & robust-0.4.71对应的路径为 \sdk\build\intermediates\transforms\proguard\release\0.jar
            String jarDirName = (isProguard.toBoolean() ? "proguard" : "robust") + File.separator + flavorName
            String robustJarPath = intermediatesPath + File.separator + "transforms" + File.separator + jarDirName + File.separator + "release" + File.separator + "0.jar"

            // gradle-2.3.3 & robust-0.4.7对应的路径为 \sdk\build\intermediates\transforms\proguard\release\jars\3\1f\main.jar
            // String robustJarPath = intermediatesPath + File.separator + "transforms" + File.separator + "proguard"  + File.separator + "release" + File.separator + "jars" + File.separator + "3" + File.separator + "1f" + File.separator + "main.jar"

            // 资源文件的路径,\sdk\build\intermediates\assets\release
            String assetsPath = intermediatesPath + File.separator + "assets" + File.separator + flavorName + File.separator + "release"

            // 依赖本地jar包路径,\sdk\build\intermediates\jniLibs\release
            String libsPath = intermediatesPath + File.separator + "jniLibs" + File.separator + flavorName + File.separator + "release"

            // res资源文件的路径,\sdk\build\intermediates\res\merged\release,经测试发现此目录下生成的.9图片会失效,因此弃置,换另外方式处理
            // String resPath = intermediatesPath + File.separator + "res" + File.separator + "merged" + File.separator + "release"

            // 由于上述问题,直接用项目的res路径 \sdk\src\main\res ,因此第三方依赖的资源文件无法整合,但是我是基于生成只包含自身代码的jar包和资源,其余依赖宿主另外再依赖的方案,所以可以这样处理
            String resPath = projectDir.toString() + File.separator + "src" + File.separator + "main" + File.separator + "res"

            // 资源id路径,\sdk\build\intermediates\symbols\release
            String resIdPath = intermediatesPath + File.separator + "symbols" + File.separator + flavorName + File.separator + "release"

            // 清单文件路径,\sdk\build\intermediates\manifests\full\release,由于是生成的application的清单文件,因此下面还会做删除组件声明的处理
            String manifestPath = intermediatesPath + File.separator + "manifests" + File.separator + "full" + File.separator + flavorName + File.separator + "release"

            // 整合上述文件后的目标路径,${path}\origin
            String destination = path + File.separator + 'origin'

            // 貌似aidl的文件夹没啥用,打包会根据例如G:\\sms-hotfix\\SmsParsingForRcs-Library\\library\\src\\main\\aidl\\com\\cmic\\IMyAidlInterface.aidl的定义代码生成com.cmic.IMyAidlInterface到jar包里面,因此aidl仅仅是空文件夹
            // String aidlPath = buildDir.toString() + File.separator + "generated" + File.separator + "source" + File.separator + "aidl"  + File.separator + "release"
            println '-- robustJarPath ' + robustJarPath
            File file = file(robustJarPath)
            if (!file.exists()) {
                println '--【告警】robust插桩jar包不存在,结束'
                return
            }
            println '--开始复制robust插桩jar包'
            copy {

                // 拷贝到assets目录
                from(assetsPath) {
                    into 'assets'
                }
                
                // .so文件拷贝到jni目录
                from(libsPath) {
                    into 'jni'
                    include '**/*/*.so'
                }

                // 资源文件拷贝到res目录
                from(resPath) {
                    // 排除MainActivity加载的布局文件,因为输出的是jar包,加MainActivity仅仅是为了能让打apk包任务执行
                    //exclude '/layout/activity_main.xml'
                    exclude {
                        // 排除空文件夹
                        it.isDirectory() && it.getFile().listFiles().length == 0
                    }
                    into 'res'
                }

                // aidl的文件夹没啥用,不处理
                // from(aidlPath) {
                //     into 'aidl'
                // }

                // 拷贝此目录下资源id文件 R.txt
                from resIdPath

                // 拷贝到目录 ${path}\origin
                into destination

            }


            // 补丁生成需要的mapping.txt和methodsMap.robust文件
            copy {
                // 混淆mapping文件的路径,\sdk\build\outputs\mapping\release\mapping.txt
                from(buildDir.toString() + File.separator + 'outputs' + File.separator + 'mapping' + File.separator + flavorName + File.separator + 'release') {
                    include 'mapping.txt'
                }
                // 拷贝到目录 ${path}
                into path
            }

            copy {
                // robust生成的methodsMap文件路径,\sdk\build\outputs\robust\methodsMap.robust
                from(buildDir.toString() + File.separator + 'outputs' + File.separator + 'robust') {
                    include 'methodsMap.robust'
                }
                // 拷贝到目录 ${path}
                into path
            }

            // 若不存在aidl目录,创建aidl空目录
            createDir(destination + File.separator + "aidl")
            // 同上
            createDir(destination + File.separator + "assets")
            // 同上
            createDir(destination + File.separator + "jni")
            // 同上
            createDir(destination + File.separator + "libs")
            // 同上
            createDir(destination + File.separator + "res")

            //将清单文件application节点的内容和activity节点的内容替换,将清单文件provider节点的内容和meta-data节点的内容替换
            def oldStr = ["<application[\\s\\S]*?>", "<activity[\\s\\S]*?</activity>", "<provider[\\s\\S]*?(</provider>|/>)", "<meta-data[\\s\\S]*?(</meta-data>|/>)"]
            def newStr = ["<application\n" + "        android:allowBackup=\"false\"\n" + "        android:supportsRtl=\"true\">", "", "", ""]
            try {
                //处理 \sdk\build\intermediates\manifests\full\release\AndroidManifest.xml
                String strBuffer = fileReader(manifestPath + File.separator + "AndroidManifest.xml", oldStr, newStr)
                //输出至 ${path}\origin\AndroidManifest.xml
                fileWrite(destination + File.separator + "AndroidManifest.xml", strBuffer)
            } catch (FileNotFoundException e) {
                e.printStackTrace()
            }
            println '--输出robust插桩jar包成功!'

            println 'task name : ' + jarTaskName + "_" + flavorName

            createJarTask('jar_' + 'sdk_hotfix' + "_" + flavorName, path, sdkPackageName, flavorName)
            // 执行打jar包的task,这里会做原jar包的过滤处理,只保留我们需要的代码
            createAarTask('aar_' + 'sdk_hotfix' + "_" + flavorName, path)
            //delete project.buildDir
        }
    }
}

private Task createAarTask(taskName, path) {
    tasks.create(name: taskName, type: Zip) {
        // aar包输出路径为 ${path}\aar
        File destDir = file(path + File.separator + 'aar')
        // aar包命名为 library-release.aar
        archiveName 'library-release.aar'
        // 源路径为 ${path}\origin
        from path + File.separator + 'origin'
        // 设置压缩后输出的路径
        destinationDir destDir

        println '--创建压缩aar包Task完毕'
    }.execute()
}

private Task createJarTask(taskName, path, sdkPackageName, flavorName) {
    tasks.create(name: taskName, type: Jar) {

        // jar包命名为classes.jar
        baseName 'classes'

        String intermediatesPath = buildDir.toString() + File.separator + "intermediates"

        // gradle-3.0.0 & robust-0.4.71对应的路径为 \sdk\build\intermediates\transforms\proguard\release\0.jar
        String jarDirName = (isProguard.toBoolean() ? "proguard" : "robust") + File.separator + flavorName
        String robustJarPath = intermediatesPath + File.separator + "transforms" + File.separator + jarDirName + File.separator + "release" + File.separator + "0.jar"

        def zipFile = new File(robustJarPath)
        // 将jar包解压
        FileTree jarTree = zipTree(zipFile)

        from jarTree

        // jar包输出路径为 ${path}\origin
        File destDir = file(path + File.separator + 'origin')
        // 设置输出路径
        setDestinationDir destDir

        include {
            // 只打包我们需要的类
            it.path.startsWith(sdkPackageName)
        }
//
//        exclude {
//            // println "执行排除:" + it.path
//            // 排除R相关class文件,排除MainActivity.class文件
//            it.path.startsWith(sdkPackageName + '/R$') || it.path.startsWith(sdkPackageName + '/R.class') || it.path.startsWith(sdkPackageName + '/MainActivity.class')
//        }

        println '--创建压缩jar包Task完毕--' + taskName
    }.execute()
}


//读取文件并替换字符串
static def fileReader(path, oldStr, newStr) {
    def readerString = new File(path).getText('UTF-8')
    for (int i = 0; i < oldStr.size(); i++) {
        readerString = readerString.replaceFirst(oldStr[i], newStr[i])
    }
    return readerString
}

//写文件
static def fileWrite(path, stringBuffer) {
    new File(path).withWriter('UTF-8') {
        within ->
            within.append(stringBuffer)
    }
}

// 创建目录
static def createDir(String destDirName) {
    File dir = new File(destDirName)
    if (dir.exists()) {
        println '--目标目录已存在!无需创建'
        return false
    }
    if (!destDirName.endsWith(File.separator)) {
        destDirName = destDirName + File.separator
    }
    if (dir.mkdirs()) {
        println '--创建目录成功!' + destDirName
        return true
    } else {
        println '--创建目录失败!'
        return false
    }
}

//项目uploadArchives时,必然会调用到bundleRelease Task,hook bundle* Task 用于在上传maven前把本地打包的aar改为插桩后的aar
private void hookBundle(path) {
    tasks.whenTaskAdded { task ->
        if (isAppModule.toBoolean()) {
            // 是Application模式不处理,因为Application模式没有bundleRelease Task
            return
        }

        if (!'bundleRelease'.equals(task.name)) {
            return
        }
        task.doFirst {
            println '--hook bundleRelease!'
            forEachInputs(it, path)
        }
    }
}

private void forEachInputs(Task it, String path) {
    String jarName = 'classes.jar'
    it.inputs.files.each { input ->
        if (input.absolutePath.indexOf(jarName) != -1) {
            String jarInputPath = input.absolutePath.substring(0, input.absolutePath.lastIndexOf(File.separator) + 1)
            copy {
                // 源路径为 ${path}\origin
                from(path + File.separator + 'origin') {
                    include jarName
                }
                into jarInputPath
            }
        }
    }
}

复制代码



原文地址:访问原文地址
快照地址: 访问文章快照