简介

  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函数。

 class NuwaPlugin implements Plugin<Project> {
HashSet<String> includePackage
HashSet<String> excludeClass
def debugOn
def patchList = []
def beforeDexTasks = []
private static final String NUWA_DIR = "NuwaDir"
private static final String NUWA_PATCHES = "nuwaPatches"
private static final String MAPPING_TXT = "mapping.txt"
private static final String HASH_TXT = "hash.txt"
private static final String DEBUG = "debug" @Override
void apply(Project project) {
project.extensions.create("nuwa", NuwaExtension, project)
project.afterEvaluate {
def extension = project.extensions.findByName("nuwa") as NuwaExtension
includePackage = extension.includePackage
excludeClass = extension.excludeClass
debugOn = extension.debugOn
}
}
}

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

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

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

 class NuwaExtension {
HashSet<String> includePackage = []
HashSet<String> excludeClass = []
boolean debugOn = true NuwaExtension(Project project) {
}
}

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

     HashSet<String> includePackage
HashSet<String> excludeClass
def debugOn
def patchList = []
def beforeDexTasks = []

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

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

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

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

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

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

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

 /**
参数说明:
hashFile: 本次编译所有类的“类名:hash”存放文件
jarFile: jar包, 调用这个函数的地方会遍历所有的jar包
patchDir: 有变更的文件统一存放到这个目录里
map: 上一次编译所有类的hash映射
includePackage: 额外指定只需要注入这些包下的类
excludeClass: 额外指定不参与注入的类
*/ public static processJar(File hashFile, File jarFile, File patchDir, Map map, HashSet<String> includePackage, HashSet<String> excludeClass) {
if (jarFile) {
// 先在原始jar同级目录下创建“同名.opt”文件,每注入完成一个类则打到这个opt文件中,
// opt文件实际上也是一个jar包,所有类都处理完后将文件后缀opt改为jar替换掉原来的jar
def optJar = new File(jarFile.getParent(), jarFile.name + ".opt”)
def file = new JarFile(jarFile);
Enumeration enumeration = file.entries();
// 创建输入opt文件,实际也是一个jar包
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar));
while (enumeration.hasMoreElements()) { // 遍历jar包中的每一个entry
JarEntry jarEntry = (JarEntry) enumeration.nextElement();
String entryName = jarEntry.getName();
ZipEntry zipEntry = new ZipEntry(entryName); InputStream inputStream = file.getInputStream(jarEntry);
jarOutputStream.putNextEntry(zipEntry); if (shouldProcessClassInJar(entryName, includePackage, excludeClass)) { // 根据一些规则和includePackage与excludeClass判断这个类要不要处理
def bytes = referHackWhenInit(inputStream); // 拿到这个类的输入流调用这个函数完成字节码注入
jarOutputStream.write(bytes); // 将注入完成的字节码写入opt文件中 def hash = DigestUtils.shaHex(bytes) // 生成文件hash
hashFile.append(NuwaMapUtils.format(entryName, hash)) 将hash值以键值对的形式写入到hash文件中,以便下次对比 if (NuwaMapUtils.notSame(map, entryName, hash)) { // 如果这个类和map中上次生成的hash不一样,则认为是修改过的,拷贝到需要最终打包的文件夹中
NuwaFileUtils.copyBytesToFile(bytes, NuwaFileUtils.touchFile(patchDir, entryName))
}
} else {
jarOutputStream.write(IOUtils.toByteArray(inputStream)); // 如果这个类不处理则直接写进opt文件
}
jarOutputStream.closeEntry();
}
jarOutputStream.close();
file.close(); if (jarFile.exists()) {
jarFile.delete()
}
optJar.renameTo(jarFile)
} }
 /**
负责注入,这里用到了asm框架(asm框架用来修改java字节码文件,非常强大,感兴趣的同学可以搜一下,类似的框架还有Javassist和BCEL).实际的动作就是给类注入一个无参的构造函数,构造函数里引用了“jiajixin/nuwa/Hack”类,这个类是另外一个dex中的,这个dex需要在application入口处加载,
这样就能保证所有类在用到这个类之前它已经被夹在到内存了,这么做就是为了防止类被打上ISPREVERIFIED标记,从而绕过android对类的检查,保证补丁生效。
*/
private static byte[] referHackWhenInit(InputStream inputStream) {
ClassReader cr = new ClassReader(inputStream);
ClassWriter cw = new ClassWriter(cr, 0);
ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
mv = new MethodVisitor(Opcodes.ASM4, mv) {
@Override
void visitInsn(int opcode) {
if ("<init>".equals(name) && opcode == Opcodes.RETURN) {
super.visitLdcInsn(Type.getType("Lcn/jiajixin/nuwa/Hack;")); // 引用另一个dex中的类
super.visitInsn(opcode);
}
}
return mv;
} };
cr.accept(cv, 0);
return cw.toByteArray();
 /**
NuwaAndroidUtils.dex
对NuwaProcessor.processJar中拷贝到patch文件夹的类执行打包 操作,这里用到了build-tools中的命令行。
参数说明:
project: 工程对象,从插件那里传过来的
classDir: 包含需要打包的类的文件夹
*/ public static dex(Project project, File classDir) {
if (classDir.listFiles().size()) {
def sdkDir Properties properties = new Properties()
File localProps = project.rootProject.file("local.properties")
if (localProps.exists()) {
properties.load(localProps.newDataInputStream())
sdkDir = properties.getProperty("sdk.dir")
} else {
sdkDir = System.getenv("ANDROID_HOME")
}
if (sdkDir) {
def cmdExt = Os.isFamily(Os.FAMILY_WINDOWS) ? '.bat' : ''
def stdout = new ByteArrayOutputStream()
project.exec {
commandLine "${sdkDir}/build-tools/${project.android.buildToolsVersion}/dx${cmdExt}",
'--dex',
"--output=${new File(classDir.getParent(), PATCH_NAME).absolutePath}",
"${classDir.absolutePath}"
standardOutput = stdout
}
def error = stdout.toString().trim()
if (error) {
println "dex error:" + error
}
} else {
throw new InvalidUserDataException('$ANDROID_HOME is not defined')
}
}
}

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

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

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

