简介

  Nuwa是比较流行的一种Android热补丁方案的开源实现,它的特点是成功率高,实现简单。当然,热补丁的方案目前已经有很多了,AndFix, Dexposed, Tinker等,之所以要分析Nuwa,是因为它代表了一种热修复的思想,通过它可以窥探到很多这方面的知识,包括更进一步的插件化。

Nuwa工作原理

  Nuwa的实现分为Gradle插件和SDK两部分。插件部分负责编译补丁包, SDK部分负责具体打补丁。概括起来看似就两句话,实现起来还是有一定难度的。在插件源码解析之前,我们来具体分析一下这两个部分的工作原理,以便对Nuwa有个技术上的认识。
  
  产生补丁首先需要知道你对哪些类做了修改,比如我们发布了2.8.1版本,然后在2.8.1的代码上修改了类:A, B和C, 那这三个类就应该打成一个补丁包。Nuwa plugin就是负责产生补丁包的,他是一个gradle插件, 插件被应用上去以后首先会找到gradle编译时的task链条,然后实现一个自定义的task,我们称作customTask, 将customTask插入到生成dex的task之前,接着将dexTask的依赖作为customTask的依赖,然后让dexTask依赖于customTask,为什么要把customTask插入到这个位置,我们通过分析编译流程知道,dexTask之前的task会把所有类都编译成字节码class,然后作为dexTask的输入。 dexTask负责将这些classes编译成一个或者多个dex以备后续生成apk. 插入到这个位置就能确保我们在生成dex之前拿到所有的class,以便我们分析所有class然后生成补丁dex,这个过程称作hook。
  
  有了上述hook这个基础,我们还需要做两件事情,1:对所有类插庄, 2:收集变动过的类打成dex包。
  
  解释1: 为什么要插庄,这里涉及到android类加载的机制,我们不展开讲,简单理解就是,android上替换类不是说替换就替换的,android会有校验机制,不合规是不行的,插庄就是用一种讨巧的方式绕过这个校验机制,具体就是通过修改字节码, 为每一个编译好的class插入一个无参的构造函数, 然后让这个构造函数引用一个单独的dex中的类(这个类没有任何意义,只是为了跨dex引用)。
  
  解释2: 如何收集变动过的类? 我们在customTask里会给每个参与编译的类文件生成hash, 第二次执行这个任务时对比每个类的hash值,如果不一样就认为是修改过的,将这些类收集到文件夹,然后调用build tools里的工具生成dex.

  步骤2中生成的dex就是我们的补丁了, 他可以发布到服务器,通过一些下载机制,下载到用户手机,然后就交给sdk部分去完成真正的“打”补丁的过程。

  SDK: SDK是一个Android library,需要打在Apk里,程序运行的适当的时候调用其中的方法,它提供一个核心方法:loadPatch(String path). 负责将传入的补丁加载到内存,当启动应用时,Apk内的dex文件会被挨个通过ClassLoader加载到内存, 同时dex会按顺序维持一个列表,当程序需要加载一个类时,就去这个列表里查,一但查到就会使用对应dex具体的类,如果都没找到就会报ClassNotFound错误, 我们加载补丁的原理就是通过反射将我们的补丁dex插入到列表的最开始,这样当需要加载bug类时就会先在补丁dex里面找到,这样系统就会使用修复过的类,便达到了热修复的目的。要注意的是loadPatch一定要在bug类使用前调用,一旦bug类使用过了,本次修复就会没有效果,只能杀死进程再启动应用才会生效。

  本次我们只会分析Gradle插件部分的代码,sdk的代码以后有机会另开一篇分析。
  
  下面开始结合工程来分析 Nuwa plugin的实现, 为了篇幅,我们只关注主流程

项目目录结构

代码分析

