SE_Work3_結隊項目

項目 內容
課程:北航-2020-春-軟件工程 博客園班級博客
要求:求交點個數 結對項目做業
班級:005 Sample
GitHub地址 intersect
北航網盤地址 SE結隊項目

1. PSP 表格記錄下你估計將在程序的各個模塊的開發上耗費的時間

PSP2.1 Personal Software Process Stages 預估耗時(分鐘) 實際耗時(分鐘)
Planning 計劃
· Estimate · 估計這個任務須要多少時間 10 10
Development 開發
· Analysis · 需求分析 (包括學習新技術) 30 180
· Design Spec · 生成設計文檔 30 30
· Design Review · 設計複審 (和同事審覈設計文檔) 5 0
· Coding Standard · 代碼規範 (爲目前的開發制定合適的規範) 5 0
· Design · 具體設計 60 120
· Coding · 具體編碼 240 400
· Code Review · 代碼複審 60 0
· Test · 測試(自我測試,修改代碼,提交修改) 120 240
Reporting 報告
· Test Report · 測試報告 60 120
· Size Measurement · 計算工做量 10 10
· Postmortem & Process Improvement Plan · 過後總結, 並提出過程改進計劃 240 240
合計 870 1350

之因此實際耗時遠在估計耗時之上,是由於結隊雙方沒有充分交流,由於互相之間干擾強烈,最後變成了各作各的項目。最後實際上全部部分(包括計算模塊和UI模塊)都是一我的完成的。我只能說:經過live的遠程交流,真是太太太太不方便了!而結隊編程自己並未達到其應有的效果。html

2. 接口設計

