-----------回顧分割線-----------
系列之一講述了游戲規則,系列之二講述了舊版的前臺效果、代碼中不好的地方、以及新版的改進核心,此篇開始就是新版代碼編寫全過程。此系列旨在開發類似“誰是臥底+殺人游戲”的捉鬼游戲在線版,記錄從分析游戲開始的開發全過程,通過此項目讓自己熟悉面向對象的SOLID原則,提高對設計模式、重構的理解。
索引目錄:
0. 索引(持續更新中)
1. 游戲流程介紹與技術選用
2. 設計業務對象與對象職責劃分(1)(圖解舊版本)
3. 設計業務對象與對象職責劃分(2)(舊版本代碼剖析)
4. 設計業務對象與對象職責劃分(3)(新版本業務對象設計)
5. 業務對象核心代碼編寫與單元測試(游戲開始前:玩家入座與退出)
……(未完待續)
-----------回顧結束分割線-----------
先放上svn代碼,地址:https://115.29.246.25/svn/CatGhost/
賬號:guest 密碼:guest(支持源代碼下載,已設只讀權限,待我基本做出初始版本后再放到git)
-----------本篇開始分割線-----------
從本篇開始,后續都是代碼的編寫記錄了,看的會有些枯燥,但如果是開發人員,應該會看的津津有味。建議看了前面四篇說明之后(個人覺得本系列亮點還是在前四篇,有助于面向對象開發的思考方式整理),再下個svn代碼。我寫的這些就不用再看代碼了,大致感受一下流程和思路即可。(個人對于寫完整個項目后直接貼代碼上來毫無講解的那種文章比較那個……反正我是看不下去的)
代碼是為文中的闡述服務的,即:主要看中文,必要理解時才看代碼。
一、按類圖搭建基礎類模型
上一篇中的這張關鍵類圖,想必大家還有印象,下面就先從這副類圖入手,先把類名、屬性名、方法名搭建起來,方法都是空的(即便再簡單的方法都先不寫,若要求返回值,也大致應付一下別出紅線),訪問修飾符也暫不過多考慮,如下兩類:
public abstract class Player { public string NickName { get; set; } public void Speak() { } public void Vote() { } }
public class SpeakManager { PRivate StringBuilder _record; public void PlayerSpeak() { } public void SystemSpeak() { } public string ShowRecord() { if (this._record != null) { return this._record.ToString(); } return string.Empty; } public void ClearRecord() { } public void SetSpeaker() { } }
二、從單元測試開始,從類創建、初始化類內屬性、維護類屬性的角度,逐步完善類
以下是Table類單例模式的測試,很簡單
[TestMethod]public void CreateTableUnitTest(){ Table table = Table.GetInstance(); Assert.IsNotNull(table);}
private static Table _table = new Table();private Table() { }public static Table GetInstance(){ return _table;}
以下是Setting類初始化的同時,獲取配置文件游戲人數的測試,附上目前的Setting類,也比較好理解
[TestMethod]public void GetSettingCountUnitTest(){ Setting setting = Setting.GetInstance(); int iTotalCount = setting.GetTotalCount(); int iExpectCount = 9, iExpectCivilianCount = 4, iExpectIdiotCount = 2, iExpectGhostCount = 3; Assert.AreEqual(iExpectCount, iTotalCount); Assert.AreEqual(iExpectCivilianCount, setting.CivilianCount); Assert.AreEqual(iExpectIdiotCount, setting.IdiotCount); Assert.AreEqual(iExpectGhostCount, setting.GhostCount);}
[TestMethod]public void IsFullFromSettingUnitTest(){ Setting setting = Setting.GetInstance(); bool iExpectFalse = setting.IsFull(8); bool iExpectTrue = setting.IsFull(9); Assert.AreEqual(false, iExpectFalse); Assert.AreEqual(true, iExpectTrue);}
public class Setting { // field private int _civilianCount; private int _idiotCount; private int _ghostCount; // property public int CivilianCount { get { return _civilianCount; } } public int IdiotCount { get { return _idiotCount; } } public int GhostCount { get { return _ghostCount; } } #region singleton private static Setting _setting = new Setting(); private Setting() { InitialCount(); } /// <summary> /// 初始化配置文件人數 /// </summary> private void InitialCount() { string strCivilianCount = System.Configuration.ConfigurationManager.AppSettings["CivilianCount"]; string strIdiotCount = System.Configuration.ConfigurationManager.AppSettings["IdiotCount"]; string strGhostCount = System.Configuration.ConfigurationManager.AppSettings["GhostCount"]; this._civilianCount = StringConvertToInt32(strCivilianCount); this._idiotCount = StringConvertToInt32(strIdiotCount); this._ghostCount = StringConvertToInt32(strGhostCount); } /// <summary> /// 轉換字符串為數字 /// </summary> /// <param name="strNumber">字符串</param> /// <returns>數字</returns> private int StringConvertToInt32(string strNumber) { int result = 0; int.TryParse(strNumber, out result); return result; } public static Setting GetInstance() { return _setting; } #endregion // method /// <summary> /// 返回游戲總人數 /// </summary> /// <returns>總人數</returns> public int GetTotalCount() { return CivilianCount + IdiotCount + GhostCount; } /// <summary> /// 是否玩家已滿 /// </summary> /// <param name="iCurrentCount">當前玩家數</param> /// <returns>是否已滿</returns> public bool IsFull(int iCurrentCount) { return GetTotalCount() == iCurrentCount; } }
以下是PlayerManager類增減玩家名單的測試
[TestMethod]public void GetPlayerNameArrayUnitTest(){ PlayerManager manager = new PlayerManager(); string[] names = new string[] { "jack", "peter", "lily", "puppy", "coco", "kimi", "angela", "cindy","vivian" }; for (int order = 0; order < names.Length; order++) { string name = names[order]; manager.SetPlayer(order, name); } string[] array = manager.GetNameArray(); Assert.AreEqual(names.Length, array.Length); manager.DeletePlayer(5); Assert.AreEqual(names.Length, array.Length); foreach (string name in array) { Console.WriteLine(name); }}
public class PlayerManager { // field private string[] _nameArray; private Player[] _playerArray; // method public PlayerManager() { InitialArray(); } /// <summary> /// 初始化數組個數 /// </summary> private void InitialArray() { Setting setting = Setting.GetInstance(); int totalCount = setting.GetTotalCount(); this._nameArray = new string[totalCount]; this._playerArray = new Player[totalCount]; } /// <summary> /// 設置指定座位號的玩家名 /// </summary> /// <param name="order">座位號</param> /// <param name="nickName">玩家名</param> public void SetPlayer(int order, string nickName) { this._nameArray[order] = nickName; } public void SetPlayer(Player player) { } /// <summary> /// 刪除指定座位號的玩家名 /// </summary> /// <param name="order">座位號</param> public void DeletePlayer(int order) { this._nameArray[order] = string.Empty; } /// <summary> /// 返回玩家名單數組 /// </summary> /// <returns>玩家名單數組</returns> public string[] GetNameArray() { return this._nameArray; } /// <summary> /// 返回指定角色的玩家數組 /// </summary> /// <param name="type">玩家角色</param> /// <returns>玩家數組</returns> public Player[] GetPlayerArray(Type type) { List<Player> returnList = new List<Player>(); foreach (Player player in this._playerArray) { if (player.GetType().Equals(type)) { returnList.Add(player); } } return returnList.ToArray(); } }
在逐漸完善類的過程中,需要注意幾點:
(1)命名問題:方法名別怕長,一定要寫清楚,英文不好就找翻譯,再不行就用拼音,再不行就寫中文。
(2)新方法的加入:如Setting中獲取游戲總人數的方法GetTotalCount(),以后在PlayerManager類需要以此為據初始化數組大?。ㄗ粩盗浚筮€有許多這種在設計階段沒考慮到的方法,也反襯出:設計階段不要過于糾結如何做到最perfect的類設計之后再去寫代碼,而是做個大概,主要是劃分職責(單一職責原則),以后再寫代碼的時候發現需要這么一個方法了再去添加(也要考慮這個方法/職責應該劃分給誰的問題)。
(3)訪問修飾符問題:多考慮迪米特法則(知道最少原則)——如果外界沒必要知道,就private,然后再慢慢升級訪問修飾符。好處是:在編寫單元測試的時候就能夠最早看出此類哪些是有必要開放的、哪些是要保護起來的,因為單元測試是最先覆蓋所有類內路徑的測試,比整個項目做得差不多了再考慮、或者邊做邊考慮,前者更完整(程序員都有代碼潔癖,別跟我說你沒有)。
(4)注釋寫得好,代碼改的少。
相信上述幾點已經耳濡目染很久了,這里寫出來也是為和我一樣深有感悟的新手們做再次提醒,自己的感悟+別人的提醒=記憶更深刻。
三、自定義異常
我指的自定義是類似這樣的:
namespace Catghost.Common.Exceptions{ public sealed class NickNameIsNullOrEmptyException : Exception { public NickNameIsNullOrEmptyException() { throw new Exception("昵稱不能為空。"); } }}
// 檢查昵稱是否為空 if (string.IsNullOrEmpty(nickName)) { throw new NickNameIsNullOrEmptyException(); } // 檢查昵稱是否重復 foreach (string name in this._nameArray) { // 跳過空座位的檢測 if (string.IsNullOrEmpty(name)) { continue; } if (name.Equals(nickName)) { throw new NickNameRepeatException(); } }
單元測試情況:
系統不是會自己拋異常嗎,如果需要寫文字,不是還有new Exception(string)重載嗎,干嘛還要自定義手寫一堆?理由很簡單:復用。
假設有五個方法都要判斷昵稱是否為空,你可能會想到提取出一個private方法或者在common文件夾下新建類并將CheckNickName()公開,這是對的,復用嘛,但如果將異常也放在此類,甚至更糟糕的放在各處消費代碼中時,那提示的中文語句可能是“用戶名不能為空”、“您的用戶名為空了”、“對不起,請保證不為空的用戶名”——各種不一致,各種不復用。而且自定義異常也許在項目中后期會增加很多,甚至是你的隊友們在增加,那怎么溝通呢?
團隊約定這時候出現作用了:約定所有異常放在Common/Exceptions文件夾下,且命名有規則……巴拉巴拉,其實團隊約定真的很重要,不然你寫了半天的日期格式化,結果是團隊大哥早寫好放那的(以前我就遇到過這種蠢事),所以從頭開始項目團隊的時候,拿出一份Word,一條條寫著團隊約定(在哪個文件夾放什么東西、命名規則如何、注釋如何寫等等)。同樣,進入新的團隊或者有新人加入的時候,一定要自己主動去問或者告訴新人:這個項目的開發約定有哪些。
四、分析代碼
如此高大上的章節標題,當然要依賴高級點的功能,其實我不是很能參透這一塊,看了《重構》就稍微能理解多一些了:
右鍵解決方案管理器中的根目錄->分析
(1)運行代碼分析
意思是要加個可序列化標簽(沒明白深層原因,實現了ISerializble接口就要標記?回頭再明白吧,目前先走完項目,寫這個開發系列的好處也在這里,不明白的先記下,回頭再看,以免影響主要事件),行,那就加上。果斷ok。
(2)計算代碼度量值
這里是本篇亮點(利用重構提高可維護性、減低耦合與復雜度)
簡單介紹一下,代碼度量值是檢測代碼可維護性較好的指標。
可維護性指數(越大越好):表示指定代碼容易修改、利于應對需求變換的程度。
類耦合度(越小越好):高內聚低耦合,說的就是這里,表示這里的代碼與其他類之間的直接關系有多大,關系越小就越不容易一改都改——越容易維護。
圈復雜度(越小越好):表示代碼中的分支情況有多少的問題,當然是越少越利于開發人員理解,越容易維護。
繼承深度、代碼行數(都是越小越好):簡單不贅述。
來吧開始:看到圖中鼠標點亮的那一行——PlayerManager類的SetPlayer()方法,此方法職責是在玩家點擊“入座”按鈕后,將玩家名字添加到列表中(很簡單吧,這有什么難的?看下去,有戲)??梢钥吹娇删S護性非常之低(56,還沒及格呢),耦合(7)、復雜度(10)看似比較低,但放眼看去其他方法(不要看成上面的其他類或者目錄總和)的這些指標,都很?。?以下),說明:這個方法的代碼沒寫好、要大改!
先來看看這個方法都寫了啥
/// <summary> /// 設置指定座位號的玩家名 /// </summary> /// <param name="order">座位號</param> /// <param name="nickName">玩家名</param> public void SetPlayer(int order, string nickName) { // 檢查座位序號正確性 if (order < 0 || order >= this._nameArray.Length) { throw new IndexOutOfRangeException("入座位置不正確。"); } // 檢查昵稱是否為空 if (string.IsNullOrEmpty(nickName)) { throw new NickNameIsNullOrEmptyException(); } // 檢查昵稱是否重復 foreach (string name in this._nameArray) { // 跳過空座位的檢測 if (string.IsNullOrEmpty(name)) { continue; } if (name.Equals(nickName)) { throw new NickNameRepeatException(); } } this._nameArray[order] = nickName; if (Setting.GetInstance().IsFull(this._nameArray.Where(n => !string.IsNullOrEmpty(n)).Count())) { this._table.StartGame(); } }
感覺還是比較清晰的,通俗易懂,還有注釋——沒錯,就是注釋出的問題:在《重構》中,注釋就是最大的需要改的信號——你會對string myName="jack"; 添加一段這樣的注釋嗎? // 我的名字是jack
肯定不會。為什么?你可能會回答:因為我一看就懂,不用注釋,只有難懂的、太長的代碼,才需要注釋。
沒錯,那既然知道這是一段難懂、太長的代碼,為何不去優化、拆分,使他變得又容易、又簡短呢?下面就隨筆者來做這個事兒:
(1)先測試,保證目前的代碼是ok的。好了,測了,ok。
(2)把“檢查座位序號正確性”的代碼提出去,起個好名字,消掉注釋。
代碼改為:
/// <summary> /// 設置指定座位號的玩家名 /// </summary> /// <param name="order">座位號</param> /// <param name="nickName">玩家名</param> public void SetPlayer(int order, string nickName) { CheckSeatOrder(order); // 檢查昵稱是否為空 if (string.IsNullOrEmpty(nickName)) { throw new NickNameIsNullOrEmptyException(); } // 檢查昵稱是否重復 foreach (string name in this._nameArray) { // 跳過空座位的檢測 if (string.IsNullOrEmpty(name)) { continue; } if (name.Equals(nickName)) { throw new NickNameRepeatException(); } } this._nameArray[order] = nickName; if (Setting.GetInstance().IsFull(this._nameArray.Where(n => !string.IsNullOrEmpty(n)).Count())) { this._table.StartGame(); } } /// <summary> /// 檢查座位號正確性 /// </summary> /// <param name="order">座位號</param> private void CheckSeatOrder(int order) { if (order < 0 || order >= this._nameArray.Length) { throw new IndexOutOfRangeException("座位號不正確。"); } }
(3)回到(1)——測試。全部通過。
(4)繼續(2)——找到其他注釋的地方,提取方法,起個好名字,消掉注釋。
(5)測試。全部通過。
直到SetPlayer()代碼成為下面這樣(提取后的我就不貼了,都一樣的):
public void SetPlayer(int order, string nickName) { CheckSeatOrder(order); CheckNickNameIsNullOrEmpty(nickName); CheckNickNameIsRepeat(nickName); this._nameArray[order] = nickName; if (Setting.GetInstance().IsFull(this._nameArray.Where(n => !string.IsNullOrEmpty(n)).Count())) { this._table.StartGame(); } }
此時我們再來測一下代碼度量值:
哎喲不錯喔!(80后的偶像周杰倫?。┛删S護性提高了9個點,耦合與復雜度都減小了一大半,嘿嘿~說明路子對了,那么咱繼續優化,看能做到多好。
此時的SetPlayer方法中,最主要的就是一句 this._nameArray[order] = nickName; 這一句已經不能再簡單了。除此之外,都是附加的內容(前三行的檢測、之后的也算是檢測——檢測夠不夠人開始游戲)。那么要改的肯定是附加內容的。代碼先貼一下:
if (Setting.GetInstance().IsFull(this._nameArray.Where(n => !string.IsNullOrEmpty(n)).Count())){ this._table.StartGame();}
怎么看有問題的地方?其實很簡單,計算機的腦子轉的很快(只要內存夠),我們看10秒,計算機10毫秒不到,所以代碼的問題,主要不是為了給計算機方便,而是給人方便,給程序員方便,作為程序員,你覺得看哪里不順眼、不爽,那就是有問題。
這段代碼中我看if的判斷里面就很長,不爽!
改為這個樣子(也是用的提取方法):
if (GetSetting().IsFull(GetCurrentPlayerCount())){ this._table.StartGame();}
哈哈,爽多了,簡直跟看中文一樣,只不過換成英文罷了。
還有問題的地方很明顯,if判斷中,需要看完整個代碼才明白是做什么的,此時再做一次提取方法,成為這樣:
public void SetPlayer(int order, string nickName){ CheckSeatOrder(order); CheckNickNameIsNullOrEmpty(nickName); CheckNickNameIsRepeat(nickName); this._nameArray[order] = nickName; CheckStartGame();}
感覺嗨了吧,現在回想一下,從一開始的一長段代碼,成為這個樣子,有點寫純粹寫英文方法名就能完成方法一樣,看起來簡直不要太爽。
在最后這個if的判斷這里,也許有的朋友會問,為什么不直接把整個if換掉,而要先換if判斷里面的問題:
首先我想你認可的是:if判斷里面肯定有問題。那么,如果直接換掉整個if,你還會記得要繼續去解決if里面本來就像毒瘤一樣存在的問題嗎?一段代碼的臭味道并沒有什么,但如果養成想一步登天的壞習慣來執行重構,這是最糟糕的了。所以咱還是鼓勵當傻逼,看見了也說沒看見,每次只做一點點。
當然,別忘了每改動一處,就測試一次。
現在我們再來測一下代碼度量值:(也許你會擔心那一堆多出來的方法會不會使得整個類太臃腫,答案是no,還是那句話,計算機根本不怕麻煩,最怕麻煩也因為繁瑣而容易出錯的是人,只要那些提取出來的方法都是private,外界就不會知道,也就是程序員外界調用此類時也根本不會知道這個類里面到底有多少私有方法)
哎呀媽呀!(甜馨威武~)各指標數據也是好看的不要不要的。(還能不能更優化?此處拋磚引玉,大家可下載源碼自行嘗試)
五、總結
1. 搭建基礎類模型【從無到有的質變,總好過對著一個空的class就開始無頭緒的寫】
2. 從單元測試開始,逐步完善類【保障正確率,而不是建了前臺才測試】
3. 自定義異?!纠谥赜谩?/p>
4. 代碼分析【通過重構,優化代碼,利于那一堆好處——易維護、易復用、易擴展、靈活性好——四個好處都有對應的意思和做法,詳細請參照程杰版《大話設計模式》】
上述四步是我在做這個項目(其他項目,也許會有其他的步驟)到目前為止,不斷在迭代(循環重復)操作的過程,使得代碼不斷向更正確更好的方向前進。建議大家把對自己有用的開發流程(也許我的只是小項目可用,大家可以多看大牛們的文章)當做內功一樣來修為,而不是外在的尚方寶劍(拿到了才NB,沒拿到就是菜B),要把思路練到不怕丟不怕忘,甚至總結自己的方法步驟。
不說了,得覓食去了,回來繼續照著上面步驟改代碼去,爭取早點兒提交svn給大家下載最新源碼(本篇的代碼已提交)。
新聞熱點
疑難解答