天天看點

軟體工程結對項目作業

軟體工程結對項目作業

1.在文章開頭給出教學班級和可克隆的 Github 項目位址。
項目 内容
課程連結 2020春季計算機學院軟體工程(羅傑 任健)
作業要求 結對項目作業
教學班級 006
項目位址 https://github.com/17373432/Pairng-Project
2.在開始實作程式之前,在下述 PSP 表格記錄下你估計将在程式的各個子產品的開發上耗費的時間。
PSP2.1 Personal Software Process Stages 預估耗時(分鐘) 實際耗時(分鐘)
Planning 計劃
· Estimate · 估計這個任務需要多少時間 10
Development 開發
· Analysis · 需求分析 (包括學習新技術) 120
· Design Spec · 生成設計文檔 30
· Design Review · 設計複審 (和同僚稽核設計文檔)
· Coding Standard · 代碼規範 (為目前的開發制定合适的規範)
· Design · 具體設計
· Coding · 具體編碼 300 480
· Code Review · 代碼複審 240 180
· Test · 測試(自我測試,修改代碼,送出修改) 420 600
Reporting 報告
· Test Report · 測試報告
· Size Measurement · 計算工作量
· Postmortem & Process Improvement Plan · 事後總結, 并提出過程改進計劃
合計 1330 1630

​ 由于疫情原因,這次結對作業并沒有像書上說的那樣面對面程式設計,因為交流實在太不友善了,是以更偏向于兩人分工,各做各的。再加上事先沒有商量好編譯器配置和平台,導緻交接時受到了一定的影響,我主要負責封裝部分和錯誤處理,但是在把編譯器的平台從

x86

改成

x64

之後不知道改了什麼設定,就再也無法生成

.dll

檔案了,是以每次有改動都得把原碼發給隊友,讓他幫忙生成

.dll

檔案再發過來,十分不便。

3.看教科書和其它資料中關于 Information Hiding,Interface Design,Loose Coupling 的章節,說明你們在結對程式設計中是如何利用這些方法對接口進行設計的。
  • 資訊隐藏:

    類中的屬性全為私有屬性,對于其他類來說屬性不可見。如果其他類要通路類中屬性,需通過

    get()

    方法來實作。隻暴露必要接口,核心部分中的類以及各種屬性對外不可見。
  • 接口設計:

    核心部分隻留了一個

    getPoints()

    的接口,通過傳入一個文本資訊流,傳回這些對象的交點集合或者是異常資訊。
  • 松耦合:

    類與類之間獨立,隻能通過調用方法傳遞資訊來産生聯系,是以修改一個類對其他的類影響不大。核心部分與外部隻通過接口産生聯系,是以修改一方對另一方影響不大。

4.子產品接口的設計與實作過程。設計包括代碼如何組織,比如會有幾個類,幾個函數,他們之間關系如何,關鍵函數是否需要畫出流程圖?說明你的算法的關鍵(不必列出源代碼),以及獨到之處。
  • 主類:将各個類聯系起來,完成基本功能以及錯誤處理。
    • 屬性:
      • 線的集合,用于存儲所有的直線、線段、射線的資訊,用vector儲存。
      • 圓的集合,用于儲存所有的圓的資訊,用vector儲存。
      • 點的集合,用于儲存所有交點的資訊,用unordered_set儲存。
      • 其他用于輔助判斷資料是否合法的屬性。
    • 方法:求解交點,判斷兩條線是否重合等方法。
  • Line類:采用一般式\(Ax+By+C=0\),避免了斜率為正無窮的讨論。
    • 屬性:基本的A,B,C的值,線的種類,線的兩個端點坐标,以及其他簡化計算的屬性。
    • 方法:求與直線的交點,判斷點是否線上上,以及構造函數,傳回屬性的值,重載小于運算符等方法。
  • Point類:
    • 屬性:橫坐标x,縱坐标y,交于此點的直線集合。
    • 方法:構造函數,傳回屬性的值,添加經過這個點的直線,重載小于、等于運算符等方法。
  • Circle類:
    • 屬性:圓心(使用Point),半徑,以及其他簡化計算的屬性。
    • 方法:求與直線的交點,求與圓的交點,以及構造函數,傳回屬性的值,重載小于運算符等方法。
    其他方法:計算Point類的hash值,計算pair的hash值,保留四位有效數字,浮點數比較等方法。

