android 熱更新nuwa


簡介

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM