Android熱修復(fù)技術(shù)——QQ空間補(bǔ)丁方案解析(3)

如前文所述茅特,要想實現(xiàn)熱更新的目的哆料,就必須在dex分包完成之后操作字節(jié)碼文件。比較常用的字節(jié)碼操作工具有ASM和javaassist策添。相比之下ASM提供一系列字節(jié)碼指令材部,效率更高但是要求使用者對字節(jié)碼操作有一定了解。而javaassist雖然效率差一些但是使用門檻較低舰攒,本文選擇使用javaassist败富。關(guān)于javaassist可以參考Java 編程的動態(tài)性悔醋, 第四部分: 用 Javassist 進(jìn)行類轉(zhuǎn)換

正常App開發(fā)過程中摩窃,編譯,打包過程都是Android Studio自動完成芬骄。如無特殊需求無需人為干預(yù)猾愿,但是要實現(xiàn)插樁就必須在Android Studio的自動化打包流程中加入插樁的過程。

1. Gradle,Task,Transform,Plugin

Android Studio采用Gradle作為構(gòu)建工具账阻,所有有必要了解一下Gradle構(gòu)建的基本概念和流程蒂秘。如果不熟悉可以參考一下下列文章:

Gradle的構(gòu)建工程實質(zhì)上是通過一系列的Task完成的,所以在構(gòu)建apk的過程中就存在一個打包dex的任務(wù)淘太。Gradle 1.5以上版本提供了一個新的API:Transform姻僧,官方文檔對于Transform的描述是:

The goal of this API is to simplify injecting custom class manipulations without having to deal with tasks, and to offer more flexibility on what is manipulated. The internal code processing (jacoco, progard, multi-dex) have all moved to this new mechanism already in 1.5.0-beta1.

    1. The Dex class is gone. You cannot access it anymore through the variant API (the getter is still there for now but will throw an exception)
    1. Transform can only be registered globally which applies them to all the variants. We'll improve this shortly.
    1. There's no way to control ordering of the transforms.

Transform任務(wù)一經(jīng)注冊就會被插入到任務(wù)執(zhí)行隊列中,并且其恰好在dex打包task之前蒲牧。所以要想實現(xiàn)插樁就必須創(chuàng)建一個Transform類的Task撇贺。

1.1 Task

Gradle的執(zhí)行腳本就是由一系列的Task完成的。Task有一個重要的概念:input的output冰抢。每一個task需要有輸入input松嘶,然后對input進(jìn)行處理完成后在輸出output。

1.2 Plugin

Gradle的另外一個重要概念就是Plugin挎扰。整個Gradle的構(gòu)建體系都是有一個一個的plugin構(gòu)成的翠订,實際Gradle只是一個框架巢音,提供了基本task和指定標(biāo)準(zhǔn)。而具體每一個task的執(zhí)行邏輯都定義在一個個的plugin中尽超。詳細(xì)的概念可以參考:Writing Custom Plugins
在Android開發(fā)中我們經(jīng)常使用到的plugin有:"com.android.application"官撼,"com.android.library","java"等等。
每一個Plugin包含了一系列的task似谁,所以執(zhí)行g(shù)radle腳本的過程也就是執(zhí)行目標(biāo)腳本所apply的plugin所包含的task歧寺。

1.3 創(chuàng)建一個包含Transform任務(wù)的Plugin

    1. 新建一個module,選擇library module棘脐,module名字必須叫BuildSrc
    1. 刪除module下的所有文件斜筐,除了build.gradle,清空build.gradle中的內(nèi)容
    1. 然后新建以下目錄 src-main-groovy
    1. 修改build.gradle如下蛀缝,同步
apply plugin: 'groovy'

repositories {
    jcenter()
}

dependencies {
    compile gradleApi()
    compile 'com.android.tools.build:gradle:1.5.0'
    compile 'org.javassist:javassist:3.20.0-GA'//javaassist依賴
}
    1. 像普通module一樣新建package和類顷链,不過這里的類是以groovy結(jié)尾,新建類的時候選擇file屈梁,并且以.groovy作為后綴
    1. 自定義Plugin:
package com.hotpatch.plugin

import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.io.FileUtils
import org.gradle.api.Project

public class PreDexTransform extends Transform {

    Project project;

