Gradle深入解析 - Task原理(Graph篇)

50679fa99bddfb02aa84f0f62d05c14c.png

你是否对gradle如何处理task间的依赖感到好奇,创建task的方式有很多种,建立依赖的方式也很多,gradle是如何确定最终task的执行顺序的,下面我们就来探究一下

作者:近地小行星
链接:https://juejin.cn/post/7241492186919239717

先用一张图来展示task相关的概念

51d3d17689809932b0f9d38619e0f01b.jpeg

Creation

Task的创建

先来张图帮助理解

2b81ff5b1988bd11518056dde0176249.jpeg

task的创建主要可以分为2种方式

  1. create

  2. register

create会立即创建task
register只是注册了一个task provider(后面再解释这个概念),此时并没有立即创建task实例,这也是官方目前推荐的task创建方式,官方plugin中task的创建方式都已修改为了register

task会在build script脚本或者plugins中,通过调用tasks.create/regster的方式被添加到task container

tasks.create('hello') {
    doLast {
        println 'greeting'
    }
}


tasks.register('hello') {
    doLast {
        println 'greeting'
    }
}

TaskContainer

我们知道gradle对每个Project都会创建一个Project对象与之关联,且我们在build script使用到的Task相关的方法,都会被定向到Project对象上来,而Project对象关于Task的处理都是委托给TaskContainer的,可以简单的将它理解为一个存放Task的容器

3631b64c4fbac62e840a961fcce93c48.jpeg

从2者的签名可以看出,createconfigureClosure是Closure类型,这个Closure是groovy.lang.Closure,而register是Action,2者并存,是因为早期重度使用groovy导致,前者会通过ConfigureUtil.configureUsing(configureClosure) 将closure转为action

TaskContainer可以简单分为2部分,一个map,一个是pendingMapcreate创建的task是添加到map中的,register注册的task provider放在pendingMap中,pendingMap中的task provider,在其task被创建时会主动添加到map中,并从pendingMap中移除自己

最终的task实例是通过反射创建的,如果没有指定其Task类型,那么默认会生成DefautTask的类型,可以在create/register时传入构造器参数,也可以通过configure action的方式传参

懒加载

create和register的区别

简单的可以理解为create对task的创建是eager的,而register是懒加载
gradle执行有3个阶段,initialization、configuration、execution,而不管执行哪个Task,configuration阶段都是一定存在的,在这一阶段会执行build script


如果是create方式,那Task就会被立即创建,这其实隐含了一个问题--被创建的Task可能并不会被运行,例如在我们想要运行compileJava这个task,build scripteval过程中,将test相关的Task也都创建了
使用register就可以规避这个问题,Task并没有立即创建,而是在需要的时候创建

这里你可能还会有疑问,虽然create创建了Task,但是register也是会创建Task Provider的呀,而大部分Task在其构造器中可能并没有额外操作,register有好在哪呢?

其实register相比于create,不仅是Task本身创建的时机延迟,还体现在对configuration action的执行时机上,create在创建完Task后是会立即对其进行configure的,而register方式注册的Task,是在其需要时才被创建,也在那时才进行configure

官方称为task configuration avoidance,用以规避不必要的Task的创建、配置
例如使用register替代create
使用named替代getByName等等

理想的task创建时间是在Task Graph calculation期间,build scan提供了可视化的数据帮助定位过早创建task的问题

可以参考官方文档task_configuration_avoidance

Lazy Properties

除了Task本身创建的lazy化外,Task的property也是可以lazy的,Task属性的lazy化主要解决的问题是,在对Task进行配置时,有些属性不一定能立刻得它的值,它可能要通过复杂的计算或者是依赖其他Task运行的结果,随着构建复杂性的增加,手动维护这些依赖关系会变得复杂,而将这些属性lazy化后,不立刻求值,等到需要的时候再去评估其值,来降低构建脚本的维护成本

Lazy Properties可以通过2种类型进行配置

  • Provider

  • Property

区别在于Property是可变的,Provider值是不可变的。Property实际上是Provider的子类
register方法返回的Task Provider正是Provider的子类

Property有get/set方法设置和获取值
Provider只能get获取值

属性也可以通过Extension设置

interface CompileExtension {
    Property<String> getClasspath()
}


