简介java
Nuwa是比较流行的一种Android热补丁方案的开源实现,它的特色是成功率高,实现简单。固然,热补丁的方案目前已经有不少了,AndFix, Dexposed, Tinker等,之因此要分析Nuwa,是由于它表明了一种热修复的思想,经过它能够窥探到不少这方面的知识,包括更进一步的插件化。android
Nuwa工做原理api
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部分去完成真正的“打”补丁的过程。app
SDK: SDK是一个Android library,须要打在Apk里,程序运行的适当的时候调用其中的方法,它提供一个核心方法:loadPatch(String path). 负责将传入的补丁加载到内存,当启动应用时,Apk内的dex文件会被挨个经过ClassLoader加载到内存, 同时dex会按顺序维持一个列表,当程序须要加载一个类时,就去这个列表里查,一但查到就会使用对应dex具体的类,若是都没找到就会报ClassNotFound错误, 咱们加载补丁的原理就是经过反射将咱们的补丁dex插入到列表的最开始,这样当须要加载bug类时就会先在补丁dex里面找到,这样系统就会使用修复过的类,便达到了热修复的目的。要注意的是loadPatch必定要在bug类使用前调用,一旦bug类使用过了,本次修复就会没有效果,只能杀死进程再启动应用才会生效。框架
本次咱们只会分析Gradle插件部分的代码,sdk的代码之后有机会另开一篇分析。
下面开始结合工程来分析 Nuwa plugin的实现, 为了篇幅,咱们只关注主流程ide
项目目录结构函数
代码分析工具
实现一个plugin首先要实现Plugin接口,重写apply函数。 gradle
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部分会负责把补丁打上去。