Gradle插件实战之编译期修改代码

背景

在工作中我们经常会用到第三方库,不可避免这些库中会有各种问题,没办法只有给开源库作者提issue,但是这种效率很低,而且作者不一定会去修改,因此只有自己去改。

解决方案

  • 我们可以把项目chone下来修改,但是这种效率很低,也可能会遇到不可预知的问题,这样大大增加了开发成本。
  • 开发Gradle插件,利用Javassit动态修改class中已有的方法,这种方法效率很高,不会影响到源码。

Javassit

javassist是一个动态修改java字节码的开源库,它可以在编译好的class文件中添加/修改方法、插入代码等,并且不需要对字节码方面有深入的了解。同时也可以去生成一个新的类对象,通过完全手动的方式。 javassit具体的使用方法可以查看:Tutorial-1Tutorial-2Tutorial-3

Gradle

Android Studio 项目是使用 Gradle 构建的,构建工具 Gradle 可以看做是一个脚本,包含一系列的Task,依次执行这些 Task 后,项目就打包成功了。自定义 Gradle 插件的本质就是把逻辑独立的代码进行抽取和封装,以便于我们更高效地通过插件依赖这一方式进行功能复用。而在 Android 下的 gradle 插件共分为 两大类:

  • 脚本插件:同普通的 gradle 脚本编写形式一样,可以直接写在build.gradle文件中,也可以自己新建一个 gradle 脚本文件中写
  • 对象插件:通过插件全路径类名或 id 引用,它主要有 三种编写形式,如下所示:
    1)在当前构建脚本下直接编写。
    2)在 buildSrc 目录下编写。
    3)在完全独立的项目中编写。

buildSrc

我们就使用buildSrc默认插件目录,该目录下的代码会在构建时自动地进行编译打包,然后它会被添加到 buildScript 中的 classpath 下,所以不需要任何额外的配置,就可以直接被其他模块中的 gradle 脚本引用。注意:在Gradle 6.0版本以上,不要在settings.gradle文件中配置,否则会产生:'buildSrc' cannot be used as a project name as it is a reserved name异常

  • buildSrc 的执行时机不仅早于任何⼀个 project(build.gradle),而且也早于 settings.gradle。
  • settings.gradle 中如果配置了 ‘:buildSrc’ ,buildSrc ⽬录就会被当做是子 Project , 因会它会被执行两遍。所以在 settings.gradle 里面应该删掉 ‘:buildSrc’ 的配置。

步骤

1.在项目根目录新建一个module,命名为buildSrc且名字固定。
2.src 目录下删除仅保留一个空的 main 目录,并在 main 目录下新建 1 个groovy目录与 1 个resources目录。
3.删除build.gradles所有配置,并添加以下配置

apply plugin: 'groovy'

dependencies {
    
    
    implementation gradleApi()
    implementation localGroovy()
    implementation "com.android.tools.build:gradle:4.1.0"
    implementation "commons-io:commons-io:2.4"
    implementation "org.javassist:javassist:3.25.0-GA"
}
repositories {
    
    
    google()
    jcenter()
}

4.在groovy目录中创建你自己的包名com.gh.gamecenter.plugin,然后创建两个groovy类,分别继承PluginTransform,代码如下:

class GhTransform extends Transform {
    
    
  ......
}
class GhPlugin implements Plugin<Project> {
    
    
    void apply(Project project) {
    
    
        project.android.registerTransform(new GhTransform(project))
    }
}

5.在 resources 目录下创建一个 META-INF.gradle-plugins 的目录,然后里面创建一个com.gh.gamecenter.plugin.properties文件,properties之前的内容是插件的名字,在该文件中添加如下内容:

implementation-class=com.gh.gamecenter.plugin.GhPlugin

6.最后我们在build.gradle中引入我们的插件,这样我们就基本配置完了。

apply plugin: "com.gh.gamecenter.plugin"
android {
......
}

以上就是自定义插件的基本配置,接下来就是实现插件功能

Transform

