Android热补丁动态修复技术(四):自动化生成补丁——解决混淆问题

一、前言

在上一章中,我们使用javassist成功为项目注入了System.out.println(AntilazyLoad.class);这行代码,解决了class_ispreverified问题,可以正常使用了,但肯定还存在着很多未知的问题。

  • 首先是javassist的问题
    • class中使用到的类,必须添加到classpool的classpath中,我在Demo中写了一个自定义控件,注入代码的时候报错,提示没有找到Context,然后我将android.jar整个添加到classpath之后成功
    • 如果该类是一个接口,会提示没有构造函数的异常。所以需要判断构造函数是否为null
    • 如果该类的构造函数是私有的,也会报错。所以要使用getDeclaredConstructors这种方式获取,和反射有点像。
  • 自定义控件使用问题
    • 自定义控件在使用的时候,预览界面会出现异常,找不到AntilazyLoad.class类,这时候只能clean或者rebuild项目
  • 代码写死问题
    • 说实话,我对于代码封装不太在行,没有封装经验,对设计模式也只会用用单例。所以有一些东西是写死了的。
    • 我将写死的东西先放到一个类里面去了,如下
    • 这里写图片描述

这几个问题目前来说还不是很重要,而且我也没有比较好的解决方案,只能写完这个系列的博客后再慢慢重构,如果大家有兴趣的话希望能帮忙改进。

二、Transform的坑

在前几篇博文中,我们都是建立在不混淆的基础上完成热补丁框架的。

那么,如果开启混淆后会出现什么问题呢?
可能有网友会说,这还不简单嘛,补丁无效呗,因为混淆之后类名已经变了,而我们的补丁还是原来的类名,包括里面的成员变量。
这是一个问题,也是本篇博文重点内容。


然而这之前还有个奇葩问题得先解决,如果现在开启混淆,紧接着打包,那么会报错。
因为我们是通过注册Transform注入代码的,而这个Transfrom在proguardTransform之前就已经执行。而混淆的时候发现找不到AntilazyLoad,然后就会报classNotFound错误。

所以,如果我们开启混淆的话,在Transfrom中注入代码是不可行的。因为我们无法改变Transfrom的执行顺序,我们注册的PreDexTransfrom肯定在ProguardTransform之前执行,而ProguardTransform混淆又会因为找不到类而报错。

以上说的就是Transform的坑了!
早知道我就不搞什么Transfrom了,直接使用task hook一下也不会麻烦到哪里去。
不过研究了半天还是有了个解决方案,就是在ProguardTransform执行之前将注入的代码移除,然后再ProguardTransfrom执行之后再次注入代码。

2.1 开启混淆后Task的inputs和outputs

没有开启混淆的时候,我们注册的preDexTransform就在DexTransform之前执行,PreDexTransform的outputs就是DexTransfrom的inputs

而开启混淆后,其实没有多大变化,直接PreDexTransfrom与DexTransform之间间多了一个ProguardTask,他们的inputs和outputs我就不多说了,记住相邻的两个Task,前一个的outputs必然是下一个Task的inputs。

2.2 取消注入

那么我们怎么移除掉System.out.println(AntilazyLoad.class);这行代码呢?
再次使用javassist显然太费劲了,我们可以在PreDexTransfrom注入代码之前,将文件先备份到某个文件夹。如果发现使用了Proguard,那么将备份的文件还原就行了。

备份到哪里呢,app的build目录就可以了,因为执行clean的时候会清除这个目录。
下面是Transform修改后的代码