關鍵函數:

  • 判斷兩條線是否重合:

    因為對于線來說隻有截距相同才可能會重合,是以事先儲存線的截距到線的映射,通過截距将線分組,這樣隻用同一組的線是否重合,具體判斷如下:

    result = false;
    if (兩條線都為線段) {
    	if (某線段某端點在另一條線段兩端點之間) {
            if (兩線段隻有一個點p重合) {
                pointSet.insert(p);
            }
            else {
            	result = true;
            }
        }
    }
    else if (兩條線都為射線) {
    	if (某射線端點在另一條射線上) {
            if (兩射線隻有一個點p重合) {
                pointSet.insert(p);
            }
            else {
            	result = true;
            }
        }
    }
    else if (兩條線分别為一條射線與一條線段) {
    	if (線段某端點在射線上) {
            if (兩條線隻有一個點p重合) {
                pointSet.insert(p);
            }
            else {
            	result = true;
            }
        }
    }
    return result;
               

    僞代碼看起來很簡單,但實作起來十分繁瑣,這個函數寫了140多行,通過畫圖将各種情況畫出來,然後再寫的代碼,這樣寫條理十厘清楚,也并沒有寫出bug。

    更重要一點,在這裡将所有截距相同的線的交點全部算出來了,是以之後算線與線的交點時可以不必考慮平行的線的交點,可直接複用上一次代碼,也節省了計算。

  • 判斷點是否線上上:

    因為我設計的射線和線段隻是在直線的基礎上加了一個屬性來判斷線的種類,是以算點是否線上上是通過先假定該線為直線,計算出直線的交點坐标(x, y),之後需比較坐标是否在範圍内即可,具體實作過程如下:

    bool isOnLine(double x, double y) {
    	bool result = true;
    	if (是線段) {
    		if (線段的兩端點橫坐标不相等) {
    			result = (x是否線上段兩端點橫坐标之間);
    		}
    		else {
    			result = (y是否線上段兩縱坐标之間);
    		}
    	}
    	else if (是射線) {
    		if (構造射線的兩端點橫坐标不相等) {
    			result = (x是否在射線延伸的一邊);
    		}
    		else {
    			result = (y是否在射線延伸的一邊);
    		}
    	}
    	return result;
    }
               
    這裡我一開始隻傳了橫坐标

    x

    ,認為隻用

    x

    就能判斷點是否線上上,這樣就導緻了當線段或者射線兩端點橫坐标相同時,我會把這些交點全當成線上上的點(應為兩端點橫坐标相同,是以交出來的點橫坐标一定在兩點之間),這樣就導緻我的點算多了,我花了很久才定位出這個bug,是以說不能為了省時間沒考慮全面就想當然的寫代碼,否則debug浪費的時間就更多。

    另外,我在這個函數中加入了點是否在直線上的判斷後,最終求得的點數量變少了,從理論上進行分析加不加這個判斷對結果應該是沒有影響的,通過調試才發現浮點誤差通過加減乘除運算之後,誤差會變得更大,導緻超過了一開始設定的浮點精度\(10^{-12}\),是以才判斷出計算出直線的交點不在直線上的情況,通過電腦驗算,誤差達到了\(0.03\),說明四則運算對浮點誤差的影響很大,然而我隻考慮了比較時的浮點誤差,沒有考慮計算時誤差會變化的情況,這個地方雖然可以不用加點是否在直線上的判斷進而忽略這個問題,但這個問題是普遍存在的,也不好避免,我想到最後也隻有用不同的精度:計算前取高精度,比較時用低精度。但這樣也不能完全解決問題,這次作業資料範圍是\(10^5\),假設

    double

    類型精度為\(10^{-15}\),計算直線時直線的參數\(a,b\)的範圍會變成\(10^5\),精度變成\(10^{-14}\),\(c\)的範圍變成\(10^{10}\),精度變成\(10^{-9}\),然後在計算交點時,分子的範圍會變成\(10^{15}\),精度變成\(10^{-3}\),這已經到了無法忽視的地步了,因為沒考慮精度變化,是以很有可能兩個點相同但是判為不同或者是不同的點判為相同的情況。這個問題我并沒有想到一個好的方法解決。
  • 浮點數的hash函數:

    注意浮點數在hash前要保留有效數字,防止浮點誤差對hash值産生影響。

  • 接口:

    通過讀取資料資訊,傳回交點個數或者抛出異常。

    extern "C" void __declspec(dllexport) 
        getPoints(vector<pair<double, double>> & points, stringstream & in);
               
    一開始的想法是設計

    addObj()

    ,

    deleteObj()

    這些接口的,但是考慮到删除可能和重新導入一遍資料的複雜度差不了多少,是以幹脆就将删除操作定義為先删除資料再将資料重新導入,這樣設計了之後,感覺單單為了一個增加操作而在

    GUI

    上再寫一遍錯誤處理不值得,是以索性就把增加操作也換成導入資料資訊,最後将幾個接口合而為一,變成上述單一的接口,雖然在改動時開銷更大,但是實作起來簡單了許多,也減少了代碼的備援。