Google 官方在 Android Gradle V1.5.0 版本以后提供了 Transfrom API, 允许第三方 Plugin 在打包成 .dex 文件之前的编译过程中操作 .class 文件,我们需要做的就是实现 Transform 来对 .class 文件遍历以拿到所有方法,修改完成后再对原文件进行替换即可。

在文章背景中我们讲到开发插件的目的是修改第三方开源库中一个类的方法,具体是需要在这个方法之前加入一段我们自己的代码来根据需求实现拦截,我的做法是:在项目中写一个静态方法,然后在这个方法之前调用我们的静态方法,这样就可以实现拦截功能了。具体代码如下:

class GhTransform extends Transform {
    
    

    private ClassPool classPool = ClassPool.getDefault()
    Project project

    GhTransform(Project project) {
    
    
        this.project = project
    }

    @Override
    String getName() {
    
    
        return "GhTransform"
    }
    /**
      * 需要处理的数据类型,目前 ContentType有六种枚举类型,通常我们使用比较频繁的有前两种:
      * 1、CONTENT_CLASS:表示需要处理 java 的 class 文件。
      * 2、CONTENT_JARS:表示需要处理 java 的 class 与 资源文件。
      * 3、CONTENT_RESOURCES:表示需要处理 java 的资源文件。
      * 4、CONTENT_NATIVE_LIBS:表示需要处理 native 库的代码。
      * 5、CONTENT_DEX:表示需要处理 DEX 文件。
      * 6、CONTENT_DEX_WITH_RESOURCES:表示需要处理 DEX 与 java 的资源文件。 
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
    
    
        return TransformManager.CONTENT_CLASS
    }

    /**
      * Transform 要操作的内容范围,目前 Scope 有五种基本类型:
      * 1、PROJECT:只有项目内容
      * 2、SUB_PROJECTS:只有子项目
      * 3、EXTERNAL_LIBRARIES:只有外部库
      * 4、TESTED_CODE:由当前变体(包括依赖项)所测试的代码
      * 5、PROVIDED_ONLY:只提供本地或远程依赖项
      * SCOPE_FULL_PROJECT 是一个Scope集合,包含Scope.PROJECT,Scope.SUB_PROJECTS,Scope.EXTERNAL_LIBRARIES 这三项,即当前Transform的作用域包括当前项目、子项目以及外部的依赖库
     */
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
    
    
        //通常我们使用 SCOPE_FULL_PROJECT
        return TransformManager.SCOPE_FULL_PROJECT
    }

    /**
     * 是否需要增量编译
     */
    @Override
    boolean isIncremental() {
    
    
        return false
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
    
    
        super.transform(transformInvocation)
        //添加加android.jar目录
        classPool.appendClassPath(project.android.bootClasspath[0].toString())
        def outputProvider = transformInvocation.outputProvider
        // 删除之前的输出
        if (outputProvider != null) {
    
    
            outputProvider.deleteAll()
        }
        transformInvocation.inputs.each {
    
     input ->
            input.directoryInputs.each {
    
     dirInput ->
                handleDirectory(dirInput.file)

                def dest = outputProvider.getContentLocation(dirInput.name, dirInput.contentTypes, dirInput.scopes, Format.DIRECTORY)
                // 将input的目录复制到output指定目录
                FileUtils.copyDirectory(dirInput.file, dest)
            }

        }
        transformInvocation.inputs.each {
    
     input ->
            input.jarInputs.each {
    
     jarInput ->
                if (jarInput.file.exists()) {
    
    
                    def srcFile = handleJar(jarInput.file)

                    //必须给jar重新命名,否则会冲突
                    def jarName = jarInput.name
                    def md5 = DigestUtils.md5Hex(jarInput.file.absolutePath)
                    if (jarName.endsWith(".jar")) {
    
    
                        jarName = jarName.substring(0, jarName.length() - 4)
                    }
                    def dest = outputProvider.getContentLocation(md5 + jarName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
                    FileUtils.copyFile(srcFile, dest)
                }
            }
        }

    }

    void handleDirectory(File dir) {
    
    
        //将类路径添加到classPool中
        classPool.insertClassPath(dir.absolutePath)
        if (dir.isDirectory()) {
    
    
            dir.eachFileRecurse {
    
     file ->
                def filePath = file.absolutePath
                classPool.insertClassPath(filePath)
                if (shouldModify(filePath)) {
    
    
                    def inputStream = new FileInputStream(file)
                    CtClass ctClass = modifyClass(inputStream)
                    ctClass.writeFile()
                    //调用detach方法释放内存
                    ctClass.detach()
                }
            }
        }
    }

    /**
     * 主要步骤:
     * 1.遍历所有jar文件
     * 2.解压jar然后遍历所有的class
     * 3.读取class的输入流并使用javassit修改,然后保存到新的jar文件中
     */
    File handleJar(File jarFile) {
    
    
        classPool.appendClassPath(jarFile.absolutePath)
        def inputJarFile = new JarFile(jarFile)
        def entries = inputJarFile.entries()
        //创建一个新的文件
        def outputJarFile = new File(jarFile.parentFile, "temp_" + jarFile.name)
        if (outputJarFile.exists()) outputJarFile.delete()
        def jarOutputStream = new JarOutputStream(new BufferedOutputStream(new FileOutputStream(outputJarFile)))
        while (entries.hasMoreElements()) {
    
    
            def jarInputEntry = entries.nextElement()
            def jarInputEntryName = jarInputEntry.name

            def outputJarEntry = new JarEntry(jarInputEntryName)
            jarOutputStream.putNextEntry(outputJarEntry)

            def inputStream = inputJarFile.getInputStream(jarInputEntry)
            if (!shouldModify(jarInputEntryName)) {
    
    
                jarOutputStream.write(IOUtils.toByteArray(inputStream))
                inputStream.close()
                continue
            }

            def ctClass = modifyClass(inputStream)
            def byteCode = ctClass.toBytecode()
            ctClass.detach()
            inputStream.close()
            jarOutputStream.write(byteCode)
            jarOutputStream.flush()
        }
        inputJarFile.close()
        jarOutputStream.closeEntry()
        jarOutputStream.flush()
        jarOutputStream.close()
        return outputJarFile
    }

    static boolean shouldModify(String filePath) {
    
    
        return filePath.endsWith(".class") &&
                !filePath.contains("R.class") &&
                !filePath.contains('$') &&
                !filePath.contains('R$') &&
                !filePath.contains("BuildConfig.class") &&
                filePath.contains("ExoSourceManager")
    }

    CtClass modifyClass(InputStream is) {
    
    
        def classFile = new ClassFile(new DataInputStream(new BufferedInputStream(is)))
        def ctClass = classPool.get(classFile.name)
        //判断是否需要解冻
        if (ctClass.isFrozen()) {
    
    
            ctClass.defrost()
        }

        def method = ctClass.getDeclaredMethod("release")
        //必须使用全类名,否则编译会找不到类
        def body = '''
            int size = com.gh.gamecenter.video.detail.CustomManager.getVideoManagerSize();
            if (size > 1) {
                android.util.Log.e(\"gh_tag\",\"拦截成功\");
                return;
            }
        '''
        method.insertBefore(body)
        return ctClass

    }
}

至此我们就完成了插件的开发,接下来就要验证代码是否插入成功,我们可以使用Android Studio自带的Build/Analyze APK工具,选择编译的apk文件
在这里插入图片描述
找到修改的类,右键点击 Show Bytecode查看字节码,然后搜索我们修改的方法:
在这里插入图片描述
上图红色框中就是添加的代码,我在代码中插入了一行log,运行代码查看logcat打印:
在这里插入图片描述
最后我们完成了插件的开发并且验证其有效性,虽然看来稍微有些麻烦,但是如果以后遇到类似的问题我们也可以基于上面的代码来解决问题。

需要注意的一些问题

  • 开源库和自己写的插入代码注意不要混淆
  • buildSrc中build.gradle的AGP版本要和app模块中一致
  • 插入代码引用的类要使用全路径
  • 插入代码中用到的类需要将类路径添加到classPool中,否则会编译不过
  • buildSrc不要在settings.gradle中配置
  • 不管我们有没有修改jar的操作,也要拷贝到目标路径

猜你喜欢

转载自blog.csdn.net/ZYJWR/article/details/113091371