@Override
    void transform(Context context, Collection<TransformInput> inputs,
                   Collection<TransformInput> referencedInputs,
                   TransformOutputProvider outputProvider, boolean isIncremental)
            throws IOException, TransformException, InterruptedException {

        // 清除备份文件夹
        File backupDir = new File(project.buildDir,"backup")
        if(backupDir.exists()) {
            FileUtils.cleanDirectory(backupDir)
        }

        // 遍历transfrom的inputs
        // inputs有两种类型,一种是目录,一种是jar,需要分别遍历。
        inputs.each {TransformInput input ->
            input.directoryInputs.each {DirectoryInput directoryInput->

                // 这是transfrom的输出目录
                def dest = outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

                // 备份dir
                def dirBackup = dest.absolutePath.replace('intermediates','backup')
                File dirBackupFile = new File(dirBackup)
                if(!dirBackupFile.exists()) {
                    dirBackupFile.mkdirs()
                }
                FileUtils.copyDirectory(directoryInput.file, dirBackupFile)


                //TODO 注入代码
                Inject.injectDir(directoryInput.file.absolutePath)
                // 将input的目录复制到output指定目录
                FileUtils.copyDirectory(directoryInput.file, dest)
            }

            input.jarInputs.each {JarInput jarInput->

                // 重命名输出文件(同目录copyFile会冲突)
                def jarName = jarInput.name
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                if(jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0,jarName.length()-4)
                }
                def dest = outputProvider.getContentLocation(jarName+md5Name, jarInput.contentTypes, jarInput.scopes, Format.JAR)

                // 备份jar
                def jarBackup = dest.absolutePath.replace('intermediates','backup').replace(jarName,jarName+md5Name)
                File jarBackupFile = new File(jarBackup)
                FileUtils.copyFile(jarInput.file,jarBackupFile)

                //TODO 注入代码
                String jarPath = jarInput.file.absolutePath;
                String projectName = project.rootProject.name;
                if(jarPath.endsWith("classes.jar") && jarPath.contains("exploded-aar\\"+projectName)) {

                    // 排除不需要注入的module
                    def flag  = true
                    Configure.noInjectModules.each {
                        if(jarPath.contains("exploded-aar\\$projectName\\$it")) {
                            flag = false
                        }
                    }

                    if(flag) {
                        Inject.injectJar(jarPath)
                    }
                }

                FileUtils.copyFile(jarInput.file, dest)
            }
        }
    }

然后我们需要在proguardTransform执行之前将备份还原

/**
 * Created by AItsuki on 2016/4/8.
 * 
 */
public class Register implements Plugin<Project> {
    @Override
    public void apply(Project project) {

        def android = project.extensions.findByType(AppExtension)
        PreDexTransform preDexTransform = new PreDexTransform(project)
        android.registerTransform(preDexTransform)

        /**
         * 我们是在混淆之前就完成注入代码的,这会出现问题,找不到AntilazyLoad这个类
         *
         * 我的解决方式:
         * 在PreDexTransform注入代码之前,先将原来没有注入的代码保存了一份到 buildDir/backup
         * 如果开启了混淆,则在混淆之前将代码覆盖回来
         */
        project.afterEvaluate {
            project.android.applicationVariants.each {variant->
                def proguardTask = project.getTasks().findByName("transformClassesAndResourcesWithProguardFor${variant.name.capitalize()}")
                if(proguardTask) {

                    // 如果有混淆,执行之前将备份的文件覆盖原来的文件(变相的清除已注入代码)
                    proguardTask.doFirst {
                        File backupDir = new File(project.buildDir,"backup\\transforms\\$preDexTransform.name\\$variant.name")
                        if(backupDir.exists()) {
                            def srcDirPath = backupDir.getAbsolutePath().replace('backup','intermediates')
                            File srcDir = new File(srcDirPath)
                            FileUtils.cleanDirectory(srcDir)
                            FileUtils.copyDirectory(backupDir,srcDir)
                        }
                    }

                    proguardTask.doLast {
                        //TODO 开启混淆后在这里注入代码
                    }
                }
            }
        }
    }
}

再次开启混淆,运行Generate Signed Apk已经可以成功签名打包了,但是很明显,我们的代码也没有注入,因为已经还原备份了。

2.3 补充

看看我们的备份
这里写图片描述

然后看看preDex, proguard,dex三个task的inputs和outpus。
这里写图片描述
看到输出我们也知道了,需要混淆的代码就保存在
\app\build\intermediates\transforms\proguard\release
当然,如果你用debug的话最后这里就是proguard\debug
比如这么设置,然后BuildApk
这里写图片描述

三、混淆后的代码注入

在上面的dolast中就可以有个todo注释,在那里注入代码就可以了,注入的方式和上一篇博客一样。
但是特别有一点提醒一下,混淆后的输出目录只有一个main.jar包(目前试了几个项目都是……不知道有没有特殊情况),直接解压这个jar包注入就可以了


但是,混淆后怎么打包补丁呢,难道解压main.jar,从里面复制么。类名已经变了,找起来太费劲了啊。继续往下看,教你实现自动化打包补丁!

四、自动化打包补丁

4.1 思路

Transfrom的问题已经解决了,回归到最开始的问题。
混淆之后,类名,方法名,变量名都可能已经改变,那么我们如何注入dex修复bug呢?
按照之前的制作补丁方法(在我的热补丁系列第二篇博文中有)肯定是不行了,我们制作的补丁必须是已经混淆过的类,而不能直接从debug中直接获取。

所以,我们需要为这个框架加入自动化生成补丁的功能!
那么怎么生成自动化补丁呢?

