构建与打包能力(三、深入浅出Gradle插件开发)

776 阅读7分钟

构建与打包能力系列

1、Gradle 构建脚本基础

  • groovy语法:继承自java语法,不需要专门去学习它。
  • gradle api:类似于android sdk一样的存在,在开发gradle插件时提供底层支持。
  • buildscript:声明gradle脚本构建项目时所需要使用依赖项、仓库地址、第三方插件等。构建项目时会优先执行buildscript代码块中的内容。

2、项目构建生命周期

image.png

  • 初始化阶段:会执行项目根目录下的Settings.gradle文件,分析哪些project参与本次构建
  • 配置阶段:加载所有参与本次构建项目下的build.gradle文件,会将build.gradle文件解析并实例化为一个Gradle的project对象,然后分析Project之间的依赖关系,分析Project下的Task之间的依赖关系,生成有向无环拓扑结构图 TaskGraph

每个gradle项目都会有一个build.gradle文件,该文件是该项目构建的入口,可以在这里针对该项目进行配置,比如配置版本,需要哪些插件,依赖哪些库等。解析完成后会生成与之对应的Project对象。

  • 执行阶段:这是Task真正被执行的阶段,Gradle会根据依赖关系决定哪些Task需要被执行,以及执行的先后顺序。

Task是Gradle中的最小执行单元,我们所有的构建,编译,打包,debug都是执行了某一个(多个)Task,一个Project可以有多个task,Task之间可以互相依赖。

// 1.在Settings.gradle配置如下代码监听构建生命周期阶段回调
gradle.buildStarted {
    println "项目构建开始..."
}
// 2. 初始化阶段完成
gradle.projectsLoaded {
    println "从settings.gradle解析完成参与构建的所有项目..."
}

// 3.配置阶段开始 解析每一个project/build.gradle
gradle.beforeProject { proj ->
    println "${proj.name} build.gradle解析之前"
}
gradle.afterProject { proj ->
    println "${proj.name} build.gradle解析完成"
}
gradle.projectsEvaluated {
    println "所有项目的build.gradle解析配置完成" --> 2.配置阶段完成
}

gradle.getTaskGraph().addTaskExecutionListener {
    //某任务开始执行前
    void beforeExecute(Task task){}
    //某任务执行完成
    void afterExecute(Task task, TaskState state){}
}

gradle.buildFinished {
    println "项目构建结束"
}

image.png

  • 打印构建阶段task依赖关系及输入输出
afterEvaluate { project ->
    // 收集所有project的 task 集合
    Map<Project, Set<Task>> allTasks = project.getAllTasks(true)
    // 遍历每一个project下的task集合
    allTasks.entrySet().each {projTasks ->
        projTasks.value.each {task ->
            //输出task的名称和dependIn依赖
            System.out.println(task.getName());
            for (Object o : task.getDependsOn()) {
                System.out.println("dependOn-->" + o.toString());
            }
            System.out.println("-----------------------------")
        }
    }
}

image.png Project工程树

每个项目都会有一个build.gradle文件,在配置阶段会生成与之对应的Project对象,RootProject也不例外。而且RootProject可以获取所有的子项目subProject,所以可以在RootProject的build.gradle文件里对所有subProject统一配置,比如应用的插件,依赖项等等。

3、Gradle项目构建之Task任务

Task定义和配置

Task是Gradle项目构建的最小执行单元,Gradle通过将一个个Task串联起来完成具体的构建任务,每个Task都属于一个Project。Gradle在构建的过程中,会构建一个TaskGraph任务依赖图,这个依赖图会保证各个Task的执行顺序关系。

image.png

  • Task Propterty属性配置
//第一种配置方法,创建的时候就配置task的group和description
//description就是个说明,类似注释
task tinyPngTask(group:'demo',description:'compress images') {
    println 'this is TinyPngTask'
}

project.tasks.create(name:'tinyPngTask'){
    //第二种配置方式:直接在闭包中配置
    setGroup('demo')
    setDescription('compress images')
    println 'this is TinyPngTask'
}

//第三种是继承自DefaultTask,需要向project中注册
//一般开发插件的时候会这么写
class TinyPngTask extends DefaultTask {
    @TaskAction
    void run(){
        //......
    }
}
  • Task Actions执行动作 doFirst给Task添加一个执行动作,在该Task的执行阶段,是最先执行 doLast给Task添加一个执行动作,在该Task的执行阶段,是最后执行
