簡介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部分會負責把補丁打上去。