首先我们来想一下我们热补丁的需求;

  1. 发布正式版本(release)的时候,我们会将项目copy一份作为备份(target)
  2. 如果线上的release版本出现了问题,我们就会到target项目中去修复bug
  3. 修复完毕后,我们直接在target中运行打包,就会自动生成补丁保存到指定的目录。

那么如何实现上面的需求呢

  1. 在release版本发布的时候,我们可以生成所有.class文件的md5
  2. 然后将该.class对应的完整类名和md5作为键值对保存到某个文件
  3. 当我们在target中修复了bug,打包项目的时候,再次生成所有.class文件的md5
  4. 将target的md5和release的md5作对比,如果md5不一致,那么代表这个类的代码已经变更,将这个class拷贝出来。这些拷贝出阿里的class就是应该打补丁的类了

4.2 实现自动化打包补丁

首先,我们需要生成所有类的md5,保存到app module的根目录下
怎么遍历所有的类, 在这之前我们已经做过备份了,可以直接遍历备份文件夹生成md5.

在Transfrom方面最后面加上这段代码

        // 生成md5, 因为做了备份,可以直接读取备份生成
      // 首先需要判断是否是release版本,只有在release版本的时候需要生成md5
      File releaseDir = new File(backupDir,"transforms\\${getName()}\\release")
      if(releaseDir.exists()) {
          // 创建patch目录, 用来保存MD5文件
          File patchDir = new File("$project.projectDir.absolutePath\\patch")
          if(!patchDir.exists()) {
              patchDir.mkdirs()
          }

          // 创建md5文件
          File md5File = new File(patchDir, "classesMD5.txt")
          if(md5File.exists()) {
              md5File.delete()
          }

          def pw = md5File.newPrintWriter()

          // 遍历所有class,获取md5,获取完整类名,写入到classesMd5文件中
          releaseDir.eachFileRecurse {File file->
              String filePath = file.getAbsolutePath()

              if(filePath.endsWith('.class') && Inject.needInject(filePath)) {
                  int beginIndex = filePath.lastIndexOf('release')+8
                  String className = filePath.substring(beginIndex, filePath.length()-6).replace('\\','.').replace('/','.')
                  InputStream inputStream = new FileInputStream(file)
                  String md5 = DigestUtils.md5Hex(inputStream)
                  inputStream.close()
                  pw.println("$className-$md5")
              }

              if(filePath.endsWith('.jar')) {
                  File destDir = new File(file.parent,file.getName().replace('.jar',''))
                  JarZipUtil.unzipJar(filePath,destDir.absolutePath)
                  destDir.eachFileRecurse {File f->
                      String fPath =  f.absolutePath
                      if(fPath.endsWith('.class') && Inject.needInject(fPath)) {
                          int beginIndex = fPath.indexOf(destDir.name)+ destDir.name.length()+1
                          String className = fPath.substring(beginIndex, fPath.length()-6).replace('\\','.').replace('/','.')
                          InputStream inputStream= new FileInputStream(f)
                          String md5 = DigestUtils.md5Hex(inputStream)
                          inputStream.close()
                          pw.println("$className-$md5")
                      }
                  }
                  FileUtils.deleteDirectory(destDir)
              }
          }
          pw.close()
      }

我们现在签名打包一下release版本
可以看到在app module下生成了classesMd5文件,如图(过滤了support包的class,但是没有过滤掉hotpatch module的,目前项目比较乱,等写完这章之后就重构一下)
这里写图片描述

MD5是成功生成了,但是我们怎么校验呢,什么时候校验呢?
我们都知道打包可以选择Debug版本或者Release版本,但是不知道我们也可以自己添加一个版本,其实更标准的说法是变体,variant。

在build.gradle的BuildTypes中这样写,就成功添加了一个dopatch的变体了。
这里写图片描述
签名打包的时候可以选择打包哪一个,使用过多渠道打包的应该有点了解
这里写图片描述

我们现在就来约定一下规则,只要打包这个dopatch,就证明我们已经修复好bug,会自动生成补丁包

在transfrom中,我们继续加入以下代码