看教科書和其它資料中關於 Information Hiding,Interface Design,Loose Coupling 的章節,說明大家在結對編程中是如何利用這些方法對接口進行設計的。(5')node

信息隱藏、接口設計、鬆耦合都是面向對象設計的重要方法,都是使程序設計時更接近平常認識,在大模塊之間關係中不用過於擔憂細節,只需在模塊設計時下功夫。c++

信息隱藏:git

  • 在類中,定義的變量和方法能夠再前面加上一個下劃線"_"來標識,這是一個好的命名規範,能夠避免無心中對私有成員進行賦值
  • 類與類之間交換信息時,要交流私有變量時,要用事先設計好的方法來訪問,這樣若是咱們在其它類裏面調用另一個類的私有變量,那麼咱們必須定義set和get方法
  • 在實現代碼過程當中,過分的靈活性反而會帶來錯誤率的提高,故咱們可使得類中的信息對外不可見

接口設計:github

  • 一個好的接口可以提供給後面的程序設計一個良好的框架
  • 在此次結隊項目裏,Diagram做爲一切圖形(包括Line, Circle)的父類(接口),而Line同時包括了線段和射線
  • 咱們經過Diagram能很快的調用其intersect、tostring方法,而不用關心具體是哪個圖形實現的;這樣咱們的軟件測試也變得更簡單了

鬆耦合:算法

  • 這種類與類之間依賴性低的設計方法,使一個類與另一個類彷彿隔開了,它們之間只是經過消息來聯繫的,因此設計一類時,能夠不用擔憂破壞另一個類。如Line和Circle類
  • 當代碼有改動時,能夠不用大規模的改動咱們的代碼,咱們只用定位於一個出問題的模塊,而後對其進行更改就行了,並且能作到不改變其它模塊的服務
  • 在覈心模塊中只有兩個函數add_diagram和sub_diagram,和一個全局變量point_set是能夠直接調用的。故任何在覈心模塊的錯誤,只在核心模塊去測試改正,而不用去動界面模塊的代碼

固然,面向接口應當適度使用,也爲不少狀況下,接口的實現是定死的,好比說,若是線型只有直線、線段、射線三種,都有兩個端點屬性,就不須要單首創建Ray和Segment兩個類了,只須要在Line中添加一個type字段,不然顯得更累贅。「爲了接口而寫接口」的作法是愚蠢的,應該是「爲了需求而寫接口」。編程

3. 計算模塊接口的設計與實現過程

設計包括代碼如何組織,好比會有幾個類,幾個函數,他們之間關係如何,關鍵函數是否須要畫出流程圖?說明你的算法的關鍵(沒必要列出源代碼),以及獨到之處。(7')數據結構

計算模塊實現擴展射線與線段添加/刪減圖形計算交點進行部分錯誤處理的核心功能。app

  1. 首先爲了存儲交點,創建了類Dot,使用C++STL的set。由於C++的set採用紅黑樹生成,必須重載<=,實現方法同SE_Work2_交點個數。又由於C++不支持double相等運算,必須本身寫equals方法。框架

    #define equals(a, b) (fabs((a) - (b)) < EPS)
    bool operator<(const Dot &p) const {return !equals(first, p.first) ? first < p.first : second < p.second;}
    bool operator==(const Dot &p) const {return equals(first, p.first) && equals(first, p.first);}
  2. 保存四類不一樣的圖形,創建了三個類:Diagram,Line,Circle,爲了統一接口,咱們的Diagram是全部圖形的統一接口。咱們必須使父類爲抽象類(使用virtual函數),Diagram *纔可以動態匹配到子類上。

    class Diagram {
    public:
        ...
        virtual ~Diagram() = default;
        virtual string tostring() = 0;
        void intersect(Diagram *diagram);
    };
  3. 本次做業擴展了線段及射線兩種圖形,爲了實現射線及線段與其餘圖形的交點,必須判斷交點是在兩點之間仍是在射線之上。因此須要給Dot類設定兩個方法:

    inline bool onray(Dot *s, Dot *t) {
        return (first - s->first) * (t->first - s->first) >= 0 && (second - s->second) * (t->second - s->second) >= 0;
    }
    
    inline bool onsegment(Dot *s, Dot *t) {
        return (first - s->first) * (first - t->first) <= 0 && (second - s->second) * (second - t->second) <= 0;
    }

    s和t分別對應射線或線段的起點和終點,經過以上方式能夠判斷該點是否在該射線或線段上,而在intersect方法中也只用加一句:

    void Line::intersect(Line *l) {
        try {
            Dot *d = intersect0(l);
            if (!has_dot(d) || !l->has_dot(d)) return;
            add_pair(this, l, d);
        } catch (exception e) {}
    }
  4. 這是本次設計中最爲精彩的地方,能夠說核心模塊一半的工程量都在這裏!

    界面模塊:支持幾何對象的添加、刪除。

    是的,添加容易,可是刪除一個幾何對象,難道不是須要從頭開始對其他每一個對象從新計算一次嗎?若是已經有了上千個幾何對象,刪除一個對象都須要幾分鐘的時間!雖然這一需求在界面模塊,可是若是我不提供一個高效接口來刪除一個幾何對象,根本不可能實現這一需求!

    最開始,咱們但願每一個圖形和每一個點之間有一個對應關係。也就是創建map,可是,若是有上萬個節點和上千個圖形,就意味着有上萬個map,而map的每個value都是集合!在空間複雜度上是徹底不能接受的。

    後來,咱們想到,其實上節點和圖形之間其實是一個巨大的稀疏矩陣。若是節點在圖形上意味着對應的位置爲1,不然爲0。實際上存儲這樣一個龐大的矩陣有更高效的方式——舞蹈鏈

    舞蹈鏈是一種雙向循環十字鏈表。在如圖所示的樣例中:四個圖形(1圓2線段3直線4)有五個交點(\(I_1-I_5\)),圖形做爲舞蹈鏈的列首,交點做爲舞蹈鏈的行首。交點在圖形上則圖形節點的列鏈上和交點的行鏈上同時出現一個節點。

    這種數據結構可以清晰地看到某個節點是哪幾個圖形相交得來的,同時經過圖形,咱們也能夠很是便捷地找到對應的節點。同時對於舞蹈鏈的動態構建和變化也十分靈活。

    然而,正如「舞蹈鏈是一種指針的舞蹈」所說,一旦出現處理不到爲的地方,很容易出現空指針或者未定義的現象。雖然舞蹈鏈對於時間和空間的佔用並不大,維護一個舞蹈鏈的複雜度仍是很高的。

  5. 舞蹈鏈實現過程

    Node結構:

    因爲是十字雙向鏈表,含有指向上下左右四個指針,同時含有diagramdot字段表示該節點對應的圖形和交點。

    除了Head之外,其餘Node分爲三種,圖形對應的Node,交點對應的Node,和(圖形,交點)這種聯繫對應的Node。以下所示爲三種狀況下的構造方法。

    class Node {
    public:
        Node *up;
        Node *down;
        Node *left;
        Node *right;
        Diagram *diagram;
        Dot *dot;
    
        Node() : diagram(nullptr), dot(nullptr), left(this), right(this), down(this), up(this) {}
        Node(Diagram *d) : Node() { diagram = d; }
        Node(Dot *d) : Node() { dot = d; }
        Node(Diagram *d1, Dot *d2) : Node() { diagram = d1; dot = d2; }
    };

    (圖形,交點)關係的構建:

    在求出一個交點後,要分別構建(diagram1,diagram2,dot)對應Node節點,在構建以前,須要判斷是否已經有(圖形,交點)關係 。分爲如下三步:

    void add_pair(Diagram *d1, Diagram *d2, Dot *dot) {
        Node *n = get_node(dot);		// 1. 找到交點對應的節點
        Node *d = n->right;
        bool valid1 = true, valid2 = true;
        while (d != n) {							// 2. 對於兩個圖形是否已經存在該關係
            if (d->diagram == d1) valid1 = false; 
            if (d->diagram == d2) valid2 = false;
            d = d->right;
        }
    
        if (valid1) {
            // 3. 若是不存在則須要從新構建
            Node *p = new Node(d1, dot);
            n->left_insert(p);
            get_node(d1)->up_insert(p);
        }
        ...
    }

    圖形的刪除:

    在刪除一個圖形時,經過圖形的節點,對其全部的(圖形,交點)關係判斷(如1),中間節點對應的交點只有少於兩個圖形則刪除該交點(如2)。

    void Node::invalify() {
        if (dot == nullptr) {
            // 1. 該節點是Diagram頭結點
            Node *d = down;
            Node *dd = d->down;
            while (d != this) {
                d->invalify();
                d = dd;
                dd = d->down;
            }
        } else {
            // 2. 該節點是中間結點
            if ((right->diagram == nullptr && left->left == right)
                || (left->diagram == nullptr && right->right == left)) {
                left->remove();
                right->remove();
            }
        }
        remove();
    }

4. 畫出 UML 圖顯示計算模塊部分各個實體之間的關係

閱讀有關 UML 的內容:https://en.wikipedia.org/wiki/Unified_Modeling_Language。(畫一個圖便可)。(2’)

詳見SE_Work3_UML圖

5.計算模塊接口部分的性能改進

記錄在改進計算模塊性能上所花費的時間,描述你改進的思路,並展現一張性能分析圖(由VS 2015/2017的性能分析工具自動生成),並展現你程序中消耗最大的函數。(3')

N 時間(ms)
200 16
400 71
600 188
800 334
1000 604
2000 3242
3000 6998
4000 14559

如表所示,本次核心模塊幾乎是上次功能耗時的兩倍。經過性能分析工具獲得耗時函數以下:

最耗時的是Line的構造函數:由於在構造內部還進行了邊界點的計算,爲了跟UI部分進行對接,須要計算直線或射線在(-10000,10000)邊界上的點來替代其端點。

Line::Line(int x0, int y0, int x1, int y1, char ty) {
    // 轉換成通常式,並保證互質,同時a要非負,a爲0,b要非負
    double divider = gcd(gcd(abs(y1 - y0), abs(x0 - x1)), abs(x1 * y0 - x0 * y1));
    if (equals(divider, 0)) handle_error("Line::Line\ttwo dots coincide!");
    a = (y1 - y0) / divider;
    b = (x0 - x1) / divider;
    c = (x1 * y0 - x0 * y1) / divider;
    if (a < 0 || (a == 0 && b < 0)) {
        a = -a;
        b = -b;
        c = -c;
    }
    s = new Dot(x0, y0);
    t = new Dot(x1, y1);
    type = ty;

    // 更新端點值,以便後續做圖
    if (type == 'L') {
        if (equals(b, 0)) {
            s = new Dot(-c / a, -REIGN);
            t = new Dot(-c / a, REIGN);
        } else if (equals(a, 0)) {
            s = new Dot(-REIGN, -c / b);
            t = new Dot(REIGN, -c / b);
        } else {
            set<Dot> dot_stack;
            if (INREIGN((-c + a * REIGN) / b)) dot_stack.insert(Dot(-REIGN, (-c + a * REIGN) / b));
            if (INREIGN((-c + b * REIGN) / a)) dot_stack.insert(Dot((-c + b * REIGN) / a, -REIGN));
            if (INREIGN((-c - a * REIGN) / b)) dot_stack.insert(Dot(REIGN, (-c - a * REIGN) / b));
            if (INREIGN((-c - b * REIGN) / a)) dot_stack.insert(Dot((-c - b * REIGN) / a, REIGN));
            auto it = dot_stack.begin();
            s = new Dot((*it).first, (*it).second);
            it++;
            t = new Dot((*it).first, (*it).second);
        }
    } else if (type == 'R') {
        if (equals(b, 0)) {
            t->second = s->second < t->second ? REIGN : -REIGN;
        } else if (equals(a, 0)) {
            t->first = s->first < t->first ? REIGN : -REIGN;
        } else {
            Dot *dot = new Dot(-REIGN, (-c + a * REIGN) / b);
            if (dot->onray(s, t)) {
                t = dot;
                return;
            }
            dot = new Dot((-c + b * REIGN) / a, -REIGN);
            if (dot->onray(s, t)) {
                t = dot;
                return;
            }
            dot = new Dot(REIGN, (-c - a * REIGN) / b);
            if (dot->onray(s, t)) {
                t = dot;
                return;
            }
            dot = new Dot((-c - b * REIGN) / a, REIGN);
            if (dot->onray(s, t)) {
                t = dot;
                return;
            }
        }
    }
}

6. 契約設計

看 Design by Contract,Code Contract 的內容:

描述這些作法的優缺點,說明你是如何把它們融入結對做業中的。(5')

契約設計採起前置條件,後置條件和對象不變式的形式。實際上這種設計方式起源於合同,該「合同」定義:

  • 供應商必須提供某種產品(義務),並有權指望客戶已支付其費用(利益)——前置條件
  • 客戶必須支付費用(義務),並有權得到產品(利益)——後置條件
  • 雙方必須履行適用於全部合同的某些義務,例如法律和法規——不變式

優勢:

  • 跳過方法的實現,直接描述方法的功能
  • 規範化的註釋,而且可以被自動檢測正確性
  • 定義詳細的函數接口,使用時沒必要再擔憂函數具體實現流程,開發函數時也有明確的需求,沒必要擔憂需求的變更

缺點:

  • 部署自動化軟件進行檢測代價大而複雜
  • 書寫規範化的JML代碼甚至比直接寫源代碼還要複雜

關於JML的實現,在面向對象課程中OO_Unit3_JML規格模式已經有所領教,本次做業主要是使用契約設計進行了接口設計。在UI模塊只須要計算模塊的兩個函數及一個map,計算模塊自己保證了其實現求交點、添加圖形、刪減圖形的功能正確性。

7. 計算模塊部分單元測試展現

展現出項目部分單元測試代碼,並說明測試的函數,構造測試數據的思路。並將單元測試獲得的測試覆蓋率截圖,發表在博客中。

單元測試的設計主要在對於不一樣形狀的增添與刪減上。在原有基礎上增添一個圖形,或者刪減一個圖形一共8種狀況分別進行了單元測試。測試的對象爲 add_diagramsub_diagram

如圖所示,雖然最後整體覆蓋率爲88.89%,但測試的樣例基本上已經覆蓋8種狀況,因爲時間的緣由,沒有進行深刻覆蓋。

8. 計算模塊部分異常處理說明

在博客中詳細介紹每種異常的設計目標。每種異常都要選擇一個單元測試樣例發佈在博客中,並指明錯誤對應的場景。(5')

錯誤類型 輸入(其中一種) 描述 輸出
線型圖形的重合 2
L 1 2 3 4
R 0 1 -1 0
線型圖形共線,有無數個交點 add_diagram repeated lines or collinear lines
圓的重合 2
C 0 0 1
C 0 0 1
- add_diagram repeated circles
線型圖形的輸入點重合 1
L 25 72 25 72
端點重合,不能肯定 Line::Line two dots coincide!
文件沒法打開 intersect.exe 沒法讀取文件 cannot open file: <name>
輸入格式錯誤 L 1 2 3 4
R 0 1 -1 0
缺乏數量參數 why not input a N?
圖形類型未定義 1
A 25 72 25 23
未定義類型A line <i> undefined type!
(UI)刪除未定義圖形 - 在UI界面內刪除某圖形,但該圖像不存在 required diagram not found!
(cmd)不合要求的命令行參數 intersect.exe in.txt 在cmd界面沒有命令行參數選項 please type right input!

9. 界面模塊的詳細設計過程

在博客中詳細介紹界面模塊是如何設計的,並寫一些必要的代碼說明解釋實現過程。(5')

我使用了QT進行圖像繪製,QT基於C++開發,自己也是一門很複雜的編程軟件,光是學習QT的使用方法,就花了整整半天,能夠說本次做業量實在是太大了,而且有問題的是:QT的dll文件與VS不兼容!須要在QT中從新封裝模塊。基於QWidget組件進行座標系及圖形繪製,UI模塊須要支持的功能:

  1. 拖拽文件進入界面做爲輸入

    Widget類中定義相應函數實現文件拖拽進行輸入

    ///判斷是否爲有效的文件
    virtual bool IsValidDragFile(QDropEvent *e);
    
    ///接受目錄
    /// @note 遍例目錄,調用AcceptFile
    virtual void AcceptFolder(QString folder);
    
    ///接受文件
    virtual void AcceptFile(QString pathfile);

    AcceptFile中進行詳細的輸入定義及錯誤處理:

    void Widget::AcceptFile(QString pathfile)
    {
        ifstream file;
        cout<<"reading " << pathfile.toStdString()<<endl;
        file.open(pathfile.toStdString());
    
        if(!file) handle_error("cannot open file: "+pathfile.toStdString());
        char s;
        int num, x0, y0, x1, y1;
    
        try{
            file>>num;
        } catch(exception()) {
            handle_error("why not input a N?");
        }
    
        for (int i = 0; i < num; i++) {
            if (file >> s) {
                if (s == 'L' || s == 'R' || s == 'S') {
                    if (file >> x0 >> y0 >> x1 >> y1) add_diagram(s, x0, y0, x1, y1);
                } else if (s == 'C') {
                    if (file >> x0 >> y0 >> x1) add_diagram(s, x0, y0, x1, 0);
                } else {
                    handle_error("line " + DoubleToString(i + 1) + " format error");
                }
            } else {
                    handle_error("need more lines");
            }
        }
    }
  2. 在文字框中輸入,可使用「添加圖像」或「刪減圖像」

    定義槽,並設計UI界面:

    private slots:
        void on_add_diagram_clicked();
    
        void on_sub_diagram_clicked();

    實現相應的槽函數:

    void Widget::on_add_diagram_clicked()
    {
        stringstream streambuf(ui->input->text().toStdString());
        char s;
        int x0, y0, x1, y1;
        if (streambuf >> s) {
            if (s == 'L' || s == 'R' || s == 'S') {
                if (streambuf >> x0 >> y0 >> x1 >> y1) {
                    add_diagram(s, x0, y0, x1, y1);
                    return;
                }
            } else if (s == 'C') {
                if (streambuf >> x0 >> y0 >> x1) {
                    add_diagram(s, x0, y0, x1, 0);
                    return;
                }
            }
        }
        handle_error("input format error");
    }
  3. 繪製圓、線型、點,並顯示出全部交點的個數

    paintEvent()函數中實現刷新繪製功能,該函數每幀調用一次,能實現窗口視圖的實時刷新:

    void Widget::paintEvent(QPaintEvent *event)
    {
        ...
    
        QPainter painter2(&image);
        QRectF rec(DisplayPtoObjectP(rect_topl), DisplayPtoObjectP(rect_bottomr));
    
    //    cout<<"repaint! "<<circles.size()<<" "<<lines.size()<<endl;
        for(auto &it:circles) {
            drawCircle(it.x, it.y, it.r, &painter2);
        }
        for (auto &it:lines) {
            drawLine(it.s->first, it.s->second, it.t->first, it.t->second, &painter2);
        }
        for (auto &it:point_map) {
            drawPoint(it.first.first, it.first.second, &painter2);
        }
        ui->textBrowser->clear();
        ui->textBrowser->append(QString::number(point_map.size()));
    
       ...
    
        painter.drawImage(paint_org, image);
    }

    因爲繪圖座標系(相對)與QWidget座標系(絕對)之間存在轉換關係,故必須對繪製的圖形和點進行座標系變換,同時由於點在屏幕上現實太小,必須在點周圍畫一個小圓來強調,這種圓不會隨着圖像的縮放而變更:

    QPointF Widget::ValuePtoObjectP(QPointF valPoint)
    {
        return DisplayPtoObjectP(QPointF(valPoint.rx() * pixel_per_mm + offsetv_x, valPoint.ry() * pixel_per_mm + offsetv_y));
    }
    
    
    void Widget::drawLine(double x1, double y1, double x2, double y2, QPainter* painter) {
        painter->drawLine(ValuePtoObjectP(QPointF(x1, y1)), ValuePtoObjectP(QPointF(x2, y2)));
    }
    
    void Widget::drawCircle(double x, double y,double r, QPainter* painter){
        painter->drawEllipse(ValuePtoObjectP(QPointF(x, y)), r * pixel_per_mm, r * pixel_per_mm);
    }
    
    void Widget::drawPoint(double x, double y, QPainter* painter){
        painter->drawPoint(ValuePtoObjectP(QPointF(x,y)));
        painter->drawEllipse(ValuePtoObjectP(QPointF(x, y)), 3, 3);
    }
  4. 標出座標系及相應刻度,而且能進行縮放,平移

    這方面較爲複雜,要實現如下函數,在此略:

    QPointF scaleIn(QPointF pos_before, QPointF scale_center, double scale_value);
    QPointF scaleOut(QPointF pos_before, QPointF scale_center, double scale_value);
    
    void paintEvent(QPaintEvent *event);
    void wheelEvent(QWheelEvent *event);
    void mousePressEvent(QMouseEvent *event);
    void mouseMoveEvent(QMouseEvent *event);

10. 界面模塊與計算模塊的對接

詳細地描述 UI 模塊的設計與兩個模塊的對接,並在博客中截圖實現的功能。(4')

接口設計

計算模塊封裝成dll文件,其中頭文件有以上全局變量和函數。有circleslines兩個集合是爲了繪製圖形,有point_map是爲了繪製交點。調用add_diagramsub_diagram便可進行圖像的增長和刪除。

  • void Widget::paintEvent(QPaintEvent *event)中調用了point_map,circles,lines進行繪圖
  • on_add_diagram_clicked()中調用了add_diagram加入圖形
  • on_sub_diagram_clicked()中調用了sub_diagram刪去圖形

實現功能

注:以上窗口中座標系可經過縮放及平移,而且咱們能夠經過拖拽.txt文件進行輸入。

11. 描述結對的過程

提供兩人在討論的結對圖像資料(好比 Live Share 的截圖)。關於如何遠程進行結對參見做業最後的注意事項。(1')

如圖是使用了騰訊會議的桌面共享功能和QQ交流的截圖。

12. 結隊編程優缺點

看教科書和其它參考書,網站中關於結對編程的章節。例如:http://www.cnblogs.com/xinz/archive/2011/08/07/2130332.html ,說明結對編程的優勢和缺點。同時描述結對的每個人的優勢和缺點在哪裏(要列出至少三個優勢和一個缺點)。(5')

結隊編程 結隊夥伴
優勢 1.兩我的考慮問題的方式會比一我的更全面;2.有監督效果使得編程不會那麼放鬆,更能集中注意力;3.通過雙人複審,有效減小bug數 代碼熟練;執行力快;擅長學習新知識 心細踏實;能很快找到軟件bug;思考全面
缺點 監督編程可能會干擾到對方,雙方的代碼風格及習慣可能不兼容,磨合期不能成功渡過就沒法完成項目 輕視軟件測試部分 代碼書寫速度較慢

13. 結隊模塊交換

因爲和對方團隊(1506102五、 17373263 )提早商量好了接口,所以模塊的替換較爲容易,基本無需更改。

可是因爲對方沒有計算直線與邊界的端點,咱們繪製的圖像只能按照線段的方式來繪製。以下圖所示:

雖然繪製出來仍是線段,點都標明的很清楚。但通過與對方小組的討論,咱們發現有QT的第三方庫支持直線的繪製,不像我傻傻地去計算直線與邊界的交點。

基本上此次模塊交換很是便捷,咱們時限就定義好了接口。只須要根據對方的定義的改一下名稱和習慣,導入對應的庫,就能很快的生成:

他們的接口:

咱們的接口:

相關文章
相關標籤/搜索