结对编程项目-最长英语单词链

结对编程作业

项目内容
这个作业属于哪个课程2022年北航敏捷软件工程
这个作业的要求在哪里结对编程项目-最长英语单词链
我在这个课程的目标是学习现代软件工程开发的模式,提高团队协作能力
这个作业在哪个具体方面帮助我实现目标切身体验结对编程

发表在你的个人博客上,也可以同时转发到你的团队博客上来增加你们团队博客的人气。博客共 50 分,具体要求如下:

1 班级及项目地址

2 PSP表格

PSP2.1Personal Software Process Stages预估耗时(分钟)实际耗时(分钟)
Planning计划6060
· Estimate· 估计这个任务需要多少时间6060
Development开发940850
· Analysis· 需求分析 (包括学习新技术)6090
· Design Spec· 生成设计文档6060
· Design Review· 设计复审 (和同事审核设计文档)3030
· Coding Standard· 代码规范 (为目前的开发制定合适的规范)1010
· Design· 具体设计120150
· Coding· 具体编码180150
· Code Review· 代码复审180120
· Test· 测试(自我测试,修改代码,提交修改)300240
Reporting报告200200
· Test Report· 测试报告120150
· Size Measurement· 计算工作量2020
· Postmortem & Process Improvement Plan· 事后总结, 并提出过程改进计划3030
合计12001110

3 编程原则

Information Hiding

信息隐藏(Information Hiding)是结构化设计与面向对象设计的基础。主要的思想类似于面向对象中提到的封装,通过封装,处理对象内的操作时只需要考虑对象本身而不需要考虑外界,处理对象间的操作时只需要考虑对象间的联系而不用考虑对象内部的实现细节。如此,方便程序的修改也保护了程序的其余部分不受影响。

依照这个原则,我们将IO系统、计算模块两大模块分割开来,分为Core,FileOutput,FileReader,在两个模块中也实现了更细分的信息隐藏方法。在IO系统中,对于题目要求的读取文件或手动输入的两种输入方式,只对最后的接口提供读取出的字符串;在计算模块中,只需要接受一个满足题意的字符串数组及相应参数便可计算出相应答案,而不需要关心具体实现细节。各部分只有接口函数进行交互而隐藏了内部的成员细节。

Interface Design

用户界面设计(Interface Design)的重点在于最大化用户体验及可用性。以用户为核心,设计易于使用且满足美感需求的UI界面,尽可能使得用户体验时交互更加简单但又不必忍受类似CLI界面的呆板。

关于我们的UI设计请见后文第十部分的GUI

Loose Coupling

即松耦合思想,正如OO要求的“高内聚、低耦合”,松耦合思想能尽量降低程序中一个组件的变化引发的对其他组件的影响,从而增加各个程序模块的可移植性。

我们实现计算功能时就遵守了松耦合的原则,除了前面所述的将IO与计算模块拆分,对于计算模块Processor,由于我们是通过建立和维护一个由多叉树组成的森林,每个节点属于一个专门的类ConcatTree,通过Processor建立森林,最后通过ResGen处理单词树生成结果,各个模块相互独立。最终也使得与其他小组交换计算模块时十分顺利。

4 计算模块接口的设计与实现过程

方法设计

我们的方法主要基于建立和维护一个由多叉树组成的森林(Wikipedia: Forest is a collection of disjoint trees. In other words, we can also say that forest is a collection of an acyclic graph which is not connected. Here is a pictorial representation of a forest)。一个叶节点的构造为:一个子叶节点群和一个自身的值。用 C# 描述为:

class ConcatTree
{
    private List<ConcatTree> _kids;
    private string _val;
}

在建立森林之前,我们先处理输入。对于输入,我们建立一个单词字典,将词语和首字母联系起来,其构造用 C# 描述为 Dictionary<char, List<string>>。比如,若输入为:

Heaven!#@(*$element TABLE:teach!0tAlK

我们则将其处理为:

{
    ['e': "element"], 
    ['h': "heaven"], 
    ['t': "table", "teach", "talk"]
}