abstract class Compile extends DefaultTask {  
    @Input  
    abstract Property<String> getJdkVersion()
    @Input  
    abstract Property<String> getClasspath()
}  


project.extensions.create('compile', CompileExtension)
def a = tasks.register('a', Compile) {  
    classpath = compile.classpath
    jdkVersion = '11'
    doLast {  
        println classpath.get()
        println jdkVersion.get()
    }
}


compile {  
    classpath = 'src/main/java'  
}

./gradlew a
输出
src/main/java
11

Property泛型不是对所有类型都能使用,filescollections比较特殊,有单独的Property
对于文件file和directory还有区分

RegularFileProperty
DirectoryProperty

ListProperty
SetProperty
MapProperty

对于属性如果使用错误,gradle会有报错提示,例如给RegularFileProperty设置了文件目录,或者文件不存在,都会有相应的报错提示

Property必须用input/output注解标记(例如上面代码中的@Input),否则会报错,Property和task依赖,task up-to-date检查都有关系,下面在依赖关系处理中会介绍inputs/outputs

Property不用手动进行初始化,上面的例子中可以看出都是abstract的,gradle在创建task实例时会默认去创建好,我们在使用时只需考虑赋值,而且在配置时必须赋值否则会报错,或者标注@Optional来表示此Property非必须

更多内容请参考官方文档lazy_configuration

NamedDomainObjectCollection

TaskContainer实现了NamedDomainObjectCollection接口,这个概念需要提一下,gradle中有很多东西用到
例如tasksextensions实际都是NamedDomainObjectCollection
可以直观地从名字来理解它
Named 具名的
Domain 用于某一域的
ObjectCollection 对象集合

NamedDomainObjectCollection实现了java的集合Collection接口
因为它的具名属性,实际上可以简单地将其简单地看作一个Map,实际最终的逻辑也确实是交给map处理的
它还有一个namer方法需要重写,这个作用就是用来给添加进来的元素进行命名的

Task Graph

整体流程

build script执行完之后,Task的创建和注册也就完成了,所有的Task都被添加到了Project的TaskContainer中,之后就是构建所有要执行的Task的有向无环图了,这个图是以我们在运行gradle命令时输入的entry tasks为起点开始构建起来的,例如./gradlew build中的buildentry task可以存在多个

ExecutionPlan是存放Task的容器,所有的Task都会被添加到中,在entry tasks被添加进来之后,会触发对Task依赖的探索,循环执行直到所有的Task依赖关系都明晰

之后求到entry tasks的拓扑排序,确定最终的执行计划

这里包含了2个大体的工作

  1. task依赖的resolve

  2. task执行顺序的确定

以下图举例,在执行./gradlew D

536b26d7453d773780d0bf5fcdd0024f.jpeg

以D作为entry task
D依赖C
C依赖B和A
B依赖A

整个执行流程就是A -> B -> C -> D这样的顺序

Task Relationship

在说具体的依赖处理前,我们先需要明白有多少种建立依赖关系的方式

Task之间有以下几种方式建立关联的方式

task inputs依赖
dependsOn
finalizedBy
mustRunAfter
shoulRunAfter

dependsOn是最常见的,这里就不说了,简单介绍下其他的方式

Task inputs

  • property方式

abstract class A extends DefaultTask {
    @OutputFile
    abstract RegularFileProperty getOutputFile()
}


def a = tasks.register('a', A) {
  outputFile = layout.buildDirectory.file('build/a')
}


tasks.register('b') {
  inputs.property('a.outputFile', a.flatMap { it.outputFile })
  doLast {
    println inputs.properties['a.outputFile']
  }
}

task b通过property和task a建立依赖关系

  • files方式

def a = tasks.register('a') {
  outputs.files('build/a')
}


tasks.register('b') {
  inputs.files(a)
}

task b的inputs和task a的outputs建立了依赖关系

finalizedBy

finalizedBy顾名思义,会把依赖的Task放在entry task之后执行, 例如

def c = tasks.regsiter('c')


tasks.regsiter('d') {
  finalizedBy c
}

执行./gradlew d,会先执行d,然后执行c

mustRunAfter/shouldRunAfter

