天天看點

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

一、關于CSDN mardown編輯器的坑

Android熱更新檔動态修複技術(三)這篇博文其實在4月8日的晚上已經釋出了,然後緊接着寫第四篇,但是我将(四)儲存到草稿箱時,發現已經釋出的(三)消失了,取而代之的是第四篇博文。

在論壇問過版主,可能是因為我誤操作導緻的,第三篇博文已經無法恢複。

真是手賤!寫了好幾天的東西啊,不過比起誤操作我更傾向認為這是csdn的bug……

markdown編輯器絕對有坑!光是寫新文章時不會自動清楚緩存我認為就是一個很嚴重的Bug了!

二、前言

因為第三篇博文消失的原因,伴随着示範的Demo項目也修改了很多内容,我也沒那麼精力重新寫一篇,就和第四篇博文合并在一起當做第三篇吧,這可能導緻内容跨度有些大,并且不會像之前的博文這麼詳細,希望大家多多支援和了解。

上一篇部落格中,我們再Application中成功注入了patch_dex.jar到ClassLoader中。

但是伴随着CLASS_ISPREVERIFIED問題,解決方式就在在所有類的構造函數中添加一行代碼

System.out.println(AntilazyLoad.class);

三、Gradle, Transfrom, Task, Plugin

我們來分析一下如何在所有類的構造函數中添加

System.out.println(AntilazyLoad.class);

  1. 在源碼中直接添加,這個不行。AntilazyLoad.class這個類找不到,編譯不通過
  2. 繞過編譯,使用javassist操作位元組碼,直接注入代碼。

第二點是可行的,但是AndroidStudio項目是使用Gradle建構的,編譯-打包-簽名都是自動化。

我們在什麼時候注入代碼?

看過我上一篇博文推薦的文章就知道,Gradle是通過一個一個Task執行完成整個流程的,其中肯定也有将所有class打包成dex的task。

(在gradle plugin 1.5 以上和以下版本有些不同)

  • 1.5以下,preDex這個task會将依賴的module編譯後的class打包成jar,然後dex這個task則會将所有class打包成dex
  • 1.5以上,preDex和Dex這兩個task已經消失,取而代之的是TransfromClassesWithDexForDebug

3.1 Transfrom

Transfrom是Gradle 1.5以上新出的一個api,其實它也是Task,不過定義方式和Task有點差別。

對于熱更新檔來說,Transfrom反而比原先的Task更好用。

  • 在Transfrom這個api出來之前,想要在項目被打包成dex之前對class進行操作,必須自定義一個Task,然後插入到predex或者dex之前,在自定義的Task中可以使用javassist或者asm對class進行操作。
  • 而Transform則更為友善,Transfrom會有他自己的執行時機,不需要我們插入到某個Task前面。Tranfrom一經注冊便會自動添加到Task執行序列中,并且正好是項目被打包成dex之前。

而本文就是使用Gradle1.5以上版本,下面則是Google對Transfrom的描述文檔。

http://tools.android.com/tech-docs/new-build-system/transform-api

有時候會通路不了,你可能需要一把梯子……

3.2 Task的inputs和outputs

Gradle可以看做是一個腳本,包含一系列的Task,依次執行這些task後,項目就打包成功了。

而Task有一個重要的概念,那就是inputs和outputs。

Task通過inputs拿到一些東西,處理完畢之後就輸出outputs,而下一個Task的inputs則是上一個Task的outputs。

例如:一個Task的作用是将java編譯成class,這個Task的inputs就是java檔案的儲存目錄,outputs這是編譯後的class的輸出目錄,它的下一個Task的inputs就會是編譯後的class的儲存目錄了。

3.3 Plugin

Gradle中除了Task這個重要的api,還有一個就是Plugin。

Plugin的作用是什麼呢,這一兩句話比較難以說明。

Gralde隻能算是一個建構架構,裡面的那麼多Task是怎麼來的呢,誰定義的呢?

是Plugin,細心的網友會發現,在module下的build.gradle檔案中的第一行,往往會有

apply plugin : 'com.android.application'

亦或者

apply plugin : 'com.android.library'

com.android.application

:這是app module下Build.gradle的

com.android.library

:這是app依賴的module中的Builde.gradle的

就是這些Plugin為項目建構提供了Task,使用不同的plugin,module的功能也就不一樣。

可以簡單的了解為: Gradle隻是一個架構,真正起作用的是plugin。而plugin的主要作用是往Gradle腳本中添加Task。

當然,實際上這些是很複雜的東西,plugin還有其他作用這裡用不上。

四、如何注冊一個Transfrom

我們可以自定義一個plugin,然後使用plugin注冊一個Transfrom。

4.1 apply plugin

在此之前,先教大家怎麼自定義一個plugin。

  1. 建立一個module,選擇library module,module名字必須叫BuildSrc
  2. 删除module下的所有檔案,除了build.gradle,清空build.gradle中的内容
  3. 然後建立以下目錄 src-main-groovy
  4. 修改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'
    }
               

