AndroidStudio开发Gradle学习

转自:http://blog.csdn.net/innost/article/details/48228651

Gradle的官网:http://gradle.org/

文档位置:https://docs.gradle.org/current/release-notes。其中的UserGuideDSL Reference很关键。User Guide就是介绍Gradle的一本书,而DSLReference是Gradle API的说明。

以Ubuntu为例,下载Gradle:http://gradle.org/gradle-download/  选择Completedistribution和Binary only distribution都行。然后解压到指定目录。

最后,设置~/.bashrc,把Gradle加到PATH里,如图20所示:


图20  配置Gradle到bashrc


执行source ~/.bashrc,初始化环境。

执行gradle --version,如果成功运行就OK了。

注意,为什么说Gradle是一个编程框架?来看它提供的API文档:

https://docs.gradle.org/current/javadoc/org/gradle/api/Project.html


图21  Project接口说明


原来,我们编写所谓的编译脚本,其实就是玩Gradle的API....所以它从更底层意义上看,是一个编程框架!

既然是编程框架,我在讲解Gradle的时候,尽量会从API的角度来介绍。有些读者肯定会不耐烦,为嘛这么费事?

从我个人的经历来看:因为我从网上学习到的资料来看,几乎全是从脚本的角度来介绍Gradle,结果学习一通下来,只记住参数怎么配置,却不知道它们都是函数调用,都是严格对应相关API的。

而从API角度来看待Gradle的话,有了SDK文档,你就可以编程。编程是靠记住一行行代码来实现的吗?不是,是在你掌握大体流程,然后根据SDK+API来完成的!

其实,Gradle自己的User Guide也明确说了

Buildscripts are code

4.2  基本组件

Gradle是一个框架,它定义一套自己的游戏规则。我们要玩转Gradle,必须要遵守它设计的规则。下面我们来讲讲Gradle的基本组件:

Gradle中,每一个待编译的工程都叫一个Project。每一个Project在构建的时候都包含一系列的Task。比如一个Android APK的编译可能包含:Java源码编译Task、资源编译Task、JNI编译Task、lint检查Task、打包生成APK的Task、签名Task等

一个Project到底包含多少个Task,其实是由编译脚本指定的插件决定。插件是什么呢?插件就是用来定义Task,并具体执行这些Task的东西。

刚才说了,Gradle是一个框架,作为框架,它负责定义流程和规则。而具体的编译工作则是通过插件的方式来完成的。比如编译Java有Java插件,编译Groovy有Groovy插件,编译Android APP有Android APP插件,编译Android Library有Android Library插件

好了。到现在为止,你知道Gradle中每一个待编译的工程都是一个Project,一个具体的编译过程是由一个一个的Task来定义和执行的。

4.2.1  一个重要的例子

下面我们来看一个实际的例子。这个例子非常有代表意义。图22是一个名为posdevice的目录。这个目录里包含3个Android Library工程,2个Android APP工程。


图22 重要例子


在图22的例子中:

  • CPosDeviceSdk、CPosSystemSdk、CPosSystemSdkxxxImpl是Android Library。其中,CPosSystemSdkxxxImpl依赖CPosSystemSdk
  • CPosDeviceServerApk和CPosSdkDemo是Android APP。这些App和SDK有依赖关系。CPosDeviceServerApk依赖CPosDeviceSdk,而CPosSdkDemo依赖所有的Sdk Library。

请回答问题,在上面这个例子中,有多少个Project

请回答问题,在上面这个例子中,有多少个Project

请回答问题,在上面这个例子中,有多少个Project

答案是:每一个Library和每一个App都是单独的Project。根据Gradle的要求,每一个Project在其根目录下都需要有一个build.gradle。build.gradle文件就是该Project的编译脚本,类似于Makefile。

看起来好像很简单,但是请注意:posdevice虽然包含5个独立的Project,但是要独立编译他们的话,得:

cd  某个Project的目录。比如 cd CPosDeviceSdk

然后执行 gradle  xxxx(xxx是任务的名字。对Android来说,assemble这个Task会生成最终的产物,所以gradleassemble)

这很麻烦啊,有10个独立Project,就得重复执行10次这样的命令。更有甚者,所谓的独立Project其实有依赖关系的。比如我们这个例子。

那么,我想在posdevice目录下,直接执行gradle assemble,是否能把这5个Project的东西都编译出来呢?

答案自然是可以。在Gradle中,这叫Multi-Projects Build。把posdevice改造成支持Gradle的Multi-Projects Build很容易,需要:

  • 在posdevice下也添加一个build.gradle。这个build.gradle一般干得活是:配置其他子Project的。比如为子Project添加一些属性。这个build.gradle有没有都无所属。
  • 在posdevice下添加一个名为settings.gradle。这个文件很重要,名字必须是settings.gradle。它里边用来告诉Gradle,这个multiprojects包含多少个子Project。

来看settings.gradle的内容,最关键的内容就是告诉Gradle这个multiprojects包含哪些子projects:

[settings.gradle]

[java]  view plain  copy
  1. //通过include函数,将子Project的名字(其文件夹名)包含进来  
  2. include  'CPosSystemSdk' ,'CPosDeviceSdk' ,  
  3.        'CPosSdkDemo','CPosDeviceServerApk','CPosSystemSdkWizarPosImpl'  


 

强烈建议:

如果你确实只有一个Project需要编译,我也建议你在目录下添加一个settings.gradle。我们团队内部的所有单个Project都已经改成支持Multiple-Project Build了。改得方法就是添加settings.gradle,然后include对应的project名字。

另外,settings.gradle除了可以include外,还可以设置一些函数。这些函数会在gradle构建整个工程任务的时候执行,所以,可以在settings做一些初始化的工作。比如:我的settings.gradle的内容:

//定义一个名为initMinshengGradleEnvironment的函数。该函数内部完成一些初始化操作

//比如创建特定的目录,设置特定的参数等

[java]  view plain  copy
  1. def initMinshengGradleEnvironment(){  
  2.     println"initialize Minsheng Gradle Environment ....."  
  3.     ......//干一些special的私活....  
  4.     println"initialize Minsheng Gradle Environment completes..."  
  5. }  
  6. //settings.gradle加载的时候,会执行initMinshengGradleEnvironment  
  7. initMinshengGradleEnvironment()  
  8. //include也是一个函数:  
  9. include 'CPosSystemSdk' , 'CPosDeviceSdk' ,  
  10.       'CPosSdkDemo','CPosDeviceServerApk','CPosSystemSdkWizarPosImpl'  


