自定義一個gradle外掛動態修改jar包Class檔案

南山伐木發表於2018-09-06

動態修改jar包中的class檔案,預埋佔位符字串,在編譯程式碼時動態植入要修改的值。記錄一下整個過程及踩過的坑。

Github 地址:ClassPlaceholder

  1. 建立一個Android專案,再建立一個Android library,刪掉裡面所有程式碼。新增groovy支援。如:
apply plugin: 'groovy'

sourceCompatibility = 1.8
targetCompatibility = 1.8

buildscript {
    repositories {
        mavenCentral()
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation localGroovy()
    implementation gradleApi()
    implementation 'com.android.tools.build:gradle:3.1.4'
    implementation 'com.android.tools.build:gradle-api:3.1.4'
    implementation 'org.javassist:javassist:3.20.0-GA'
}
複製程式碼
  1. 建立自定義的外掛的配置檔案: resource/META_INF/gradle-plugins,該目錄為固定目錄,下面建立自定義的外掛配置檔案。並將替換原來src/main/java替換為src/main/groovy;在配置檔案中新增外部引用的外掛名:
implementation-class=me.xp.gradle.placeholder.PlaceholderPlugin
複製程式碼
  1. 建立完成後整個目錄結構為:

自定義一個gradle外掛動態修改jar包Class檔案

  1. 準備工作完成,開始建立程式碼。先定義要擴充套件的內容格式:
class PlaceholderExtension {
/**
* 要替換的檔案
*/
    String classFile = ''
    /**
     * 要替換的模板及值,
     * 如:${template}* map->
     *{"template","value"}*/
    Map<String, String> values = [:]

    /**
     * 是否修改專案下的java原始檔
     */
    boolean isModifyJava = false
}

複製程式碼
  1. 再將建立的擴充套件註冊到transform中:
class PlaceholderPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        //build.gradle中使用的擴充套件
        project.extensions.create('placeholders', Placeholders, project)

        def android = project.extensions.getByType(AppExtension)
        def transform = new ClassPlaceholderTransform(project)
        android.registerTransform(transform)
    }
}
複製程式碼
  1. 在自定義的Transform中遍歷專案下的jar包及所有檔案,找到要替換的檔案及預埋的佔位符的字串,關鍵程式碼:
@Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
        // Transform的inputs有兩種型別,一種是目錄,一種是jar包,要分開遍歷
        def outputProvider = transformInvocation.getOutputProvider()
        def inputs = transformInvocation.inputs

        def placeholder = project.extensions.getByType(Placeholders)
        println "placeholders = ${placeholder.placeholders.size()}"
        inputs.each { TransformInput input ->
            input.directoryInputs.each { DirectoryInput dirInput ->
                // 獲取output目錄
                def dest = outputProvider.getContentLocation(dirInput.name,
                        dirInput.contentTypes, dirInput.scopes,
                        Format.DIRECTORY)

                File dir = dirInput.file
                if (dir) {
                    HashMap<String, File> modifyMap = new HashMap<>()
                    dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
                        File classFile ->
                            def isNeedModify = Utils.isNeedModify(classFile.absolutePath)
                            if (isNeedModify) {
                                println " need modify class ${classFile.path}"
                                File modified = InjectUtils.modifyClassFile(dir, classFile, transformInvocation.context.getTemporaryDir())
                                if (modified != null) {
                                    //key為相對路徑
                                    modifyMap.put(classFile.absolutePath.replace(dir.absolutePath, ""), modified)
                                }
                            }
                    }

                    modifyMap.entrySet().each {
                        Map.Entry<String, File> entry ->
                            File target = new File(dest.absolutePath + entry.getKey())
                            println "entry --> ${entry.key} target = $target"
                            if (target.exists()) {
                                target.delete()
                            }
                            FileUtils.copyFile(entry.getValue(), target)
                            println "dir = ${dir.absolutePath} "

                            saveModifiedJarForCheck(entry.getValue(), new File(dir.absolutePath + entry.getKey()))
                            entry.getValue().delete()
                    }
                }
                // 將input的目錄複製到output指定目錄
                FileUtils.copyDirectory(dirInput.file, dest)
            }

            input.jarInputs.each { JarInput jarInput ->

                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)
                def modifyJarFile = InjectUtils.replaceInJar(transformInvocation.context, jarInput.file)
                if (modifyJarFile == null) {
                    modifyJarFile = jarInput.file
//                    println "modifyJarFile = ${modifyJarFile.absolutePath}"
                } else {
                    //檔案修改過
                    println "++++ jar modified  >> ${modifyJarFile.absolutePath}"
                    saveModifiedJarForCheck(modifyJarFile, jarInput.file)
                }

                //將輸入內容複製到輸出
                FileUtils.copyFile(modifyJarFile, dest)
            }
        }