5.閱讀有關 UML 的内容:https://en.wikipedia.org/wiki/Unified_Modeling_Language。畫出 UML 圖顯示計算子產品部分各個實體之間的關系(畫一個圖即可)。
軟體工程結對項目作業
6.計算子產品接口部分的性能改進。記錄在改進計算子產品性能上所花費的時間,描述你改進的思路,并展示一張性能分析圖(由VS 2015/2017的性能分析工具自動生成),并展示你程式中消耗最大的函數。

這是\(6000\)個資料,最終交點個數為\(10146161\)個點的性能分析圖:

軟體工程結對項目作業

因為隻有一個接口,是以這個接口開銷最大:

軟體工程結對項目作業
7.看 Design by Contract,Code Contract 的内容:
  • http://en.wikipedia.org/wiki/Design_by_contract
  • http://msdn.microsoft.com/en-us/devlabs/dd491992.aspx
描述這些做法的優缺點,說明你是如何把它們融入結對作業中的。

DbC的主要思想是如果一個方法在程式設計中提供一種特定的功能,它可以滿足:

  • 任何調用它的子產品在輸入時都會保證一定的條件,即該方法的前置條件。
  • 保證退出時具有一定的屬性,即方法的後置條件。
  • 維護一個确定的屬性,該屬性假定是在進入時假定的并且在退出時保證的,即不變式。

優點:

  1. 提供必要的說明,調用者無需知道這個方法實作過程,隻需知道這個方法的具體功能。
  2. 明确輸入和輸出資訊,便于調試。
  3. 各方法之間獨立,修改部分方法對其他部分影響不大。
  4. 寫代碼的人可直接根據契約的要求來進行實作,無需考慮其他。

缺點:

  1. 契約有時候甚至比代碼本身更加複雜。

這種契約例如

JML

之前在面向對象課程中已經體驗過了,雖然實作起來十分複雜,但是一旦實作後後續工作會十分輕松,因為本次就一個接口,隻需保證輸入一個字元串流,就能傳回交點集合或者抛出異常,在

GUI

或指令行程式調用接口時遵守這個契約,就能保證這部分運作的正确性。

8.計算子產品部分單元測試展示。展示出項目部分單元測試代碼,并說明測試的函數,構造測試資料的思路。并将單元測試得到的測試覆寫率截圖,發表在部落格中。要求總體覆寫率到 90% 以上,否則單元測試部分視作無效。

部分單元測試代碼以及測試的函數如下所示。