4.2.2  gradle命令介绍
1.  gradle projects查看工程信息

到目前为止,我们了解了Gradle什么呢?

每一个Project都必须设置一个build.gradle文件。至于其内容,我们留到后面再说。

对于multi-projects build,需要在根目录下也放一个build.gradle,和一个settings.gradle。

一个Project是由若干tasks来组成的,当gradlexxx的时候,实际上是要求gradle执行xxx任务。这个任务就能完成具体的工作。

当然,具体的工作和不同的插件有关系。编译Java要使用Java插件,编译Android APP需要使用Android APP插件。这些我们都留待后续讨论

gradle提供一些方便命令来查看和Project,Task相关的信息。比如在posdevice中,我想看这个multi projects到底包含多少个子Project:

执行gradle projects,得到图23:


图23  gradle projects


你看,multi projects的情况下,posdevice这个目录对应的build.gradle叫Root

 Project,它包含5个子Project。

如果你修改settings.gradle,使得include只有一个参数,则gradle projects的子project也会变少,比如图24:


图24  修改settings.gradle,使得只包含CPosSystemSdk工程


2.  gradle tasks查看任务信息

查看了Project信息,这个还比较简单,直接看settings.gradle也知道。那么Project包含哪些Task信息,怎么看呢?图23,24中最后的输出也告诉你了,想看某个Project包含哪些Task信息,只要执行:

gradleproject-path:tasks 就行。注意,project-path是目录名,后面必须跟冒号。

对于Multi-project,在根目录中,需要指定你想看哪个poject的任务。不过你要是已经cd到某个Project的目录了,则不需指定Project-path。

来看图25:


图25  gradle CPosSystemSdk:tasks


图25是gradleCPosSystemSdk:tasks的结果。

  • cd CPossystemSdk
  • gradle tasks 得到同样的结果

CPosSystemSdk是一个Android Library工程,Android Library对应的插件定义了好多Task。每种插件定义的Task都不尽相同,这就是所谓的Domain Specific,需要我们对相关领域有比较多的了解。

这些都是后话,我们以后会详细介绍。

3.  gradle task-name执行任务

图25中列出了好多任务,这时候就可以通过 gradle 任务名来执行某个任务。这和make xxx很像。比如:

  • gradle clean是执行清理任务,和make clean类似。
  • gradle properites用来查看所有属性信息。

gradle tasks会列出每个任务的描述,通过描述,我们大概能知道这些任务是干什么的.....。然后gradletask-name执行它就好。

这里要强调一点:Task和Task之间往往是有关系的,这就是所谓的依赖关系。比如,assemble task就依赖其他task先执行,assemble才能完成最终的输出

依赖关系对我们使用gradle有什么意义呢?

如果知道Task之间的依赖关系,那么开发者就可以添加一些定制化的Task。比如我为assemble添加一个SpecialTest任务,并指定assemble依赖于SpecialTest。当assemble执行的时候,就会先处理完它依赖的task。自然,SpecialTest就会得到执行了...

大家先了解这么多,等后面介绍如何写gradle脚本的时候,这就是调用几个函数的事情,Nothing Special!

 

4.3  Gradle工作流程

Gradle的工作流程其实蛮简单,用一个图26来表达:


图26  Gradle工作流程


图26告诉我们,Gradle工作包含三个阶段:

  • 首先是初始化阶段。对我们前面的multi-project build而言,就是执行settings.gradle
  • Initiliazation phase的下一个阶段是Configration阶段。
  • Configration阶段的目标是解析每个project中的build.gradle。比如multi-project build例子中,解析每个子目录中的build.gradle。在这两个阶段之间,我们可以加一些定制化的Hook。这当然是通过API来添加的。
  • Configuration阶段完了后,整个build的project以及内部的Task关系就确定了。恩?前面说过,一个Project包含很多Task,每个Task之间有依赖关系。Configuration会建立一个有向图来描述Task之间的依赖关系。所以,我们可以添加一个HOOK,即当Task关系图建立好后,执行一些操作。
  • 最后一个阶段就是执行任务了。当然,任务执行完后,我们还可以加Hook。

下面展示一下我按图26为posdevice项目添加的Hook,它的执行结果:


图26  加了Hook后的执行结果


我在:

  • settings.gradle加了一个输出。
  • 在posdevice的build.gradle加了图25中的beforeProject函数。
  • 在CPosSystemSdk加了taskGraph whenReady函数和buidFinished函数。

好了,Hook的代码怎么写,估计你很好奇,而且肯定会埋汰,搞毛这么就还没告诉我怎么写Gradle。马上了!

最后,关于Gradle的工作流程,你只要记住:

  • Gradle有一个初始化流程,这个时候settings.gradle会执行。
  • 在配置阶段,每个Project都会被解析,其内部的任务也会被添加到一个有向图里,用于解决执行过程中的依赖关系。
  • 然后才是执行阶段。你在gradle xxx中指定什么任务,gradle就会将这个xxx任务链上的所有任务全部按依赖顺序执行一遍!

下面来告诉你怎么写代码!

4.4  Gradle编程模型及API实例详解

希望你在进入此节之前,一定花时间把前面内容看一遍!!!

https://docs.gradle.org/current/dsl/  <==这个文档很重要

Gradle基于Groovy,Groovy又基于Java。所以,Gradle执行的时候和Groovy一样,会把脚本转换成Java对象。Gradle主要有三种对象,这三种对象和三种不同的脚本文件对应,在gradle执行的时候,会将脚本转换成对应的对端:

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

注意,对于其他gradle文件,除非定义了class,否则会转换成一个实现了Script接口的对象。这一点和3.5节中Groovy的脚本类相似

当我们执行gradle的时候,gradle首先是按顺序解析各个gradle文件。这里边就有所所谓的生命周期的问题,即先解析谁,后解析谁。图27是Gradle文档中对生命周期的介绍:结合上一节的内容,相信大家都能看明白了。现在只需要看红框里的内容:


图27  Gradle对LifeCycle的介绍


4.4.1  Gradle对象

我们先来看Gradle对象,它有哪些属性呢?如图28所示:


图28  Gradle的属性


我在posdevice build.gradle中和settings.gradle中分别加了如下输出:

