手把手教你寫熱修復(HOTFIX)

cuieney發表於2017-07-05

前提

寫這篇文章的目的呢,也是理一下自己的思路吧,同時把最近看到的一些熱修復知識獻給讀者們。不知道同學們最近是不是聽到了很多關於熱修復的事情,各大廠商,各界大佬們都有屬於自己的熱修復框架,最近阿里不也推出了個爆炸訊息,堪稱最牛逼的修復框架Sophix,同時還推出了對應的一本pdf(叫什麼深入理解Android熱修復技術原理),不知道多少同學看過,深入看應該是可以看到個原理,但是我感覺看了我也寫不出這樣的程式碼,畢竟大廠大佬。這篇文章呢,就簡單的教大家如何寫一個屬於自己公司或者自己的熱修復框架

友情提醒

1.這篇文章的重點在於.class檔案的打樁,可能會偏重於groovy語言,與java相通沒事,相信我你絕對能看懂。

2.如果沒有看過我之前的那篇文章可能會有些懵哦,之前的那篇文章講的是原理,通過DexClassLoader如何熱修復。

3.因為熱修復關鍵點還是在於打樁生成差異檔案的dex,而不是在於把這個dex檔案插入到已安裝的app中(兩個相輔相成(打樁和插入dex)),因為google的multidex裡面已經把這個操作做的很好了,我們只需要修修改改就可以完成這個插入操作,還有就是之前那篇文章還留了一個坑。

4.如果沒有接觸過熱修復的同學可能會對下面的一些詞彙比較悶(打樁修改位元組碼檔案(.class)打樁目的為了解決CLASS_ISPREVERIFIED預定義,不明白的可參考上一篇文章)

5.附文章傳送門及這篇文章的專案原始碼

DexClassLoader熱修復的入門到放棄

AutoFix歡迎star,fork,issue

小節

  • 如何正確的打樁避免Gradle1.4以上Transform API導致的無法打包(解決之前文章的坑)
  • 如何在編譯成dex檔案前進行打樁
  • 如何打樁
  • 如何區分差異檔案及正確的打包出patch.jar(只對修改後的檔案進行打包)

解決Gradle1.4以上的Transform問題