TEST_METHOD(TestMethod14)
	    {
			//測試平行于x軸直線,測試line類
			Proc p;
			string s = "2 L 1 0 2 0\n L -1 1 -1 2";
			std::stringstream ss(s);
			p.process(ss);
			vector <pair<double, double>> result;
			p.getPointSet(result);

			Assert::AreEqual(1, (int)result.size());
		}
	TEST_METHOD(TestMethod15)
		{
			//測試直線、線段、射線之間沖突,測試line類
			Proc p;
			string s = "7 L 1 3 4 2\n L -1 4 5 2\n S 2 4 3 2\n R 2 5 -1 2\n C 3 3 3\n C 2 							2 1\n C 3 2 2";
			std::stringstream ss(s);
			p.process(ss);
			vector <pair<double, double>> result;
			p.getPointSet(result);

			Assert::AreEqual(20, (int)result.size());
		}
	TEST_METHOD(TestMethod16)
		{
			//大型暴力測試,主要測試proc類
			Proc p;
			string s = "34\nL 1 3 4 2\nL -1 4 5 2\nS 2 4 3 2\nR 2 5 -1 2\nC 3 3 3\nC 2 2 						1\nC 3 2 2\nL 99999 99999 -99999 -99999\nL -99998 99998 99998 							-99998\nR 0 99 -1 100\nS 0 99 1 98\nS 2 97 1 98\nS 2 97 3 96\nS 4 						95 3 96\nS 4 95 5 94\nS 6 93 5 94\nR 99 0 100 -1\nR 99 0 100 1\nR 0 						 99 -1 -100\nS 0 -99 1 -98\nS 1 -98 2 -97\nS 99 0 98 -1\nS 3 -96 						4 -95\nS 2 -97 3 -96\nS 99 0 98 1\nS 11 88 10 89\nS 12 87 11 88\nS 						  10000 10000 99999 10000\nS 10000 9999 10000 10000\nR 8888 8888 							8888 8889\nS 1245 1245 1244 1247\nS 1244 1244 1243 1246\nS 2444 						2444 2443 2447\nS 2442 2442 2443 2445\n\n\n\n\n\n\n\n";
			std::stringstream ss(s);
			p.process(ss);
			vector <pair<double, double>> result;
			p.getPointSet(result);

			Assert::AreEqual(54, (int)result.size());
		}
           

​ 我們構造單元測試的思路主要是首先針對小資料,特定情況,對特定情況的測試大概覆寫完全後進行更大規 模的測試。

​ 單元測試通過截圖:

軟體工程結對項目作業

​ 單元測試覆寫率截圖:

軟體工程結對項目作業
9.計算子產品部分異常處理說明。在部落格中詳細介紹每種異常的設計目标。每種異常都要選擇一個單元測試樣例釋出在部落格中,并指明錯誤對應的場景。

公共父類:

class InputException :public exception {
private:
	string msg;
public:
	InputException(string e) {
		msg = e;
	}
	const char* what() const throw() {
		cout << msg << endl;
		return msg.data();
	}
	string getMsg() const {
		return msg;
	}
};
           

N不在範圍内:

\(1<=N<=500000\)

class NumberException :public InputException {
public:
	NumberException() :
		InputException("N is out of range!\n") {};
	const char* what() const throw() {
		cout << getMsg() << endl;
		return getMsg().data();
	}
};
           

樣例1:

輸出1:

N is out of range!
           

樣例2:

500001
           

輸出2:

N is out of range!
           

參數不在範圍内:

範圍為 \((-100000, 100000)\)

class OutOfRangeException :public InputException {
public:
	OutOfRangeException(int i, string str) :
		InputException("Line " + to_string(i) + ": \"" + str + "\"\nThe parameter(s) is(are) out of range!\n") {};
	const char* what() const throw() {
		cout << getMsg() << endl;
		return getMsg().data();
	}
};
           

樣例3:

4
C 3 3 3
S 222222 4 3 2
L -1 4 5 2
R 2 5 -1 2
           

輸出3:

Line 2: "S 222222 4 3 2"
The parameter(s) is(are) out of range!
           

構造線的兩點重合:

class CoincideException :public InputException {
public:
	CoincideException(int i, string str) :
		InputException("Line " + to_string(i) + ": \"" + str + "\"\nThe two points coincide!\n") {};
	const char* what() const throw() {
		cout << getMsg() << endl;
		return getMsg().data();
	}
};
           

樣例4:線段兩點重合

4
C 3 3 3
S 2 4 2 4
L -1 4 5 2
C 2 5 1
           

輸出4:

Line 2: "S 2 4 2 4"
The two points coincide!
           

圖形重疊:

class CoverException :public InputException {
public:
	CoverException(int i, string str) :
		InputException("Line " + to_string(i) + ": \"" + str + "\"\nOverlap with added drawings!\n") {};
	const char* what() const throw() {
		cout << getMsg() << endl;
		return getMsg().data();
	}
};
           

樣例5:圓與圓重合

5
C 3 3 3
S 2 4 3 2
L -1 4 5 2
R 2 5 -1 2
C 3 3 3
           

輸出5:

Line 5: "C 3 3 3"
Overlap with added drawings!
           

樣例6:以線段與線段重合為例