5. 這時候就可以像普通module一樣建立package和類了,不過這裡的類是以groovy結尾,建立類的時候選擇file,并且以.groovy作為字尾。 ![這裡寫圖檔描述](https://img-blog.csdnimg.cn/img_convert/84987fdc41abf541de90bc069524b069.png) Register就是我自定義個Plugin(無視黑色塗塊,Demo被我修改太多了,再次鄙視csdn) 代碼如下

package com.aitsuki.plugin

import org.gradle.api.Plugin;
import org.gradle.api.Project

/**
 * Created by hp on 2016/4/8.
 */
public class Register implements Plugin<Project> {
    @Override
    public void apply(Project project) {
        project.logger.error "================自定義插件成功!=========="
    }
}
           

在app module下的buiil.gradle中添apply 插件

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

說明:如果plugin所在的module名不叫BuildSrc,這裡是無法apply包名的,會提示找不到。是以之前也說明取名一定要叫buildsrc

運作一下項目就可以看到"======自定義插件成功!"這句話了

和gradle有關的輸出都會顯示在gradle console這個視窗中。

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

4.2 自定義Transfrom

建立一個groovy繼承Transfrom,注意這個Transfrom是要

com.android.build.api.transform.Transform

這個包的

要先添加依賴才能導入此包,如下

dependencies {
    compile gradleApi()
    compile 'com.android.tools.build:gradle:1.5.0'
    compile 'org.javassist:javassist:3.20.0-GA'
}
           

javassist待會要用到,順便添加進來了。

我們定義一個PreDexTransform,代碼如下

package com.aitsuki.plugin

import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import org.gradle.api.Project

public class PreDexTransform extends Transform {

    Project project
	// 添加構造,為了友善從plugin中拿到project對象,待會有用
    public PreDexTransform(Project project) {
        this.project = project
    }

	// Transfrom在Task清單中的名字
	// TransfromClassesWithPreDexForXXXX
    @Override
    String getName() {
        return "preDex"
    }

	// 指定input的類型
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

	// 指定Transfrom的作用範圍
    @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 {

       // inputs就是輸入檔案的集合
       // outputProvider可以擷取outputs的路徑
    }
}
           

然後再Register這個plugin的apply方法中添加一下代碼,注冊Transfrom

def android = project.extensions.findByType(AppExtension)
        android.registerTransform(new PreDexTransform(project))
           

再次運作項目(需要先clean項目,否則apply plugin不會重新編譯)

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

首先,我們看到自定義的PreDexTransfrom已經運作了,但是接下來的DexTransform卻報錯了。

那是因為我們自定義的Transfrom的transfrom方法為空,沒有将inputs輸出到outputs,DexTransfrom是在PreDexTransfrom下面,擷取到的inputs為空,是以就報錯了。

我們隻需要在Tranfrom中将inputs檔案複制到ouputs目錄就可以了,代碼如下。

// Transfrom的inputs有兩種類型,一種是目錄,一種是jar包,要分開周遊
        
        inputs.each {TransformInput input ->

            input.directoryInputs.each {DirectoryInput directoryInput->

                //TODO 這裡可以對input的檔案做處理,比如代碼注入!

                // 擷取output目錄
                def dest = outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

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

            input.jarInputs.each {JarInput jarInput->

                //TODO 這裡可以對input的檔案做處理,比如代碼注入!

                // 重命名輸出檔案(同目錄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)
            }
        }
           

加入這段代碼到transform方法中再次運作就沒問題了,再次說明:要先Clean項目!

上面有兩個TODO注釋,我們在擷取inputs複制到outpus目錄之前,可以在這裡對class注入代碼!

4.3 檢視inputs和ouputs

我們先來看看Transfrom的inputs和outputs,這裡有個方法:

在app module下的build.gradle中添加以下代碼即可。

applicationVariants.all { variant->
        def dexTask = project.tasks.findByName("transformClassesWithDexForDebug")
        def preDexTask = project.tasks.findByName("transformClassesWithPreDexForDebug")
        if(preDexTask) {
            project.logger.error "======preDexTask======"
            preDexTask.inputs.files.files.each {file ->
                project.logger.error "inputs =$file.absolutePath"
            }

            preDexTask.outputs.files.files.each {file ->
                project.logger.error "outputs =$file.absolutePath"
            }
        }
        if(dexTask) {
            project.logger.error "======dexTask======"
            dexTask.inputs.files.files.each {file ->
                project.logger.error "inputs =$file.absolutePath"
            }

            dexTask.outputs.files.files.each {file ->
                project.logger.error "outputs =$file.absolutePath"
            }
        }
    }
           

輸出如下:

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

glide和xutils是app依賴的jar包

hotpatch是我将application中加載dex的代碼抽取成獨立module後,app依賴此module的結果

其餘的則是項目預設依賴的jar包。

得出一個結論,app依賴的module在dex之前會被打包成classes.jar,和其他依賴的jar包一起放到

exploded-arr

這個目錄。

而依賴的module會放在

exploded-arr\項目名\module名

這個目錄下

附上hotPatch這個将application中的代碼打包好的module

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

然後這是

inputs =D:\aitsuki\HotPatchDemo\app\build\intermediates\exploded-aar\HotPatchDemo\hotpatch\unspecified\jars\classes.jar

解壓後的結果

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

五、使用javassist注入代碼

建議先去了解下javassit的最基本使用方法,否則可能看不懂我在說什麼。

5.1 建立Hack Module

注入

System.out.println(AntilazyLoad.class);

這行代碼的時候,如果javasssit找到AntilazyLoad.class這個類就會抛異常

是以建立AntilazyLoad.class,并且将AntilazyLoad.class所在的路徑append到ClassPool的classpath中。

首先我們建一個hack module,如下

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

5.2 制作hack.jar

制作方式在上一篇部落格中就有。

将AntilazyLoad.class複制到同包名的檔案夾下,然後運作打包指令,不重複贅述了。

然後将hack.jar放到app module中的assets檔案夾中,如圖

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

然後我們在加載patch_dex之前就要先将這個hack加載進classLoader,加載hack的方式和步驟跟加載更新檔是一摸一樣的,不再贅述,具體請直接看Demo,最後面有下載下傳連結。

5.3 使用javassist注入代碼

代碼量稍多,我就不那麼詳細的解釋了,這裡說下最基本的兩點

  1. app module編譯後class檔案儲存在debug目錄,直接周遊這個目錄使用javassist注入代碼就行了
  2. app module依賴的module,編譯後會被打包成jar,放在exploded-aar這個目錄,需要将jar包解壓–周遊注入代碼–重新打包成jar

首先我們專門寫一個用來操作javassist注入代碼的inject類。

package com.aitsuki.plugin

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

/**
 * Created by AItsuki on 2016/4/7.
 * 注入代碼分為兩種情況,一種是目錄,需要周遊裡面的class進行注入
 * 另外一種是jar包,需要先解壓jar包,注入代碼之後重新打包成jar
 */
public class Inject {

    private static ClassPool pool= ClassPool.getDefault()

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

    /**
     * 周遊該目錄下的所有class,對所有class進行代碼注入。
     * 其中以下class是不需要注入代碼的:
     * --- 1. R檔案相關
     * --- 2. 配置檔案相關(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")) {
                    // 這裡是應用包名,也能從清單檔案中擷取,先寫死
                    int index = filePath.indexOf("com\\aitsuki\\hotpatchdemo")
                    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()
        }

        CtConstructor[] cts = c.getDeclaredConstructors()

        if (cts == null || cts.length == 0) {
            insertNewConstructor(c)
        } else {
            cts[0].insertBeforeBody("System.out.println(com.aitsuki.hack.AntilazyLoad.class);")
        }
        c.writeFile(path)
        c.detach()
    }

    private static void insertNewConstructor(CtClass c) {
        CtConstructor constructor = new CtConstructor(new CtClass[0], c)
        constructor.insertBeforeBody("System.out.println(com.aitsuki.hack.AntilazyLoad.class);")
        c.addConstructor(constructor)
    }

}

           

下面這是解壓縮jar包的類

package com.aitsuki.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的完整類名類名集合,其中一條資料如: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()
    }
}
           

然後再Transfrom中這麼使用,我将整個類再貼一遍好了

package com.aitsuki.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 project) {
        this.project = project

        // 擷取到hack module的debug目錄,也就是Antilazy.class所在的目錄
        def libPath = project.project(':hack').buildDir.absolutePath.concat("\\intermediates\\classes\\debug")
        Inject.appendClassPath(libPath)
        Inject.appendClassPath("D:\\Sdk\\platforms\\android-22\\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的目錄複制到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)
            }
        }
    }
}
           

然後運作項目(最後再重複一次:記得先clean項目!),成功注入更新檔!不報錯了

##六、Demo的GIF示範

Android熱更新檔動态修複技術(三)—— 使用Javassist注入位元組碼,完成熱更新檔架構雛形(可使用)

關于SDCard:如果手機支援TF卡,那麼請将更新檔複制到内部存儲。

還有這裡是下載下傳位址, 更新檔已經放在根目錄

http://download.csdn.net/detail/u010386612/9490542

##七、寫在後面

補充一點:在上面代碼中,我們為所有的module編譯後的jar注入了代碼。

實際上在hotpatch這個module是不需要注入代碼的,因為這個module是用于加載dex的,而執行該module的時候,AntilazyLoad.class肯定沒加載進來,是以注入代碼毫無作用,應該排除這個module

這篇博文解決了class_ispreverified問題,并且成功使用javassist注入位元組碼,完成了熱更新檔架構的雛形。

但是還有幾個需要解決的問題

  1. 更新檔沒有簽名校驗,不安全,容易被惡意注入代碼
  2. 混淆開啟的情況下,類名可能被更換,更新檔打包不成功。

    下一篇博文可能是關于混淆或者更新檔簽名