[java]  view plain  copy
  1. //在settings.gradle中,则输出"In settings,gradle id is"  
  2. println "In posdevice, gradle id is " +gradle.hashCode()  
  3. println "Home Dir:" + gradle.gradleHomeDir  
  4. println "User Home Dir:" + gradle.gradleUserHomeDir  
  5. println "Parent: " + gradle.parent  


得到结果如图29所示:


图29  gradle示例


  • 你看,在settings.gradle和posdevice build.gradle中,我们得到的gradle实例对象的hashCode是一样的(都是791279786)。
  • HomeDir是我在哪个目录存储的gradle可执行程序。
  • User Home Dir:是gradle自己设置的目录,里边存储了一些配置文件,以及编译过程中的缓存文件,生成的类文件,编译中依赖的插件等等。

Gradle的函数接口在文档中也有。

4.4.2  Project对象

每一个build.gradle文件都会转换成一个Project对象。在Gradle术语中,Project对象对应的是BuildScript

Project包含若干Tasks。另外,由于Project对应具体的工程,所以需要为Project加载所需要的插件,比如为Java工程加载Java插件。其实,一个Project包含多少Task往往是插件决定的。

所以,在Project中,我们要:

  • 加载插件。
  • 不同插件有不同的行话,即不同的配置。我们要在Project中配置好,这样插件就知道从哪里读取源文件等
  • 设置属性。

1.  加载插件

Project的API位于https://docs.gradle.org/current/javadoc/org/gradle/api/Project.html。加载插件是调用它的apply函数.apply其实是Project实现的PluginAware接口定义的:


图30  apply函数


来看代码:

[apply函数的用法]

apply是一个函数,此处调用的是图30中最后一个apply函数。注意,Groovy支持

函数调用的时候通过  参数名1:参数值2,参数名2:参数值2 的方式来传递参数

[java]  view plain  copy
  1. apply plugin: 'com.android.library'    <==如果是编译Library,则加载此插件  
  2. apply plugin: 'com.android.application'  <==如果是编译Android APP,则加载此插件  


除了加载二进制的插件(上面的插件其实都是下载了对应的jar包,这也是通常意义上我们所理解的插件),还可以加载一个gradle文件。为什么要加载gradle文件呢?

其实这和代码的模块划分有关。一般而言,我会把一些通用的函数放到一个名叫utils.gradle文件里。然后在其他工程的build.gradle来加载这个utils.gradle。这样,通过一些处理,我就可以调用utils.gradle中定义的函数了。

加载utils.gradle插件的代码如下:

utils.gradle是我封装的一个gradle脚本,里边定义了一些方便函数,比如读取AndroidManifest.xml中

的versionName,或者是copy jar包/APK包到指定的目录

[java]  view plain  copy
  1. apply from: rootProject.getRootDir().getAbsolutePath() + "/utils.gradle"  


也是使用apply的最后一个函数。那么,apply最后一个函数到底支持哪些参数呢?还是得看图31中的API说明:


图31  apply API说明


我这里不遗余力的列出API图片,就是希望大家在写脚本的时候,碰到不会的,一定要去查看API文档!

2.  设置属性

如果是单个脚本,则不需要考虑属性的跨脚本传播,但是Gradle往往包含不止一个build.gradle文件,比如我设置的utils.gradle,settings.gradle。如何在多个脚本中设置属性呢?

Gradle提供了一种名为extra property的方法。extra property是额外属性的意思,在第一次定义该属性的时候需要通过ext前缀来标示它是一个额外的属性。定义好之后,后面的存取就不需要ext前缀了。ext属性支持Project和Gradle对象。即Project和Gradle对象都可以设置ext属性

举个例子:

我在settings.gradle中想为Gradle对象设置一些外置属性,所以在initMinshengGradleEnvironment函数中

[java]  view plain  copy
  1. def initMinshengGradleEnvironment(){  
  2.     //属性值从local.properites中读取  
  3.     Propertiesproperties = new Properties()  
  4.     FilepropertyFile = new File(rootDir.getAbsolutePath() +"/local.properties")  
  5.    properties.load(propertyFile.newDataInputStream())  
  6.     //gradle就是gradle对象。它默认是Settings和Project的成员变量。可直接获取  
  7.    //ext前缀,表明操作的是外置属性。api是一个新的属性名。前面说过,只在  
  8.    //第一次定义或者设置它的时候需要ext前缀  
  9.     gradle.ext.api =properties.getProperty('sdk.api')  
  10.      
  11.     println gradle.api  //再次存取api的时候,就不需要ext前缀了  
  12.     ......  
  13.     }  


再来一个例子强化一下:

我在utils.gradle中定义了一些函数,然后想在其他build.gradle中调用这些函数。那该怎么做呢?

[utils.gradle]

[java]  view plain  copy
  1. //utils.gradle中定义了一个获取AndroidManifests.xmlversionName的函数  
  2. def  getVersionNameAdvanced(){  
  3. Œ  下面这行代码中的project是谁?  
  4.    defxmlFile = project.file("AndroidManifest.xml")  
  5.    defrootManifest = new XmlSlurper().parse(xmlFile)  
  6.    returnrootManifest['@android:versionName']    
  7. }  
  8. //现在,想把这个API输出到各个Project。由于这个utils.gradle会被每一个Project Apply,所以  
  9. //我可以把getVersionNameAdvanced定义成一个closure,然后赋值到一个外部属性  
  10.   下面的ext是谁的ext?  
  11. ext{ //此段花括号中代码是闭包  
  12.     //除了ext.xxx=value这种定义方法外,还可以使用ext{}这种书写方法。  
  13.     //ext{}不是ext(Closure)对应的函数调用。但是ext{}中的{}确实是闭包。  
  14.     getVersionNameAdvanced = this.&getVersionNameAdvanced  
  15.  }  


上面代码中有两个问题:

  • Œ  project是谁?
  •   ext是谁的ext?

上面两个问题比较关键,我也是花了很长时间才搞清楚。这两个问题归结到一起,其实就是:

加载utils.gradle的Project对象和utils.gradle本身所代表的Script对象到底有什么关系?