// -------------自动生成补丁包-----------------
        // 如果运行dopatch变体的话,代表我们需要自动生成补丁了
        File dopatchDir = new File(backupDir,"transforms\\${getName()}\\dopatch")
        // 这个是我们release版本打包时保存的md5文件
        File md5File = new File("$project.projectDir\\patch\\classesMD5.txt")
        if(dopatchDir.exists() && md5File.exists()) {
            // 这个是保存补丁的目录
            File patchCacheDir = new File(Configure.patchCacheDir)
            if(patchCacheDir.exists()) {
                FileUtils.cleanDirectory(patchCacheDir)
            } else {
                patchCacheDir.mkdirs()
            }

            // 使用reader读取md5文件,将每一行保存到集合中
            def reader = md5File.newReader()
            List<String> list = reader.readLines()
            reader.close()

            // 遍历当前的所有class文件,再次生成md5
            dopatchDir.eachFileRecurse {File file->
                String filePath = file.getAbsolutePath()
                if(filePath.endsWith('.class') && Inject.needInject(filePath)) {
                    int beginIndex = filePath.lastIndexOf('dopatch')+8
                    String className = filePath.substring(beginIndex, filePath.length()-6).replace('\\','.').replace('/','.')
                    InputStream inputStream = new FileInputStream(file)
                    String md5 = DigestUtils.md5Hex(inputStream)
                    inputStream.close()
                    String str = className +"-"+md5

                    // 然后和release中的md5进行对比,如果不一致,代表这个类已经修改,复制到补丁文件夹中
                    if(!list.contains(str)) {
                        String classFilePath = className.replace('.','\\').concat('.class')
                        File classFile = new File(patchCacheDir,classFilePath)
                        FileUtils.copyFile(file,classFile)
                    }
                }

                // jar包需要先解压,(⊙o⊙)…有很多重复代码,不管了,下次重构再抽取。
                if(filePath.endsWith('.jar')) {
                    File destDir = new File(file.parent,file.getName().replace('.jar',''))
                    JarZipUtil.unzipJar(filePath,destDir.absolutePath)
                    destDir.eachFileRecurse {File f->
                        String fPath =  f.absolutePath
                        if(fPath.endsWith('.class') && Inject.needInject(fPath)) {
                            int beginIndex = fPath.indexOf(destDir.name)+ destDir.name.length()+1
                            String className = fPath.substring(beginIndex, fPath.length()-6).replace('\\','.').replace('/','.')
                            InputStream inputStream= new FileInputStream(f)
                            String md5 = DigestUtils.md5Hex(inputStream)
                            inputStream.close()
                            String str = className+"-"+md5
                            if(!list.contains(str)) {
                                String classFilePath = className.replace('.','\\').concat('.class')
                                File classFile = new File(patchCacheDir,classFilePath)
                                FileUtils.copyFile(file,classFile)
                            }
                        }
                    }
                    FileUtils.deleteDirectory(destDir)
                }
            }
        }

现在我们去修改一下cat类,将cat的汪汪汪改成喵喵喵!
然后运行签名打包,记得打包的时候使用dopatch这个BuildType
锵锵锵锵!!!已经自动将改变过的类复制出来了
这里写图片描述
然后我们进入命令行
这里写图片描述
这样就成功生成补丁了!
这里写图片描述

是不是比之前手动打包补丁方便多了呢?
但是,自动生成补丁包最重要的一点不是为了方便,而是为了生成混淆后的补丁包,因为混淆后的补丁我们是很难手动制作的。

4.3 实现混淆后的自动化打包补丁

那么混淆后,我们怎么自动打包补丁呢,其实也不难。
因为在混淆签名打包后,会在outputs目录中生成一个mapping文件,里面记录了混淆的规则。如图
这里写图片描述
我们可以将这个mapping文件保存起来,下次执行dopatch自动化打包的时候,解析这个mapping就知道我们需要打包哪些类了。

具体思路是这样子的:

  1. dopatch引用一个新的混淆文件,里面使用release的mapping(-apply mapping)
    这里写图片描述
    然后build.gradle中可以这样使用
    这里写图片描述
  2. 在transform中,我们已经将需要打包成补丁的class文件事先复制到了patchCacheDir目录中
  3. 遍历patchCacheDir目录,获取到所有类的完整类名。
  4. 解析mapping文件,获取到该类名对应的混淆类名
  5. 这个获取到的混淆类名就是我们需要打包成补丁的类了!

然后代码实现
在Register这个类中,hook proguardTransfrom这个task,在dolast中这么做,博文开头也提到过