实现一个plugin首先要实现Plugin接口,重写apply函数。

 1 class NuwaPlugin implements Plugin<Project> {
 2     HashSet<String> includePackage
 3     HashSet<String> excludeClass
 4     def debugOn
 5     def patchList = []
 6     def beforeDexTasks = []
 7     private static final String NUWA_DIR = "NuwaDir"
 8     private static final String NUWA_PATCHES = "nuwaPatches"
 9     private static final String MAPPING_TXT = "mapping.txt"
10     private static final String HASH_TXT = "hash.txt"
11     private static final String DEBUG = "debug"
12
13     @Override
14     void apply(Project project) {
15         project.extensions.create("nuwa", NuwaExtension, project)
16         project.afterEvaluate {
17             def extension = project.extensions.findByName("nuwa") as NuwaExtension
18             includePackage = extension.includePackage
19             excludeClass = extension.excludeClass
20             debugOn = extension.debugOn
21            }
22       }
23 }

apply会在build.gradle声明插件的时候执行,比如使用插件的module的build.gradle文件的最开始声明应用插件,则执行这个build.gradle的时候就会先执行插件内apply函数的内容。

1 apply plugin: 'com.android.application'
2 apply plugin: 'plugin.test'

apply函数一开始执行了:project.extensions.create(“nuwa”, NuwaExtension, project),这一句的作用是根据NuwaExtension类创建一个扩展,后面就可以按照NuwaExtension既有字段在build.gradle声明属性了。

1 class NuwaExtension {
2     HashSet<String> includePackage = []
3     HashSet<String> excludeClass = []
4     boolean debugOn = true
5
6     NuwaExtension(Project project) {
7     }
8 }

然后可以在build.gradle中声明:

1     HashSet<String> includePackage
2     HashSet<String> excludeClass
3     def debugOn
4     def patchList = []
5     def beforeDexTasks = []

创建扩展的作用是方便我们动态的做一些配置。 
代码执行分为两个大的分支:混淆和不混淆,我们这里只分析不混淆的情况。