    public PreDexTransform(Project project1) {
        this.project = project1;

        def libPath = project.project(":hack").buildDir.absolutePath.concat("/intermediates/classes/debug")
        println libPath
        Inject.appendClassPath(libPath)
        Inject.appendClassPath("/Users/liyazhou/Library/Android/sdk/platforms/android-24/android.jar")
    }
    @Override
    String getName() {
        return "preDex"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {

        // 遍歷transfrom的inputs
        // inputs有兩種類型嗤练,一種是目錄,一種是jar在讶,需要分別遍歷煞抬。
        inputs.each {TransformInput input ->
            input.directoryInputs.each {DirectoryInput directoryInput->

                //TODO 注入代碼
                Inject.injectDir(directoryInput.file.absolutePath)

                def dest = outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
                // 將input的目錄復(fù)制到output指定目錄
                FileUtils.copyDirectory(directoryInput.file, dest)
            }

            input.jarInputs.each {JarInput jarInput->


                //TODO 注入代碼
                String jarPath = jarInput.file.absolutePath;
                String projectName = project.rootProject.name;
                if(jarPath.endsWith("classes.jar")
                        && jarPath.contains("exploded-aar/"+projectName)
                        // hotpatch module是用來加載dex,無需注入代碼
                        && !jarPath.contains("exploded-aar/"+projectName+"/hotpatch")) {
                    Inject.injectJar(jarPath)
                }

                // 重命名輸出文件(同目錄copyFile會沖突)
                def jarName = jarInput.name
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                if(jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0,jarName.length()-4)
                }
                def dest = outputProvider.getContentLocation(jarName+md5Name, jarInput.contentTypes, jarInput.scopes, Format.JAR)
                FileUtils.copyFile(jarInput.file, dest)
            }
        }
    }
}
  • 8.Inject.groovy, JarZipUtil.groovy
package com.hotpatch.plugin

import javassist.ClassPool
import javassist.CtClass
import org.apache.commons.io.FileUtils

public class Inject {

    private static ClassPool pool = ClassPool.getDefault()

    /**
     * 添加classPath到ClassPool
     * @param libPath
     */
    public static void appendClassPath(String libPath) {
        pool.appendClassPath(libPath)
    }

    /**
     * 遍歷該目錄下的所有class构哺,對所有class進(jìn)行代碼注入革答。
     * 其中以下class是不需要注入代碼的:
     * --- 1. R文件相關(guān)
     * --- 2. 配置文件相關(guān)(BuildConfig)
     * --- 3. Application
     * @param path 目錄的路徑
     */
    public static void injectDir(String path) {
        pool.appendClassPath(path)
        File dir = new File(path)
        if(dir.isDirectory()) {
            dir.eachFileRecurse { File file ->

                String filePath = file.absolutePath
                if (filePath.endsWith(".class")
                        && !filePath.contains('R$')
                        && !filePath.contains('R.class')
                        && !filePath.contains("BuildConfig.class")
                        // 這里是application的名字,可自行配置
                        && !filePath.contains("HotPatchApplication.class")) {
                    // 應(yīng)用程序包名曙强,可自行配置
                    int index = filePath.indexOf("com/hotpatch/plugin")
                    if (index != -1) {
                        int end = filePath.length() - 6 // .class = 6
                        String className = filePath.substring(index, end).replace('\\', '.').replace('/','.')
                        injectClass(className, path)
                    }
                }
            }
        }
    }

    /**
     * 這里需要將jar包先解壓残拐,注入代碼后再重新生成jar包
     * @path jar包的絕對路徑
     */
    public static void injectJar(String path) {
        if (path.endsWith(".jar")) {
            File jarFile = new File(path)


            // jar包解壓后的保存路徑
            String jarZipDir = jarFile.getParent() +"/"+jarFile.getName().replace('.jar','')

            // 解壓jar包, 返回jar包中所有class的完整類名的集合(帶.class后綴)
            List classNameList = JarZipUtil.unzipJar(path, jarZipDir)

            // 刪除原來的jar包
            jarFile.delete()

            // 注入代碼
            pool.appendClassPath(jarZipDir)
            for(String className : classNameList) {
                if (className.endsWith(".class")
                        && !className.contains('R$')
                        && !className.contains('R.class')
                        && !className.contains("BuildConfig.class")) {
                    className = className.substring(0, className.length()-6)
                    injectClass(className, jarZipDir)
                }
            }

            // 從新打包jar
            JarZipUtil.zipJar(jarZipDir, path)

            // 刪除目錄
            FileUtils.deleteDirectory(new File(jarZipDir))
        }
    }

    private static void injectClass(String className, String path) {
        CtClass c = pool.getCtClass(className)
        if (c.isFrozen()) {
            c.defrost()
        }
        def constructor = c.getConstructors()[0];
        constructor.insertAfter("System.out.println(com.hotpatch.hack.AntilazyLoad.class);")
        c.writeFile(path)
    }

}

package com.hotpatch.plugin

import java.util.jar.JarEntry
import java.util.jar.JarFile
import java.util.jar.JarOutputStream
import java.util.zip.ZipEntry

/**
 * Created by hp on 2016/4/13.
 */
public class JarZipUtil {