5
C 3 3 3
S 2 4 4 0
L -1 4 5 2
R 2 5 -1 2
S 3 2 5 -2
           

輸出6:

Line 5: "S 3 2 5 -2"
Overlap with added drawings!
           

對象數目不等于N:

class NumOfObjException :public InputException {
public:
	NumOfObjException() :
		InputException("The number of geometric objects is not equal to N!\n") {};
	const char* what() const throw() {
		cout << getMsg() << endl;
		return getMsg().data();
	}
};
           

樣例7:對象數大于N

3
C 3 3 3
S 2 4 3 2
L -1 4 5 2
R 2 5 -1 2
           

輸出7:

The number of geometric objects is not equal to N!
           

樣例8:對象數小于N

5
C 3 3 3
S 2 4 3 2
L -1 4 5 2
R 2 5 -1 2
           

輸出8:

The number of geometric objects is not equal to N!
           

輸入格式錯誤:

class FormatException :public InputException {
public:
	FormatException(int i, string str) :
		InputException("Line " + to_string(i) + ": \"" + str + "\"\nThe format of input is illgal!\n") {};
	const char* what() const throw() {
		cout << getMsg() << endl;
		return getMsg().data();
	}
};
           

樣例9:參數有前導零

4
C 3 3 3
S 2 4 3 2
L -01 4 5 2
R 2 5 -1 2
           

輸出9:

Line 3: "L -01 4 5 2"
The format of input is illgal!
           

樣例10:辨別字母小寫

4
C 3 3 3
s 2 4 3 2
L -1 4 5 2
R 2 5 -1 2
           

輸出10:

Line 2: "s 2 4 3 2"
The format of input is illgal!
           

圓的半徑不大于零:

class LessThanZeroException :public InputException {
public:
	LessThanZeroException(int i, string str) :
		InputException("Line " + to_string(i) + ": \"" + str + "\"\nRadius of circle must be greater than zero!\n") {};
	const char* what() const throw() {
		cout << getMsg() << endl;
		return getMsg().data();
	}
};
           

樣例11:半徑小于0

4
C 3 3 3
S 2 4 3 2
L -1 4 5 2
C 2 5 -1
           

輸出11:

Line 4: "C 2 5 -1"
Radius of circle must be greater than zero!
           

樣例12:半徑等于0

4
C 3 3 3
S 2 4 3 2
L -1 4 5 2
C 2 5 0
           

輸出12:

Line 4: "C 2 5 0"
Radius of circle must be greater than zero!
           

空檔案:

class EmptyFileException :public InputException {
public:
	EmptyFileException() :
		InputException("The input file is empty!\n") {};
	const char* what() const throw() {
		cout << getMsg() << endl;
		return getMsg().data();
	}
};
           

樣例13:

輸出13:

The input file is empty!
           

檔案第一行不是N:

class NoNException :public InputException {
public:
	NoNException() :
		InputException("The first line of input file is not a number!\n") {};
	const char* what() const throw() {
		cout << getMsg() << endl;
		return getMsg().data();
	}
};
           

樣例14:

C 3 3 3
S 2 4 2 5
L -1 4 5 2
C 2 5 1
           

輸出14:

The first line of input file is not a number!
           

指令行參數有誤:

樣例15:

./intersect.exe
           

輸出15:

Right Format: intersect.exe -i <path to input file> -o <path to output file>
           

檔案無法打開:

樣例16:

./intersect.exe -i dir -o output.txt
           

輸出16:

Input File Cannot Open!
           

最後錯誤處理的單元測試:

軟體工程結對項目作業
10.界面子產品的詳細設計過程。在部落格中詳細介紹界面子產品是如何設計的,并寫一些必要的代碼說明解釋實作過程。

界面子產品利用

VS

Qt

插件開發。

GUI

共含4個視窗。分别如下所示:

軟體工程結對項目作業
軟體工程結對項目作業
軟體工程結對項目作業

第一個視窗為主視窗,在其中我們進行互動、輸出圖像資訊。剩下三個視窗全部用來進行手動導入資料。

主視窗的圖像繪制是我們自己手繪的,因為沒有找到很好的繪制圖像的類。我們将

paintEvent

函數重定向到

widget

元件中,使

widget

元件收到重繪的指令後執行

widgetPaint

函數對整個畫面重新繪制。

繪制過程中比較重要的是坐标與像素之間的轉換。