mustRunAftershouldRunAfter相比于其他几种偏弱,实际上并不是依赖,而是设置执行顺序,这2种方式引入的task依赖,如果在task graph中没有的话是不会被执行的

def c = tasks.regsiter('c')


tasks.regsiter('d') {
  mustRunAfter c
}

例如执行./gradlew d命令,只执行d task,c 不会执行 执行./gradlew d c命令,会先执行c,再执行d

mustRunAfter/shouldRunAfter只是用来设置task执行的优先级,并不会给task添加强依赖shouldRunAfter相比mustRunAfter更弱一些,执行的优先级不一定能够完全保证,例如在parallel模式下或者task有因它而成环的问题时

每种relationship都有自己对应的TaskDependencyTaskDependency本质上是一个存放依赖的容器。调用上面对应的方法,就是在往对应的容器中添加元素,同一容器内保存依赖的顺序是按照其name的排序来的

依赖的类型没有限定,例如dependsOn字符串(Task的name),create的Task实例,registerTask Provider实例都可以,也就是说TaskDependency这个容器内存放的元素成分很复杂,接下来看看gradle如何resolve这些依赖

Task Dependency Resolve

ExecutionPlan

ExecutionPlan是用来处理整个Task Graph的入口,Task依赖resolve及执行拓扑序的确定都是由这处理的

先以一张整体的流程图来帮助理解

3c7d0e43790d2d462bff5fe5c406c1d2.jpeg

entry tasks被添加到ExecutionPlan后则会触发对task依赖的探索,对应于DefaultExecutionPlandiscoverNodeRelationships

DefaultExecutionPlan
以下代码有删改,这里保留了大体逻辑

public void addEntryTasks(Collection<? extends Task> tasks) {
  LinkedList<Node> queue = new LinkedList<>(tasks);
  discoverNodeRelationships(queue);
}


private void discoverNodeRelationships(LinkedList<Node> queue) {
  Set<Node> visiting = new HashSet<>();
  while (!queue.isEmpty()) {
    Node node = queue.getFirst();
    if (visiting.add(node)) {
      node.resolveDependencies(dependencyResolver);
      for (Node successor : node.getDependencySuccessors()) {  
          if (!visiting.contains(successor)) {  
              queue.addFirst(successor);  
          }
      }
    } else {
      queue.removeFirst();  
      visiting.remove(node);
      for (Node finalizer : node.getFinalizers()) {  
          finalizers.add(finalizer);  
          if (!visiting.contains(finalizer)) {  
              queue.addFirst(finalizer);  
          }  
      }
    }
  }
}

总体上是一个DFS,node的DependencySuccessors是上面介绍过的Task RelationshipinputsdependsOn建立的依赖。在node的依赖全部处理完后,会将它的finalizer task添加到自己后边

Task的依赖关系保存在多个TaskDependency中,对于Task依赖的resolve就是去遍历这些TaskDependency,代码逻辑入口处是在LocalTaskNode中的,也就是由entry task开始,将整个依赖关系进行处理,见下图(有删减)

LocalTaskNode是一个封装了taskNodeNode有多种类型,这里的算法是可以针对所有类型的Node

1a5e0364704d5aa65cbd2d5da0c8714a.jpeg

对Task依赖的resolve是通过TaskDependencyResolver来完成的,而TaskDependencyResolver对依赖的处理最终是交给CachingDirectedGraphWalker来处理的

CachingDirectedGraphWalker

里面使用的是tarjan强连通图算法的变体,它有2个功能

  • findValues 查找从start node可达的nodes

  • findCycles 查找图中存在的环

熟悉强连通图算法Tarjan's strongly connected components algorithm - Wikipedia的同学应该知道它可以用来查找图中的环,强连通的概念本身就是节点间俩俩都能互达,而在有向无环图中是不可能存在的,所以是对算法进行了修改,以便可以找到依赖节点
更多关于强连通图算法的知识大家可以自行搜索了解,这里不做更多说明了。

这里目前是用findValues去寻找依赖的节点,实际上这里并不是把Task的依赖及其间接依赖完全确定下来,只是将start node的直接依赖确定下来。
还是以上图举例,从D出发只是先找到C,然后C只找到BAB找到A
并非是这个类能力缺失导致不能一次将所有依赖都搜索完,这里是因为graph给出node的方式导致的。不确定是否是故意如此设计的,但是会产生大量的中间节点,配合缓存导致空间的浪费