複製程式碼
  1. 在遍歷找到要替換的字串後,直接替換即可:
@Override
FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {
    println('* visitField *' + " , " + access + " , " + name + " , " + desc + " , " + signature + " , " + value)
    if (!isOnlyVisit) {
        modifyMap.each { k, v ->
            def matchValue = "\${$k}"
            println "matchValue = $matchValue , value = $value --> ${matchValue == value}"
            if (matchValue == value) {
                value = v
            }
        }
    }
    return super.visitField(access, name, desc, signature, value)
}

複製程式碼

踩過的坑:

  1. 對於要內嵌的擴充套件,需要動態的新增。如這裡在使用時的格式為:
placeholders {
    addholder {
        //is modify source java file
        isModifyJava = true
        //modify file name
        classFile = "me/xp/gradle/classplaceholder/AppConfig.java"
        //replace name and value
        values = ['public' : 'AppConfigPubic',
                  'private': 'AppConfigPrivate',
                  'field'  : 'AppConfigField']
    }
    addholder {
        isModifyJava = false
        classFile = "me/xp/gradle/jarlibrary/JarConfig.class"
        values = ['config': 'JarConfigPubic']
    }
}
複製程式碼

由於addholder擴充套件內嵌在placeholders擴充套件中,就需要將addholder動態新增擴充套件,而最外層的placeholders則需要在自定義的PlaceholderPlugin類中靜態新增:

project.extensions.create('placeholders', Placeholders, project)
複製程式碼

在自定義的placeholders類中動態新增addholder擴充套件,將閉包作為當作引數傳入,這樣才能自動將build.gradle中定義的lambda值轉成對應的extension物件:

/**
* 新增一個擴充套件物件
* @param closure
*/
void addholder(Closure closure) {
    def extension = new PlaceholderExtension(project)
    project.configure(extension, closure)
    println " -- > $extension"
    placeholders.add(extension)
}
複製程式碼
  1. 若要修改在java原始檔的值,則只需要在generateBuildConfig任務新增一個任務執行即可。建立一個任務並依賴在 generateBuildConfigTask後:
//執行修改java原始碼的任務
        android.applicationVariants.all { variant ->

            def holders = project.placeholders
            if (holders == null || holders.placeholders == null) {
                println "not add place holder extension!!!"
                return
            }
            ExtensionManager.instance().cacheExtensions(holders.placeholders)
//            println "holders = ${holders.toString()} --> ${holders.placeholders}"

            //獲取到scope,作用域
            def variantData = variant.variantData
            def scope = variantData.scope

            //建立一個task
            def createTaskName = scope.getTaskName("modify", "PlaceholderPlugin")
            println "createTaskName = $createTaskName"
            def createTask = project.task(createTaskName)
            //設定task要執行的任務
            createTask.doLast {
                modifySourceFile(project, holders.placeholders)
            }
            //設定task依賴於生成BuildConfig的task,在其之後生成我們的類
            String generateBuildConfigTaskName = variant.getVariantData().getScope().getGenerateBuildConfigTask().name
            def generateBuildConfigTask = project.tasks.getByName(generateBuildConfigTaskName)
            if (generateBuildConfigTask) {
                createTask.dependsOn generateBuildConfigTask
                generateBuildConfigTask.finalizedBy createTask
            }
        }
複製程式碼
  1. 要修改java原始碼,這裡相當於直接修改檔案。使用gradle自帶的ant工具便非常適用。如:
ant.replace(
        file: filPath,
        token: matchKey,
        value: v
) {
    fileset(dir: dir, includes: className)
}
複製程式碼

ant還提供常用的正則匹配替換的函式ant.replaceregexp,但由於這裡使用佔位符,使用$關鍵字,在java中會自動當作正則的一部分使用,故這裡直接使用ant.repace方法,修改完成後直接呼叫fileset函式即可修改原始檔。

相關文章