Android熱修復技術(三)字節碼注入

前言

首先我們考慮一個問題,爲什麼需要進行字節碼注入代碼?

那是因爲apk在加載外部dex包的時候程序會出現崩潰(5.0以下),那爲什麼會崩潰呢?
java.lang.IllegalAccessError:Class ref in pre-verified class resolved to unexpected implementation

1. 崩潰原因—>類預校驗問題引起的

  • 在apk安裝的時候系統會將dex文件優化成odex文件,在優化過程中會涉及一個預校驗過程

  • 如果一個類的static方法,private方法,override方法以及構造函數中引用了其他類,而這些類都屬於同一個dex文件,此時類就會被打上CLASS_ISPREVERIFIED

  • 如果在運行時被打上CLASS_ISPREVERIFIED的類引用其他dex的類,就會報錯

  • 正常的分包方案會保證相關類被打入同一個dex文件

  • 要想使得patch可以被正常加載,就必須保證類不會被打上CLASS_ISPREVERIFIED標記,而要實現這個目的就必須要在分包完成前的class中植入對其他dex文件中類的引用

  • 要在已經編譯完成後的類中植入對其他類的引用,就需要操作字節碼,慣用的方案是插樁,常見的工具有javassist、asm等

所以熱修復的關鍵就在於字節碼的注入而非dex的加載了。

2. 爲什麼只在5.0以下崩潰

Android 5.0(API 級別 21)之前的平臺版本使用 Dalvik 運行時來執行應用代碼。默認情況下,Dalvik 限制應用的每個 APK 只能使用單個 classes.dex 字節碼文件。要想繞過這一限制,您可以使用 Dalvik 可執行文件分包支持庫(Multidex),它會成爲您的應用主要 DEX 文件的一部分,然後管理對其他 DEX 文件及其所包含代碼的訪問。

Android 5.0(API 級別 21)及更高版本使用名爲 ART 的運行時,後者原生支持從 APK 文件加載多個 DEX 文件。ART 在應用安裝時執行預編譯,掃描 classesN.dex 文件,並將它們編譯成單個 .oat 文件,供 Android 設備執行。因此,如果您的 minSdkVersion 爲 21 或更高值,則不需要 Dalvik 可執行文件分包支持庫。所以5.0以上不存在類預校打標記驗問題,進而導致加載其他dex崩潰的問題,但是爲了兼容5.0以下,我們還是需要注入代碼

  • ./gradle中配置 multiDexEnabled true 表示編譯apk需要分包
  • Application中配置MultiDex.install(this);表示要加載其他dex包,5.0以下起作用(內部有判斷),5.0以上原生支持從APK文件加載多個dex文件

google文檔:配置方法數超過 64K 的應用

一、Transform

由之前的分析我們知道,Gradle構建工程實質是通過一系列的Task的完成的,在構建apk的過程中存在打包dex的任務

Task :app:transformClassesWithDexBuilderForXXX
Task :app:transformDexArchiveWithDexMergerForXXX

Gradle 1.5以上版本提供了一個新的API:Transform,這個API允許第三方插件在class文件轉爲dex文件前操作編譯好的class文件,這個Trasnform的目標是簡化注入自定義類操作,而不必處理Task,並提供更靈活的操作,dex任務已經全部移動到這個新的機制中。

Transform任務一經註冊就會被插入到任務執行隊列中,並且其恰好在dex打包任務之前,所以要想實現插樁就需要創建一個Transform,其實它內部也是通過創建task來實現的,它的task名爲transformClassesWithPreDexInjectForXXX

有一個TransformManager對Transform進行管理,從一個Transform流入,然後對字節碼進行加工處理以後再輸出,然後流入下一個Transform,直到所有的Transform過濾處理完成,所以我們的Transform就需要註冊到TransformManager中去

  • 創建一個Transform類,繼承一個抽象類Transform
class PreDexInjectTransform extends Transform {
    Project mProject
    InjecterByTransform mInjecter