task tinyPngTask(group: 'demo',description: 'compress images'){
    println 'this is TinyPngTask' //1.直接写在闭包里面的,是在配置阶段就执行的
    doFirst {
        println 'task in do first1' //3.运行任务时,后于first2执行
    }
    doFirst {
        println 'task in do first2' //2.运行任务时,所有actions第一个执行
    }
    doLast {
        println 'task in do last' //4.运行任务时,会最后一个执行
    }
}
  • Task Denpendency任务关系

    • dependsOn-设置任务依赖关系。执行任务B需要任务A首先被执行。
    task taskA {
        doFirst {
            println 'taskA'
        }
    }
    
    task taskB {
        doFirst {
            println 'taskB'
        }
    }
    
    taskB.dependsOn taskA //B在执行时,会首先执行它的依赖任务A
    

    image.png

    • mustRunAfter-设置任务执行顺序。执行任务B不需要执行任务A,但如果任务A和B都存在的场景下,任务A必须先于任务B执行。
    task taskA {
        doFirst {
            println 'taskA'
        }
    }
    
    task taskB {
        doFirst {
            println 'taskB'
        }
    }
    
    taskB.mustRunAfter taskA //在一次任务执行流程中,A,B都存在,这里设置的执行顺序才有效
    

    image.png

    • finalizeBy-为任务A添加一个当前任务结束后立马执行任务B。根dependsOn相反
    task taskA {
        doFirst {
            println 'taskA'
        }
    }
    
    task taskB {
        doFirst {
            println 'taskB'
        }
    }
    
    taskB.finalizedBy taskA //B执行完成,会执行A
    

4、Gradle插件开发之Transform

Transform的定义与配置

如果我们想对编译时产生的Class文件,在转换成Dex之前做一些处理(字节码插桩,替换父类...)。我们可以通过Gradle插件来注册我们编写的Transform。注册后Transform也会被Gradle包装一个Gradle Task,这个Transform Task会在java compile Task 执行完毕后运行。一般我们使用Transform会有下面两种场景

  • 我们需要对编译生成的class文件做自定义处理。
  • 我们需要读取编译产生的class文件,做一些其他事情,但是不需要修改它。
  • 比如Hit DI框架中会修改superclass为特定的class,
  • Hugo耗时统计库会在每个方法插入代码来统计方法耗时...
  • InstantPatch热修复,在所有方法前插入一个预留的函数,可以将有bug的方法替换成下发的方法。
  • CodeCheck代码检查,都是使用transform来做的。

image.png

如何自定义插件

  • 输入输出
    • TransformInput是指输入文件的一个抽象,包括:

      DirectoryInput集合是指以源码的方式参与项目编译的所有目录结构及其目录下的源码文件;

      JarInput集合是指以jar包方式参与项目编译的所有本地jar包和远程jar包(包括aar);

    • TransformOutputProvider通过它可以获取到输出路径等信息

public class MyTransform extends Transform {

    @Override
    public String getName(){
        return "MyTransform";
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        //该transform接收哪些内容作为输入参数
        //CLASSED(0x01)
        //RESOURCES(0x02),assets/目录下的资源,而不是res下的资源
        return TransformManager.CONTENT_CLASS;
    }
    
    @Override
    public Set<? super QualifiedContent.Scorp> getScopes() {
        //该ransform工作的作用域--->见下表
        return TransformManager.SCOPR_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental() {
        //是否增量编译
        //基于Task的上次输出快照和这次输入快照对比,如果相同,则跳过
        return false;
    }

    @Override
    public void transform(TransfromInvocation transfromInvocation) {
        //注意:当前编译对于transform是否是增量编译受两个方面影响:
        //(1) isIncremental()方法的返回值;
        //(2)当前编译是否有增量基础(clean之后的第一次编译没有增量基础,之后的编译有增量基础)
        def isIncremental = transfromInvocation.isIncremental && !isIncremental();
        //获取一个能够获取输出路径的工具
        def outputProvider = transfromInvocation.outputProvider;
        if (!isIncremental) {
            //不是增量更新则必须删除上一次构建产生的缓存文件
            outputProvider.deleteAll();
        }
        transfromInvocation.inputs.each { input ->
                // 遍历所有目录
                input.directoryInouts.each { dirInput ->
                    println("MyTransform:" + dirInput.file.absolutePath);
                    File dest = outputProvider.getContentLocation(dirInput.getName, dirInput.getContentTypes(), dirInput.getScopes(), Format.DIRECTORY);
                    FileUtils.copyDirectory(dirInput.getFile(), dest);
            }
            
            //遍历所有的jar包(包括aar)
            input.jarInputs.each{ file,state ->
                println("MyTransform:"+file.absolutePath);
                File dest = outputProvider.getContentLocation(
                            jarInput.getFile().getAbsolutePath(),
                            jarInput.getContentTypes(),
                            jarInput.getScopes(),
                            Format.JAR);
                FileUtils.copyDirectory(jarInputs.getFile(), dest);
            }
        }
    }
}
  • Transform Scope作用域

image.png 自定义Transform工作流程

image.png