因為google的gradle升級了嘛,主要是他引入了transform API(官網解釋The API existed in 1.4.0-beta2 but it's been completely revamped in 1.5.0-beta1),導致我們的plugin找不到之前我們寫好的task任務名。

下面我給大家講一下通過plugin進行打樁操作,接下來我會給大家看一下nuwa熱修復專案中的部分程式碼。

def preDexTask = project.tasks.findByName("preDex${variant.name.capitalize()}")
def dexTask = project.tasks.findByName("dex${variant.name.capitalize()}")
def proguardTask = project.tasks.findByName("proguard${variant.name.capitalize()}")複製程式碼

這是nuwa熱修復的原始碼他事先定義好了這些任務,這些任務就是把位元組碼檔案打包成dex檔案的任務,上面的程式碼意思就是獲取這些任務的名字。(就是apply plugin: 'com.android.application'裡面的任務)。從上面的程式碼可以看到,我們定義的任務名稱分別是(preDex${variant.name.capitalize()})(dex${variant.name.capitalize()})(proguard${variant.name.capitalize()})($這個符號就是拼接字串的意思和kotlin一樣,variant.name.capitalize()這個就是獲取的字串是debug 還是release)。然後上面我們也說了,gradle1.4之後google改名字了,我們當然找不到這些任務名了,當然報錯了哦。現在呢我們只需要做一些簡單的if判斷操作不就可以了嗎?根據不同的gradle版本號修改一下名字不就得了,下面貼出RocooFix的程式碼塊如何解決的


    static String getProGuardTaskName(Project project, BaseVariant variant) {
        if (isUseTransformAPI(project)) {
            return "transformClassesAndResourcesWithProguardFor${variant.name.capitalize()}"
        } else {
            return "proguard${variant.name.capitalize()}"
        }
    }

    static String getPreDexTaskName(Project project, BaseVariant variant) {
        if (isUseTransformAPI(project)) {
            return ""
        } else {
            return "preDex${variant.name.capitalize()}"
        }
    }

    static String getDexTaskName(Project project, BaseVariant variant) {
        if (isUseTransformAPI(project)) {
            return "transformClassesWithDexFor${variant.name.capitalize()}"
        } else {
            return "dex${variant.name.capitalize()}"
        }
    }複製程式碼

看到了嗎?就是判斷一下當前的專案gradle版本號,然後修改一下名稱返回給你。簡單吧。幾行程式碼解決了相容性問題。

何時打樁

之前那篇文章也說了,apk編譯的生命週期。所以這邊顧名思義當然是在被打成dex檔案之前對class檔案的時候操作啊。

接下來問題來了,如何在被打成dex檔案前操作呢,剛剛上面說的獲取那些task名稱還記得嗎?這就是關鍵。因為在groovy中有這麼一個語法,任務之間可以通過dependsOn來新增依賴。

那麼好現在舉個例子。

task A{}
task B{}

A dependsOn B複製程式碼

很明顯嗎?就是執行A前必須B執行完了才行。知道了這個嗎?我們下面繼續看專案原始碼如何設定在我們打樁完成之後再執行dex操作

def autoJarBeforeDexTask = project.tasks[autoJarBeforeDex]

 autoJarBeforeDexTask.dependsOn dexTask.taskDependencies.getDependencies(dexTask)
 autoJarBeforeDexTask.doFirst(prepareClosure)
 autoPatchTask.dependsOn autoJarBeforeDexTask
 dexTask.dependsOn autoPatchTask複製程式碼

一下來這麼多程式碼 而且這些程式碼還不認識可能會有點蒙哦,不要著急,一行一句的講解給你聽他們的依賴關係(簡單講一下上面程式碼的意思
autoJarBeforeDexTask這個任務就是進行打樁的任務,dexTask這個任務就是打包成dex的任務,prepareClosure初始化操作的,autoPatchTask打包成補丁檔案的任務

第一行呢 獲取專案中的task 名字是autoJarBeforeDex

第二行呢 先說一下這句話的意思(dexTask.taskDependencies.getDependencies(dexTask) 這句話就是拿到dexTask的依賴任務),然後我們的autoJarBeforeDexTask這個任務對dexTask的依賴任務 進行依賴

第三行呢 autoJarBeforeDexTask 點doFirst(prepareClosure)意思就是說在執行autoJarBeforeDexTask任務前先執行這個prepareClosure

第四行和第五行不用說了吧

最後邏輯如prepareClosure -> autoJarBeforeDexTask -> autoPatchTask -> dexTask 依次執行

這樣操作就解決了在dex檔案生成前進行位元組碼檔案的插入

如何打樁

首先呢,打樁你的先獲取位元組碼檔案吧。就是這些file。如何獲取這些檔案呢,因為我們編譯專案的時候會在專案中生成一個build目錄,裡面有專案相關的所有檔案,我們可以通過下面的方式獲取這些檔案,我們打樁只需要獲取jar檔案和intermediates/class下面對於的class檔案,程式碼如下

 static Set getDexTaskInputFiles(Project project, BaseVariant variant, Task dexTask) {
        if (dexTask == null) {
            dexTask = project.tasks.findByName(getDexTaskName(project, variant));
        }

        if (isUseTransformAPI(project)) {
            def extensions = [SdkConstants.EXT_JAR] as String[]

            Set files = Sets.newHashSet();

            dexTask.inputs.files.files.each {
                if (it.exists()) {
                    if (it.isDirectory()) {
                        Collection jars = FileUtils.listFiles(it, extensions, true);
                        files.addAll(jars)
                        //intermediates/class下面對應的class檔案
                        if (it.absolutePath.toLowerCase().endsWith("intermediates${File.separator}classes${File.separator}${variant.dirName}".toLowerCase())) {
                            files.add(it)
                        }
                        //jar包
                    } else if (it.name.endsWith(SdkConstants.DOT_JAR)) {
                        files.add(it)
                    }
                }
            }
            return files
        } else {
            return dexTask.inputs.files.files;
        }
    }複製程式碼

檔案這時候我們已經拿到了。然後我們要遍歷這些檔案依次給他們打樁,同時要過濾掉jar包中不需要打樁的檔案不然會耗時

    //打樁等一些工作
  def autoJarBeforeDex = "autoJarBeforeDex${variant.name.capitalize()}"
                project.task(autoJarBeforeDex) << {
                    //獲取build/intermediates/下的檔案
                    Set inputFiles = AutoUtils.getDexTaskInputFiles(project, variant, dexTask)
                    inputFiles.each { inputFile ->
                        def path = inputFile.absolutePath
                        if (path.endsWith(SdkConstants.DOT_JAR)) {
                            //對jar包進行打樁
                            NuwaProcessor.processJar(hashFile,hashMap,inputFile, patchDir, includePackage, excludeClass)
                        } else if (inputFile.isDirectory()) {
                            //intermediates/classes/debug 目錄下面需要打樁的class
                            def extensions = [SdkConstants.EXT_CLASS] as String[]
                            //過濾不需要打樁的檔案class
                            def inputClasses = FileUtils.listFiles(inputFile, extensions, true);
                            inputClasses.each {
                                inputClassFile ->
                                    def classPath = inputClassFile.absolutePath
                                    //過濾R檔案和config檔案
                                    if (classPath.endsWith(".class") && !classPath.contains("/R\$") && !classPath.endsWith("/R.class") && !classPath.endsWith("/BuildConfig.class")) {
                                        //引用nuwa而來的
                                        if (NuwaSetUtils.isIncluded(classPath, includePackage)) {
                                            if (!NuwaSetUtils.isExcluded(classPath, excludeClass)) {
                                                def bytes = NuwaProcessor.processClass(inputClassFile)

                                                if ("\\".equals(File.separator)) {
                                                    classPath = classPath.split("${dirName}\\\\")[1]
                                                } else {
                                                    classPath = classPath.split("${dirName}/")[1]
                                                }
                                                def hash = DigestUtils.shaHex(bytes)
                                                hashFile.append(AutoUtils.format(classPath, hash))
                                                //根據hash值來判斷當前檔案是否為差異檔案需要做成patch嗎?
                                                if (AutoUtils.notSame(hashMap,classPath, hash)) {
                                                    def file = new File("${patchDir}${File.separator}${classPath}")
                                                    file.getParentFile().mkdirs()
                                                    if (!file.exists()) {
                                                        file.createNewFile()
                                                    }
                                                    FileUtils.writeByteArrayToFile(file, bytes)
                                                }
                                            }

                                        }
                                    }

                            }
                        }
                    }
                }複製程式碼

好吧程式碼有點長,但是每一個關鍵點都有相應的註釋,
上面的程式碼的意思簡單的說就是 先判斷檔案是jar包還是路徑 如果是jar包,進行jar包的打樁方式,如果是路徑的話 找到class檔案判斷這個class是否要打樁(如R檔案就不需要)。然後根據檔案的hash值來來判斷這個類是否修改過,如果修改過吧吧這些類放在一個資料夾中,最後統一打包成補丁。

打樁程式碼有兩處

 //對jar包進行打樁 
 NuwaProcessor.processJar(hashFile,hashMap,inputFile, patchDir, includePackage, excludeClass)複製程式碼
//對檔案進行打樁
def bytes = NuwaProcessor.processClass(inputClassFile)複製程式碼

這次介紹的打樁用的是asm這個庫,具體程式碼都在專案中可以去看看,這裡就不詳細說了。

如何區分差異檔案打包成patch.jar

這個關鍵點在於專案要在gradle中配置一些資訊 有興趣的同學可以看一下專案裡面有整合過程
AutoFix

auto_fix {
    lastVersion = '1'//需要打補丁的情況下開啟此處
}複製程式碼

如果細心的同學會發現我們的專案中建立了hashFile這個檔案。這個檔案是用來記錄每個版本的打樁了的位元組碼檔案的hash值。

上面的程式碼也可以看出需要配置上一次的版本號,你出現bug了肯定要修改你的versionCode 然後把之前的填寫到lastVersion上,他會根據上次的hashFile來和這次生成的hashFile進行對比,如果不相同說明這個類被修改過,然後吧這個檔案copy已發到patch目錄下,打樁完成之後我們,我們到對於的patch目錄下會找到這些檔案然後把它們打包成patch.jar生成相應的補丁檔案。有這麼一段程式碼

//根據hash值來判斷當前檔案是否為差異檔案需要做成patch嗎?
    if (AutoUtils.notSame(hashMap,classPath, hash)) {
        def file = new File("${patchDir}${File.separator}${classPath}")
        file.getParentFile().mkdirs()
        if (!file.exists()) {
            file.createNewFile()
        }
        FileUtils.writeByteArrayToFile(file, bytes)
    }複製程式碼

這就是上面說的意思根據hashMap 和當前的hash值來做判斷。最終生成差異檔案。打包成補丁檔案(問題又來了,如何生成補丁檔案呢。還記得之前說的執行任務的流程嗎?prepareClosure -> autoJarBeforeDexTask -> autoPatchTask -> dexTask 依次執行)autoPatchTask這個任務就是打補丁任務可以看一下原始碼

//製作patch補丁包
                def autoPatchTaskName = "applyAuto${variant.name.capitalize()}Patch"
                project.task(autoPatchTaskName) << {
                    if (patchDir) {
                        AutoUtils.makeDex(project, patchDir)
                    }
                }
                def autoPatchTask = project.tasks[autoPatchTaskName]複製程式碼

沒錯就是他,還是上一篇文章講到的打包操作只不過程式碼話了 具體程式碼可以去專案中看,這裡就不詳解了。

總結

說了這麼多,發現我咋還不會寫呢,怎麼辦,這篇文章說的都是tmd什麼打樁,對的你沒聽錯,因為熱修復就是插入dex 然後就是打樁 就這兩件事之前的文章講的是插入dex這篇文章講的是打樁,如果在不會可以去看看專案原始碼。

ending

有什麼疑問和不解可以留言哦,希望這次分享帶給大家帶來的不是時間的浪費,而是能力的提升。謝謝

原始碼奉上:AutoFix歡迎star,fork,issue

相關文章