    PreDexInjectTransform(Project project) {
        this.mProject = project
    }

    @Override
    String getName() {
        return "preDexInject"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        //獲取輸入類型
        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        //指定範圍
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        //是否支持增量編譯,如果返回true,可以根據TransformInput來獲得更改、移除或者添加的文件目錄
        //JarInput  --> getStatus()
        //DirectoryInput --> getChangedFiles()
        return false
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        //TransformInvocation包含了輸入,輸出相關信息
        //可以通過transformInvocation.inputs得到輸入然後進行遍歷得到多個TransformInput,每個TransformInput都包含目錄的輸入(directoryInputs)和jar包的輸入(jarInputs)
        //其輸出相關內容由TransformOutputProvider來做處理,getContentLocation()方法可以獲取文件的輸出目錄,如果目錄存在的話直接返回,如果不存在就會重新創建一個
    }
}
  • 主要重寫transform方法,在這裏對字節碼進行處理
@Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        //遍歷transform的inputs
        //inputs有兩種類型,一種是目錄,一種是jar,需要分別遍歷
        transformInvocation.inputs.each { TransformInput input ->
            input.directoryInputs.each { DirectoryInput directoryInput ->
                //TODO 在這裏可以注入代碼

                def dest = transformInvocation.outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

                //將input的目錄複製到output指定目錄
                FileUtils.copyDirectory(directoryInput.file, dest)
            }

            input.jarInputs.each { JarInput jarInput ->
                //注入代碼
                def jarPath = jarInput.file.absolutePath
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                def jarInputFile = jarInput.file

                //重命名輸出文件(同目錄copyFile會衝突)
                def jarName = jarInput.name
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
                def dest = transformInvocation.outputProvider.getContentLocation(jarName + md5Name,
                        jarInput.contentTypes, jarInput.scopes, Format.JAR)
                FileUtils.copyFile(jarInputFile, dest)
            }
        }
    }

這裏用到了apache-common包,需要在build.gradle中引入

 compile "commons-io:commons-io:+"
 compile 'commons-codec:commons-codec:+'
  • 對自定義的Transform進行註冊
def preDexTransform = new PreDexInjectTransform(project)
project.android.registerTransform(preDexTransform)

在執行編譯過程中會生成對應的目錄,例如在/app/build/intermediates/transforms目錄下生成了名爲preDexInject目錄,這個名稱是根據自定義的Transform類getName()方法返回的字符串來的

這裏寫圖片描述

這裏寫圖片描述

該目錄下還有有一個content.json文件,該文件配置了文件內容

[
{
    "name": "android.local.jars:WeiboSDK_fat.jar:98d96d3a2f5f41858187ce3a9c6be4e2c0ef1ada2d8d0dc3850d7b81fbaba211ed26348f",
    "index": 0,
    "scopes": [
      "EXTERNAL_LIBRARIES"
    ],
    "types": [
      "CLASSES"
    ],
    "format": "JAR",
    "present": true
  },
  {
    "name": "com.android.support:appcompat-v7:27.1.19e6cc2c0adcd05ea48fa0acf923bfc09",
    "index": 1,
    "scopes": [
      "EXTERNAL_LIBRARIES"
    ],
    "types": [
      "CLASSES"
    ],
    "format": "JAR",
    "present": true
  },
{
    "name": ":testlibrary07c1666863d4d53d82ec5fd8c2d80afd",
    "index": 19,
    "scopes": [
      "SUB_PROJECTS"
    ],
    "types": [
      "CLASSES"
    ],
    "format": "JAR",
    "present": true
  }
  ...
]

Transform中的transform()方法是如何被執行的?