  • 插件开发模式

image.png

image.png

image.png

  • 字节码技术三剑客AspectJ,Javasssist,Asm

    • Apt:代表框架:DataBinding,Dagger2,ButterKnife,EventBus3,DBFlow。通过继承自AbstractProcessor,在Process方法内来完成类的生成
    • AspectJ:防止重复点击
    • ASM:实现方法耗时检测

image.png

5、javassist字节码插桩技术实战

字节码插桩技术

  • 向工程内所有Activity的onCreate函数内插入Toast代码块
  • 工程内源码参与编译的.class,以jar(aar)参与编译.class,都需要修改
class TinyPngPTransform extends Transform {
    private ClassPool classPool = ClassPool.getDefault()

    TinyPngPTransform(Project project) {
        //为了能够查找到android 相关的类,需要把android.jar包的路径添加到classPool  类搜索路径
        classPool.appendClassPath(project.android.bootClasspath[0].toString())

        classPool.importPackage("android.os.Bundle")
        classPool.importPackage("android.widget.Toast")
        classPool.importPackage("android.app.Activity")

        classPool.importPackage("java.lang.Runnable")
        classPool.importPackage("android.widget.ImageView")
        classPool.importPackage("androidx.appcompat.widget.AppCompatImageView")
        classPool.importPackage("android.graphics.drawable.Drawable")
    }

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

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        //接收的输入数据的类型
        return TransformManager.CONTENT_CLASS
    }

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

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        //1. 对inputs -->directory-->class 文件进行遍历
        //2 .对inputs -->jar-->class 文件进行遍历
        //3. //符合我们的项目包名,并且class文件的路径包含Activity.class结尾,还不能是buildconfig.class,R.class $.class

        def outputProvider = transformInvocation.outputProvider
        transformInvocation.inputs.each { input ->

            input.directoryInputs.each { dirInput ->
                println("dirInput abs file path:" + dirInput.file.absolutePath)
                handleDirectory(dirInput.file)

                //把input->dir->class-->dest目标目录下去。
                def dest = outputProvider.getContentLocation(dirInput.name, dirInput.contentTypes, dirInput.scopes, Format.DIRECTORY)
                FileUtils.copyDirectory(dirInput.file, dest)
            }

            input.jarInputs.each { jarInputs ->
                println("jarInputs abs file path :" + jarInputs.file.absolutePath)
                //对jar 修改完之后,会返回一个新的jar文件
                def srcFile = handleJar(jarInputs.file)

                //主要是为了防止重名
                def jarName = jarInputs.name
                def md5 = DigestUtils.md5Hex(jarInputs.file.absolutePath)
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
                //获取jar包的输出路径
                def dest = outputProvider.getContentLocation(md5 + jarName, jarInputs.contentTypes, jarInputs.scopes, Format.JAR)
                FileUtils.copyFile(srcFile, dest)
            }
        }