另外从名字中的Caching可以看出它是带有缓存功能的,也就是探索过的node,下次再探索到的时候可以直接复用缓存结果

CachingDirectedGraphWalker在搜索的过程中会调用graph.getNodeValues去获取节点,

7eb5ee1118fbfdcbfb651d94dac08099.jpeg

getNodeValues有3个参数,node是当前节点,values是node对应的值,connectedNodes是关联的节点,例如task d依赖于task c的话,那么task c就是task dconnectedNodes

TaskGraphImpl实现了DirectedGraph接口,它主要负责2件事情

  1. 调用DefaultTaskDependency.visitDependencies去resolve task的依赖

  2. 调用WorkDependencyResolverTask 转化为LocalTaskNode

这一步当前的目的是为了将Task的依赖图Graph厘清,并没有确定其执行顺序

依赖resolve

visitDependencies

这里用到了Visitor设计模式,很多对象实现了TaskDependencyContainer接口,而且大多都是作为容器使用,使用Visitor模式的好处就是可以不修改这些类的实现来增加功能,Visitor对这些类进行遍历访问后,逻辑在自己内部处理

Task依赖可以有很多种类型,这里分析几种主要的情况

  • Task

依赖create方式创建的Task

def a = tasks.create('a')


tasks.register('b') {
  dependsOn a
}
  • Provider

依赖register方式创建的Task,register的Task会返回Task Provider对象

def a = tasks.register('a')


tasks.register('b') {
  dependsOn a
}
  • TaskDependencyContainer

inputs的引入的依赖

这里需要先了解一下inputs概念

input analysis
概念

一般来说,Task都会有inputsoutputsinputs可以有文件或者属性,而outputs就是文件

task将输入输出属性的定义主要分为4个类别

  • Simple values
    基本类型,字符串等实现了Serializable的类型

  • Filesystem types
    File,或者用Project.file()等gradle文件操作生成的对象

  • Dependency resolution results
    依赖裁决的结果,实质上也是文件

  • Nested values
    以上类型的嵌套组合

compileJava task为例,在编译java代码时inputs可以有很多,例如source filestarget jvm version,还可以指定编译时可用最大内存,outputs就是class文件

自定义Task的属性必须用注解标注,如果没有标注的话,运行时会报错。 这里的属性是指JavaBeans的带有getter/setter方法的public字段,和上面提到的用于lazy configuration的Property不一样

Task的属性分析会解析父类的,有些方法例如继承自DefaultTask或者Object的方法不会被解析

作用

标记上注解有2个主要的作用

  1. inputs/outputs相关的依赖分析

  2. Incremental Buildup-to-date check

如何给属性标注注解

gradle提供的注解有很多

Input 用以标注一个普通类型
InputFiles 用以标注是一个输入的文件相关类型
Nested 用以标注潜套类型
OutputFiles 用以标注是一个输出的文件相关类型
Internal 用以标注一个属性是内部使用
...

等等,具体参考task_input_output_annotations
@Internal这个注解值得多说一句
例如上面提到的编译时可用最大内存。source filestarget jvm version的改变都会影响到class文件的编译结果,但是运行时可用最大内存对编译结果无影响。这种和输入输出无关的属性,对Incremental Build缓存结果不产生影响的结果,可以用这个进行标注
这也表明@Input@InputFiles等这些注解标注的属性是对缓存结果有影响的

例如

class SimpleTask extends DefaultTask {  
    @Input String inputString 
    @InputFiles File inputFiles  
    @OutputFiles Set<File> outputFiles   
    @Internal Object internal  
}

inputs/outputs有2个来源

  1. 通过给属性加注解的方式

  2. 调用inputs的api添加

例如

abstract class Compile extends DefaultTask {  
    @Input  
    abstract Property<String> getClasspath()  
}


tasks.register('compile', Compile) {
  classpath = 'src/main'// 1. 属性注解方式
  inputs.property('name', 'compile')// 2. inputs添加属性
  inputs.files(project.files('libs'))// 3. inputs添加文件
}

