gradle快速入门

1. Groovy语法

1.1 变量

// groovy支持类型推断,def可以不写,但是规范一点还是带上
def a = 5;
def b = "groovy"

1.2 函数与闭包

1.2.1 函数

函数比较简单,就不赘述了。

下面的示例演示了函数的定义与调用:

def add(var1, var2){
	//函数的最后一行作为返回值,与kotlin一致,可以省略return
	var1+var2
}
// 传统的调用函数方式
println "test1: ${add(1, 2)}"
// 不用括号调用函数
println "test2: ${add 'a', 'b'}"

下面演示了利用groovy执行命令行的方法:

def gitVersion(){
	if(System.getenv('CI_BUILD')){
		return 1;
	}
	def cmd ='git rev-list HEAD --first-parent --count'
	cmd.execute().text.trim().toInteger()
}

android {
  defaultConfig {
    versionCode gitVersion()
  }
  ......
}

1.2.2 闭包

闭包是groovy的重要特性之一,详细的使用方法可以参考官网的相关介绍。闭包的定义及基本使用:

// 闭包的定义及调用
def closure = { String param ->
	println "param.len = ${param.length()}"
}
closure("abc")
closure.call("abc")

闭包作为函数入参:

def func(Closure closure){
	closure()
}

func {
	println 'closure as parameter'
}

闭包有this、owner、delegate三个属性,在闭包内调用方法时,由他们来确定使用哪个对象来处理。来看一个委托模式的示例:

class Person {
    String personName = '李四'  
    int personAge = 18
    def printPerson(){
        println "name is ${personName},age is ${personAge}"
    }
}

def person(Closure<Person> closure){
    Person p = new Person();
    // 将闭包与代理对象绑定,这一步是必须的
    closure.delegate = p    
    // 代理模式:
    // 0. Closure.OWNER_FIRST:自身优先,默认
    // 1. Closure.DELEGATE_FIRST:代理优先
    // 2. Closure.OWNER_ONLY:仅自身
    // 3. Closure.DELEGATE_ONLY:仅代理
    closure.setResolveStrategy(Closure.DELEGATE_FIRST);
    return closure
}
 
def closure = person {
    personName = '张三'
    personAge = 20
    printPerson()
}
 
task configClosure {
    doLast {
        closure()
    }
}

将ResolveStrategy分别设置为4种类型,运行:./gradlew configClosure 将得到下面的运行结果:

Closure.OWNER_FIRST Closure.DELEGATE_FIRST Closure.OWNER_ONLY Closure.DELEGATE_ONLY
> Task :configClosure name is 张三,age is 20 > Task :configClosure name is 张三,age is 20 Execution failed for task ':configClosure'. > Could not set unknown property 'personName' of type org.gradle.api.Project. > Task :configClosure name is 张三,age is 20

可见除了Closure.OWNER_ONLY其余结果都是我们希望得到的,如果想打印默认的“李四”该怎么办呢?很简单不要重写对应的属性即可:

def closure = person {
    // personName = '张三'
    // personAge = 20
    printPerson()
}

相信到这里你应该理解了闭包与代理对象间的相互关系了,Gradle中大量使用的Extension正是利用了这一特性,比如APG常见的:

  • Plugin com.android.application uses {@link AppExtension}
  • Plugin com.android.library uses {@link LibraryExtension}
  • Plugin com.android.test uses {@link TestExtension}
  • Plugin com.android.atom uses {@link AtomExtension}
  • Plugin com.android.instantapp uses {@link InstantAppExtension}

上述XXXExtension都继承自BaseExtension,我们覆写相关Extension的属性达到更新配置的目的。

闭包的写法也很有特色,现有如下接口原型:PublicationContainer#create(String name, Class type, Action configuration) ,下面两种写法都是可以的:

publishing {
    publications{ PublicationContainer it->
        libName(MavenPublication) {
            version "$libVersion"
            artifactId "$libArtifactId"
            groupId "$libGroupId"
        }
    }

    publications.create("$libArtifactId", MavenPublication) {
        version "$libVersion"
        artifactId "$libArtifactId"
        groupId "$libGroupId"
    }
}

1.3 操作符重载

操作符重载

1.4 拓展方法

參考:http://www.blogjava.net/johnnyjian/archive/2010/03/19/315962.html