        classPool.clearImportedPackages()
    }

    //处理当前目录下所有的class文件
    void handleDirectory(File dir) {
        classPool.appendClassPath(dir.absolutePath)

        if (dir.isDirectory()) {
            dir.eachFileRecurse { file ->
                def filePath = file.absolutePath
                ///Users/timian/Desktop/AndroidArchitect/AndroidArchitect/ASProj/app/build/intermediates/transforms/AndroidEntryPointTransform/debug/1/org/devio/as/proj/main/degrade/DegradeGlobalActivity.class
                println("handleDirectory file path:" + filePath)
                if (shouldModifyClass(filePath)) {
                    def inputStream = new FileInputStream(file)
                    def ctClass = modifyClass(inputStream)
                    ctClass.writeFile(dir.name)
                    ctClass.detach()
                }
            }
        }
    }

    File handleJar(File jarFile) {
        classPool.appendClassPath(jarFile.absolutePath)
        //ssesWithTinyPngPTransformForDebug
        //jarInputs abs file path :/Users/timian/Desktop/AndroidArchitect/AndroidArchitect/ASProj/app/build/intermediates/transforms/com.alibaba.arouter/debug/0.jar
        def inputJarFile = new JarFile(jarFile)
        def enumeration = 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 (enumeration.hasMoreElements()) {
            def inputJarEntry = enumeration.nextElement()
            def inputJarEntryName = inputJarEntry.name

            def outputJarEntry = new JarEntry(inputJarEntryName)
            jarOutputStream.putNextEntry(outputJarEntry)
            //com/leon/channel/helper/BuildConfig.class
            println("inputJarEntryName: " + inputJarEntryName)

            def inputStream = inputJarFile.getInputStream(inputJarEntry)
            if (!shouldModifyClass2(inputJarEntryName)) {
                jarOutputStream.write(IOUtils.toByteArray(inputStream))
                inputStream.close()
                continue
            }

            def ctClass = modifyClass2(inputStream)
            def byteCode = ctClass.toBytecode()
            ctClass.detach()
            inputStream.close()

            jarOutputStream.write(byteCode)
            jarOutputStream.flush()
        }
        inputJarFile.close()
        jarOutputStream.closeEntry()
        jarOutputStream.flush()
        jarOutputStream.close()
        return outputJarFile
    }


    //这个方法是 往appcomimageview -setimagedrawable --插入不合理大图检测的代码段
    CtClass modifyClass2(InputStream is) {
        def classFile = new ClassFile(new DataInputStream(new BufferedInputStream(is)))
        //org.devio.as.proj.main.degrade.DegradeGlobalActivity
        println("modifyClass name:" + classFile.name)//全类名
        def ctClass = classPool.get(classFile.name)
        if (ctClass.isFrozen()) {
            ctClass.defrost()
        }

        def drawable = classPool.get("android.graphics.drawable.Drawable")
        CtClass[] params = Arrays.asList(drawable).toArray()
        def setImageDrawableMethod = ctClass.getDeclaredMethod("setImageDrawable", params)


        CtClass runnableImpl = classPool.makeClass("org.devio.as.proj.debug.RunnableImpl")
        if (runnableImpl.isFrozen()) {
            runnableImpl.defrost()
        }

        CtField viewField = new CtField(classPool.get("androidx.appcompat.widget.AppCompatImageView"), "view", runnableImpl)
        viewField.setModifiers(Modifier.PUBLIC)
        runnableImpl.addField(viewField)


        CtField drawableField = new CtField(classPool.get("android.graphics.drawable.Drawable"), "drawable", runnableImpl)
        drawableField.setModifiers(Modifier.PUBLIC)
        runnableImpl.addField(drawableField)

        runnableImpl.addConstructor(CtNewConstructor.make("public RunnableImpl(android.view.View view, android.graphics.drawable.Drawable drawable) {\n" +
                "            this.view = view;\n" +
                "            this.drawable = drawable;\n" +
                "        }", runnableImpl))

        runnableImpl.addInterface(classPool.get("java.lang.Runnable"))

        CtMethod runMethod = new CtMethod(CtClass.voidType, "run", null, runnableImpl)
        runMethod.setModifiers(Modifier.PUBLIC)
        runMethod.setBody("{int width = view.getWidth();\n" +
                "            int height = view.getHeight();\n" +
                "            int drawableWidth = drawable.getIntrinsicWidth();\n" +
                "            int drawableHeight = drawable.getIntrinsicHeight();\n" +
                "            if (width > 0 && height > 0) {\n" +
                "                if (drawableWidth >= 2 * width && drawableHeight >= 2 * height) {\n" +
                "                    android.util.Log.e("LargeBitmapChecker", "bitmap:[" + drawableWidth + "," + drawableHeight + "],view:[" + width + "," + height + "],className:" + getContext().getClass().getSimpleName());\n" +
                "                }\n" +
                "            }\n" +
                "            android.util.Log.e("LargeBitmapChecker", "bitmap:[" + drawableWidth + "," + drawableHeight + "],view:[" + width + "," + height + "],className:" + getContext().getClass().getSimpleName());}")
        runnableImpl.addMethod(runMethod)
        runnableImpl.writeFile("hi_debugtool/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes")
        runnableImpl.toClass()

        classPool.insertClassPath("org.devio.as.proj.debug.RunnableImpl")

        setImageDrawableMethod.insertBefore("if(drawable=!=null){ post(new RunnableImpl(this, drawable));}")
        return ctClass
    }

    CtClass modifyClass(InputStream is) {
        def classFile = new ClassFile(new DataInputStream(new BufferedInputStream(is)))
        //org.devio.as.proj.main.degrade.DegradeGlobalActivity
        println("modifyClass name:" + classFile.name)//全类名
        def ctClass = classPool.get(classFile.name)
        if (ctClass.isFrozen()) {
            ctClass.defrost()
        }

        def bundle = classPool.get("android.os.Bundle")
        CtClass[] params = Arrays.asList(bundle).toArray()
        def method = ctClass.getDeclaredMethod("onCreate", params)

        def message = classFile.name
        method.insertAfter("Toast.makeText(this," + """ + message + """ + ",Toast.LENGTH_SHORT).show();")

        return ctClass
    }

    boolean shouldModifyClass2(String filePath) {
        return filePath.contains("androidx/appcompat/widget/AppCompatImageView")
    }

    boolean shouldModifyClass(String filePath) {
        return (filePath.contains("org/devio/as/proj")
                && filePath.endsWith("Activity.class")
                && !filePath.contains("R.class")
                && !filePath.contains('$')
                && !filePath.contains('R$')
                && !filePath.contains("BuildConfig.class"))
    }
}