    /**
     * 將該jar包解壓到指定目錄
     * @param jarPath jar包的絕對路徑
     * @param destDirPath jar包解壓后的保存路徑
     * @return 返回該jar包中包含的所有class的完整類名類名集合,其中一條數(shù)據(jù)如:com.aitski.hotpatch.Xxxx.class
     */
    public static List unzipJar(String jarPath, String destDirPath) {

        List list = new ArrayList()
        if (jarPath.endsWith('.jar')) {

            JarFile jarFile = new JarFile(jarPath)
            Enumeration<JarEntry> jarEntrys = jarFile.entries()
            while (jarEntrys.hasMoreElements()) {
                JarEntry jarEntry = jarEntrys.nextElement()
                if (jarEntry.directory) {
                    continue
                }
                String entryName = jarEntry.getName()
                if (entryName.endsWith('.class')) {
                    String className = entryName.replace('\\', '.').replace('/', '.')
                    list.add(className)
                }
                String outFileName = destDirPath + "/" + entryName
                File outFile = new File(outFileName)
                outFile.getParentFile().mkdirs()
                InputStream inputStream = jarFile.getInputStream(jarEntry)
                FileOutputStream fileOutputStream = new FileOutputStream(outFile)
                fileOutputStream << inputStream
                fileOutputStream.close()
                inputStream.close()
            }
            jarFile.close()
        }
        return list
    }

    /**
     * 重新打包jar
     * @param packagePath 將這個目錄下的所有文件打包成jar
     * @param destPath 打包好的jar包的絕對路徑
     */
    public static void zipJar(String packagePath, String destPath) {

        File file = new File(packagePath)
        JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(destPath))
        file.eachFileRecurse { File f ->
            String entryName = f.getAbsolutePath().substring(packagePath.length() + 1)
            outputStream.putNextEntry(new ZipEntry(entryName))
            if(!f.directory) {
                InputStream inputStream = new FileInputStream(f)
                outputStream << inputStream
                inputStream.close()
            }
        }
        outputStream.close()
    }
}

    1. 在app module下build.gradle文件中添加新插件:apply plugin: com.hotpatch.plugin.Register

2. 創(chuàng)建hack.jar

創(chuàng)建一個單獨的module碟嘴,命名為com.hotpatch.plugin.AntilazyLoad:

package com.hotpatch.plugin
public class AntilazyLoad {
}

使用上一篇博客介紹的方法打包hack.jar溪食。然后將hack.jar復(fù)制到app module下的assets目錄中。另外注意:app module不能依賴hack module娜扇。之所以要創(chuàng)建一個hack module错沃,同時人為地在dex打包過程中插入對其他hack.jar中類的依賴,就是要讓apk文件在安裝的時候不被打上CLASS_ISPREVERIFIED標(biāo)記雀瓢。
另外由于hack.jar位于assets中枢析,所以必須要在加載patch_dex之前加載hack.jar。另外由于加載其他路徑的dex文件都是在Application.onCreate()方法中執(zhí)行的致燥,此時還沒有加載hack.jar登疗,所以這就是為什么在上一章節(jié)插樁的時候不能在Application中插樁的原因。

插樁的過程介紹完了,整個熱修復(fù)的過程也就差不多了辐益,讀者可以參考完整的代碼進(jìn)行demo試用:Hotpatch Demo

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末断傲,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子智政,更是在濱河造成了極大的恐慌认罩,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件续捂,死亡現(xiàn)場離奇詭異垦垂,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)牙瓢,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進(jìn)店門劫拗,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人矾克,你說我怎么就攤上這事页慷。” “怎么了胁附?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵酒繁,是天一觀的道長。 經(jīng)常有香客問我控妻,道長州袒,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任弓候,我火速辦了婚禮郎哭,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好牧抽,可當(dāng)我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布谷炸。 她就那樣靜靜地躺著,像睡著了一般畦徘。 火紅的嫁衣襯著肌膚如雪毕籽。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天井辆,我揣著相機(jī)與錄音关筒,去河邊找鬼。 笑死杯缺,一個胖子當(dāng)著我的面吹牛蒸播,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼袍榆,長吁一口氣:“原來是場噩夢啊……” “哼胀屿!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起包雀,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤宿崭,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后才写,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體葡兑,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年赞草,在試婚紗的時候發(fā)現(xiàn)自己被綠了讹堤。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡厨疙,死狀恐怖蜕劝,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情轰异,我是刑警寧澤岖沛,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站搭独,受9級特大地震影響婴削,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜牙肝,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一唉俗、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧配椭,春花似錦虫溜、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至敦姻,卻和暖如春瘾境,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背镰惦。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工迷守, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人旺入。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓兑凿,卻偏偏與公主長得像凯力,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子礼华,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,979評論 2 355

推薦閱讀更多精彩內(nèi)容