在建立森林的过程中,我们以给定的单词组中的每一个单词为根,分别建树。即有 n 棵树就有 n 棵树。在建立时,我们采取这样的方法:对于一个单词树节点,将其值的尾字母作为子节点群的首字母;在字典中检索这个字母的单词,将它们依次加入子节点;对所有子节点重复该操作。

至此,一个完整的单词森林已经建立完毕,之后在尝试获得所有单词链结果时,遍历每一课树并每次记录结果即可。

代码组织

我们将核心计算模块、文件读入和输出模块封装成相互独立的部分。现在整个项目的文件结构为:

├── Core
│   ├── ConcatTree.cs
│   ├── Core.cs
│   ├── Core.csproj
│   ├── LoopException.cs
│   ├── OverflowException.cs
│   ├── Processor.cs
│   ├── WordChain.cs
│   └── WordsGen.cs
├── FileOutput
│   ├── FileOutput.cs
│   └── FileOutput.csproj
├── FileReader
│   ├── FileNotFoundException.cs
│   ├── FileReader.cs
│   └── FileReader.csproj
└── WordList
    ├── WordList
    │   ├── ArgsConflictException.cs
    │   ├── ArgsMissCharacterException.cs
    │   ├── ArgsMissNecessaryException.cs
    │   ├── ArgsParser.cs
    │   ├── ArgsShouldBeCharException.cs
    │   ├── ArgsTypeException.cs
    │   ├── Program.cs
    │   ├── WordList.csproj
    └── WordList.sln

三个独立模块和主项目文件平行。

在与交换测试小组商讨完毕后,我们决定将我们的接口设计为:

// -n
public static int gen_chains_all(HashSet<string> words, int len, ArrayList result) { ... }
// -w
public static int gen_chain_word(HashSet<string> words, int len, ArrayList result, char head, char tail, bool enable_loop) { ... }
// -m
public static int gen_chain_word_unique(HashSet<string> words, int len, ArrayList result) { ... }
// -c
public static int gen_chain_char(HashSet<string> words, int len, ArrayList result, char head, char tail, bool enable_loop) { ... }

5 UML

6 计算模块接口部分的性能改进

根据输入:

ab ba ac ca ad da ae ea
bd db bf fb bq qb
ca ac cb bc
po op
1
2
3
4

我们运行Visual Studio的性能分析工具,得到:

改进前CPU及耗时:

 改进前各函数执行时间占比:

我们发现性能损耗最大的地方在建立单词树的相关方法上。我们针对这部分核心方法进行的优化主要有:

  • 剪枝递归;

  • 优化数据初始化方式;

  • 避免滥用反射;

  • 尽量使用 foreach 代替 for。

优化后,我们再传入相同的数据运行性能分析工具,得到:

改进后CPU及耗时

改进后执行时间占比

可以发现,改进后CPU占用和运行时间都有较大改善。CPU占用从平均 40 \% 降低到 30 \% ,降低了 25 \% ,并且运行时间也降至了之前的 68 \% ,加速比大约为 1.5 。

7 Design by Contract, Code Contract

Design by Contract,即契约式设计,通过定义每个组件的前置条件、后置条件和不变式,使得软件的各个组件都拥有更为正式、精准且验证的接口规范。

优点

  • 可在测试阶段能够更精准的验证组件的行为,使得程序更可靠

  • 事先约定规格,代码编写更加规范

缺点

  • 在书写代码前便要做大量设计工作,过于繁琐

  • 若用断言来验证契约,会使得代码过于冗长,影响可读性

8 计算模块部分单元测试展示

利用VS的单元测试模块进行测试,对于每个接口,针对输入参数的所有可能组合(head,tail,enable_loop)进行测试。将得到的result与正确的realResult挨个元素进行比较,确保单词链生成正确,利用equalList函数进行比较,函数及测试样例如下:

public void eqaulList(ArrayList result, ArrayList realResult)
{
    for (var i = 0; i < result.Count; ++i)
    {
        Assert.AreEqual(result[i], realResult[i]);
    }
}
[TestMethod()]
public void gen_chain_word_blendTest()
{
    HashSet<string> words = new HashSet<string> { "abb", "bbc", "ccd", "dde", "wq", "qwe", "ert", "cew", "bw" };
    int len = words.Count;
    ArrayList result = new ArrayList();
​
    //test with head and tail
    char head = 'b';
    char tail = 'd';
    bool enable_loop = false;
    int cnt = Core.gen_chain_word(words, len, result, head, tail, enable_loop);
    ArrayList realResultWithHeadAndTail = new ArrayList { "bbc", "ccd" };
    Assert.AreEqual(cnt, realResultWithHeadAndTail.Count);
    eqaulList(result, realResultWithHeadAndTail);
​
    //test with head and loop
    head = 'w';
    tail = '\0';
    enable_loop = true;
    words = new HashSet<string> { "bc", "cw", "wb", "ber", "rw", "wc" };
    len = words.Count;
    result = new ArrayList();
    ArrayList realResultWithHeadAndLoop = new ArrayList { "wc", "cw", "wb", "ber", "rw" };
    cnt = Core.gen_chain_word(words, len, realResultWithHeadAndLoop, head, tail, enable_loop);
    Assert.AreEqual(cnt, realResultWithHeadAndLoop.Count);
    eqaulList(result, realResultWithHeadAndLoop);
​
    //test with tail and loop
    head = '\0';
    tail = 'd';
    enable_loop = true;
    words = new HashSet<string> { "abc", "cwa", "acd", "dwq", "cde", "efg" };
    len = words.Count;
    result = new ArrayList();
    ArrayList realResultWithTailAndLoop = new ArrayList { "abc", "cwa", "acd" };
    cnt = Core.gen_chain_word(words, len, realResultWithTailAndLoop, head, tail, enable_loop);
    Assert.AreEqual(cnt, realResultWithTailAndLoop.Count);
    eqaulList(result, realResultWithTailAndLoop);
​
    //test with head, tail and loop
    head = 'q';
    tail = 'w';
    enable_loop = true;
    words = new HashSet<string> { "asd", "dfg", "qwe", "ewq", "qta", "qw" };
    ArrayList realResultWithHeadTailAndLoop = new ArrayList { "qwe", "ewq", "qw" };
    len = words.Count;
    cnt = Core.gen_chain_word(words, len, realResultWithHeadTailAndLoop, head, tail, enable_loop);
    Assert.AreEqual(cnt, realResultWithHeadTailAndLoop.Count);
    eqaulList(result, realResultWithHeadTailAndLoop);
}

对所有可能出现的参数组合构造了相应样例,最终代码覆盖率如图:

9 计算模块部分异常处理说明

9.1 参数冲突异常

根据题意我们可以知道,-n/-m指令不能与-h/-t/-r复合使用,同时-n/-w/-m/-c四条指令只能出现一条,在ArgsParser中分析输入时,若存在上述指令的错误复合,则会抛出ArgsConflictException异常 。

为此设计单元测试样例,穷举了所有可能的错误组合,样例如下:

[TestMethod()]
public void ArgsConflictExceptionTest()
{
    string[] args = new string[] { "test.txt", "-n", "-w" };
    ArgsParser argsParser;
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-w" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-c" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-m" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-w", "-c" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-w", "-m" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-c", "-m" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-w", "-c", "-m" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-w", "-c" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-w", "-m" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-w", "-c", "-m" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-c", "-m" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-h", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-t", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-r" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-r", "-h", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-r", "-t", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-r", "-h", "a", "-t", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-n", "-h", "a", "-t", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-m", "-h", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-m", "-t", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-m", "-r" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-m", "-r", "-h", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-m", "-r", "-t", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-m", "-r", "-h", "a", "-t", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-m", "-h", "a", "-t", "a" };
    Assert.ThrowsException<ArgsConflictException>(() => argsParser = new ArgsParser(args));
}

9.2 指定首尾字母异常

当使用-h/-t指令时,后面需要紧跟其所需的字母,在ArgsParser中分析输入时,若不存在紧跟着的字母,则抛出ArgsMissCharacterException异常,表示缺少紧跟字母;若紧跟的参数长度大于1,则抛出ArgsShouldBeCharException异常,表示紧跟参数不是单个字母。