我们在Groovy中也讲过怎么在一个Script中import另外一个Script中定义的类或者函数(见3.5 脚本类、文件I/O和XML操作一节)。在Gradle中,这一块的处理比Groovy要复杂,具体怎么搞我还没完全弄清楚,但是Project和utils.gradle对于的Script的对象的关系是:

  • 当一个Project apply一个gradle文件的时候,这个gradle文件会转换成一个Script对象。这个,相信大家都已经知道了。
  • Script中有一个delegate对象,这个delegate默认是加载(即调用apply)它的Project对象。但是,在apply函数中,有一个from参数,还有一个to参数(参考图31)。通过to参数,你可以把delegate对象指定为别的东西。
  • delegate对象是什么意思?当你在Script中操作一些不是Script自己定义的变量,或者函数时候,gradle会到Script的delegate对象去找,看看有没有定义这些变量或函数。

现在你知道问题1,2和答案了:

  • 问题1:project就是加载utils.gradle的project。由于posdevice有5个project,所以utils.gradle会分别加载到5个project中。所以,getVersionNameAdvanced才不用区分到底是哪个project。反正一个project有一个utils.gradle对应的Script。
  • 问题2:ext:自然就是Project对应的ext了。此处为Project添加了一些closure。那么,在Project中就可以调用getVersionNameAdvanced函数了

比如:我在posdevice每个build.gradle中都有如下的代码:

[java]  view plain  copy
  1. tasks.getByName("assemble"){  
  2.    it.doLast{  
  3.        println "$project.name: After assemble, jar libs are copied tolocal repository"  
  4.         copyOutput(true)  //copyOutput是utils.gradle输出的closure  
  5.      }  
  6. }  


通过这种方式,我将一些常用的函数放到utils.gradle中,然后为加载它的Project设置ext属性。最后,Project中就可以调用这种赋值函数了!

注意:此处我研究的还不是很深,而且我个人感觉:

1  在Java和Groovy中:我们会把常用的函数放到一个辅助类和公共类中,然后在别的地方import并调用它们。

2  但是在Gradle,更正规的方法是在xxx.gradle中定义插件。然后通过添加Task的方式来完成工作。gradle的user guide有详细介绍如何实现自己的插件。

3.  Task介绍

Task是Gradle中的一种数据类型,它代表了一些要执行或者要干的工作。不同的插件可以添加不同的Task。每一个Task都需要和一个Project关联。

Task的API文档位于https://docs.gradle.org/current/dsl/org.gradle.api.Task.html。关于Task,我这里简单介绍下build.gradle中怎么写它,以及Task中一些常见的类型

关于Task。来看下面的例子:

[build.gradle]

/

[java]  view plain  copy
  1. /Task是和Project关联的,所以,我们要利用Project的task函数来创建一个Task  
  2. task myTask  <==myTask是新建Task的名字  
  3. task myTask { configure closure }  
  4. task myType << { task action } <==注意,<<符号是doLast的缩写  
  5. task myTask(type: SomeType)  
  6. task myTask(type: SomeType) { configure closure }  

上述代码中都用了Project的一个函数,名为task,注意:

  • 一个Task包含若干Action。所以,Task有doFirst和doLast两个函数,用于添加需要最先执行的Action和需要和需要最后执行的Action。Action就是一个闭包。
  • Task创建的时候可以指定Type,通过type:名字表达。这是什么意思呢?其实就是告诉Gradle,这个新建的Task对象会从哪个基类Task派生。比如,Gradle本身提供了一些通用的Task,最常见的有Copy 任务。Copy是Gradle中的一个类。当我们:task myTask(type:Copy)的时候,创建的Task就是一个Copy Task。
  • 当我们使用 taskmyTask{ xxx}的时候。花括号是一个closure。这会导致gradle在创建这个Task之后,返回给用户之前,会先执行closure的内容。
  • 当我们使用taskmyTask << {xxx}的时候,我们创建了一个Task对象,同时把closure做为一个action加到这个Task的action队列中,并且告诉它“最后才执行这个closure”(注意,<<符号是doLast的代表)。

图32是Project中关于task函数说明:


图32  Project中task函数


陆陆续续讲了这么些内容,我自己感觉都有点烦了。是得,Gradle用一整本书来讲都嫌不够呢。

anyway,到目前为止,我介绍的都是一些比较基础的东西,还不是特别多。但是后续例子该涉及到的知识点都有了。下面我们直接上例子。这里有两个例子:

  • posdevice的例子
  • 另外一个是单个project的例子

4.4.3  posdevice实例

现在正是开始通过例子来介绍怎么玩gradle。这里要特别强调一点,根据Gradle的哲学。gradle文件中包含一些所谓的Script Block(姑且这么称它)。ScriptBlock作用是让我们来配置相关的信息。不同的SB有不同的需要配置的东西。这也是我最早说的行话。比如,源码对应的SB,就需要我们配置源码在哪个文件夹里。关于SB,我们后面将见识到!

posdevice是一个multi project。下面包含5个Project。对于这种Project,请大家回想下我们该创建哪些文件?

  • settings.gradle是必不可少的
  • 根目录下的build.gradle。这个我们没讲过,因为posdevice的根目录本身不包含代码,而是包含其他5个子project。
  • 每个project目录下包含对于的build.gradle
  • 另外,我把常用的函数封装到一个名为utils.gradle的脚本里了。

马上一个一个来看它们。

1.  utils.gradle

utils.gradle是我自己加的,为我们团队特意加了一些常见函数。主要代码如下:

[utils.gradle]