Metaclass就是Class的Class,Class定義了該類實例的行為,Metaclass則定義了該類及其實例的行為(http://en.wikipedia.org/wiki/Metaclass))。Groovy通過Metaclass使程序可以在運行時修改/添加類的方法、屬性等(動態性)。

在Groovy中,每個Class都有一個對應的Metaclass,通過這個Metaclass可以給這個Class添加方法或屬性:

示例:為String類添加新方法

 String.metaClass.capitalize = {  delegate[0].toUpperCase() + delegate[1..-1] }
 println("very".capitalize())  // 首字母大寫

2. 基本概念

2.0 获取帮助

// 获取指定task的帮助说明
gradle help --task "taskName"

// 获取所有task
gradle tasks --all

Tips

  • 执行指定task
// 默认对rootProject执行命令
gradle assembleRelease
// 指定subProject执行命令
gradle :app:assembleRelease
  • task支持缩写
// gradle支持驼峰缩写执行命令:等同于执行gradle assembleRelease
gradle aR
  • task批量执行

来源:经验:Android批量打包APK并批量安装

./gradlew :B:assembleRelease
./gradlew :C:assembleDebug
./gradlew :D:assembleAndroidTest

// 三个任务可以合并为下面
./gradlew clean :B:assembleRelease :C:assembleRelease :D:assembleAndroidTest

2.1 gradle构建周期

  1. Initialization
    此阶段会根据setting.gradle创建Project对象,默认至少有个rootProject,可以添加更多子项目,如:
// 创建3个子项目
include ':app', ':a', ':b'
  1. Configuration
    执行每个Projectbuild.gradle脚本,完成脚本的构建流程。该阶段执行完毕后,整个脚本有多少个task以及task之间的依赖关系就确定了。需要注意的是该阶段不会执行task的执行内容(doFirst {}/ doLast {}),但是会执行task的构建内容。
  2. Execution
    task 的执行阶段。先执行 doFirst {} 闭包中的内容,最后执行 doLast {} 闭包中的内容。

例如下build.gradle文件:

task printString {
    group 'test'
    description 'this is a task grouped as test.'
	println "block run during configuration."
	doLast {
		println "doLast group:$group, description:$description"
	}
	doFirst {
		println "doFirst group:$group, description:$description"
	}
}

我们在命令行执行:gradle printString,结果如下:

Starting a Gradle Daemon (subsequent builds will be faster)

> Configure project :
block run during configuration.

> Task :printString
doFirst group:test, description:this is a task grouped by test
doLast group:test, description:this is a task grouped by test

BUILD SUCCESSFUL in 8s
1 actionable task: 1 executed
  • gradle的执行入口文件默认是build.gradle文件,也称为构建脚本。执行gradle命令时从当前目录下寻找 build.gradle 文件来执行构建。
  • project中的全局变量可以通过在rootProject的build.gradle中定义 ext{…}来配置,需要引用的地方直接$varName即可。

2.2 构建周期hook入口

前面提到了在Configuration执行完毕后才建立起task graph,因此如果我们想做一些依赖于非gradle自带task的操作时,就要等待Configuration执行完毕,并且是自动执行。gradle为我们提供了这样的hook入口:
gradle快速入门_第1张图片
在上面的示例build.gradle中继续增加:

afterEvaluate { Project project ->
	println "afterEvaluate ${project.name}"
}

task hook {
	afterEvaluate { Project project ->
		println "afterEvaluate2 ${project.name}"
	}
}

gradle.projectsEvaluated {
	println "projectsEvaluated"
	tasks.findByName("printString").doFirst {
		println 'hook printString from projectsEvaluated'
	}
}

然后执行gradle printString,输出结果为:

> Configure project :
block run during configuration.
afterEvaluate test1
afterEvaluate2 test1
projectsEvaluated

> Task :printString
hook printString from projectsEvaluated
doFirst group:test, description:this is a task grouped by test
doLast group:test, description:this is a task grouped by test

BUILD SUCCESSFUL in 1s
1 actionable task: 1 executed

2.3 三个对象

在gradle执行的时候,会将三种不同的脚本文件转换成对应的三个对象:

  1. Gradle对象:当我们执行gradle xxx命令的时候,gradle会从默认的配置脚本中构造出一个Gradle对象。在整个执行过程中,只有这么一个对象。Gradle对象的数据类型就是Gradle。我们一般很少去定制这个默认的配置脚本。
  2. Project对象:每一个build.gradle会转换成一个Project对象。
  3. Settings对象:每一个settings.gradle都会转换成一个Settings对象。
initialization
configuration
settings.gradle
Settings对象
build.gradle
Project对象

3. gradle插件

gradle插件分为:

  1. 脚本插件:
    一个.gradle为后缀的脚本文件,定义一些函数或执行一些逻辑,使用方法:eg. apply from: ‘xxx.gradle’。
  2. 对象插件:
    通过编写一个自定义类实现Plugin接口,重写其@Override void apply(Project project) {…}方法,使用方法:eg. apply plugin: PluginFullClassname或者PluginID 。

gradle插件本质上为一系列task的集合,通过插件的形式实现gradle task的复用和传播,相当于开发中的lib。例如作为安卓研发,谷歌为我们开发的com.android.tools.build:gradle:version插件(gradle-plugin与gradle版本关系)就包含了com.android.applicationcom.android.library两大功能,涵盖了安卓开发汇总涉及到的各种task,熟练掌握这些task将获得很多我们“一般认知”之外的开发能力,可以查阅文末参考资料进一步学习。

3.1 常用插件简介

3.1.1 Gradle Plugin

在gradle官方用户文档中,特别强调了要熟悉核心插件的使用。java和android插件的通用task体包括:

  • assemble:执行打包动作
  • check:执行测试动作
  • build = assemble + check;
  • clean:清除build生成物

这里有几点需要注意:

  • assemble和check不执行具体任务,而是一个统一的抽象入口,在不同类型的插件中组织调用对应具体task,比如assemble对于Java插件是调用jar这个task,而Android插件则是aar;Java check会调用test这个task,Android test会调用lint、等。
  • 由于java插件和android插件存在功能的重合区,因此同时添加将会报错;
java插件

用于构建任何java项目,提供了’implementation’, 'testImplementation’等依赖方法。

  • 官方文档:
    The Java Plugin

  • 引用方式:
    apply plugin:‘java’

  • 项目结构:

src
main
test
java
resources
java-library插件

此插件是java插件的拓展,用于构建java库文件。

  • 官方文档:
    The Java Library Plugin

  • 引用方式:
    apply plugin:‘java-library’

groovy插件

扩展了java插件,提供了对groovy开发的支持。

  • 官方文档
    The Groovy Plugin

  • 引用方式
    apply plugin:'groovy'

maven插件

用于发布成果物到maven仓库

  • 官方文档

  • 引用方式
    apply plugin:'maven'

3.1.2 Android Gradle Plugin

AGP需要读取Android SDK的路径以调用相关工具,因此需要相关配置,可以通过以下几种方式之一进行:

  1. local.properties中指定sdk.dir;
  2. 配置ANDROID_HOME环境变量;
  3. 在gradle文件中配置android.sdkDirectory;

需要用到NDK时同样需要通过下述方式指定NDK路径:

  1. local.properties中指定ndk.dir;
  2. 在gradle文件中配置android.ndkDirectory;

【参考Gradle官方说明】。由于AGP不是gradle自带的插件,需要在buildScript.dependencies中引入:classpath 'com.android.tools.build:gradle:4.1.0'

除此之外还应当熟悉安卓的工程结构:

src
main
test
androidTest
java
kotlin
res
assets
aidl
jni
rs
AndroidManifest.xml
com.android.application插件

用于构建安卓apk等安装包。

  • 引用方式:
apply plugin:'com.android.application'
  • extension:
android {
    // 指定构建工具版本,有默认值
    buildToolsVersion 'version'
	defaultConfig{ 
        applicationId	 '**.**.**'
        applicationIdSuffix '.two'  
        minSdkVersion 14
        targetSdkVersion 25
        versionCode  1
        versionName "1.0"
        versionNameSuffix ".0" 
        //用于Library中,可以将混淆文件输出到aar中,供Application混淆时使用。
        consumerProguardFiles 'proguard-rules.pro' 
        //给渠道一个分组加维度的概念,比如现在有三个渠道包,分成免费和收费两种类型,可以添加一个dimension, 
        //打渠道包的时候会自动打出6个包,而不需要添加6个渠道,详细的说明可见 https://developer.android.com/studio/build/build-variants.html#flavor-dimensions。
	    dimensions 'api', 'fee' 
        //ndk的配置,AS2.2之后推荐切换到cmake的方式进行编译。
        externalNativeBuild {   
            cmake {
                cppFlags "-frtti -fexceptions"
                arguments "-DANDROID_ARM_NEON=TRUE"
                buildStagingDirectory "./outputs/cmake"
                path "CMakeLists.txt"
                version "3.7.1"
            }
            ndkBuild {
                path "Android.mk"
                buildStagingDirectory "./outputs/ndk-build"
            }
        }
		ndk{ 
            //只保留特定的api输出到apk文件中。
            abiFilters 'x86', 'x86_64', 'armeabi'   
        }
		javaCompileOptions{
            annotationProcessorOptions {    //注解的配置。
                includeCompileClasspath true //需要使用注解功能。
                arguments = [ eventBusIndex : 'org.greenrobot.eventbusperf.MyEventBusIndex' ]   //AbstractProcessor中可以读取到该参数。
                classNames 
            }
        }

		splits { 
			abi {
                enable true  //开启abi分包
                universalApk true  //是否创建一个包含所有有效动态库的apk
                reset()  //清空defaultConfig配置
                include 'x86','armeabi' //打出包含的包 这个是和defaultConfig累加的
                exclude 'mips'   //排除指定的cpu架构
            }
			density{
                enable true  //开启density分包
                reset()  //清空所有默认值
                include 'xhdpi','xxhdpi'   //打出包含的包 这个是和默认值累加的
                exclude 'mdpi' //排除指定
            }
            language {
              enable true  //开启language分包
              include 'en','cn'  // 指定语言
            }
		}
        //manifest占位符 定义参数给manifest调用,如不同的渠道id。
        manifestPlaceholders = [key:'value']     
        //开启multiDex
        multiDexEnabled true      
        //手动拆包,将具体的类放在主DEX。
        multiDexKeepFile  file('multiDexKeep.txt')    
        //支持Proguard语法,进行一些模糊匹配。
        multiDexKeepProguard  file('multiDexKeep.pro')  
        //混淆文件的列表,如默认的android混淆文件及本地proguard文件,
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 

        //代码中可以通过BuildConfig.IS_RELEASE 调用。
        buildConfigField('boolean','IS_RELEASE','false')   
        //在res/value 中添加demo
        resValue('string','appname','demo')  
        //指定特定资源,可以结合productFlavors实现不同渠道的最小的apk包。
        resConfigs "cn", "hdpi"  
        // 给每个模块指定一个特定的资源前缀,可以避免多模块使用相同的文件命名后合并冲突,在build.gradle中指定了这个配置后,AS会检查不合法的资源命名并报错
        resourcePrefix 'xxx_'
	}
	buildTypes{
        debug {
            applicationIdSuffix   '.debug'
            versionNameSuffix '.1'
            //生成的apk是否可以调试 debug默认是true   release默认false
            debuggable true  
            //是否可以调试NDK代码 使用lldb进行c和c++代码调试
            jniDebuggable true   
            //是否开启png优化,会对png图片做一次最优压缩,影响编译速度,debug默认是false,release默认true
            crunchPngs   true  
            //Android Wear的支持
            embedMicroApp true  
            //是否开启混淆
            minifyEnabled  true 
            //是否开启渲染脚本
            renderscriptDebuggable false   
            //渲染脚本等级 默认是5
            renderscriptOptimLevel 5        
            //是否zip对齐优化 默认就是true
            zipAlignEnabled true   
        }
        release {......}
    }
	productFlavors{ 
        // 配置所有风味,可以覆盖defaultConfig的参数配置
        free { 
            dimension 'fee' 
            ......
        }
        vip {
            dimension 'fee'
            ......
        }
    }
	sourceSets{
        main  {
            res.srcDirs 'src/main/res' 
            jniLibs.srcDirs = ['libs'] 
            aidl.srcDirs 'src/main/aidl'
            assets.srcDirs 'src/main/assets'
            java.srcDirs 'src/main/java'
            jni.srcDirs 'src/main/jni'
            renderscript.srcDirs 'src/main/renderscript' 
            resources.srcDirs 'src/main/resources'
            manifest.srcFile 'src/main/AndroidManifest.xml'
        }
 
        free  {   //除了main,也可以给不同的风味指定不同的配置
 
        }

    }
	signingConfigs{ 
        //签名文件的路径
        storeFile file('debug.keystore')
        //签名文件密码
        storePassword 'android'
        //别名
        keyAlias 'androiddebygkey'
        //key的密码
        keyPassword 'android'
    }
	compileOptions{
        //java源文件的编码格式 默认UTF-8
        encoding 'UTF-8'  
        //java编译是否使用gradle新的增量模式 
        incremental true    
        //java源文件编译的jdk版本 
        sourceCompatibility JavaVersion.VERSION_1_7    
        //编译出的class的版本
        targetCompatibility JavaVersion.VERSION_1_7   
    }
	lintOptions{ 
        // 设置为 true时lint将不报告分析的进度
        quiet true 
        // 如果为 true,则当lint发现错误时停止 gradle构建
        abortOnError false    
        // 如果为 true,则只报告错误
        ignoreWarnings true   
        // 如果为 true,则当有错误时会显示文件的全路径或绝对路径 
        absolutePaths true   
        // 如果为 true,则检查所有的问题,包括默认不检查问题
        checkAllWarnings true  
        // 如果为 true,则将所有警告视为错误
        warningsAsErrors true  
        // 不检查给定的问题id
        disable 'TypographyFractions','TypographyQuotes'  
        // 检查给定的问题 id
        enable 'RtlHardcoded','RtlCompat', 'RtlEnabled'   
        // 仅检查给定的问题 id
        check 'NewApi', 'InlinedApi'    
        // 如果为true,则在错误报告的输出中不包括源代码行
        noLines true  
        // 如果为 true,则对一个错误的问题显示它所在的所有地方,而不会截短列表,等等。
        showAll true  
        // 重置 lint 配置(使用默认的严重性等设置)。
        lintConfig file("default-lint.xml")  
        // 如果为 true,生成一个问题的纯文本报告(默认为false)
        textReport true  
        // 配置写入输出结果的位置;它可以是一个文件或 “stdout”(标准输出)
        textOutput 'stdout' 
        // 如果为真,会生成一个XML报告,以给Jenkins之类的使用
        xmlReport false 
        // 用于写入报告的文件(如果不指定,默认为lint-results.xml)
        xmlOutput file("lint-report.xml")  
        // 如果为真,会生成一个HTML报告(包括问题的解释,存在此问题的源码,等等)
        htmlReport true 
        // 写入报告的路径,它是可选的(默认为构建目录下的 lint-results.html )
        htmlOutput file("lint-report.html")  
        // 设置为 true, 将使所有release 构建都以issus的严重性级别为fatal(severity=false)的设置来运行lint,并且,如果发现了致命(fatal)的问题,将会中止构建(由上面提到的 abortOnError 控制.
        checkReleaseBuilds true   
        //设置给定问题的严重级别(severity)为fatal (这意味着他们将会在release构建的期间检查 (即使 lint 要检查的问题没有包含在代码中)
        fatal 'NewApi', 'InlineApi'  
        // 设置给定问题的严重级别为error
        error 'Wakelock', 'TextViewEdits'   
        // 设置给定问题的严重级别为warning
        warning 'ResourceAsColor'   
        // 设置给定问题的严重级别(severity)为ignore(和不检查这个问题一样)
        ignore 'TypographyQuotes'    
    }
	testOptions{ }
	aaptOptions{
        //aapt执行时的额外参数 
        additionalParameters '--rename-manifest-package',
                'cct.cn.gradle.lsn13','-S','src/main/res2','--auto-add-overlay'  
        //对png进行优化检查,buildTypes中配置也可
        cruncherEnabled true    
        //对res目录下的资源文件进行排除 把res文件夹下面的所有.jpg格式的文件打包到apk中
        ignoreAssets '*.jpg'  
        //对所有.jpg文件不进行压缩
        noCompress '.jpg'   
    }
	dexOptions{
        //dx命令附加参数
        additionalParameters '--minimal-main-dex','--set-max-idx-number=10000'  
        //执行dx时java虚拟机可用的最大内存大小
        javaMaxHeapSize '2048m'    
        //开启大模式,所有的class打到一个dex中,可以忽略65535方法数的限制,低于14版本不可运行
        jumboMode true   
        //在dex中是否保留Runtime注解 默认是true
        keepRuntimeAnnotatedClasses true 
        //默认dex中的进程数  默认是4 
        maxProcessCount 4   
        //默认的线程数
        threadCount 4  
        //对library预编译 提高编译效率 但是clean的时候比较慢  默认开启的
        preDexLibraries true  
    }
    packagingOptions{ 
        //pickFirsts做用是 当有重复文件时 打包会报错 这样配置会使用第一个匹配的文件打包进入apk
        pickFirsts = ['META-INF/LICENSE']    
        //重复文件会合并打包入apk
        merge 'META-INF/LICENSE'  
        //打包时排除匹配文件
        exclude 'META-INF/LICENSE'    
    }
    //指定自定义的adb路径,可以配置在环境变量中
    adbExecutable '/xx/yyy'
    adbOptions {
        //调用adb install命令时默认传递的参数
        installOptions '-r' '-d'  
        //执行adb命令的超时时间
        timeOutInMs 1000   
    }
    dataBinding  {
      enabled = true   //开启databinding
      version = "1.0"
      addDefaultAdapters = true
    }
    // applicationVariants是AppExtension继承自BaseExtension唯一拓展的成员变量
    // 其参数类型是DefaultDomainObjectSet,是不同buildType及Flavor的集合
    // applicationVariants最常用的是它的all方法
	applicationVariants.all { v ->
        if (v.buildType.name == 'release') {
            v.outputs.all {
                outputFileName = "app_V${v.versionName}+${v.versionCode}_${buildTime()}_${v.flavorName}.apk"
            }
        }
    }
    // flavor*buildType会构建出大量的apk,可以动态设置忽略一些产出
    variantFilter { variant ->
        def buildTypeName = variant.buildType.name
        def flavorName = variant.flavors.name
        if (flavorName.contains("360") && buildTypeName.contains("debug")) {
            // Tells Gradle to ignore each variant that satisfies the conditions above.
            setIgnore(true)
        }
    }
}
com.android.library插件

用于构建安卓依赖库(aar),大部分功能与com.android.application重合,使用方法参见上面。

  • 引用方式:
    classpath ‘com.android.tools.build:gradle:4.1.0’ # 由于不是gradle自带,所以需要在构建工具的classpath中额外引入
    apply plugin:‘com.android.library’

  • 独有属性:

android.libraryVariants.all { variant ->
    def mergedFlavor = variant.getMergedFlavor()
    // Defines the value of a build variable you can use in the manifest.
    mergedFlavor.manifestPlaceholders = [hostName:"www.example.com"]
}

3.2 开发工具选择

可以使用Android Studio或者IDEA进行开发,不同的是IDEA支持创建Gradle Plugin类型的工程,而AS完全需要手工引入相关依赖,因此需要对Gradle Plugin工程目录结构有所了解。以AS开发为例,其

创建一个Java Library Module
修改build.gradle引入groovy依赖
删除libs目录等无关目录 重命名java目录为groovy
创建groovy文件 开始编码和调试
发布插件
项目rootProject的build.gradle中引入插件 module的build.gradle中apply插件

补充说明:插件的编码语言不一定必须为groovy,也可以是kotlin、java

关键步骤截图:

名称 截图
module工程目录结构 gradle快速入门_第2张图片
build.gradle gradle快速入门_第3张图片也可以自定义sourceSets 等属性
插件properties文件 注意文件名规则:pluginId.properties,pluginId可以与包名不一致在这里插入图片描述

3.3 自定义插件方式

3.3.1 build.gradle编写

直接在rootProject build.gradle编写下编写自定义Plugin,然后在脚本头部引用:

// 演示一个最简单的Gradle插件
class SimpleTestExt {
    String msg
    String code
}

class SimpleTestPlugin implements Plugin<Project> {

    void apply(Project project){
        def extension = project.extensions.create('simpleTest', SimpleTestExt)
        project.task('showSimpleTest'){
            doFirst {
                println "${extension.msg} from ${extension.code}"
            }
        }
    }
}

// 在gradle脚本中引入插件
apply plugin: SimpleTestPlugin
 
simpleTest {
    msg = 'Hi,Gradle'
    code = '0'
}

3.3.2 创建插件module

如上面【使用AS开发】章节所示的方式,创建一个java module,添加groovy依赖进行开发,添加maven插件结合resource资源文件实现插件的发布和使用。这是一种方式,便于插件的传播与共享,有时我们仅希望在当前工程使用一些定制功能,此时可以使用下面buildSrc的方式。

3.3.3 创建buildSrc module

同样创建一个java module,但名称必须是buildSrc,同样修改java目录为groovy,具体流程如下:

创建一个Java Library Module 名称为buildSrc
修改build.gradle引入groovy依赖
删除libs目录等无关目录 重命名java目录为groovy
创建groovy文件 开始编码和调试
module的build.gradle中apply插件

与一般的创建插件工程的方式相比,区别是:

  1. 不需要发布插件和rootProject中引入插件,可在module中直接apply;
  2. 常规是apply plugin: pluginId,而这种方式只需apply plugin: 类名。

补充说明:buildSrc也可以创建pluginId.properties,然后通过apply plugin: pluginId方式进行导入,区别是不需要发布到仓库。其实不难发现,buildSrc的本质就是把这个module作为rootProject的构建时的依赖的源码,所以相关代码可以使用。

关键操作截图:

名称 截图
工程结构 gradle快速入门_第4张图片

3.4 插件调试

出了log日志这种被动方式,还是使用debug进行调试。开启方法为:

  • 在AndroidStudio => Edit Configurations… 中增加一个 Remote 配置:
    gradle快速入门_第5张图片
    只需要输入配置名称,其余保持默认即可
  • 在命令行以debug模式执行assemble命令
./gradlew --no-daemon -Dorg.gradle.debug=true :app:assembleRelease
  • 代码打好断点
  • 选中我们创建的configuration,然后按Debug按钮

4. 核心API

4.1 Extension

用于在gradle脚本中定义参数。

定义Extension实体:

class FileSizer {
    boolean includeCode = true
    boolean includeResource = true
    boolean enableBuildLog = false


    @Override
    String toString() {
        return "FileSizer{" +
                "includeCode=" + includeCode +
                ", includeResource=" + includeResource +
                ", enableBuildLog=" + enableBuildLog +
                '}';
    }
}

gradle中设置实体:

apply plugin: 'me.plugin.filesizer'
fileSizer {
    includeResource true
    includeCode false
    enableBuildLog false
}

插件中读取实体:

def fileSizer = project.extensions.create('fileSizer', FileSizer)

4.2 Task

构建Task

org.gradle.api.Project中定义了task方法,返回一个Task对象。共有5个方法的重载,如下图所示:
gradle快速入门_第6张图片

最复杂的是入参含有Map参数的重构方法(术语为:map-style notation),Map参数包括:

Option Description Default Value
“name” The name of the task to create. None. Must be specified.
“type” The class of the task to create. DefaultTask
“action” The closure or Action to execute when the task executes. See Task.doFirst(Action). null
“overwrite” Replace an existing task? false
“dependsOn” The dependencies of the task. See here for more details. []
“group” The group of the task. null
“description” The description of the task. null
“constructorArgs” The arguments to pass to the task class constructor. null
简单构建示例

从上可知,task支持如下形式进行创建:

  • 调用task方法创建Task
// 只传一个name参数
task myTask
task(name: myTask)  //调用map重载方法

// name + closure
task myTask { /* logic */}

// name+action
task(name: myTask, action: new Action<Task>() {
  @Override
  void execute(Task task) {
    /* logic */
  }
})
task myTask {
 doFirst {
 	/* logic */
 }
}

task myTask << { /* logic */ }

操作符 << 在Gradle 4.x中被弃用(deprecated),并且在Gradle 5.0 被移除(removed),使用会报:Could not find method leftShift() for arguments .

  • 简单的自定义Task类
/**
 * 第一步:自定义类继承DefaultTask,用@TaskAction标记task任务实体
 */
class MyTask extends DefaultTask {
    String msg='hello from myTask'
    List<String> list = new ArrayList<>()
    /** 
    方法名可以随意取,重点是执行入口需要用@TaskAction进行注解
    TaskAction在doFirst之后,doLast之前执行
     */
    @TaskAction
    def doWork(){
        println msg
        list.forEach{ e-> println e}
    }

    def add(String... args){
        list.addAll(args)
    }
}

/**
 * 第二步:向gradle注册我们自定义的task,这样我们可以从AS的gradle面板中看到这个task
 */
task callMyTask(type: MyTask){
    group 'test'
    msg = 'hello task'  //重写了msg属性
    add 'a','b','c'     //调用了add方法
}
Task依赖

Task的一个不可忽视的属性就是其依赖关系,是一种有向无环图接口。通过上面构造参数中的dependsOn指定所需依赖的任务:

task A << {println 'Hello from A'}
task B << {println 'Hello from B'}
task C << {println 'Hello from C'}
B.dependsOn A
C.dependsOn B

我们可以通过下面的方式查看指定task的依赖:

afterEvaluate {
    project.tasks.findByName('assembleDebug').taskDependencies.getDependencies().each {
        println "dependOn: ${it.name}"
        it.taskDependencies.getDependencies().each {
            println "        : ${it.name}"
        }
    }
}

前面提到了使用task.doFirst{...}来hook目标任务,这里又多了一种方式,即:taskA.dependsOn taskB

增量构建

在Gradle中,每一个task都有inputs和outputs,如果在执行一个Task时,如果它的输入和输出与前一次执行时没有发生变化,那么Gradle便会认为该Task是最新的,因此Gradle将不予执行而是在控制台打印[UP-TO-DATE],这就是增量构建的概念。

一个task的inputs和outputs可以是一个或多个文件,可以是文件夹,还可以是project的某个property,甚至可以是某个闭包所定义的条件。自定义task默认每次执行,但通过指定inputs和outputs,可以达到增量构建的效果。

示例:查看dependencies任务的输入输出

def dp = tasks.findByName("dependencies")
println "dp.inputs = ${dp.inputs}"
println "dp.outputs = ${dp.outputs}"

打印结果:

> Configure project :app
dp.inputs = org.gradle.api.internal.tasks.DefaultTaskInputs@2d417d27
dp.outputs = org.gradle.api.internal.tasks.DefaultTaskOutputs@6cddd0c3

可以使用附加参数-- rerun-taks来强制执行task,忽略增量编译

常用Task

JavaCompile
Copy
// 创建一个独立的task
task copyTask(type: Copy) {
    from 'src/main/resources'   //必填,原型:CopySpec.from(java.lang.Object[])
    into 'build/config'   		//必填,原型:CopySpec.into(java.lang.Object)
    include '**/*.html'    		//可选,仅拷贝符合规则的文件
    include '**/*.jsp'			//可选
    exclude { details -> 		//可选,剔除符合规则的文件
    	details.file.name.endsWith('.html') &&
        details.file.text.contains('staging') 
    }
}
// 更常用的做法是直接调用Project.copy(org.gradle.api.Action)函数去复制文件
copy {
	from 'src/main/resources'
    into 'build/config'
    rename { String oldName->  //配合重命名函数使用
		return "newName"
	}
	// 使用正则表达示来映射文件名
    rename '(.+)-staging-(.+)', '$1$2'
    rename(/(.+)-staging-(.+)/, '$1$2')
}
// 嵌套形式的拷贝
task nestedSpecs(type: Copy) {
    into 'build/explodedWar'
    exclude '**/*staging*'
    from('src/dist') {
    	eachFile {
            println(it)
        }
        include '**/*.html'
    }
    // 将运行时的依赖库拷贝到libs目录下
    into('libs') {
        from configurations.runtime
    }
}

from()方法接受的参数和文件集合时files()一样:

  1. 当参数为一个目录时,该目录下所有的文件都会被拷贝到指定目录下(目录自身不会被拷贝),如:from 'src/main/webapp'
  2. 当参数为一个文件时,该文件会被拷贝到指定目录,如:from 'src/staging/index.html'
  3. 如果参数指定的文件不存在,就会被忽略;
  4. 当参数为一个 Zip 压缩文件,该压缩文件的内容会被拷贝到指定目录,如from zipTree('src/main/assets.zip')
Sync

同步拷贝:在拷贝的时候,把原文件拷贝到目标目录时,会把目标目录下之前的全部清除,这种方式很适合项目的依赖库拷贝

task libs(type: Sync) {
    from configurations.runtime
    // 拷贝之前会把$buildDir/libs目录下所有的清除
    into "$buildDir/libs"
}
Delete
// 删除文件夹
task myClean(type: Delete) {
    delete buildDir
}
// 删除符合规则的文件
task cleanTempFiles(type: Delete) {
    delete fileTree("src").matching {
        include "**/*.tmp"
    }
}
Zip
apply plugin: 'java'

task zip(type: Zip) {
    from 'src/dist'
    // 方式一:通过into指定文件目录
    into('libs') {
        from configurations.runtime
    }
    // 方式二:通过指定文件目录和文件名指定
    baseName = 'myGame'  // 设置文件名称,效果:myGame-1.0.zip
    archiveName = "my-distribution.zip"  // 完整文件名
    destinationDir = file("${buildDir}/dist") // 文件目录
}

解压缩用的是Copy:

task unpackFiles(type: Copy) {
    from zipTree("${buildDir}/dist/my-distribution.zip")
    into "${buildDir}/resources"
}
Jar/Tar/War/Ear
plugins {
    id 'java'
}
tasks.jar{
    manifest {
        attributes( "Main-Class": "gradle.test.App")
    }
}

不借助插件:

task buildJar {
    mkdir("jar")
    copy {
        from(layout.buildDirectory.dir("classes/java/main"))
        into("./jar")
    }
    Files.write(Paths.get(layout.projectDirectory.file("jar/MANIFEST.MF").asFile.absolutePath),"Main-Class: gradle.test.App\r".getBytes())
    copy {
        from(layout.projectDirectory.file("MANIFEST.MF"))
        into(layout.projectDirectory.file("app/jar"))
    }
    this.exec {
        workingDir("./jar")
        executable("jar")
        args("-cvfe" ,"test.jar", "gradle.test.App" ,"./")
    }
}

Exec
task taskCompressPngs(type: Exec) {
    def compressPngs = 1
    // 不同task传入不同的入参
    if("task_2" in gradle.startParameter.taskNames){
    	compressPngs = 0
    }
    // 命令主体
    commandLine "myshell.sh $compressPngs".tokenize()
    // 命令参数
    // args compressPngs
}

自定义Task示例

class FileSizerTask extends DefaultTask {
    private Set<File> paths
    private final List<RecordItem> codeRecordItems = new ArrayList<>()
    private final List<RecordItem> resRecordItems = new ArrayList<>()
    private final isWindows = org.gradle.internal.os.OperatingSystem.current().isWindows()

    void addPaths(Set<File> paths) {
        this.paths = paths
    }

    @TaskAction
    def start() {
        codeRecordItems.clear()
        resRecordItems.clear()

        if (paths != null && !paths.isEmpty()) {
            paths.forEach { f ->
                calcFileSizeInDir(f)
            }
        }

        printResult()
    }

    void printResult() {
        File dstFile = new File("${project.buildDir.path}/${FileSizerPlugin.GROUP}/filesizer.json")
        if (!dstFile.exists()) {
            if (!dstFile.parentFile.exists()) {
                dstFile.parentFile.mkdirs()
            }
            dstFile.createNewFile()
        }
        JsonObject json = new JsonObject()
        if (!codeRecordItems.isEmpty()) {
            def array = new JsonArray()
            for (RecordItem item : codeRecordItems) {
                array.add(item.toJson())
            }
            json.add("codeRecordItems", array)
        }
        if (!resRecordItems.isEmpty()) {
            def array = new JsonArray()
            for (RecordItem item : resRecordItems) {
                array.add(item.toJson())
            }
            json.add("resRecordItems", array)
        }


        BufferedWriter writer = null
        try {
            writer = new BufferedWriter(new FileWriter(dstFile))
            writer.write(json.toString())
        } catch (IOException e) {
            e.printStackTrace()
        } finally {
            if (writer != null)
                writer.close()
        }
    }

    void calcFileSizeInDir(File dir) {
        def files = dir.listFiles()
        for (File file : files) {
            if (file.isFile()) {
                if (isResource(file)) {
                    resRecordItems.add(new RecordItem(file.length(), file.path))
                } else {
                    codeRecordItems.add(new RecordItem(file.length(), file.path))
                }
            } else {
                calcFileSizeInDir(file)
            }
        }
    }

    boolean isResource(File file) {
        return isWindows?file.path.contains('main\\res'):file.path.contains('main/res')
    }

}

插件中注册task:

project.afterEvaluate {
    project.task("fileSizer", type: FileSizerTask) {
                group GROUP
                description 'statistic files size including resource and code.'
                project.logger.println fileSizer
                addPaths resolveFilePaths(project, fileSizer)
            }
            if (fileSizer.enableBuildLog){
                BuildCycleListener listener = new BuildCycleListener()
                project.gradle.addListener(listener)
            }

}

4.3 Variant

android gradle plugin V3.x 之后,每个 flavor 必须对应一个 dimension。Variants 共有三种类型:

  1. applicationVariants:只适用于 app plugin;
  2. libraryVariants:只适用于 library plugin;
  3. testVariants:在 app plugin 与 libarary plugin 中都适用;

这三种变量的公有属性【参考资料】:

属性名 属性类型 说明
name String Variant的名字,必须是唯一的。
description String Variant的描述说明。
dirName String Variant的子文件夹名,必须也是唯一的。可能也会有不止一个子文件夹,例如“debug/flavor1”
baseName String Variant输出的基础名字,必须唯一。
outputFile File Variant的输出,这是一个可读可写的属性。
processManifest ProcessManifest 处理Manifest的task。 vaidlCompile AidlCompile 编译AIDL文件的task。
renderscriptCompile RenderscriptCompile 编译Renderscript文件的task。
mergeResources MergeResources 混合资源文件的task。
mergeAssets MergeAssets 混合asset的task。
processResources ProcessAndroidResources 处理并编译资源文件的task。
generateBuildConfig GenerateBuildConfig 生成BuildConfig类的task。
javaCompile JavaCompile 编译Java源代码的task。
processJavaResources Copy 处理Java资源的task。
assemble DefaultTask Variant的标志性assemble task。

ApplicationVariant类还有以下附加属性:

属性名 属性类型 说明
buildType BuildType Variant的BuildType。
productFlavors List Variant的ProductFlavor。一般不为空但也允许空值。
mergedFlavor ProductFlavor android.defaultConfig和variant.productFlavors的合并。
signingConfig SigningConfig Variant使用的SigningConfig对象。
isSigningReady boolean 如果是true则表明这个Variant已经具备了所有需要签名的信息。
testVariant BuildVariant 将会测试这个Variant的TestVariant。
dex Dex 将代码打包成dex的task。如果这个Variant是个库,这个值可以为空。
packageApplication PackageApplication 打包最终APK的task。如果这个Variant是个库,这个值可以为空。
zipAlign ZipAlign zip压缩APK的task。如果这个Variant是个库或者APK不能被签名,这个值可以为空。
install DefaultTask 负责安装的task,不能为空。
uninstall DefaultTask 负责卸载的task。

LibraryVariant类还有以下附加属性:

属性名 属性类型 说明
buildType BuildType Variant的BuildType.
mergedFlavor ProductFlavor The defaultConfig values
testVariant BuildVariant 用于测试这个Variant。
packageLibrary Zip 用于打包库项目的AAR文件。如果是个库项目,这个值不能为空。

TestVariant类还有以下属性:

属性名 属性类型 说明
buildType BuildType Variant的Build Type。
productFlavors List Variant的ProductFlavor。一般不为空但也允许空值。
mergedFlavor ProductFlavor android.defaultConfig和variant.productFlavors的合并。
signingConfig SigningConfig Variant使用的SigningConfig对象。
isSigningReady boolean 如果是true则表明这个Variant已经具备了所有需要签名的信息。
testedVariant BaseVariant TestVariant测试的BaseVariant
dex Dex 将代码打包成dex的task。如果这个Variant是个库,这个值可以为空。
packageApplication PackageApplication 打包最终APK的task。如果这个Variant是个库,这个值可以为空。
zipAlign ZipAlign zip压缩APK的task。如果这个Variant是个库或者APK不能被签名,这个值可以为空。
install DefaultTask 负责安装的task,不能为空。
uninstall DefaultTask 负责卸载的task。
connectedAndroidTest DefaultTask 在连接设备上行执行Android测试的task。
providerAndroidTest DefaultTask 使用扩展API执行Android测试的task。

productFlavors

示例:让“demo”版应用仅支持 API 级别 23 及更高级别,使用 variantFilter 代码块过滤掉所有将“minApi21”和“demo”产品变种组合在一起的构建变体配置:

android {
  ...
  buildTypes {...}

  flavorDimensions "api", "mode"
  productFlavors {
    demo {
    	dimension "mode"
        matchingFallbacks = ["debug", "release"]
	}
    full {...}
    minApi24 {...}
    minApi23 {...}
    minApi21 {...}
  }

  variantFilter { variant ->
      def names = variant.flavors*.name
      // To check for a certain build type, use variant.buildType.name == ""
      if (names.contains("minApi21") && names.contains("demo")) {
          // Gradle ignores any variants that satisfy the conditions above.
          setIgnore(true)
      }
  }
}
...

Variant常见用法:

this.afterEvaluate {
	// 评估完成后可以获取到所有的变体
	// 当然这里applicationVariants也可以是上述的另外两种
    this.android.applicationVariants.all { variant ->
    	def name = variant.name  //比如:douyinDebug
    	def baseName = variant.baseName  //对应:douyin-debug
        variant.outputs.each {
            // 由于我们当前的变体是 application 类型的,所以
            // 这个 output 就是我们 APK 文件的输出路径,我们
            // 可以通过重命名这个文件来修改我们最终输出的 APK 文件
            outputFileName = "app-${variant.baseName}-${variant.versionName}.apk"
            println outputFileName
        }
        // 获取变体的task,taskName支持拼接,所以非常灵活
        def task = variant.checkManifest
        println task.name
        checkTask.doFirst {
        	// hook目标task,做一些特殊操作
            def bt = variant.buildType.name
            if (bt == 'qa' || bt == 'preview'
                    || bt == 'release') {
                update_plugin(bt)
            }
        }

    }
}

4.4 Transform

Transform是Android Gradle V1.5.0 版本以后提供的API,此类用于在class文件被转化为dex文件之前去修改字节码以实现插桩需求。Transform最终会在AGP中被转化成对应的TransformTask,被TaskManager管理。

自定义Transform需要依赖implementation 'com.android.tools.build:gradle:1.5.0+'

注册Transform:

/* registerTransform必须在评估完成前进行注册,afterEvaluate阶段注册是无效的 */

// 方式一:
// def android = project.extensions.findByType(AppExtension);
// android.registerTransform(new TransformerTransform(project))

// 方式二:也可以简写如下,但是就没有代码提示了
project.android.registerTransform(new TransformerTransform(project))

Transform的5个重写方法:

  1. 重写 getName 方法:返回对应的 Task 名称。
  2. 重写 getInputTypes 方法:确定对那些类型的结果进行转换。
  3. 重写 getScopes 方法:指定插件的适用范围。
  4. 重写 isIncremental 方法:表示是否支持增量更新。
  5. 重写 transform 方法:进行具体的转换过程。

重点是transform方法 ,在这个方法中去修改class文件注入我们的逻辑,通常有两种方式:JavassistASM,其中Javassist更接近java编码习惯,因此比较容易入门,ASM则比较复杂。

  • 查看更多接口信息:transform-api
  • 相关资料:深入理解Transform

4.5 File

构建过程中处理代码另一个重要的操作对象就是文件了,参考Working With Files学习如何使用gradle task体系复制、创建、移动、删除文件(夹),以及压缩/解压文件、构建jar包等。

相关接口

  • file
  • mkdir

文件遍历:

// 浅层遍历
file("/home/HouXinLin/test/").listFiles().each{
    println(it)
}

// 深层遍历
Files.walkFileTree(Paths.get("/home/HouXinLin/test/"),new SimpleFileVisitor<java.nio.file.Path>(){
    @Override
    FileVisitResult visitFile(java.nio.file.Path file, BasicFileAttributes attrs) throws IOException {
        println(file)
        return super.visitFile(file, attrs)
    }
})

FileCollection

FileCollection collection = project.files(
		'src/test1.txt',
        new File('src/test2.txt'),
        ['src/test3.txt', 'src/test4.txt'])
// 遍历所有集合
collection.each { File file ->
    println file.name
}

// 把文件集合转换为Set类型
Set set1 = collection.files
Set set2 = collection as Set
// 把文件集合转换为List类型
List list = collection as List
// 把文件集合转换为String类型
String path = collection.asPath
// 把文件集合转换为File类型
File file1 = collection.singleFile
File file2 = collection as File

// 添加或者删除一个集合
def union = collection + files('src/test5.txt')
def different = collection - files('src/test3.txt')

注意:文件集合中的文件对象都是延迟操作的,也就是说你可以创建一个 FileCollection 对象,它所包含的所有文件都是在未来被其它任务调用时才会真正的创建。

FileTree

创建文件树:

// 指定目录创建文件树对象
FileTree tree = fileTree(dir: 'src/main')

// 给文件树对象添加包含指定文件
tree.include '**/*.java'
// 给文件树对象添加排除指定文件
tree.exclude '**/Abstract*'

// 使用路径创建文件树对象,同时指定包含的文件
tree = fileTree('src').include('**/*.java')

// 通过闭包创建文件树
tree = fileTree('src') {
    include '**/*.java'
}

// 通过map创建文件树
tree = fileTree(dir: 'src', include: '**/*.java')
tree = fileTree(dir: 'src', includes: ['**/*.java', '**/*.xml'])
tree = fileTree(dir: 'src', include: '**/*.java', exclude: '**/*test*/**')

操作文件树:

// 遍历文件树的所有文件
tree.each {File file ->
    println file
}

// 过虑生成新的文件树对象
FileTree filtered = tree.matching {
    include 'org/gradle/api/**'
}

// 使用“+”号合并两个文件树,同文件集合的“+”操作一样
FileTree sum = tree + fileTree(dir: 'src/test')

// 访问文件树中各项内容
tree.visit {element ->
    println "$element.relativePath => $element.file"
}

IO

def file = file('./test.txt')
def string = file.text  //读取全文
def input = file.newDataInputStream()
def output = file.newDataOutputStream()
output << input  //IO读写语法糖
input.close()
output.close()

XML

Groovy Xml 操作

5. 字节码编辑工具

5.1 Javassist

gradle依赖:

implementation 'org.javassist:javassist:3.28.0-GA'

使用教程和示例参见:https://github.com/jboss-javassist/javassist

5.2 ASM

gradle依赖:

implementation "org.ow2.asm:asm:6.0"
implementation "org.ow2.asm:asm-util:6.0"
implementation "org.ow2.asm:asm-commons:6.0"

使用教程和示例参见:https://asm.ow2.io/。 由于classpath中引入了classpath 'com.android.tools.build:gradle:version',而gradle依赖gradle-core,gradle-core依赖lint,lint依赖lint-checks,lint-checks最后依赖到了asm,所以如果其他build.gradle中要引用asm相关的类,不用设置classpath,直接import就可以了。但是其他other.gradle不享有这种传递性,需要自己配置buildscript:

buildscript {
    repositories {
       jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:$version$'
    }
}
  1. ASM的语法虽然比较难以读懂,但是仍然有一些技巧,比如可以先用Java写出你想要实现的插桩后的代码,然后使用Asm bytecode viewerASM Bytecode Outline等插件查看其对应的ASM字节码,然后对照着最终结果反过去写实现过程。然后在build\intermedates\transforms\yourTransform去找到修改后的class文件,使用jd-gui.jar等工具查看生成的代码是否正确。
  2. 目前的这些AS插件都是支持Java转ASM bytecode,而不支持kotlin转ASM bytecode,因此如果想要对kotlin进行插桩,可以先将kotlin编译成class,然后使用asm-all.jar工具将class转为ASM bytecode。

5.3 AspectJ

aspectJ同样可以在JavaCompile结束后对生成的字节码二次编辑修改从而实现AOP。

6. 参考资料

  • Groovy官方文档
  • Gradle从入门到实战 - Groovy基础
  • Android Plugin DSL Reference
  • Gradle Android插件用户指南翻译
  • 深入理解Android之Gradle
  • ASOP Gradle 源码
  • 写给 Android 开发者的 Gradle 系列
  • 深度探索 Gradle 自动化构建技术
  • Android Gradle 插件版本说明
  • Android Gradle Plugin源码解析

7. 相关开源项目

  • Mess 四大组件及view混淆
  • ThinRPlugin R文件瘦身
  • tinker-patch-gradle-plugin热修复
  • butterknife-gradle-plugin 控件绑定
  • gradle-small-plugin 轻量级插件化
  • 本文示例项目

你可能感兴趣的:(android,Gradle,android,gradle)