我們之前講到的project.android.registerTransform()註冊方法,實際上只是把Transform對象放到了一個List集合中,實際上每個Transform都會有一個對應的TransformTask,其內部有一個transform方法,被@TaskAction所註解,TransformTask本質上就是表示Gradle中的一個Task,那麼在一個Task執行的時候其@TaskAction註解的方法就會被執行,然後內部調用了Transform的transform()方法,也就是可以任務Transform是TransformTask的包裝,它幫我們去定義Task並且指定了Task的執行時機,我們只需要做我們自己的操作而不用關心其他

那麼又有一個問題來了,他把TransformTask的執行時機定義在哪呢?他內部並沒有使用dependsOn()顯式的保證依賴關係,其實在Transform API中,使用的是TransformStream來連接TransformTask的依賴關係,進而控制Transform的執行順序,指定TransformTask的輸入輸出是在TransformManager的addTransform()方法中,創建task的時候指定了輸入和輸出。

所以當我們自定義Plugin要注入多個Transform的時候,按照添加順序來保證依賴關係,先添加的Transform先執行

二、Javassist

Javassist是一個可以用來檢查、動態修改以及創建Java字節碼的動態類庫

1.創建一個新的class

ClassPool pool = ClassPool.getDefault();

//定義類
CtClass stuClass = pool.makeClass("com.huli.Student");

//如果類已經存在,可以直接獲取
CtClass cc = pool.get("java.lang.String");

2.構造成員變量

CtField idField = new CtField(CtClass.longType,"id",stuClass);
stuClass.addField(idField);

3.構造方法

CtMethod getMethod = CtNewMethod.make("public int getAge() { return this.age }", stuClass);
CtMethod setMethod = CtNewMethod.make("public int setAge(int age) { this.age = age }", stuClass);

stuClass.addMethod(getMethod);
stuClass.addMethod(setMethod);

4.設置父類

stuClass.setSuperClass(pool.get("com.huli.Person"));
stuClass.writeFile();

5.將類凍結

如果一個CtClass通過writeFile()、toClass()、toByteCode()方法被轉換成一個類文件,此時此CtClass對象就會被凍結起來,不允許被修改。

但是,一個被凍結的CtClass也可以被解凍,例如

stuClass.defrost(); //解凍
stuClass.setSuperClass(...) //被解凍後又可以修改了,如果不調用解凍方法會報錯

6.類搜索路徑

通過ClassPool.getDefault()獲取的ClassPool使用的是JVM的類搜索路徑,但有時候我們可能需要添加其他搜索路徑,使用insertClassPath或者appendClassPath,區別在於一個是插入到前面,一個是添加到後面

pool.insertClassPath(new ClassPath(this.getClass()));

上面的語句將this指向的類添加到pool的類加載路徑中,你可以使用任意Class對象來代替,從而將Class對象添加到類加載路徑中

當然,我們也可以添加一個目錄或者jar包作爲搜索路徑

pool.insertClassPath("/Users/xueshanshan/huli/project/HotfixPatchProject/app/build/intermediates/classes/release")

pool.insertClassPath("/Users/xueshanshan/huli/project/HotfixPatchProject/app/libs/a.jar")

7.避免內存溢出

ClassPool是CtClass對象的容器,一旦一個CtClass被創建,它就會保存在ClassPool中

如果CtClass對象的數量變得非常大,ClassPool可能會導致巨大的內存消耗,爲了避免此問題,可以從ClassPool中顯示刪除不必要的CtClass對象。如果對CtClass對象調用detach(),那麼該CtClass對象將會從ClassPool中移除

另一個辦法是用新的ClassPool替換舊的ClassPool,並將舊的ClassPool丟棄,如果舊的ClassPool被垃圾回收掉,那麼包含在ClassPool中的CtClass對象也會被回收,要創建一個新的ClassPool,可以使用下面代碼:

ClassPool pool = new ClassPool(true);  //true表示添加系統搜索路徑

三、項目代碼注入流程分析

  1. 首先定義Transform,並且實現相應方法

  2. 然後plugin中註冊Transform

  3. 使用plugin

  4. 執行assemble命令,查看輸出結果

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章