[java]  view plain  copy
  1. import groovy.util.XmlSlurper  //解析XML时候要引入这个groovy的package  
  2.    
  3. def copyFile(String srcFile,dstFile){  
  4.      ......//拷贝文件函数,用于将最后的生成物拷贝到指定的目录  
  5. }  
  6. def rmFile(String targetFile){  
  7.     .....//删除指定目录中的文件  
  8. }  
  9.    
  10. def cleanOutput(boolean bJar = true){  
  11.     ....//clean的时候清理  
  12. }  
  13.    
  14. def copyOutput(boolean bJar = true){  
  15.     ....//copyOutput内部会调用copyFile完成一次build的产出物拷贝  
  16. }  
  17.    
  18. def getVersionNameAdvanced(){//老朋友  
  19.    defxmlFile = project.file("AndroidManifest.xml")  
  20.    defrootManifest = new XmlSlurper().parse(xmlFile)  
  21.    returnrootManifest['@android:versionName']    
  22. }  
  23.    
  24. //对于android library编译,我会disable所有的debug编译任务  
  25. def disableDebugBuild(){  
  26.   //project.tasks包含了所有的tasks,下面的findAll是寻找那些名字中带debug的Task。  
  27.   //返回值保存到targetTasks容器中  
  28.   def targetTasks = project.tasks.findAll{task ->  
  29.      task.name.contains("Debug")  
  30.   }  
  31.   //对满足条件的task,设置它为disable。如此这般,这个Task就不会被执行  
  32.  targetTasks.each{  
  33.      println"disable debug task  :${it.name}"  
  34.     it.setEnabled false  
  35.   }  
  36. }  
  37. //将函数设置为extra属性中去,这样,加载utils.gradle的Project就能调用此文件中定义的函数了  
  38. ext{  
  39.     copyFile= this.&copyFile  
  40.     rmFile =this.&rmFile  
  41.    cleanOutput = this.&cleanOutput  
  42.    copyOutput = this.&copyOutput  
  43.    getVersionNameAdvanced = this.&getVersionNameAdvanced  
  44.    disableDebugBuild = this.&disableDebugBuild  
  45. }  


图33展示了被disable的Debug任务的部分信息:


图33  disable的Debug Task信息


2.  settings.gradle

这个文件中我们该干什么?调用include把需要包含的子Project加进来。代码如下:

[settings.gradle]

[java]  view plain  copy
  1. /*我们团队内部建立的编译环境初始化函数 
  2.   这个函数的目的是 
  3.   1  解析一个名为local.properties的文件,读取AndroidSDK和NDK的路径 
  4.   2  获取最终产出物目录的路径。这样,编译完的apk或者jar包将拷贝到这个最终产出物目录中 
  5.   3 获取Android SDK指定编译的版本 
  6. */  
  7. def initMinshengGradleEnvironment(){  
  8.     println"initialize Minsheng Gradle Environment ....."  
  9.    Properties properties = new Properties()  
  10.    //local.properites也放在posdevice目录下  
  11.     FilepropertyFile = new File(rootDir.getAbsolutePath()+ "/local.properties")  
  12.    properties.load(propertyFile.newDataInputStream())  
  13.     /* 
  14.       根据Project、Gradle生命周期的介绍,settings对象的创建位于具体Project创建之前 
  15.       而Gradle底对象已经创建好了。所以,我们把local.properties的信息读出来后,通过 
  16.      extra属性的方式设置到gradle对象中 
  17.       而具体Project在执行的时候,就可以直接从gradle对象中得到这些属性了! 
  18.     */  
  19.     gradle.ext.api =properties.getProperty('sdk.api')  
  20.     gradle.ext.sdkDir =properties.getProperty('sdk.dir')  
  21.      gradle.ext.ndkDir =properties.getProperty('ndk.dir')  
  22.      gradle.ext.localDir =properties.getProperty('local.dir')  
  23.     //指定debugkeystore文件的位置,debug版apk签名的时候会用到  
  24.     gradle.ext.debugKeystore= properties.getProperty('debug.keystore')  
  25.      ......  
  26.     println"initialize Minsheng Gradle Environment completes..."  
  27. }  
  28. //初始化  
  29. initMinshengGradleEnvironment()  
  30. //添加子Project信息  
  31. include 'CPosSystemSdk' , 'CPosDeviceSdk' ,'CPosSdkDemo','CPosDeviceServerApk''CPosSystemSdkWizarPosImpl'  


注意,对于Android来说,local.properties文件是必须的,它的内容如下:

[local.properties]

[plain]  view plain  copy
  1. local.dir=/home/innost/workspace/minsheng-flat-dir/  
  2. //注意,根据Android Gradle的规范,只有下面两个属性是必须的,其余都是我自己加的  
  3. sdk.dir=/home/innost/workspace/android-aosp-sdk/  
  4. ndk.dir=/home/innost/workspace/android-aosp-ndk/  
  5. debug.keystore=/home/innost/workspace/tools/mykeystore.jks  
  6. sdk.api=android-19  


再次强调,sdk.dirndk.dir是Android Gradle必须要指定的,其他都是我自己加的属性。当然。不编译ndk,就不需要ndk.dir属性了。

3.  posdevicebuild.gradle

作为multi-project根目录,一般情况下,它的build.gradle是做一些全局配置。来看我的build.gradle

[posdevicebuild.gradle]

[java]  view plain  copy
  1. //下面这个subprojects{}就是一个Script Block  
  2. subprojects {  
  3.   println"Configure for $project.name" //遍历子Project,project变量对应每个子Project  
  4.   buildscript {  //这也是一个SB  
  5.     repositories {//repositories是一个SB  
  6.        ///jcenter是一个函数,表示编译过程中依赖的库,所需的插件可以在jcenter仓库中  
  7.        //下载。  
  8.        jcenter()  
  9.     }  
  10.     dependencies { //SB  
  11.         //dependencies表示我们编译的时候,依赖android开发的gradle插件。插件对应的  
  12.        //class path是com.android.tools.build。版本是1.2.3  
  13.         classpath'com.android.tools.build:gradle:1.2.3'  
  14.     }  
  15.    //为每个子Project加载utils.gradle 。当然,这句话可以放到buildscript花括号之后  
  16.    applyfrom: rootProject.getRootDir().getAbsolutePath() + "/utils.gradle"  
  17.  }//buildscript结束  
  18. }  


感觉解释得好苍白,SB在Gradle的API文档中也是有的。先来看Gradle定义了哪些SB。如图34所示:


图34 SB的类型


你看,subprojects、dependencies、repositories都是SB。那么SB到底是什么?它是怎么完成所谓配置的呢?

仔细研究,你会发现SB后面都需要跟一个花括号,而花括号,恩,我们感觉里边可能一个Closure。由于图34说,这些SB的Description都有“Configure xxx for this project”,所以很可能subprojects是一个函数,然后其参数是一个Closure。是这样的吗?

Absolutely right。只是这些函数你直接到Project API里不一定能找全。不过要是你好奇心重,不妨到https://docs.gradle.org/current/javadoc/,选择Index这一项,然后ctrl+f,输入图34中任何一个Block,你都会找到对应的函数。比如我替你找了几个API,如图35所示:


图35   SB对应的函数


特别提示:当你下次看到一个不认识的SB的时候,就去看API吧。