proguardTask.doLast {

                        // 如果是开启混淆的release,混淆注入代码,并且将mapping复制到patch目录
                        if(proguardTask.name.endsWith('ForRelease')) {
                            project.logger.error "0=============="
                            // 遍历proguard文件夹,注入代码
                            File proguardDir = new File("$project.buildDir\\intermediates\\transforms\\proguard\\release")
                            proguardDir.eachFileRecurse { File file ->
                                if(file.name.endsWith('jar')) {
                                    project.logger.error "0=00000============="
                                    Inject.injectJar(file.absolutePath)
                                    project.logger.error "0=11111============="
                                }
                            }

                            project.logger.error "1=============="
                            File mapping = new File("$project.buildDir\\outputs\\mapping\\release\\mapping.txt")
                            File mappingCopy = new File("$project.projectDir\\patch\\mapping.txt")
                            project.logger.error "2=============="
                            FileUtils.copyFile(mapping, mappingCopy)
                        }

                        // 自动打补丁
                        if(proguardTask.name.endsWith('ForDopatch')) {

                            // 解析mapping文件
                            File mapping = new File("$project.projectDir\\patch\\mapping.txt")
                            def reader = mapping.newReader()
                            Map<String, String> map = new HashMap<>()
                            reader.eachLine {String line->
                                if(line.endsWith(':')) {
                                    String[] strings = line.replace(':','').split(' -> ')
                                    if(strings.length == 2) {
                                        map.put(strings[0],strings[1])
                                    }
                                }
                            }
                            reader.close()
                            println "map= $map"

                            // 在Transfrom中已经将需要打补丁的类复制到了指定目录, 我们需要遍历这个目录获取类名
                            List<String> patchList = new ArrayList<>()
                            File patchCacheDir = new File(Configure.patchCacheDir)
                            patchCacheDir.eachFileRecurse { File file->
                                String filePath = file.absolutePath

                                if(filePath.endsWith('.class')) {
                                    // 获取类名
                                    int beginIndex = filePath.lastIndexOf(patchCacheDir.name)+patchCacheDir.name.length()+1
                                    String className = filePath.substring(beginIndex, filePath.length()-6).replace('\\','.').replace('/','.')
                                    project.logger.error "className==============$className"
                                    // 获取混淆后类名
                                    String proguardName = map.get(className)
                                    patchList.add(proguardName)
                                }
                            }

                            println "list= $patchList"
                            // patchList保存的是需要打补丁的类名(混淆后)
                            // 1. 清除原类文件夹
                            FileUtils.cleanDirectory(patchCacheDir)

                            // 2. 将混淆的后jar包解压到当前目录
                            File proguardDir = new File("$project.buildDir\\intermediates\\transforms\\proguard")
                            proguardDir.eachFileRecurse {File file->
                                if(file.name.endsWith('.jar')) {
                                    File destDir = new File(file.parent,file.getName().replace('.jar',''))
                                    JarZipUtil.unzipJar(file.absolutePath,destDir.absolutePath)
                                    // 3. 遍历destDir, 将需要打补丁的类复制到cache目录
                                    destDir.eachFileRecurse {File f->
                                        String fPath = f.absolutePath
                                        if(fPath.endsWith('.class')) {
                                            // 获取类名
                                            int beginIndex = fPath.lastIndexOf(destDir.name) + destDir.name.length() + 1
                                            String className = fPath.substring(beginIndex, fPath.length() - 6).replace('\\', '.').replace('/', '.')

                                            project.logger.error "class=======================$className"
                                            // 是否是补丁,复制到cache目录
                                            if(patchList.contains(className)) {
                                                String destPath = className.replace(".","\\").concat('.class')
                                                File destFile = new File(patchCacheDir,destPath)
                                                FileUtils.copyFile(f, destFile)
                                            }
                                        }
                                    }
                                    FileUtils.deleteDirectory(destDir)
                                }
                            }
                        }
                    }

代码的复用性有点差=。=,但是先别在意这个,现在我们来测试一下:

  1. 首先将Cat.class改回汪汪汪,然后release打开proguard,签名打包(记得选release的buildtypes)。发现patch文件夹中已经生成mapping.txt了
  2. 然后随意修改一下Cat.class,Circle.class(这次弄两个类测试好了……)
  3. 然后dopatch也打开proguard,签名打包(记得选dopatch的buildtypes)

PS:记得每次打包前都Clean一下项目哦,否则可能会出现一些问题。比如代码重复注入,反编译可能看到有多行’System.out.println(AntilazyLoad.class)’

锵锵锵锵!自动打包补丁成功!(=。=,自己手动命令行打包吧,用java代码总是说找不到dx工具,有空再找找原因)
这里写图片描述

五、写在后面

热补丁框架算是完成了,剩下一个补丁包的签名校验问题,目前也不太想研究,想重构一下项目再说。
这是重构之前的项目下载地址,乱起八糟的,有兴趣的可以下载玩玩。
http://download.csdn.net/detail/u010386612/9498420

下一章博客,我准备重构项目,可能会舍弃掉Transfrom这个api,如果不混淆的话还是很好用的,混淆的话我觉得有点反人类了!
这热补丁研究了好长时间了,算是告一段落了,感谢大家支持。

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 21
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值