前提
寫這篇文章的目的呢,也是理一下自己的思路吧,同時把最近看到的一些熱修復知識獻給讀者們。不知道同學們最近是不是聽到了很多關於熱修復的事情,各大廠商,各界大佬們都有屬於自己的熱修復框架,最近阿里不也推出了個爆炸訊息,堪稱最牛逼的修復框架Sophix,同時還推出了對應的一本pdf(叫什麼深入理解Android熱修復技術原理),不知道多少同學看過,深入看應該是可以看到個原理,但是我感覺看了我也寫不出這樣的程式碼,畢竟大廠大佬。這篇文章呢,就簡單的教大家如何寫一個屬於自己公司或者自己的熱修復框架
友情提醒
1.這篇文章的重點在於.class檔案的打樁,可能會偏重於groovy語言,與java相通沒事,相信我你絕對能看懂。
2.如果沒有看過我之前的那篇文章可能會有些懵哦,之前的那篇文章講的是原理,通過DexClassLoader如何熱修復。
3.因為熱修復關鍵點還是在於打樁生成差異檔案的dex,而不是在於把這個dex檔案插入到已安裝的app中(兩個相輔相成(打樁和插入dex)),因為google的multidex裡面已經把這個操作做的很好了,我們只需要修修改改就可以完成這個插入操作,還有就是之前那篇文章還留了一個坑。
4.如果沒有接觸過熱修復的同學可能會對下面的一些詞彙比較悶(打樁
修改位元組碼檔案(.class)打樁目的為了解決CLASS_ISPREVERIFIED預定義,不明白的可參考上一篇文章)
5.附文章傳送門及這篇文章的專案原始碼
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