android 热更新nuwa的更多相关文章

  1. 【原】Android热更新开源项目Tinker源码解析系列之三:so热更新

    本系列将从以下三个方面对Tinker进行源码解析: Android热更新开源项目Tinker源码解析系列之一:Dex热更新 Android热更新开源项目Tinker源码解析系列之二:资源文件热更新 A ...

  2. 【原】Android热更新开源项目Tinker源码解析系列之一:Dex热更新

    [原]Android热更新开源项目Tinker源码解析系列之一:Dex热更新 Tinker是微信的第一个开源项目,主要用于安卓应用bug的热修复和功能的迭代. Tinker github地址:http ...

  3. 【原】Android热更新开源项目Tinker源码解析系列之二:资源文件热更新

    上一篇文章介绍了Dex文件的热更新流程,本文将会分析Tinker中对资源文件的热更新流程. 同Dex,资源文件的热更新同样包括三个部分:资源补丁生成,资源补丁合成及资源补丁加载. 本系列将从以下三个方 ...

  4. Android热更新开源项目Tinker集成实践总结

    前言 最近项目集成了Tinker,开始认为集成会比较简单,但是在实际操作的过程中还是遇到了一些问题,本文就会介绍在集成过程大家基本会遇到的主要问题. 考虑一:后台的选取 目前后台功能可以通过三种方式实 ...

  5. Android 热修复Nuwa的原理及Gradle插件源码解析

    现在,热修复的具体实现方案开源的也有很多,原理也大同小异,本篇文章以Nuwa为例,深入剖析.  Nuwa的github地址 https://github.com/jasonross/Nuwa 以及用于 ...

  6. Android热更新实现原理

    最近Android社区的氛围很不错嘛,连续放出一系列的android动态加载插件和热更新库,这篇文章就来介绍一下Android中实现热更新的原理. ClassLoader 我们知道Java在运行时加载 ...

  7. android 热更新 tinker 从零开始到使用

    这几天项目完结了,闲来无事,想起来了以前研究的热更新,那个开源的只有nvwa.recoo,等,不是很好用,最近听说tinker开源一段时间了,用的人还挺多,决定研究一下! 首先进入了官方文档 http ...

  8. Android 热更新是如何实现的?

    Android开发中,我们常常遇到热更新这个概念,而这个热更新具体是怎么实现的呢?今天在网上看到一个大神分享的热更新相关实现原理和实现代码,感觉灰常不错,分享给广大码农盆友look look . Cl ...

  9. Egret打包App Android热更新(4.1.0)

    官网教程:http://developer.egret.com/cn/github/egret-docs/Native/native/hotUpdate/index.html 详细可看官网教程,我这里 ...

随机推荐

  1. javascript 函数对象

    http://hi.baidu.com/gdancer/blog/item/a59e2c12479b4e54f919b814.html jQuery的一些写法就是基于这篇文章的原理的..     函数 ...

  2. cmd 字符串截取

    @echo off set "url=www.mzwu.com" echo 1.字符串截取 echo %url:~4,4% echo %url:~4,-4% echo %url:~ ...

  3. Spring Boot中使用Websocket搭建即时聊天系统

    1.首先在pom文件中引入Webscoekt的依赖 <!-- websocket依赖 --> <dependency> <groupId>org.springfra ...

  4. TZOJ 1545 Hurdles of 110m(01背包dp)

    描述 In the year 2008, the 29th Olympic Games will be held in Beijing. This will signify the prosperit ...

  5. TZOJ 3533 黑白图像(广搜)

    描述 输入一个n*n的黑白图像(1表示黑色,0表示白色),任务是统计其中八连块的个数.如果两个黑格子有公共边或者公共顶点,就说它们属于同一个八连块.如图所示的图形有3个八连块. 输入 第1行输入一个正 ...

  6. 为什么大神的UI设计那么高级?答案尽在此文…

    对于每个网页设计师而言,在设计过程中总会碰到需要作出设计决策的时候.也许你的公司并没有全职设计师,而需求上则要求设计出全新的UI:又或者你正在制作一个你自己的个人项目,而你希望它比 Bootstrap ...

  7. hive的用户和用户权限

    HiverServer2支持远程多客户端的并发和认证,支持通过JDBC.Beeline等连接操作.hive默认的Derby数据库,由于是内嵌的文件数据库,只支持一个用户的操作访问,支持多用户需用mys ...

  8. SqlServer和MySql允许脏读的实现方式,提高查询效率

    --Sql Server 允许脏读查询sqlselect * from category with(nolock) --MySql 允许脏读查询sql Mysql没有语法糖,需要原生的sqlSET S ...

  9. 什么是adb命令?以及如果高效使用他们?

    接下来 我会为大家讲解 最通俗易懂的adb命令 希望大家能够喜欢...

  10. $.post 提示错误: Uncaught SyntaxError: Unexpected token :

    $.post("addRecommond",{"productId":productId,"categoryCode":categoryCo ...