对于前者,直接构造-h/-t后不紧跟字母的样例,如下:

[TestMethod()]
public void ArgsMissCharacterExceptionTest()
{
    string[] args = new string[] { "test.txt", "-w", "-h" };
    ArgsParser argsParser;
    Assert.ThrowsException<ArgsMissCharacterException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-w", "-t" };
    Assert.ThrowsException<ArgsMissCharacterException>(() => argsParser = new ArgsParser(args));
}

对于后者,构造-h/-t紧跟长度大于1的参数的样例,如下:

[TestMethod()]
public void ArgsShouldBeCharExceptionTest()
{
    string[] args = new string[] { "-w", "-h", "ab", "test.txt" };
    ArgsParser argsParser;
    Assert.ThrowsException<ArgsShouldBeCharException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "-w", "-t", "-r", "test.txt" };
    Assert.ThrowsException<ArgsShouldBeCharException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "-w", "-t", "test.txt" };
    Assert.ThrowsException<ArgsShouldBeCharException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "-w", "-h", "test.txt" };
    Assert.ThrowsException<ArgsShouldBeCharException>(() => argsParser = new ArgsParser(args));
}

9.3 缺少关键参数异常

依据题意,输入指令参数有且仅有-n/-w/-m/-c中的一个,在ArgsParser中分析输入时,若输入参数中四者都不存在,则抛出ArgsMissNecessaryException异常。

为此,构造出不存在上述四个指令的样例,如下:

[TestMethod()]
public void ArgsMissNecessaryExceptionTest()
{
    string[] args = new string[] { "test.txt" };
    ArgsParser argsParser;
    Assert.ThrowsException<ArgsMissNecessaryException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-r" };
    Assert.ThrowsException<ArgsMissNecessaryException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-h", "h" };
    Assert.ThrowsException<ArgsMissNecessaryException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "test.txt", "-t", "h" };
    Assert.ThrowsException<ArgsMissNecessaryException>(() => argsParser = new ArgsParser(args));
}

9.4 输入参数种类异常

所支持参数仅包括-n,-w,-m,-c,-h,-t,-r及.txt文件路径,在ArgsParser中分析输入时,若输入参数不属于其中之一,则抛出ArgsTypeException异常。

为此,构造几个不属于不属于上述参数的样例,如下:

[TestMethod()]
public void ArgsTypeExceptionTest()
{
    string[] args = new string[] { "-q", "test.txt" };
    ArgsParser argsParser;
    Assert.ThrowsException<ArgsTypeException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "-n", "notxt" };
    Assert.ThrowsException<ArgsTypeException>(() => argsParser = new ArgsParser(args));
​
    args = new string[] { "-x", "notxt" };
    Assert.ThrowsException<ArgsTypeException>(() => argsParser = new ArgsParser(args));
​
}

9.5 文件不存在异常

FileReader中,对于输入参数中的.txt文件路径,若该路径不存在,则抛出FileNotFoundException异常。

对此,构造几个不存在的路径传入FileRead进行测试,如下:

[TestMethod()]
public void ReadFileAsStringExceptionTest()
{
    string path = "C;\\no_exit.txt";
    FileReader fileReader = new FileReader(path);
    Assert.ThrowsException<FileNotFoundException>(() => fileReader.ReadFileAsString());
​
    path = null;
    fileReader = new FileReader(path);
    Assert.ThrowsException<FileNotFoundException>(() => fileReader.ReadFileAsString());
}

9.6 非法循环异常

对于输入的文本,若能构成单词环且没有-r或-m参数,则抛出LoopException异常。

对此,构造在不允许构成单词环的情况下输入能够形成单词环的样例,如下:

[TestMethod()]
public void gen_chain_word_loopTest()
{
    HashSet<string> words = new HashSet<string> { "abc", "cwa", "acd", "cew", "wq", "dwq" };
    int len = words.Count;
    ArrayList result = new ArrayList();
​
    //test with loop
    char head = '\0';
    char tail = '\0';
    bool enable_loop = true;
    int cnt = Core.gen_chain_word(words, len, result, head, tail, enable_loop);
    List<string> realResultWithoutTail = new List<string> { "abc", "cwa", "acd", "dwq" };
    Assert.AreEqual(cnt, realResultWithoutTail.Count);
    Assert.AreEqual(true, validChain(result, enable_loop));
​
    //test without loop
    enable_loop = false;
    Assert.ThrowsException<LoopException>(() => cnt = Core.gen_chain_word(words, len, result, head, tail, enable_loop));
}