Extension扩展配置

它的作用就是通过实现自定义的bean对象,可以在Gradle脚本中增加类似‘android’这样命名空间的配置,Gradle可以识别这种配置,并读取里面的配置内容。通常使用ExtensionContainer来创建并管理Extension。

class TinyPngExt{
    List<Integer> whiteList;
    String apiKey;
}

class TinyPngPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
       //1.获取ExtensionContainer对象
       def extContainer = project.extensions
       
       //2.注册扩展对象
       //name:要创建的Extension的名字,可以是任意符合命名规则的字符串,不能与已有的重复,否则会抛异常;
       //type:该Extension的类class类型;
       //constructionArguments:类的构造函数参数值
       extCntainer.create("tinyPng",TinyPngExt.class,Object... constructionArguments)
    }
}

6、发布插件到jcenter

Api Key与仓库配置

  • 账号注册最好使用gmail邮箱
  • 登录的username为上面注册时填写的username,使用邮箱无法登录
  • Api Key 登录之后点击右上角的Edit Profile进入个人主页,再点击左侧最后一个API KEY 在plugin和library发布时需要用到
  • repository仓库创建 添加仓库名字,和仓库的类型为maven即可

发布前的准备

  • 添加插件com.novoda.bintray-release
  • 发布可配属性 发布Bintray与发布Jcenter
  • 执行命令 但是此时还并没有发布到jcenter,只是提交了bintray。
  • 发布jcenter 点击右侧的add to Jcenter,等待审核。审核结果会有邮件通知
  • 如果通过审核了,右侧会显示Linked to(1)
  • 使用 在为审核通过之前使用的话,需要添加你的bintray上创建的仓库的地址

image.png

7、jenkins持续集成与自动化构建

传统的包构建方式

  • 传统的包构建步骤
    • git pull origin master 拉取分支最新代码
    • ./gradlew assembleDebug 构建debug包
    • build/output/apk/app-debug.apk 部署并运行或发送给测试同学
  • 传统包构建的问题
    • 显而易见这些是机械、重读且浪费时间的工作
    • 不利于多人协作 Jenkins持续集成与自动打包构建

一切重复的工作皆可自动化,大厂里面都由自动化包构建平台,大多是基于Jenkins持续集成方案来定制的。 Jenkins是一个开源软件项目,是基于Java开发的一种持续集成工具,使软件的持续集成变成可能Jenkins提供数白个插件来支持构建,部署和自动化任何项目。

  • Jenkins软件包安装与服务管理

    • 安装:brew install jenkins-lts
    • 开启服务:brew services start jenkins-lts
    • 重启服务:brew services restart jenkins-lts
    • 升级服务:brew upgrade jenkins-lts

    安装homebrew
    /usr/bin/ruby -e "$(curl -fsSL raw.githubusercontent.com/Homebrew/in…)"

  • 服务启动。这个过程首次可能需要4~5分钟。耐心等待

  • 以管理身份登陆。在terminal中打开红色字体的文件,把密码输入以继续

open /Users/电脑名/.jenkins/secrets/initialAdminPassword
  • 插件安装。这里选择'安装推荐的插件',过程需要30分钟左右
  • Jenkins实例,默认即可
  • 账户创建。这里我们不创建新用户,点击右下角的 "使用admin账户继续"。而admin账户的密码就存储在上面红色字体文件中
  • Jenkins配置 (1)安装额外的插件
    • Multiple