1 def preDexTask =project.tasks.findByName("preDex${variant.name.capitalize()}”)

查找preDexTask,如果有就说明开启了混淆,我们这里没有。

1 def dexTask = project.tasks.findByName("dex${variant.name.capitalize()}”)

查找dexTask, 这个是task非常关键,它的上一级task负责编译好了所有类,它的输入就是所有类的class文件(XXX.class)。

 1 // 创建打patch的task,这个task负责把对比出有差异的class文件打包成dex
 2 def nuwaPatch = "nuwa${variant.name.capitalize()}Patch”
 3 project.task(nuwaPatch) << {
 4     if (patchDir) {
 5         // 真正负责打包的函数, 函数实现下面会分析
 6         NuwaAndroidUtils.dex(project, patchDir)
 7     }
 8 }
 9 def nuwaPatchTask = project.tasks[nuwaPatch]
10 if(preDexTask) {
11 } else {
12     //创建一个自定义task,负责遍历所有编译好的类,针对每一个class文件注入构造函数,构造函数中引用了一个独立的dex中的类,因为这个类不在当前dex,
13     //所以会防止类被打上ISPREVERIFIED标志
14     def nuwaJarBeforeDex = "nuwaJarBeforeDex${variant.name.capitalize()}”
15     //创建一个自定义task,负责遍历所有编译好的类,针对每一个class文件注入构造函数,构造函数中引用了一个独立的dex中的类,因为这个类不在当前dex,
16     //所以会防止类被打上ISPREVERIFIED标志
17         Set<File> inputFiles = dexTask.inputs.files.files ≈
18         inputFiles.each { inputFile ->
19             // 这里它就能拿到所有编译好的jar包了(jar包不止一个,包括所有support的jar包和依赖的一些jar包还有项目源码打出的jar包,
20             // 总之这些jar包包涵了这个apk中所有的class)。
21             def path = inputFile.absolutePath
22             if (path.endsWith(".jar")) {
23                 // 真正做class注入的函数, 函数实现下面会分析
24                 NuwaProcessor.processJar(hashFile, inputFile, patchDir, hashMap, includePackage, excludeClass)
25             }
26         }
27     }
28     // 因为上一步project.task(nuwaJarBeforeDex)已经创建了nuwaJarBeforeDex的task所以这里通过tasks这个系统成员变量可以拿到真正的task对象。
29     def nuwaJarBeforeDexTask = project.tasks[nuwaJarBeforeDex]
30     // 让自定义task依赖于dexTask的依赖
31     nuwaJarBeforeDexTask.dependsOn dexTask.taskDependencies.getDependencies(dexTask)
32  // 让dexTask依赖于我们的自定义task, 这样就相当于在原来的task链中插入了我们自己的task,在不影响原有流程的情况下可以做我们自己的事情
33     dexTask.dependsOn nuwaJarBeforeDexTask
34     // 让打patch的task依赖于class注入的task, 这样我们可以在控制台手动执行这个task,就可以打出patch文件了。
35     nuwaPatchTask.dependsOn nuwaJarBeforeDexTask
36 }

好了, 主流程就是这样的, 这里你可能还有几个问题,class注入究竟是怎么做的,在哪里对比的文件差异,又是在哪里把所有变动的文件打成patch呢。这里就到关键的两个工具函数了:
NuwaProcessor.processJar和 NuwaAndroidUtils.dex。 前者负责class注入,后者负责对比和打patch。源码如下:

 1 /**
 2    参数说明:
 3    hashFile: 本次编译所有类的“类名:hash”存放文件
 4    jarFile:  jar包, 调用这个函数的地方会遍历所有的jar包
 5    patchDir:  有变更的文件统一存放到这个目录里
 6    map:  上一次编译所有类的hash映射
 7    includePackage:  额外指定只需要注入这些包下的类
 8    excludeClass: 额外指定不参与注入的类
 9 */
10
11 public static processJar(File hashFile, File jarFile, File patchDir, Map map, HashSet<String> includePackage, HashSet<String> excludeClass) {
12     if (jarFile) {
13         // 先在原始jar同级目录下创建“同名.opt”文件,每注入完成一个类则打到这个opt文件中,
14         // opt文件实际上也是一个jar包,所有类都处理完后将文件后缀opt改为jar替换掉原来的jar
15         def optJar = new File(jarFile.getParent(), jarFile.name + ".opt”)
16         def file = new JarFile(jarFile);
17         Enumeration enumeration = file.entries();
18         // 创建输入opt文件,实际也是一个jar包
19         JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar));
20         while (enumeration.hasMoreElements()) {  // 遍历jar包中的每一个entry
21             JarEntry jarEntry = (JarEntry) enumeration.nextElement();
22             String entryName = jarEntry.getName();
23             ZipEntry zipEntry = new ZipEntry(entryName);
24
25             InputStream inputStream = file.getInputStream(jarEntry);
26             jarOutputStream.putNextEntry(zipEntry);
27
28             if (shouldProcessClassInJar(entryName, includePackage, excludeClass)) {  // 根据一些规则和includePackage与excludeClass判断这个类要不要处理
29                 def bytes = referHackWhenInit(inputStream);  // 拿到这个类的输入流调用这个函数完成字节码注入
30                 jarOutputStream.write(bytes);  // 将注入完成的字节码写入opt文件中
31
32                 def hash = DigestUtils.shaHex(bytes)  // 生成文件hash
33                 hashFile.append(NuwaMapUtils.format(entryName, hash))  将hash值以键值对的形式写入到hash文件中,以便下次对比
34
35                 if (NuwaMapUtils.notSame(map, entryName, hash)) { // 如果这个类和map中上次生成的hash不一样,则认为是修改过的,拷贝到需要最终打包的文件夹中
36                     NuwaFileUtils.copyBytesToFile(bytes, NuwaFileUtils.touchFile(patchDir, entryName))
37                 }
38             } else {
39                 jarOutputStream.write(IOUtils.toByteArray(inputStream));  // 如果这个类不处理则直接写进opt文件
40             }
41             jarOutputStream.closeEntry();
42         }
43         jarOutputStream.close();
44         file.close();
45
46         if (jarFile.exists()) {
47             jarFile.delete()
48         }
49         optJar.renameTo(jarFile)
50     }
51
52 }

 1 /**
 2    负责注入,这里用到了asm框架(asm框架用来修改java字节码文件,非常强大,感兴趣的同学可以搜一下,类似的框架还有Javassist和BCEL).实际的动作就是给类注入一个无参的构造函数,构造函数里引用了“jiajixin/nuwa/Hack”类,这个类是另外一个dex中的,这个dex需要在application入口处加载,
 3    这样就能保证所有类在用到这个类之前它已经被夹在到内存了,这么做就是为了防止类被打上ISPREVERIFIED标记,从而绕过android对类的检查,保证补丁生效。
 4 */
 5 private static byte[] referHackWhenInit(InputStream inputStream) {
 6     ClassReader cr = new ClassReader(inputStream);
 7     ClassWriter cw = new ClassWriter(cr, 0);
 8     ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {
 9         @Override
10         public MethodVisitor visitMethod(int access, String name, String desc,
11                                          String signature, String[] exceptions) {
12
13             MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
14             mv = new MethodVisitor(Opcodes.ASM4, mv) {
15                 @Override
16                 void visitInsn(int opcode) {
17                     if ("<init>".equals(name) && opcode == Opcodes.RETURN) {
18                         super.visitLdcInsn(Type.getType("Lcn/jiajixin/nuwa/Hack;"));  // 引用另一个dex中的类
19                     super.visitInsn(opcode);
20                 }
21             }
22             return mv;
23         }
24
25     };
26     cr.accept(cv, 0);
27     return cw.toByteArray();
28 }

 1 /**
 2    NuwaAndroidUtils.dex
 3    对NuwaProcessor.processJar中拷贝到patch文件夹的类执行打包   操作,这里用到了build-tools中的命令行。
 4    参数说明:
 5    project: 工程对象,从插件那里传过来的
 6    classDir:  包含需要打包的类的文件夹
 7 */
 8
 9 public static dex(Project project, File classDir) {
10     if (classDir.listFiles().size()) {
11         def sdkDir
12
13         Properties properties = new Properties()
14         File localProps = project.rootProject.file("local.properties")
15         if (localProps.exists()) {
16             properties.load(localProps.newDataInputStream())
17             sdkDir = properties.getProperty("sdk.dir")
18         } else {
19             sdkDir = System.getenv("ANDROID_HOME")
20         }
21         if (sdkDir) {
22             def cmdExt = Os.isFamily(Os.FAMILY_WINDOWS) ? '.bat' : ''
23             def stdout = new ByteArrayOutputStream()
24             project.exec {
25                 commandLine "${sdkDir}/build-tools/${project.android.buildToolsVersion}/dx${cmdExt}",
26                         '--dex',
27                         "--output=${new File(classDir.getParent(), PATCH_NAME).absolutePath}",
28                         "${classDir.absolutePath}"
29                 standardOutput = stdout
30             }
31             def error = stdout.toString().trim()
32             if (error) {
33                 println "dex error:" + error
34             }
35         } else {
36             throw new InvalidUserDataException('$ANDROID_HOME is not defined')
37         }
38     }
39 }

好了, 当我们出包时,生成的apk中的所有类都是自动被注入了的,打正式包的这一次一定要把生成的hash文件所在的文件夹保存起来,以便下次改动代码后对比用,
  
  如果线上发现bug, 就把代码切回到当时版本,然后执行命令,传入上次编译出的hash文件所在的文件夹目录,就会生成一个本次修复的patch包(实际上是一个dex),包里只包含了我们需要修复的类。
命令如下:

1 gradlew clean nuwaReleasePatch -P NuwaDir=/Users/GaoGao/nuwa

类被客户端下载下来后nuwa sdk部分会负责把补丁打上去。

转载于:https://www.cnblogs.com/linghu-java/p/10419309.html

android 热更新nuwa相关推荐

  1. android 热更新 方案,热更新-热更新app开发的两种系统方案!

    针对app开发工作人员来讲,除开要会编码,热更新也是一定要学好和把握的方法,从技术性视角而言,热更新对Android和iOS各自有不一样的系统软件方案,为了更好地让大伙儿掌握这二种系统方案的差别,今日 ...

  2. Android热更新技术的研究与实现Sophix

    所以阿里爸爸一直在进步着呢,知道技术存在问题就要去解决问题,这不,从Dexposed-->AndFix-->HotFix-->Sophix,技术是越来越成熟了. Android热更新 ...

  3. Android热更新研究与实现

    第一部分重点是将当下热门的热更新方案实现之后再研究,第二部分则是自己动手实现一个自己的热更新框架. Android热更新技术的研究与实现之研究篇 ---概念讲解--– 热更新 相关概念 这个词出现的时 ...

  4. android热更新插件,与Android热更新方案Amigo的再次接触

    Amigo 作为一个"过气"的的热修复框架,用来学习和了解一下热修复的基本原理还是很好的.本文是本系列的第三篇. 前两篇: 与Android 热更新方案Amigo的初次接触 原作者 ...

  5. android 上下偏差怎么写_详解 Android 热更新升级如何突破底层结构差异?

    知道了 native 替换方式兼容性问题的原因,我们是否有办法寻求一种新的方式,不依赖于 ROM 底层方法结构的实现而达到替换效果呢? 我们发现,这样 native 层面替换思路,其实就是替换 Art ...

  6. bugly android8.1加固,2020-09-27 Bugly Android热更新使用指南

    戳我查看 DEMO Bugly Android热更新使用指南 官方文档 视频教程 第一步:添加插件依赖 工程根目录下"build.gradle"文件中添加: buildscript ...

  7. Android热更新

    Android热更新 组件化 组件化和模块化其实一回事,都是拆分多个 module 进行开发,组件化的叫法更偏向封装系统功能,比如统一对话框封装,网络封装等,而模块化叫法更偏向业务方面,比如登录模块等 ...

  8. Android热更新初探,Bugly热更新的集成和使用(让你的应用轻松具备热更新能力)

    介绍   在介绍Bugly之前,需要先向大家简单介绍下一些热更新的相关内容.当前市面的热补丁方案有很多,其中比较出名的有阿里的AndFix.美团的Robust以及QZone的超级补丁方案.但它们都存在 ...

  9. Android热更新十:自己写一个Android热修复

    很早之前就想深入的研究和学习一下热修复,由于时间的原因一直拖着,现在才执笔弄起来. Android而更新系列: Android热更新一:JAVA的类加载机制 Android热更新二:理解Java反射 ...

最新文章

  1. ITK:从灰度图像计算直方图
  2. 读书笔记--SQL必知必会03--排序检索数据
  3. NS2 分裂机制及代码分析一
  4. php 组,PHP: 子组(子模式) - Manual
  5. 四、处理表单数据 (基础教程4)
  6. 【Java入门提高篇】Day26 Java容器类详解(八)HashSet源码分析
  7. ImportError: DLL load failed: %1 不是有效的 Win32 应用程序
  8. Office机器人2.1.763.557(Excel批量打印 Word批量打印 批量加密 批量解密 批量转换格式)...
  9. GIS + 现代农业”,将会擦出怎样的火花?——智慧农业专题论坛侧记
  10. javascript计算两个时间差
  11. CentOS6.5配置eth0重启报错提示Device eth0 does not seem to be present,delaying initialization 的解决方法
  12. Java线程中yield与join方法的区别
  13. chat--hxxdfd
  14. Occupancy Flow: 4D Reconstruction by Learning Particle Dynamics(2)
  15. java写 狐狸找兔子_狐狸找兔 算法分析
  16. Python基于修正余弦相似度的电影推荐引擎
  17. 卷积神经网络中的卷积操作与信号系统中的卷积区别
  18. vue-router的实现
  19. 学计算机投影仪定义,一种计算机教学用投影仪射头的制作方法
  20. Spline导数及曲率计算

热门文章

  1. 基于关键词和基于关系链的获取流量方法
  2. 个人学习摄影接触到的网站
  3. 前端知识总结 持续更新
  4. 嵌入式Linux设备驱动程序开发指南20(Linux USB设备驱动)——读书笔记
  5. java安装找不到jvm,找不到JVM是什么原因?
  6. 玩转HTML中表单标签的使用
  7. A3Mall电商平台——功能测试(测试报告)
  8. 助力数字政府建设,中科三方构建域名安全保障体系
  9. Android调用系统照相机和摄像机
  10. 「认识AI:人工智能如何赋能商业」【02】人工智能规划