• 软件工程结对作业


    项目 内容
    本次作业所属课程 2019BUAA软件工程
    本次作业要求 结对项目-最长单词链
    我在本课程的目标 熟悉和实践软件工程流程,适应团队开发
    本次作业的帮助 熟悉结对编程

    一、GitHub项目地址

    项目地址

    二、PSP表格

    PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
    Planning 计划 70 90
    -Estimate -估计这个任务需要多少时间 70 90
    Development 开发 3400 3300
    -Analysis -需求分析 (包括学习新技术) 200 80
    -Design Spec -生成设计文档 200 180
    -Design Review -设计复审 (和同事审核设计文档) 150 90
    -Coding Standard -代码规范 (为目前的开发制定合适的规范) 150 60
    -Design -具体设计 200 240
    -Coding -具体编码 1700 1800
    -Code Review -代码复审 200 210
    -Test -测试(自我测试,修改代码,提交修改) 600 640
    Reporting 报告 400 900
    -Test Report -测试报告 210 720
    -Size Measurement -计算工作量 90 60
    -Postmortem & Process Improvement Plan -事后总结, 并提出过程改进计划 100 120
    合计 3770 4290

    三、看教科书和其它资料中关于Information Hiding, Interface Design, Loose Coupling的章节,说明你们在结对编程中是如何利用这些方法对接口进行设计的

    在查找资料时,我发现了这篇博文,其中有些部分讲的还是很好的。如“Loose Coupling几乎已经与interface design等价了”,松耦合还有其他的实例,但通过接口的合理设计,固定下来接口的形式,确实实现松耦合的方法之一。

    将API接口封装为Core类,变为.dll文件,就可以允许多个文件甚至项目直接调用,达到松耦合。由于接口已经由作业要求给定,分析可以得知,字符串数组words、result使用的类型为char *words[],使得接口可供C甚至C#使用。而我们的Core类中,存在一些只供Core内函数调用的函数(如compare()),此时就不将compare函数声明为__declspec(dllexport),即.lib中并没有这个接口,保证了Information Hiding。此外,尽管存在一些地方使用全局变量将简化算法(如保存最长链叶节点的node),但为了更加安全的实现,我们将节点的指针作为参数传入建树函数,对其他函数来说,达到Information Hiding的目的。

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

    1. 接口:项目中计算模块接口的设计采用了要求中统一的API,即:

      # 最多单词数
      int gen_chain_word(char* words[], int len, char* result[], char head, char tail, bool enable_loop);
      
      # 最多字母数
      int gen_chain_char(char* words[], int len, char* result[], char head, char tail, bool enable_loop)
      

      由于在第一次阅读作业要求时已经了解了固定API的要求,因此我们在第一次实现时已经采用了这个结构。具体算法采用树实现,而不是其他组普遍使用的图结构。

    2. 类:共有两个类和四个自定义异常结构体。其中Core类为封装好的计算模块,用于计算最长链,node类为树节点信息,自定义异常则用于抛出异常时输出信息。具体结构如下:

      class node {
      public:
          string word;
          node* parent;
          node* first_child;
          node* next;
          int word_num;
          int character_num;
      
          __declspec(dllexport) node(string cur_word, int cur_word_num, int cur_character_num);
      };
      
      class Core {
      public:
          __declspec(dllexport) int gen_chain_word(char* words[], int len, char* result[], char head, char tail, bool enable_loop);
          __declspec(dllexport) int gen_chain_char(char* words[], int len, char* result[], char head, char tail, bool enable_loop);
          __declspec(dllexport) bool gen_tree(node* cur_node, char* words[], int len, bool enable_loop, char tail, node* word_max_node, node* char_max_node, int words_index[][2]);
          __declspec(dllexport) bool find_in_chain(node* cur_node, string word);
      };
      

      自定义异常参见第九节。

    3. 函数:计算模块共有五个函数(不包括node类的构造函数),除去API的两个函数gen_chain_word()和gen_chain_char()外,还有递归生成树函数gen_tree(),在树中查找函数find_in_chain()和排序时的比较函数compare()。关键函数的流程图如下:

      函数关系图

    4. 基本算法为:首先对输入的所有单词words进行排序,并建立索引。此后将每个单词作为一棵树的根节点建树。建树时根据索引,遍历所有满足和父节点连接条件的子节点,查找该子节点是否在父节点所在链上出现过。若出现过,根据enable_loop标志判断是否抛出成环的异常;反之则将子节点加入树。为了简化查找过程,我们在建树的过程中就计算最长单词和最长字母的节点。当所有树建好,可直接通过得到的最长单词链的叶节点向上遍历,找到整个链并输出。

      因此,我们项目的独到之处包括:使用树结构、建立索引、建树完成后无需再遍历寻找最长链、自定义异常类型和创新异常单元测试方法。具体内容将在第六节和第九节详细说明。

    五、阅读有关UML的内容,画出UML图显示计算模块部分各个实体之间的关系。

    wiki百科上uml的定义如下:

    uml定义

    本项目的uml类图:

    uml

    由于只使用了两个类,且Core类单纯封装了方法,因此类之间耦合度较低。

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

    我们运行了一个有50个单词的允许成环的文本。优化后的性能分析结果如下:

    性能分析图

    函数性能消耗情况

    在初次运行时,我们发现-r参数下70个单词已经无法在300s内运行完成。在调试的过程中,我们发现,时间最长的部分是建树的过程gen_tree(),上图也印证了这个猜想。因此我们决定对words排序后建立索引,在建树时只需遍历索引指向的首位范围即可。这样做会减慢小数据的速度(5个单词时的运行速度由4ms变为了12ms),但会提升数据较多时的性能。但当实现之后发现,对大数据量时改进仍然不够令人满意。我们也尝试过对排序后的words去重,但是在单元测试时发现,代码:

    int cnt = 1;
    for (i = 1; i < len; i++) {
        if (strcmp(words[i], words[i - 1]) != 0) {
            strcpy_s(words[cnt++], strlen(words[i]) + 1, words[i]);
        }
    }
    len = cnt
    

    中的strcpy部分似乎会在单元测试中抛出异常,我们并未查到bug的原因。在网上的唯一解释是由于被赋值的指针指向了字符串常量,不能被修改,但这个解释并不符合。尽管在release版本中这部分代码可以通过,最终为了保险起见,我们删除了去重功能。

    花费时间最长的函数是find_in_chain(),其作用是在找到一个首字母可以和当前节点尾字母相连的节点时,从当前节点向上查找,如果出现过这个单词,则判断是否有-r参数。若没出现过,则加到当前节点后面。我们曾想过是否要将遍历改为维护一个“单词是否出现过的标志数组”的形式,但发现这样修改非常复杂,时间有限,没能实现。

    此外,生成树以后重新便利寻找最长链也是没有必要的时间消耗,我们在建树过程中,直接记录下当前节点的链的单词数和字母数,使得在树建完后,直接得到最长链的叶节点,反向遍历即可得到整条链。

    七、看Design by Contract, Code Contract的内容,描述这些做法的优缺点, 说明你是如何把它们融入结对作业中的

    DbC为契约式设计,详细介绍可以看Wiki中对DbC的介绍,契约式设计就是互相之间明确行为前后的状态,其目的是在设计时能明确模块单元的状态。在这个项目中,最明显的就是API的固定。相同的API设计规范决定了调用其它.dll文件的方便性。又例如我们用到的try{}catch(){}finally{}的结构本身就是DbC,不符合先验条件程序就会返回。

    但是契约式设计也有很多缺点,固定的接口让很多人在设计时非常不方便,从群里很多人在问能不能修改API就可以看出。此外,契约式设计得到的API必须设计的足够灵活又安全,因此必须在设计时花上一些时间。

    八、计算模块部分单元测试展示和覆盖率报告的生成

    1. 部分测试代码展示

      TEST_METHOD(UnitTest_gen_tree5) {
          node1 = new node(cur_word, cur_word_num, cur_character_num);
          word_max_node = new node("", 0, 0);
          char_max_node = new node("", 0, 0);
          char head = 0, tail = 0;
          int len = 6;
      
          char* words[6] = { "aj", "jhgjh","hjhjbdkjhaksjdfhkjhkjhjd" ,"hdfdrp","pd","ddfghj" };
          Assert::AreEqual(core_test.gen_chain_word(words, len, result, head, tail, true), 5);
          Assert::AreEqual(core_test.gen_chain_char(words, len, result, head, tail, true), 5);
      }
      
      TEST_METHOD(UnitTest_gen_tree6) {
          char* words[5] = { "aj", "cdfdrp", "ddfghj", "hhgjh", "sjhjb" };
          word_max_node = new node("", 0, 0);
          char_max_node = new node("", 0, 0);
          char head = 0, tail = 0;
          int len = 5;
          Assert::IsFalse(core_test.gen_tree(node1, words, len, false, 0, word_max_node, char_max_node, words_index));
      }
      
      TEST_METHOD(UnitTest_find_in_chain3) {
          char* words[5] = { "aj", "cdfdrp", "ddfghj", "hhgjh", "sjhjb" };
          Assert::IsFalse(core_test.gen_tree(node1, words, len, false, 0, word_max_node, char_max_node, words_index));
          Assert::IsFalse(core_test.gen_tree(node1, words, len, false, 0, word_max_node, char_max_node, words_index));
          Assert::IsTrue(core_test.find_in_chain(node1->first_child->first_child, word));
      }
      
      TEST_METHOD(UnitTest_command_line2) {
          argc = 4;
          char* argv[4] = { "Wordlist.exe","-w","-r","../Wordlist/file.txt" };
          Assert::IsTrue(command_handler(argc, argv, words, len, head, tail, enable_loop, w_para));
      }
      
    2. 被测试函数

      测试的函数有五个:

      int gen_chain_word(char* words[], int len, char* result[], char head, char tail, bool enable_loop);
      
      int gen_chain_char(char* words[], int len, char* result[], char head, char tail, bool enable_loop);
      
      bool gen_tree(node* cur_node, char* words[], int len, bool enable_loop, char tail, node* word_max_node, node* char_max_node, int words_index[][2]);
      
      bool find_in_chain(node* cur_node, string word);
      
      bool command_handler(int argc, char* argv[], char* words[], int &len, char &head, char &tail, bool &enable_loop, bool &w_para);
      
    3. 测试思路

      首先明确程序的架构,command_handler是处理命令行参数函数;gen_chain_word和gen_chain_char是两个主函数,在这两个主函数中均调用了get_tree和find_in_chain这两个函数。

      从小函数开始测试,根据gen_tree的输入和输出,使用assert函数,返回一个bool类型的值。主要测试重点就在于生成树的过程中是否遇到禁止环出现还出现环的情况以及树的生成是否正确。

      然后是find_in_chain函数,主要功能是找在该路径上是否出现了和这个word一样的word,不满足我们的条件(一个单词只允许出现一次),返回bool类型的值。在测试时,就用gen_tree先生成一颗树,然后设定多种word让该函数去寻找。

      对与两个主函数,因为主要的核心已经测试过,于是采用一些白盒测试的方法,测试其余没有覆盖的情况,调整-h,-t参数等等。

      最后是command_handler函数的测试,这部分就是测试输入命令的正确与否。不过因为是命令行输入,已经有了一些情况的限制,需要测试的情况也可以较好地覆盖。测试内容分为正确指令和错误指令,错误指令有包含各种错误:有不正确指令,内容缺失,文件找不到等等。是在考虑的多种用户输入时可能犯下的错误进行相应的匹配和处理。

    4. 测试报告及覆盖率报告生成的辛酸史

    测试报告

    单元测试

    覆盖率报告

    这个过程是对我来说,整个项目,最艰难的一个过程,完成它的耗时远远超过了我的估计值。总的来说,就是我使用的vs2017社区版没有这个功能并且其中较为流行的插件也均不支持vs2017的社区版(以下简称vs2017,因为专业版有现成的工具:))。

    于是我展开了搏斗:

    Round1:opencover和ReportGenerator

    不适用!vs中缺少工具。另外vs2015版还可以支持Opencover的一个UI extension,简直不要太简单。

    Round2:使用命令行运行测试文件的DLL,生成.trx文件,再转为html文件查看报告

    我们在命令行可以运行测试命令,但必须要转为超级用户后才能生成.trx文件,否则会提示“拒绝访问”。接着,vs2017生成的.trx文件没有可以解析它的工具。我们尝试了多种,trx2html,还有GitHub上开源的工具,都无法解析它。

    Round3:最后,我们得知了OpenCppCoverage这个软件可以通过命令行拿到覆盖率的html文件

    起初,我们发现,这个命令只能通过调用.exe文件得到一次结果的覆盖率。但是经过一阵思考和对这个软件GitHub主页的参数分析,我们发现可以通过参数export_type=binary先生成覆盖率的二进制文件,再使用--input_coverage arg命令将多个测试样例覆盖得到的*.cov(二进制文件)进行merge。最后就得到了相当于整个测试文件的覆盖结果,再生成.html文件,方便可视化。先贴结果:

    结果

    Wordlist

    Core

    可以看出,覆盖率均达到95%以上,可视化效果也不错。html文件自取,提取码:lpw9 。

    唯一的缺点就是需要自己生成不同的文件,手动运行多次命令,如下图

    file

    以后若要运行更多的测试,我们的思路就是写.bat脚本,也算事实行了半自动化的测试,时间有限,留给大家思考更好的方法。

    命令实例:

    OpenCppCoverage.exe --sources=Wordlist --export_type=binary -- Wordlist.exe -w file.txt

    OpenCppCoverage.exe --sources=Wordlist ----input_coverage Wordlist.cov --export_type=binary -- Wordlist.exe -w file.txt

    附几个参考过的链接

    Round1

    Round2

    OpenCppCoverage的GitHub主页

    九、计算模块异常处理说明

    因为计算模块的接口固定,使得诸如“没有出现-w和-c”、“-h后字母数超过一个”、“文件不存在”这样的异常无法在Core中处理,故在其他函数中以输出形式报出异常。本项目Core类实现的异常处理有:成环异常、-h后字母不是英文字母、-t后字母不是英文字母、链长度小于2四种异常。

    自定义异常的通用结构如下:

    struct ChainLessThen2Exception : public exception {
        const char * what() const throw () {
            return "length of chain is less than 2!";
        }
    };
    

    在catch时,只需使用:

    try {
        // some function
    }
    catch(ChainLessThen2Exception& e) {
        cout << e.what();
    }
    

    即可输出异常信息。

    由于网上没有找到异常检测的方法,我们创新了以下格式来在单元测试中检测异常。可以看到,这个方法能够检测出是否捕获异常。

    • 成环异常LoopException:对于enable_loop参数为false时,如果检测到环存在,抛出该异常。

      测试样例

      TEST_METHOD(UnitTest_Loop) {
          char* words[5] = { "aj", "hdfdrs", "jhgjh", "kjhjh", "sdfghj" };
          int len = 5;
          char head = 0, tail = 0;
          try {
              core_test.gen_chain_char(words, len, result, head, tail, false);
              Assert::IsTrue(false);
          }
          catch(struct LoopException &e) {
              Assert::IsTrue(true);
          }
          catch(...) {
              Assert::IsTrue(false);
          }
      }
      

    ​ 错误场景:"aj", "hdfdrs", "jhgjh", "kjhjh", "sdfghj"中出现环s..j-j..h-h..s,抛出LoopException异常。

    • 首字母异常HeadInvalidException:如果head不为0,且head后面的字母不在'a'-'z'或'A'-'Z'范围内,抛出异常。

      测试样例

      TEST_METHOD(UnitTest_Head) {
        char* words[7] = {"ajsdjfhkhlkjhlakjshduhcuhuhuhuhvuhjhdkajsdfnajksdjkfhksdjahkjdhjdhje" };
        try {
            core_test.gen_chain_char(words, len, result, 1, tail, true);
            Assert::IsTrue(false);
          }
          catch (struct HeadInvalidException &e) {
              Assert::IsTrue(true);
          }
          catch (...) {
              Assert::IsTrue(false);
          }
      }
      

      错误场景:-h参数后跟1,非英文字母,抛出HeadException异常。

    • 尾字母异常TailInvalidException:如果tail不为0,且tail后面的字母不在'a'-'z'或'A'-'Z'范围内,抛出异常.

      测试样例

      TEST_METHOD(UnitTest_Tail) {
        char* words[7] = {"ajsdjfhkhlkjhlakjshduhcuhuhuhuhvuhjhdkajsdfnajksdjkfhksdjahkjdhjdhje" };
          try {
              core_test.gen_chain_char(words, len, result, head, 1, true);
              Assert::IsTrue(false);
          }
          catch (struct TailInvalidException &e) {
              Assert::IsTrue(true);
          }
          catch (...) {
              Assert::IsTrue(false);
          }
      }
      

      错误场景:-h参数后跟1,非英文字母,抛出TailException异常。

    • 链长度异常ChainLessThan2Exception:如果head不为0,且head后面的字母不在'a'-'z'或'A'-'Z'范围内,抛出异常。

      测试样例

      TEST_METHOD(UnitTest_Chain) {
        char* words[7] = {"ajsdjfhkhlkjhlakjshduhcuhuhuhuhvuhjhdkajsdfnajksdjkfhksdjahkjdhjdhje" };
          try {
              core_test.gen_chain_char(words, len, result, head, tail, true);
              Assert::IsTrue(false);
        }
          catch (struct ChainLessThan2Exception &e) {
              Assert::IsTrue(true);
        }
        catch (...) {
            Assert::IsTrue(false);
        }
      }
      

      错误场景:-h参数后跟1,非英文字母,抛出ChainLessThan2Exception异常。

      此外,在Core类以外,我们已经对输入不合法、文件不存在等异常进行了处理,保证程序不会崩溃。

    十、界面模块的详细设计过程

    UI

    UI采用qt实现,由于我以前有过一次pyqt开发的经验,上手起来并不难。我们在底层放置了一些layout,同时也给每个分区设置了layout,这样做的目的是保证界面缩放时比例不变,但做出来才发现底层layout没能随窗口一起变化,导致缩放时比例仍然存在问题,最终决定固定窗口大小。具体结构如下:

    layout

    界面主要包括上方的输入区,支持文件路径和直接输入单词;中间的参数和输出区,允许用户选择参数,得到正确结果和报错信息;下方的导出区,允许用户将结果导出到指定路径。

    实现过程即为拖动控件到layout上,设置layout内控件比例即可,并未用到qt代码,因此此处无需示范代码部分。需要注意的是,由于-c和-w以及两种输入方式都满足同一时刻有且只有一个button被选中,故采用了radio button的方式,这种button默认为互相冲突的,即不可同时选中。但当ui完成后才发现,本想四个按钮分为两组相冲突,实际却是四个按钮同一时刻只能选择一个。因此使用了QButtonGroup的形式进行分组。

    十一、GUI和计算模块的对接

    由于GUI上实际只有两个按钮,因此GUI和计算模块只有两个函数进行对接。代码如下:

    QtGui_Wordlist::QtGui_Wordlist(QWidget *parent): QMainWindow(parent)
    {
        ui.setupUi(this);
        connect(ui.pushButton_generate_chain, SIGNAL(clicked()), this, SLOT(gen_chain()));
        connect(ui.pushButton_save_in_file, SIGNAL(clicked()), this, SLOT(save_file()));
    }
    

    其中gen_chain()和save_file()分别是点击pushButton_generate_chain和pushButton_save_in_file的槽函数。槽函数的实现和正常实现基本相同,在槽函数中调用Core.dll中的api接口,唯一区别在于读取和输出的位置不同,GUI的数据由text()从box读入,由setPlainText()输出到box。

    UI

    GUI实现的功能有:

    从文本框读入文件路径:

    读入文件

    从文本框中输入单词进行查找:

    输入单词

    -w和-c两种方式的选择:

    -w

    -c

    -h和-t的使用:

    -h

    -t

    -r的使用:

    -r1

    -r2

    文件导出:

    文件导出

    十二、描述结对的过程,提供非摆拍的两人在讨论的结对照片

    结对过程:因为第一堂软工课程坐在相邻的位置上,自然而然组成了结对作业的搭档。

    为了达到作业目标的要求,只要双方的时间允许就会在一起进行结对编程。从最初的计划,设计,到编码,测试,再到最后的博客撰写,都存在着结对完成和明确分工两个状态的存在。

    编码过程较为符合结对编程的要求,我们面向同一个电脑,对项目进行构建,两人轮流编码,轮流复审。在后期,做出了些许的分工。例如她负责测试的工作,我负责gui的工作。这时,项目有什么问题都可以进行随时的交流,效率很高。

    在之后的博客撰写中,在共同内容的部分,也进行了分工,写各自较为熟悉的内容,可以更好地展示我们的项目和思想。

    最后,非常感谢我的搭档,这整个任务对我也是一个不小的挑战,她帮助了我许多,让我坚持下来,也收获了不少的知识。

    照片

    十三、结对编程的优缺点

    优点:

    • 结对编程是两个人的合作,无论是心理上的监督作用,还是工作中集思广益的效率提升,结对编程都能加快两个人的项目进展。

    • 由于结对编程可以达到不断复审,而且不同于团队项目中的开会、测试等复审,而是在写代码时,两个人坐在同一台电脑前,在写代码的同时就进行了第一次复审,“被两个脑袋思考过”,因此会减少错误。

    • 此外,结对的两人可能技能水平存在差距,但在结对编程的过程中,同坐在一台电脑前,在代码的设计和编写上都有着平等的权力。

    缺点:

    • 在遇到等待运行结果等时间上,浪费的时间变成了两个人的时间。

    • 此外,一切开发的阶段,需要两个人独自思考,结对反而影响了思考的效率。

    • 如果两个人都不主动去思考问题,或者一方在思考另一方已经懈怠,很容易影响结对的情绪和效率。

    评价张圆宁:

    优点:

    • 坚持不懈,对繁复的测试工作认真完成,尝试一切办法完成测试任务。

    • 善于沟通,她会经常和我讨论项目的思路和想法,征求我的意见。

    • 尽管我们两个都有实习,仍然每天下班后熬夜碰头写代码,为项目付出自己大量的时间,真的很感谢她为项目的努力。

    缺点:

    • 由于我的强迫症,对代码风格吹毛求疵,有时候会修改她比较豪放的代码风格。

    十四、PSP实际结果

    见第二节。

    十五、松耦合测试——成功

    1. 合作小组

      16061200 陈治齐 16061076 顾展鹏

    2. 问题1:我们的main函数中有部分对于异常的处理,这些异常都是封装好了在core.h头文件中。在使用我们的主函数和GUI及对方的Core.dll之后,主函数因为找不到Core.h中的自定义异常,编译不通过,对方的exe和GUI无法运行。

      解决办法:只要删除我们main函数中的异常处理模块即可正常运行,更为正规的流程应该是在互换公用模块的时候分享并扩充异常处理模块。

      解决结果:修改main中异常处理后成功运行,通过正确样例。

    3. 问题2:对方的主函数和GUI无法使用我们的.dll文件,原因在于对方是动态调用,我们是静态调用。对方忘记修改.def文件以适应我们新的.dll。

      我们组内使用的是静态调用:静态调用比较简单,编译DLL项目前,给.h文件中的函数前加上__declspec(dllexport) ,以生成.lib文件。将.lib文件拷贝到其他项目中后,只需引用.h头文件即可使用.dll(.cpp)文件的函数和类。

      合作组使用的是动态调用:加载dll文件,在.def文件中写明.dll中的函数。若能够正确从.dll中取到函数所在地址,直接调用即可完成DLL的动态调用。

      解决办法:对方应当更新.def文件为我方的.dll中的函数,即可运行。

      修改结果:对方更新.def后成功运行我方.dll,通过正确样例。

    十六、心得体会

    讲道理,光是这个结对项目,就值两学分了……几乎每天都是下班以后熬夜写代码,尝试测试软件。不知道告诉了多少组我们用了什么测试工具成功做出了覆盖率,这背后是我们对一个又一个覆盖率工具的尝试和失败,半夜一两点回到宿舍,第二天又是带着睡眼去上班。尽管最后也没能很快跑完大的测试集,但是总算是完成了项目。这次项目让我终于认识到了什么是Debug和Release模式,什么又是解决方案和项目(工程)的区别,什么是DLL,又如何进行测试。但是似乎课程组在要求一些工具时,对工具的推荐和介绍过于少了。助教在群里说的工具在vs2017 community上并不支持。或许很多组在最后一天问出了其他组如何完成覆盖率测试,但我们花费一两个晚上寻找到了能用的覆盖率软件真的十分痛苦。此外,甚至出现了qt要求x64运行,而测试要求x86运行,我们只能在一个人的电脑上运行x64的qt和源代码,另一人的电脑上运行x86的源代码和测试……但总的来说,还是学到了一些东西。感谢老师和助教对我们项目的帮助。

  • 相关阅读:
    animation动画应用--android游戏开发
    Codeforces Round #203 (Div. 2)
    Codeforces Round #206 (Div. 2)
    Codeforces Round #204 (Div. 2): C
    Codeforces Round #204 (Div. 2): B
    Codeforces Round #204 (Div. 2): A
    Codeforces Round #205 (Div. 2) : D
    Codeforces Round #205 (Div. 2) : C
    Codeforces Round #205 (Div. 2) : B
    Codeforces Round #205 (Div. 2) : A
  • 原文地址:https://www.cnblogs.com/bvb1909/p/10533454.html
Copyright © 2020-2023  润新知