9.7 答案过长异常

根据题意,Core里接口中的result答案长度若大于20000则抛出OverflowException异常。

对此,构造一个较长的样例使其答案长度能超过20000,如下:

[TestMethod()]
public void overFlowTest()
{
    HashSet<string> words = new HashSet<string> { "aa", "ab", "bb", "bc", "cc", "cd", "dd", "de", "ee",
                                                   "ef", "ff", "fg", "gg", "gh", "hh", "hi", "ii", "ij",
                                                   "jj", "jk", "kk", "kl", "ll", "lm", "mm"};
    int len = words.Count;
    ArrayList result = new ArrayList();
​
    int cnt;
    Assert.ThrowsException<OverflowException>(() => cnt = Core.gen_chains_all(words, len, result));
}

10-11 界面模块

设计过程

CLI

在针对 CLI 的界面交互设计中,我们严格按照课程组要求的参数输入方式支持基于命令行的交互。为了让 CLI 尽可能清晰明了,我们在原有的几个参数基础上新增了帮助指令,用户可以通过 help 或者 --help 在终端中呈现使用提示和规范:

Word List
=========
Usage:
   Wordlist <function> <filename> [options]
​
Functions:
   -n              Get the numeration of word chains.
   -w              Get the word chain with most words.
   -m              Get the word chain with most words of different starting letter.
   -c              Get the word chain with most letters.
​
Options:
   -h < head >     Specify the starting letter of the chain.
   -t < tail >     Specify the ending letter of the chain.
   -r              Allow potential word circles like "ab"-"ba".

GUI

在选择制作 GUI 的工具时,考虑到UnityEngine和C#语言的天然融合性,我们最终采用Unity2D来完成我们的图形界面。秉持简洁、强引导的用户界面设计理念,我们在界面的设计上尽可能清晰地将所有功能合理排布在图形界面上:

并且在使用过程中,我们使用状态机来管理用户使用状态,避免程序不支持的指令产生,以提高软件的可靠性并且降低用户在使用过程中的疑惑。比如,我们规定在选择指定单词链首字母指定单词链尾字母允许隐含单词患之前必须选取一个基本功能,否则这三个选项将不可选;另外,在选择了一个基本功能后,其他三个基本功能将不可选。这样的状态管理在用户使用环节即把有关指令的错误全部排除了。

与计算模块的对接

无论是CLI还是GUI,我们的计算模块对接都很简单,即载入核心计算模块 Core.dll ,然后根据特定环境开发IO模块。例如在CLI中,我们根据命令行交互的单源特殊性编写的 FileReader 读入模块;然而,在GUI中,支持手动输入和文件上传,我们则另外根据UnityEngine的组件生命周期开发针对GUI的读入模块。最重要的是,计算模块都是一致普适的;在CLI和GUI中,都只需要 using Core; 即可完成对接。

12-13 结对编程

结对过程

主要采取线下交流,线上代码书写的流程

优点

  • 结对思考,思路更加开拓,对于问题的思考角度更广

  • 为了使结对对象看懂自己的代码,倒逼优化自己的代码风格

  • 合理的分工可以实现优势互补

缺点

  • 开发效率未必比个人该发稿

  • 需要调整时间以进行交流,不免存在妥协

成员互评

刘兆薰肖伟强
代码效率高,很快就完成了基础框架的书写对新工具新框架精通很快,面向对象知识非常扎实
沟通效率高,对于一些问题或者bug很快能讲清楚并解决阅读代码能力很强,可以很快找到程序出错点并且提出高效的解决办法
性格好,相处十分愉快沟通效率高,很快可以确定好接下来的任务
有些时候会先行而后思导致一些低级错误对C#有些语法不是很熟悉

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值