2者不同之处在于,注解方式能力更强,inputs api是注解方式的子集,它可以提供@Input@InputFiles等注解的部分能力,但是其他的注解类似@Internal等它没有对应的方法
提供inputs的目的是我们在创建三方库提供的Task时,可以简单的提供一些额外参数,而不用通过继承的方式,在定义自己的Task时,注解方式还是首选
以下将注解方式标注的属性称为AnnotatedPropertiesinputs加入的属性称为RegisteredProperties

gradle如何分析inputs建立的依赖

具体执行逻辑是由PropertyWalker处理的,对于每个属性的处理,也使用到了Visitor模式

7c9d577b7de9a3f70b362e13aed80cd7.jpeg

来源有2种,所以对不同的来源都要进行分析

AnnotatedProperties

要分析注解的属性,首先要把注解的属性都解析出来,gradle把解析出来的数据封装为metadata,保存有属性的名称,所标注的注解的类型,以及Method本身
这里同时会对属性进行有效性校验,每种注解都有对应的annotation handler去处理,所有的handler都保存在map中,通过annotation的类型去获取。例如@InputFiles会校验属性返回值为文件相关类型,如果是其他类型会进行报错
注解的属性解析完后会对每个属性进行遍历,对其进行visit,每种注解的处理方式也不尽相同,所以也是交给handler去处理的,对于inputs来说主要分为2种,一种是普通的属性,一种是文件属性,对应上面的PropertyVisitor的2个方法

RegisteredProperties

通过inputs api方式添加的属性会根据自身情况被加入到2个容器中,一个用于存放文件相关类型的,一个用于存放其他类型的,在visitor分析时会对2者分别进行

不同的Task之间又是如何通过这些属性建立的关联呢,让我们从一个具体的例子入手

def e = tasks.register('e', CustomTask) {  
    inputs.property('prop1', a.flatMap { it.outputFile })  
    inputs.files(b)  
    prop2 = c.flatMap { it.outputFile }  
    prop3 = d.files  
}

上面截取了部分代码,总共有5个Task,task etask a,b,c,d都有依赖关系。a,b,c都是register的Task,d是create的Task

  • prop1通过inputs.property的方式依赖task aa.flatMap返回的是Provider保存了task a的信息,task a本身也是Provider,gradle通过反射调用Task属性的getter的方式可以拿到task a,将其作为依赖

  • inputs.files直接依赖了task b,inputs.files(b)实际上是对task b的outputs文件的依赖,和FileCollection处理一致

  • prop2依赖了task c,处理方式同prop1

  • prop3依赖了task dd.files返回的是FileCollection,在创建时也保存了task d的信息

因为可以作为依赖添加的对象很多,差别也很大,所以gradle使用了visitor模式,具体的对象在visit方法中处理自己的依赖方式,最后visitor将所有的依赖进行收集

对于具体属性分析的逻辑最终收拢到了PropertyVisitor中,TaskInputs会将这些依赖添加到connectedNodes,让图的搜索工作继续进行

这里只对inputs相关做了说明,实际属性的处理还有与增量构建相关的逻辑,在之后缓存的文章中再进行说明

Task的依赖resolve完后,依赖会被保存在多个容器中,dependencyNodesdependentNodes分别表示此Task依赖的Task和依赖此Task的Task,mustRunAftershouldRunAfter等也会有独立的容器存放

Project依赖导致的Task依赖

inputs依赖方式还有一种特殊的情况,就是project间的依赖关系 假设有2个project,libAlibBlibB依赖libA

libA/build.gradle

plugins {  
    id 'java'  
}

libB/build.gradle

plugins {  
    id 'java'  
}


dependencies {
  implementation(project(':libA'))
}

通过dependencies的方式2者就建立了依赖关系,在执行./gradlew libB:compileJava时会先执行libA:jar task,这又是如何做到的呢?

也就是说因为implementation(project(':libA'))的关系,libB:compileJavalibA:jar产生了依赖

libA applyjava pluginjava plugin中将PublishArtifactJar task建立关联,并将 PublishArtifact 作为 libA Configuration 的一部分
简单地理解就是libA的输出产物是PublishArtifact,而PublishArtifact是由Jar task生成的 (Configuration是gradle Dependency的一个概念,之后在依赖处理中详细说明,这里将它简单理解为一堆文件就可以了)