double QtPairProject::transferX(double x) {
	int pointx = 20;
	int width = length - 20 - pointx;
	double kx = (double)width / (x_max - x_min );
	return pointx + kx * (x - x_min);
}

double QtPairProject::transferY(double y) {
	int pointy = wide - 20;
	int height = wide - 40;
	double ky = (double)height / (y_max - y_min );
	return pointy - (y - y_min) * ky + offset;
}
           

互動控件主要控制以下功能,修改坐标軸的範圍、從檔案導入資料、手動導入資料、删除

lineWidget

中被選中的資料、清空

lineWidget

,坐标圖和輸出欄、以及繪制交點功能。右下角的輸出框用于程式運作情況的輸出,例如異常報告、繪制交點情況回報。

其餘三個視窗用于手動導入資料。在這四個視窗之間我們借助

signals

從子視窗向父視窗發送資訊。

例如在主視窗

QtPairProject.h

中使用

signals:
	void sendsignal(QString);
           

QtPairProject.cpp

void QtPairProject::OnOpenButtonClick() {
	win->show();
}

void QtPairProject::getData(QString data) {
	this->show();
}
           

接收子視窗向父視窗發送的資訊。

在子視窗

NewQtGuiClass.h

中定義

public slots:
	void sendData();
           

NewQtGuiClass.cpp

使用

NewQtGuiClass::NewQtGuiClass(QWidget *parent)
	: QWidget(parent)
{
	ui.setupUi(this);
	connect(ui.pushButton, SIGNAL(clicked()), this, SLOT(pushButton()));
}

void NewQtGuiClass::sendData() {
	emit sendsignal(buf);
	buf = "";
	this->close();
}
           

向父視窗傳遞資訊。

在子視窗的資料輸入以及修改坐标範圍的輸入中,我們采用了正規表達式檢驗以及整型數驗證控件來限制輸入的範圍。

QRegExp rx("^-?\\d{1,6}$");
QRegExpValidator* v = new QRegExpValidator(rx, this);
QIntValidator *intValidator = new QIntValidator;
intValidator->setRange(-100000, 100000);
ui.lineEdit->setValidator(v);
ui.lineEdit_2->setValidator(v);
intValidator->setRange(0, 100000);
ui.lineEdit_3->setValidator(intValidator);
           
12.界面子產品與計算子產品的對接。詳細地描述 UI 子產品的設計與兩個子產品的對接,并在部落格中截圖實作的功能。

我們通過輸入流來實作界面子產品與計算子產品的對接。

extern "C" void __declspec(dllexport) getPoints(vector<pair<double, double>> & points, stringstream & in);
           

即GUI元件将輸入打包成控制台輸入的格式通過流傳輸給計算子產品,由計算子產品傳回計算得到的交點數組。

實作的功能:

  • 修改坐标範圍
軟體工程結對項目作業
  • 從檔案導入
軟體工程結對項目作業
  • 手動導入
軟體工程結對項目作業
  • 删除所選資料
軟體工程結對項目作業
  • 繪制交點
軟體工程結對項目作業
  • 清空
軟體工程結對項目作業
12.描述結對的過程,提供兩人在讨論的結對圖像資料(比如 Live Share 的截圖)。關于如何遠端進行結對參見作業最後的注意事項。

QQ螢幕分享:

軟體工程結對項目作業

微信交流:

軟體工程結對項目作業
13.看教科書和其它參考書,網站中關于結對程式設計的章節,例如:http://www.cnblogs.com/xinz/archive/2011/08/07/2130332.html ,說明結對程式設計的優點和缺點。同時描述結對的每一個人的優點和缺點在哪裡(要列出至少三個優點和一個缺點)。
結對程式設計 結對夥伴
優點 1.使代碼得到充分複審,及時找出bug;2.有人監督寫代碼不容易分心;3.兩個人合作比一個人單幹效率高 1.能想到巧妙的方法簡化問題;2.有耐心;3.及時回複隊友消息 1.善于發現bug;2.學習新知識快;3.代碼基本功好
缺點 兩個人同時有空的時間不多,溝通不充分會阻礙進度 考慮問題不周到,寫了很多bug 有時候回複消息不及時

消除所有警告

軟體工程結對項目作業

附加題

  • 支援圓(已實作)

繼續閱讀