下面来解释代码中的各个SB:

  • subprojects:它会遍历posdevice中的每个子Project。在它的Closure中,默认参数是子Project对应的Project对象。由于其他SB都在subprojects花括号中,所以相当于对每个Project都配置了一些信息。
  • buildscript:它的closure是在一个类型为ScriptHandler的对象上执行的。主意用来所依赖的classpath等信息。通过查看ScriptHandler API可知,在buildscript SB中,你可以调用ScriptHandler提供的repositories(Closure )、dependencies(Closure)函数。这也是为什么repositories和dependencies两个SB为什么要放在buildscript的花括号中的原因。明白了?这就是所谓的行话,得知道规矩。不知道规矩你就乱了。记不住规矩,又不知道查SDK,那么就彻底抓瞎,只能到网上到处找答案了!
  • 关于repositories和dependencies,大家直接看API吧。后面碰到了具体代码我们再来介绍

4.  CPosDeviceSdkbuild.gradle

CPosDeviceSdk是一个Android Library。按Google的想法,Android Library编译出来的应该是一个AAR文件。但是我的项目有些特殊,我需要发布CPosDeviceSdk.jar包给其他人使用。jar在编译过程中会生成,但是它不属于Android Library的标准输出。在这种情况下,我需要在编译完成后,主动copy jar包到我自己设计的产出物目录中。