CompileJava task有一个属性classpathlibB compileJava时,classpath通过project(':libA')libA产生了依赖,classpathCompileJava task inputs的一部分,它对应的也是一堆文件,有一部分是来自于libA的输出产物
在处理Task的依赖时,通过Configuration查找到了libAPublishArtifact,之后顺理成章地和libAJar task建立了依赖关系,本质上也是通过TaskInput处理的依赖关系

执行顺序

Task的依赖关系图即 Task Graph,正常情况下是一个有向无环图(DAG),在它被resolve之后,此时就可以开始对Task Graph进行拓扑序的求解了,得到最后执行的顺序

拓扑排(Topological Order) 实质上是将DAG图的顶点按照其指向关系排成一个线性序列

如果graph有环,那拓扑序求解会失败,这个时候会调用CachingDirectedGraphWalker,也就是使用tarjan强连通图算法去找环,目的是为了报错信息能够让使用者直观地看出是哪些task有相互依赖的情况,便于修改。顺带一提,强连通算法通过查找环来进行报错信息优化,在代码编译中也有很多使用场景,例如如果把正常的继承关系看作一个有向无环图,那么循环继承这种情况就可以使用这种算法找到是哪些类的发生了循环继承

求拓扑序的方式有很多,且拓扑序并不唯一,有可能有多种解,gradle使用的是DFS方式,将entry nodes作为起点添加到队列中,来进行搜索,同时了用来遍历查找task的Queue,用来保存最终结果的Set,用来保存标记是否visit过的visitingNodes这几个数据结构
entry nodes可能为多个,这里以最简单一个的情况说明一下总体步骤

  1. 判断队列是否为空

    1. 如果为空则结束,保存结果的set的顺序就是排序结果

    2. 如果不为空,取队列中第一个node

  2. node是否已经存在结果set中了,存在的话直接移除队列中的node,重复步骤1

  3. node状态是否为“搜索中”,如果搜索过node则将其保存到结果的set中,并移除队列中的node,重复步骤1,否则标记当前node

  4. node的直接依赖结点successors

    1. 如果nodesuccessors中存在状态为“搜索中”,那么表示DAG图有环,进行报错提示

    2. nodesuccessors全部添加到队列中,回到步骤1判断队列是否为空

这里的successors表示的是当前node通过上面介绍过的几种建立依赖的方式关联起来的所有Task

流程图如下

开始

结束

queue是否为空

第一个node是否已经在结果中了

第一个node是否被visit过

node的succussors存在被visit过

将第一个node加入到结果中

报错

标记node状态为visit

将node的successor加到queue中

移除第一个node

大致代码如下

void processNodeQueue() {  
    while (!queue.isEmpty()) {  
        final Node node = queue.peekFirst();  
  
        if (result.contains(node)) {  
            queue.removeFirst();  
            visitingNodes.remove(node);  
            continue;
        }  
  
        if (visitingNodes.put(node)) {  
            ListIterator<Node> insertPoint = queue.listIterator();  
            for (Node successor : node.getAllSuccessors()) {  
                if (visitingNodes.containsEntry(successor)) {
                    onOrderingCycle(successor, node);  
                }  
                insertPoint.add(successor);  
            }  
        } else {  
            queue.removeFirst();  
            visitingNodes.remove(node);  
            result.add(node);  
        }  
    }  
}

以上面图示的依赖关系来举例,大概过一下整体流程

ca3c738198701da41154bf898bb8b74c.jpeg

这里还省略了很多细节的处理,比较重要的有以下几点

  1. finalizedBy引入的依赖,会被加到对应Task的刚好后面一个,例如 a.finalizedBy(b) c.dependsOn(a) 那么b会位于queue中ac的中间,也就保证了执行的顺序

  2. 如果Task是由mustRunAfter/shouldRunAfter添加的,且没有其他强依赖的方式引用到,是不会被加到结果中的

  3. 成环的判断那里,如果是由于shouldRunAfter造成的会忽略掉

  4. entry nodes可以是多个,处理多个entry nodes时,每个entry nodes会对应一个segment将不同的node区分开来

参考文档

关注我获取更多知识或者投稿

3be2180ed1bb13e3ca45714d013fb5eb.jpeg

3ac9a645aa5a7f9bc36d84340def4008.jpeg

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值