[java]  view plain  copy
  1. //Library工程必须加载此插件。注意,加载了Android插件就不要加载Java插件了。因为Android  
  2. //插件本身就是拓展了Java插件  
  3. apply plugin: 'com.android.library'   
  4. //android的编译,增加了一种新类型的ScriptBlock-->android  
  5. android {  
  6.        //你看,我在local.properties中设置的API版本号,就可以一次设置,多个Project使用了  
  7.       //借助我特意设计的gradle.ext.api属性  
  8.        compileSdkVersion =gradle.api  //这两个红色的参数必须设置  
  9.        buildToolsVersion  = "22.0.1"  
  10.        sourceSets{ //配置源码路径。这个sourceSets是Java插件引入的  
  11.        main{ //main:Android也用了  
  12.            manifest.srcFile 'AndroidManifest.xml' //这是一个函数,设置manifest.srcFile  
  13.            aidl.srcDirs=['src'//设置aidl文件的目录  
  14.            java.srcDirs=['src'//设置java文件的目录  
  15.         }  
  16.      }  
  17.    dependencies {  //配置依赖关系  
  18.       //compile表示编译和运行时候需要的jar包,fileTree是一个函数,  
  19.      //dir:'libs',表示搜索目录的名称是libs。include:['*.jar'],表示搜索目录下满足*.jar名字的jar  
  20.      //包都作为依赖jar文件  
  21.        compile fileTree(dir: 'libs', include: ['*.jar'])  
  22.    }  
  23. }  //android SB配置完了  
  24. //clean是一个Task的名字,这个Task好像是Java插件(这里是Android插件)引入的。  
  25. //dependsOn是一个函数,下面这句话的意思是 clean任务依赖cposCleanTask任务。所以  
  26. //当你gradle clean以执行clean Task的时候,cposCleanTask也会执行  
  27. clean.dependsOn 'cposCleanTask'  
  28. //创建一个Task,  
  29. task cposCleanTask() <<{  
  30.     cleanOutput(true)  //cleanOutput是utils.gradle中通过extra属性设置的Closure  
  31. }  
  32. //前面说了,我要把jar包拷贝到指定的目录。对于Android编译,我一般指定gradle assemble  
  33. //它默认编译debug和release两种输出。所以,下面这个段代码表示:  
  34. //tasks代表一个Projects中的所有Task,是一个容器。getByName表示找到指定名称的任务。  
  35. //我这里要找的assemble任务,然后我通过doLast添加了一个Action。这个Action就是copy  
  36. //产出物到我设置的目标目录中去  
  37. tasks.getByName("assemble"){  
  38.    it.doLast{  
  39.        println "$project.name: After assemble, jar libs are copied tolocal repository"  
  40.         copyOutput(true)  
  41.      }  
  42. }  
  43. /* 
  44.   因为我的项目只提供最终的release编译出来的Jar包给其他人,所以不需要编译debug版的东西 
  45.   当Project创建完所有任务的有向图后,我通过afterEvaluate函数设置一个回调Closure。在这个回调 
  46.   Closure里,我disable了所有Debug的Task 
  47. */  
  48. project.afterEvaluate{  
  49.     disableDebugBuild()  
  50. }  


Android自己定义了好多ScriptBlock。Android定义的DSL参考文档在

https://developer.android.com/tools/building/plugin-for-gradle.html下载。注意,它居然没有提供在线文档。

图36所示为Android的DSL参考信息。


图36  Android Gradle DSL参考示意


图37为buildToolsVersioncompileSdkVersion的说明:


图37  buildToolsVersion和compileSdkVersion的说明


从图37可知,这两个变量是必须要设置的.....

5. CPosDeviceServerApk build.gradle

再来看一个APK的build,它包含NDK的编译,并且还要签名。根据项目的需求,我们只能签debug版的,而release版的签名得发布unsigned包给领导签名。另外,CPosDeviceServerAPK依赖CPosDeviceSdk。

虽然我可以先编译CPosDeviceSdk,得到对应的jar包,然后设置CPosDeviceServerApk直接依赖这个jar包就好。但是我更希望CPosDeviceServerApk能直接依赖于CPosDeviceSdk这个工程。这样,整个posdevice可以做到这几个Project的依赖关系是最新的。

[build.gradle]

[java]  view plain  copy
  1. apply plugin: 'com.android.application'  //APK编译必须加载这个插件  
  2. android {  
  3.       compileSdkVersion gradle.api  
  4.       buildToolsVersion "22.0.1"  
  5.       sourceSets{  //差不多的设置  
  6.        main{  
  7.            manifest.srcFile 'AndroidManifest.xml'  
  8.           //通过设置jni目录为空,我们可不使用apk插件的jni编译功能。为什么?因为据说  
  9.          //APK插件的jni功能好像不是很好使....晕菜  
  10.           jni.srcDirs = []   
  11.            jniLibs.srcDir 'libs'  
  12.             aidl.srcDirs=['src']  
  13.            java.srcDirs=['src']  
  14.            res.srcDirs=['res']  
  15.         }  
  16.     }//main结束  
  17.    signingConfigs { //设置签名信息配置  
  18.        debug {  //如果我们在local.properties设置使用特殊的keystore,则使用它  
  19.            //下面这些设置,无非是函数调用....请务必阅读API文档  
  20.            if(project.gradle.debugKeystore != null){  
  21.               storeFile file("file://${project.gradle.debugKeystore}")  
  22.               storePassword "android"  
  23.               keyAlias "androiddebugkey"  
  24.               keyPassword "android"  
  25.            }  
  26.         }  
  27.    }//signingConfigs结束  
  28.      buildTypes {  
  29.        debug {  
  30.            signingConfig signingConfigs.debug  
  31.            jniDebuggable false  
  32.         }  
  33.     }//buildTypes结束  
  34.    dependencies {  
  35.         //compile:project函数可指定依赖multi-project中的某个子project  
  36.        compile project(':CPosDeviceSdk')  
  37.        compile fileTree(dir: 'libs', include: ['*.jar'])  
  38.    } //dependices结束  
  39.   repositories{  
  40.    flatDir {//flatDir:告诉gradle,编译中依赖的jar包存储在dirs指定的目录  
  41.            name "minsheng-gradle-local-repository"  
  42.             dirsgradle.LOCAL_JAR_OUT //LOCAL_JAR_OUT是我存放编译出来的jar包的位置  
  43.    }  
  44.   }//repositories结束  
  45. }//android结束  
  46. /* 
  47.    创建一个Task,类型是Exec,这表明它会执行一个命令。我这里让他执行ndk的 
  48.    ndk-build命令,用于编译ndk。关于Exec类型的Task,请自行脑补Gradle的API 
  49. */  
  50. //注意此处创建task的方法,是直接{}喔,那么它后面的tasks.withType(JavaCompile)  
  51. //设置的依赖关系,还有意义吗?Think!如果你能想明白,gradle掌握也就差不多了  
  52. task buildNative(type: Exec, description: 'CompileJNI source via NDK') {  
  53.        if(project.gradle.ndkDir == null//看看有没有指定ndk.dir路径  
  54.           println "CANNOT Build NDK"  
  55.        else{  
  56.             commandLine "/${project.gradle.ndkDir}/ndk-build",  
  57.                '-C', file('jni').absolutePath,  
  58.                '-j', Runtime.runtime.availableProcessors(),  
  59.                'all''NDK_DEBUG=0'  
  60.         }  
  61.   }  
  62.  tasks.withType(JavaCompile) {  
  63.        compileTask -> compileTask.dependsOn buildNative  
  64.   }  
  65.   ......    
  66.  //对于APK,除了拷贝APK文件到指定目录外,我还特意为它们加上了自动版本命名的功能  
  67.  tasks.getByName("assemble"){  
  68.        it.doLast{  
  69.        println "$project.name: After assemble, jar libs are copied tolocal repository"  
  70.        project.ext.versionName = android.defaultConfig.versionName  
  71.        println "\t versionName = $versionName"  
  72.        copyOutput(false)  
  73.      }  
  74. }  


 

6.  结果展示

在posdevice下执行gradle assemble命令,最终的输出文件都会拷贝到我指定的目录,结果如图38所示:


图38  posdevice执行结果


图38所示为posdevice gradle assemble的执行结果:

  • library包都编译release版的,copy到xxx/javaLib目录下
  • apk编译debug和release-unsigned版的,copy到apps目录下
  • 所有产出物都自动从AndroidManifest.xml中提取versionName。

4.4.4  实例2

下面这个实例也是来自一个实际的APP。这个APP对应的是一个单独的Project。但是根据我前面的建议,我会把它改造成支持Multi-ProjectsBuild的样子。即在工程目录下放一个settings.build。

另外,这个app有一个特点。它有三个版本,分别是debug、release和demo。这三个版本对应的代码都完全一样,但是在运行的时候需要从assets/runtime_config文件中读取参数。参数不同,则运行的时候会跳转到debug、release或者demo的逻辑上。

注意:我知道assets/runtime_config这种做法不decent,但,这是一个既有项目,我们只能做小范围的适配,而不是伤筋动骨改用更好的方法。另外,从未来的需求来看,暂时也没有大改的必要。

引入gradle后,我们该如何处理呢?

解决方法是:在编译build、release和demo版本前,在build.gradle中自动设置runtime_config的内容。代码如下所示:

[build.gradle]

[java]  view plain  copy
  1. apply plugin: 'com.android.application'  //加载APP插件  
  2. //加载utils.gradle  
  3. apply from:rootProject.getRootDir().getAbsolutePath() + "/utils.gradle"  
  4. //buildscript设置android app插件的位置  
  5. buildscript {  
  6.    repositories { jcenter() }  
  7.    dependencies { classpath 'com.android.tools.build:gradle:1.2.3' }  
  8. }  
  9. //androidScriptBlock  
  10. android {  
  11.    compileSdkVersion gradle.api  
  12.    buildToolsVersion "22.0.1"  
  13.    sourceSets{//源码设置SB  
  14.         main{  
  15.            manifest.srcFile 'AndroidManifest.xml'  
  16.            jni.srcDirs = []  
  17.            jniLibs.srcDir 'libs'  
  18.            aidl.srcDirs=['src']  
  19.            java.srcDirs=['src']  
  20.            res.srcDirs=['res']  
  21.            assets.srcDirs = ['assets'//多了一个assets目录  
  22.         }  
  23.     }  
  24.    signingConfigs {//签名设置  
  25.        debug {  //debug对应的SB。注意  
  26.            if(project.gradle.debugKeystore != null){  
  27.                storeFile file("file://${project.gradle.debugKeystore}")  
  28.                storePassword "android"  
  29.                keyAlias "androiddebugkey"  
  30.                keyPassword "android"  
  31.            }  
  32.         }  
  33.     }  
  34.     /* 
  35.      最关键的内容来了: buildTypesScriptBlock. 
  36.      buildTypes和上面的signingConfigs,当我们在build.gradle中通过{}配置它的时候, 
  37.      其背后的所代表的对象是NamedDomainObjectContainer<BuildType> 和 
  38.      NamedDomainObjectContainer<SigningConfig> 
  39.      注意,NamedDomainObjectContainer<BuildType/或者SigningConfig>是一种容器, 
  40.      容器的元素是BuildType或者SigningConfig。我们在debug{}要填充BuildType或者 
  41.     SigningConfig所包的元素,比如storePassword就是SigningConfig类的成员。而proguardFile等 
  42.     是BuildType的成员。 
  43.     那么,为什么要使用NamedDomainObjectContainer这种数据结构呢?因为往这种容器里 
  44.     添加元素可以采用这样的方法: 比如signingConfig为例 
  45.     signingConfig{//这是一个NamedDomainObjectContainer<SigningConfig> 
  46.        test1{//新建一个名为test1的SigningConfig元素,然后添加到容器里 
  47.          //在这个花括号中设置SigningConfig的成员变量的值 
  48.        } 
  49.       test2{//新建一个名为test2的SigningConfig元素,然后添加到容器里 
  50.          //在这个花括号中设置SigningConfig的成员变量的值 
  51.       } 
  52.     } 
  53.     在buildTypes中,Android默认为这几个NamedDomainObjectContainer添加了 
  54.     debug和release对应的对象。如果我们再添加别的名字的东西,那么gradleassemble的时候 
  55.     也会编译这个名字的apk出来。比如,我添加一个名为test的buildTypes,那么gradle assemble 
  56.     就会编译一个xxx-test-yy.apk。在此,test就好像debug、release一样。 
  57.    */  
  58.    buildTypes{  
  59.         debug{ //修改debug的signingConfig为signingConfig.debug配置  
  60.            signingConfig signingConfigs.debug  
  61.         }  
  62.         demo{ //demo版需要混淆  
  63.            proguardFile 'proguard-project.txt'  
  64.            signingConfig signingConfigs.debug  
  65.         }  
  66.        //release版没有设置,所以默认没有签名,没有混淆  
  67.     }  
  68.       ......//其他和posdevice 类似的处理。来看如何动态生成runtime_config文件  
  69.    def  runtime_config_file = 'assets/runtime_config'  
  70.    /* 
  71.    我们在gradle解析完整个任务之后,找到对应的Task,然后在里边添加一个doFirst Action 
  72.    这样能确保编译开始的时候,我们就把runtime_config文件准备好了。 
  73.    注意,必须在afterEvaluate里边才能做,否则gradle没有建立完任务有向图,你是找不到 
  74.    什么preDebugBuild之类的任务的 
  75.    */  
  76.    project.afterEvaluate{  
  77.       //找到preDebugBuild任务,然后添加一个Action   
  78.       tasks.getByName("preDebugBuild"){  
  79.            it.doFirst{  
  80.                println "generate debug configuration for ${project.name}"  
  81.                def configFile = new File(runtime_config_file)  
  82.                configFile.withOutputStream{os->  
  83.                    os << I am Debug\n'  //往配置文件里写 I am Debug  
  84.                 }  
  85.            }  
  86.         }  
  87.        //找到preReleaseBuild任务  
  88.        tasks.getByName("preReleaseBuild"){  
  89.            it.doFirst{  
  90.                println "generate release configuration for ${project.name}"  
  91.                def configFile = new File(runtime_config_file)  
  92.                configFile.withOutputStream{os->  
  93.                    os << I am release\n'  
  94.                }  
  95.            }  
  96.         }  
  97.        //找到preDemoBuild。这个任务明显是因为我们在buildType里添加了一个demo的元素  
  98.       //所以Android APP插件自动为我们生成的  
  99.        tasks.getByName("preDemoBuild"){  
  100.            it.doFirst{  
  101.                println "generate offlinedemo configuration for${project.name}"  
  102.                def configFile = new File(runtime_config_file)  
  103.                configFile.withOutputStream{os->  
  104.                    os << I am Demo\n'  
  105.                }  
  106.             }  
  107.         }  
  108.     }  
  109. }  
  110.  .....//copyOutput  


 

最终的结果如图39所示:


图39  实例2的结果


几个问题,为什么我知道有preXXXBuild这样的任务?

答案:gradle tasks --all查看所有任务。然后,多尝试几次,直到成功

 

五、总结

到此,我个人觉得Gradle相关的内容都讲完了。很难相信我仅花了1个小时不到的时间就为实例2添加了gradle编译支持。在一周以前,我还觉得这是个心病。回想学习gradle的一个月时间里,走过不少弯路,求解问题的思路也和最开始不一样:

  • 最开始的时候,我一直把gradle当做脚本看。然后到处到网上找怎么配置gradle。可能能编译成功,但是完全不知道为什么。比如NameDomainObjectContainer,为什么有debug、release。能自己加别的吗?不知道怎么加,没有章法,没有参考。出了问题只能google,找到一个解法,试一试,成功就不管。这么搞,心里不踏实。
  • 另外,对语法不熟悉,尤其是Groovy语法,虽然看了下快速教材,但总感觉一到gradle就看不懂。主要问题还是闭包,比如Groovy那一节写得文件拷贝的例子中的withOutputStream,还有gradle中的withType,都是些啥玩意啊?
  • 所以后来下决心先把Groovy学会,主要是把自己暴露在闭包里边。另外,Groovy是一门语言,总得有SDK说明吧。写了几个例子,慢慢体会到Groovy的好处,也熟悉Groovy的语法了。
  • 接着开始看Gradle。Gradle有几本书,我看过Gradle in Action。说实话,看得非常痛苦。现在想起来,Gradle其实比较简单,知道它的生命周期,知道它怎么解析脚本,知道它的API,几乎很快就能干活。而Gradle In Action一上来就很细,而且没有从API角度介绍。说个很有趣的事情,书中有个类似下面的例子

[plain]  view plain  copy
  1. task myTask <<  {  
  2.    println 'I am myTask'  
  3. }  


书中说,如果代码没有加<<,则这个任务在脚本initialization(也就是你无论执行什么任务,这个任务都会被执行,I am myTask都会被输出)的时候执行,如果加了<<,则在gradle myTask后才执行。

尼玛我开始完全不知道为什么,死记硬背。现在你明白了吗????

这和我们调用task这个函数的方式有关!如果没有<<,则闭包在task函数返回前会执行,而如果加了<<,则变成调用myTask.doLast添加一个Action了,自然它会等到grdle myTask的时候才会执行!

现在想起这个事情我还是很愤怒,API都说很清楚了......而且,如果你把Gradle当做编程框架来看,对于我们这些程序员来说,写这几百行代码,那还算是事嘛??


  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值