【C# Lab】基於Winform的GPA計算程序——開發小結

前言

最近剛完成大三的考試,距離上次寫博客有個把月了。考慮到身邊很多小夥伴需要計算均分來看看自己是否能獲得推免機會,便有了設計一款輕量級、方便成績導入導出的GPA計算窗體程序的想法,當然朋友們的需求只是我做出此次實踐的動機之一,更重要的是,我想借此次窗體程序設計進一步擴充自己的“技能樹”——熟悉C#的語法的同時儘可能地把這學期學過的數據庫相關的知識用於實踐中。
寫下這篇blog時我已經完成整個項目的實踐,並在同學們的幫助下完成了初步的測試,訂正了2個Errors,目前大多數同學都反映能夠正常使用。整個項目開發週期也就零零散散5天不到,去掉瑣事佔用的時間可能就用了2-3天吧(至少對我來說算短的了),過程中遇到了不少問題,這裏面大多都通過百度解決了,當然也有百度上找不到具體方案、但通過某些博主的啓示與個人的調試&思考,最終成功解決的問題,這些問題我會盡可能把它們重現並說明我的解決方案,以便於自己在進行類似開發的時候可以避免重複採坑(如果能對你有幫助那就再好不過了~),這也是我寫此篇blog的主要目的。

1. 項目任務目標與開發流程

圖1 初始界面
圖2 操作指南

最近看《The Design of Design》(《設計原本》 Frederick P.Brooks著)有一句話讓我印象深刻:
如果設計理念本身是焦點,而不是拐彎抹角的表達或殘缺不全的細節,那麼溝通就可以非常直截了當。
只可惜本次開發實踐我並沒做好前期工作——還沒開始進行軟件基本的設計就開始實踐,或許是因爲本身這個項目架構並不複雜,因此我並沒有明顯感受到自己爲此付出的代價。之所以提這句話是因爲它讓我明白了與他人溝通的要訣——直入主題,從具體到一般(泛化)。這對我寫blog也很有指導意義,故下文中我將盡可能省去冗長的描述,從本次實踐中提取出關鍵問題,並加以討論。

1.1 任務目標

  • 進行VS2017風格的UI設計
  • 通過表格來添加數據,包括課程名稱(不必要)、學分和成績,並且能進行多行刪除
  • 可以導入導出數據,其中對導入的要求爲:能夠在以有數據的表格後追加導入;對導出的要求爲:即使沒有課程名稱也可以導出
  • 對於非法輸入作合適處理,對非法導入的數據有一定魯棒性

1.2 開發流程

①UI界面設計(扁平化) \rightarrowDataGridView數據綁定\rightarrowNPOI庫實現Excel與DataGridView的關聯——數據導入與導出\rightarrow④程序健壯性保障\rightarrow⑤程序細節完善(”幫助“與”關於“窗體設計)\rightarrow⑥初步測試與bug修復。

2. 整體架構

這裏作了一個類圖,並省去了一些次要信息,主要是展示各類的功能及類與類之間的關係,方便後續的說明。
在這裏插入圖片描述

3. 關鍵部分的代碼設計(DataGridView_Operate)

窗體程序設計時文件之間的交互與聯繫是很深的(如部分(partial)類),在表達功能時不便於也不必將完整的交互關係表示出來。這裏我遵循”泛化”原則,僅僅列出實現過程中的關鍵代碼,它們幾乎都位於類DataGridView_Operate中。

3.1 DataGridView列的創建&數據綁定

在dataGridView中添加3列,這裏我僅展示一列的完整添加方法,並且將無關緊要的部分註釋掉了

public static void Init(DataGridView dgv){
  DataGridViewTextBoxColumn col1 = new DataGridViewTextBoxColumn();
  //col1.HeaderText = "課程名";
  col1.DataPropertyName = "CourseName"; //不可或缺
  //col1.Name = "CourseName";
  //col1.Width = 150;
  dgv.Columns.Add(col1);
  //......
  MainForm.dataBindings = new BindingList<InfoVo>(MainForm.list);
  dgv.DataSource = MainForm.dataBindings;
  //dgv.CausesValidation = false; //驗證取消,這樣用戶輸錯了也不會彈出ErrorText
}

這裏的數據綁定,是指將不可視的BindingList<InfoVo>DataGridView關聯,使得其中任一方的變化都將同步反映至另一方。

3.2 NPOI庫實現Excel與DataGridView的關聯

鏈接:NPOI庫的添加方法

3.2.1 將數據從DataGridView導出至Excel表格中

public static void ExportData2Excel(DataGridView dgv)
{
    //注意空行時行數爲1 (NewRow)
    dgv.EndEdit(); //停止並保存dgv的所有編輯

    if (dgv.Rows.Count <= 1)
    {
        MessageBox.Show("沒有數據可供導出!", "Warning",
            MessageBoxButtons.OK, MessageBoxIcon.Warning);
        return;
    }
    else
    {
        SaveFileDialog sfd = new SaveFileDialog();
        sfd.Title = "請選擇導出路徑";
        sfd.Filter = "Excel Workbook 97-2003| *.xls";

        if (sfd.ShowDialog() == DialogResult.OK)
        {
            if (sfd.FileName != "")
            {
                //創建Excel文件對象
                NPOI.HSSF.UserModel.HSSFWorkbook book = new
                 NPOI.HSSF.UserModel.HSSFWorkbook();

                //添加一個sheet
                NPOI.SS.UserModel.ISheet sheet1 = book.CreateSheet("sheet1");

                //給sheet1添加第一行的頭部標題
                NPOI.SS.UserModel.IRow row1 = sheet1.CreateRow(0);
                row1.CreateCell(0).SetCellValue("課程名稱");
                row1.CreateCell(1).SetCellValue("學分");
                row1.CreateCell(2).SetCellValue("成績");
                
                for (int i = 0; i < dgv.RowCount; i++)
                {
                    if (dgv.Rows[i].IsNewRow)
                        continue;
                    NPOI.SS.UserModel.IRow rowtemp = sheet1.CreateRow(i + 1);
                    if(dgv.Rows[i].Cells[0].Value != null)
                        rowtemp.CreateCell(0).SetCellValue(
                            dgv.Rows[i].Cells[0].Value.ToString());
                    else
                        rowtemp.CreateCell(0).SetCellValue(
                            "課程" + (i+1).ToString());
                    rowtemp.CreateCell(1).SetCellValue(
                        dgv.Rows[i].Cells[1].Value.ToString());
                    rowtemp.CreateCell(2).SetCellValue(
                        dgv.Rows[i].Cells[2].Value.ToString());

                }
                System.IO.FileStream fs =
                       System.IO.File.OpenWrite(sfd.FileName.ToString());
                try
                {
                    book.Write(fs);
                    fs.Seek(0, System.IO.SeekOrigin.Begin);
                    MessageBox.Show("導出成功", "提示",
                        MessageBoxButtons.OK, MessageBoxIcon.Information);
                }
                catch (Exception e)
                {
                    MessageBox.Show(e.Message, "導出失敗",
                        MessageBoxButtons.OK, MessageBoxIcon.Warning);
                }
                finally
                {
                    if (fs != null)
                        fs.Close();
                }

            }
        }
    }
}

3.2.2 從Excel表格導入數據至DataGridView

public static void ImportExcel2DGV(DataGridView dgv)
{
   OpenFileDialog ofd = new OpenFileDialog();
   ofd.Title = "選擇待導入的數據文件";
   ofd.Filter = "Excel Workbook 97-2003|*.xls|Excel Workbook|*.xlsx";
   string filePath;
   if (ofd.ShowDialog() == DialogResult.OK)
   {
       filePath = Path.GetFullPath(ofd.FileName);
       FileStream fs;
       try
       {
           fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
           NPOI.HSSF.UserModel.HSSFWorkbook book =
                new NPOI.HSSF.UserModel.HSSFWorkbook(fs);

           int sheetCount = book.NumberOfSheets;

           for (int index = 0; index < sheetCount; index++)
           {
               NPOI.SS.UserModel.ISheet sheet = book.GetSheetAt(index);
               if (sheet == null)
                   continue;

               NPOI.SS.UserModel.IRow row = sheet.GetRow(0);

               if (row == null)
                   continue;

               int firstCellNum = row.FirstCellNum;
               int lastCellNum = row.LastCellNum;  
               if (firstCellNum == lastCellNum)
                   continue;

               for (int i = start_row; i <= sheet.LastRowNum; i++)
               {
                   string courseName = sheet.GetRow(i).Cells[0].StringCellValue;
                   double credit;
                   double grade;
                   try
                   {
                       sheet.GetRow(i).Cells[1].SetCellType(NPOI.SS.UserModel.CellType.String);
                       sheet.GetRow(i).Cells[2].SetCellType(NPOI.SS.UserModel.CellType.String);
                       credit = Convert.ToDouble(sheet.GetRow(i).Cells[1].StringCellValue);
                       grade = Convert.ToDouble(sheet.GetRow(i).Cells[2].StringCellValue);
                   }
                   catch (Exception e)
                   {
                       MessageBox.Show(e.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
                       credit = 0;
                       grade = 0;
                   }
                   //MessageBox.Show(sheet.LastRowNum.ToString());
                   InfoVo info = new InfoVo()
                   { CourseName = courseName, Credit = credit, Grade = grade };
                   
                   //原創的處理InvalidOperationException的方法,當選中新行時會觸發使得dataBinding添加異常的一行
                   if (dgv.CurrentRow != null && dgv.CurrentRow.IsNewRow)//別缺少前面的條件了,否則NRE
                       MainForm.dataBindings.RemoveAt(MainForm.dataBindings.Count - 1);  
                        
                   MainForm.dataBindings.Add(info);

               }
           }
           fs.Close();
       }
       catch (Exception ex)
       {
           MessageBox.Show(ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
       }

   }
}

3.3 程序健壯性設計

這裏所謂健壯性設計,其實都是採坑之後進行的Debug而已,因此提供解決方案的同時,我會盡可能把異常復現出來。因此這一節的主題也可以說是“開發過程中遇到的BUG以及解決方案”。

3.3.1 選中空白行並導入數據時遇見“對象的當前狀態使操作無效”

【異常觸發條件】

  • 點選空白行,如圖中位置① (不選的話導入數據時不會引發異常)
  • 導入數據

【異常發生位置】

public static void ImportExcel2DGV(DataGridView dgv){
	//----省略----
	MainForm.dataBindings.Add(info); //異常位置
	//----省略----
}

【成因分析】
通過對異常句附近設置斷點,並監視靜態全局變量MainForm.dataBindings,在MainForm.dataBindings.Add(info)執行前其監視信息如下圖。
在這裏插入圖片描述
可見裏面已經額外添加了一個空行的數據,這顯然不是我們期望的。至於爲何會引發圖示異常,我沒有深入探究,但通過查閱相關文獻,初步猜想異常引發的原因:該異常應該是在控件內部調用時引發的。當編輯焦點進入到新行然後離開,控件會自動添加(在未編輯的情況下)一行空的(自定義類的默認值)數據,並且離開後控件又未刪除這個行數據。當執行Add()方法時,首先會創建一個新空行,然而這時候MainForm.dataBindings就會出現重複的空行(兩個完全相同的自定義類的默認值),或許這是不允許的。
當然以上僅僅是猜想,這裏對原因暫且不作過多討論,下面說說我的解決方案。
【解決方案】
在執行MainForm.dataBindings.Add(info)之前刪除額外添加的空行,可能這方法看起來有點奇怪,但的確能解決之前的問題。

if (dgv.CurrentRow != null && dgv.CurrentRow.IsNewRow)//別缺少前面的條件了,否則NRE
	MainForm.dataBindings.RemoveAt(MainForm.dataBindings.Count - 1);

3.3.2 導入異常——“Cannot get a text value from a numeric cell.”

這是同學發現的一個錯誤,他自己事先準備好了表格,而且給”學分“、”成績“兩列設置了數字格式,導入時出現了題述異常。

【異常觸發條件】

  • 導入的表格單元格設置了數字格式

【異常發生位置】

credit = Convert.ToDouble(sheet.GetRow(i).Cells[1].StringCellValue); //學分
grade = Convert.ToDouble(sheet.GetRow(i).Cells[2].StringCellValue); //成績

【成因分析】
如果導入的單元格的類型本身爲Numeric時,Cell.StringCellValue就會引發異常。

【解決方案】
用SetCellType()把sheet中的單元格形式轉變爲String就好了。

sheet.GetRow(i).Cells[1].SetCellType(
	NPOI.SS.UserModel.CellType.String);                              
credit = Convert.ToDouble(sheet.GetRow(i).Cells[1].StringCellValue);

3.3.3 導入異常——“Index out of range.”

這也是測試階段由同學發現的,畢竟課程名只是個標識,不是某些同學關心的對象,他們只希望能快速算出自己的均分,因此並沒有每行都輸入課程名,把這樣的數據導出以後,有一些行實際上只有兩列數據,此時候導入這種數據就很可能因爲課程名爲空而引發題述異常。

【解決方案】
我並沒考慮用戶自己製表然後再導入的情況,因爲一般情況下同學們還沒有形成成績表單,否則也不必再用這個程序,直接用Excel自帶的求和功能算就好了。因此我只保證了從本程序導出的表格其格式足夠健壯,即導出時給課程名爲空的單元格設置一個默認課程名。

//rowtemp表示Excel的待寫入行
if(dgv.Rows[i].Cells[0].Value != null)
    rowtemp.CreateCell(0).SetCellValue(
    dgv.Rows[i].Cells[0].Value.ToString());                            
else
    rowtemp.CreateCell(0).SetCellValue(
        "課程" + (i+1).ToString());

3.3.4 dataGridView敲入數據格式非法時的處理方法——ErrorText設置

由於這種表格錄入是實時的,因此通過事件處理是首選方式。

下面這段代碼的作用爲:一旦用戶進行了非法輸入並退出該單元格編輯,那麼就會出現輸入錯誤的標識,並且撤回單元格的內容至編輯前。

private void dataGridView_DataError(object sender,
    DataGridViewDataErrorEventArgs e)
{

    DataGridView dgv = sender as DataGridView;
    
    //第一列是字符串型,不會出現常見非法輸入,因此下面這句不要也罷
    if (e.ColumnIndex != dgv.Columns[1].Index &&
        e.ColumnIndex != dgv.Columns[2].Index)
    {
        return;
    }

    dgv.Rows[e.RowIndex].ErrorText = "輸入數字格式不正確";
    //輸出到文本框中
    //textBox_Output.Text += dgv.Rows[e.RowIndex].ErrorText+ Environment.NewLine;
    dgv.CancelEdit(); //恢復到之前的編輯狀態
}

這一段代碼的作用是:用戶進行合法輸入後撤銷之前的錯誤標識。

private void dataGridView_CellValidated(object sender,DataGridViewCellEventArgs e)
{
    if(e.ColumnIndex == 1 || e.ColumnIndex == 2)
    {
        (sender as DataGridView).Rows[e.RowIndex].ErrorText = string.Empty;
    }
}

附事件委託代碼:

this.dataGridView.DataError += new 
DataGridViewDataErrorEventHandler(dataGridView_DataError);
this.dataGridView.CellValidated += new 
DataGridViewCellEventHandler(dataGridView_CellValidated);

3.4 “幫助”、"關於"窗體設計——子窗體的打開與關閉

以”關於“窗體的打開爲例,其餘子窗口同理。

 private void tsb_about_Click(object sender, EventArgs e)
 {
     if ((sender as aboutForm) != null)
     {
         this.Visible = true;
     }
     else
     {
         aboutForm = new aboutForm();
         aboutForm.MouseMove += new MouseEventHandler(aboutForm_MouseDown);
         aboutForm.Show();
     }
 }

關閉用按鈕觸發,在Click事件中寫this.Visible = false;即可,畢竟這種小窗體在程序生命週期中可能反覆會打開關閉,沒必要每次都銷燬或創建子窗體對象。

圖1 helpForm 界面
圖2 aboutForm 界面

3.5 其它

以下都是我參考資料時幾乎 照搬的,雖然並不是核心,但它們是改善用戶體驗不可或缺的部分,因此我還是把它們列出來。

3.5.1 拖動無邊框窗體

[DllImport("user32.dll")]
public static extern bool ReleaseCapture();

[DllImport("user32.dll")]
public static extern bool SendMessage(IntPtr hwnd, int wMsg, int wParam, int lParam);
public const int WM_SYSCOMMAND = 0x0112;
public const int SC_MOVE = 0xF010;
public const int HTCAPTION = 0x0002;

private void MainForm_MouseDown(object sender, MouseEventArgs e)
{
    ReleaseCapture();
    //SendMessage(this.Handle, WM_SYSCOMMAND, SC_MOVE + HTCAPTION, 0);
    //if(sender is aboutForm)
    SendMessage(this.Handle, WM_SYSCOMMAND, SC_MOVE + HTCAPTION, 0);
}

3.5.2 將NPOI.dll嵌入至exe中

參考鏈接:C#將DLL嵌入到exe當中
【方法原理解析】

這是採用一種類似誘導的方式來達到嵌入目的,因爲導入至資源中的dll是不能直接解析的,因此一定會解析失敗,故可以利用程序集解釋失敗的這一事件來進行NPOI.dll的真正加載。換句話說,資源集只是一個載體,目的是供NPOI.dll以"容身之處",真正讓NPOI.dll能夠正常調用的是委託事件中的代碼。

【步驟】
STEP1 添加NPOI.dll至Resource.resx

STEP2 在MainForm.cs中添加如下事件函數

System.Reflection.Assembly CurrentDomain_AssemblyResolve
(object sender, ResolveEventArgs args)
{
    string dllName = args.Name.Contains(",") ? 
    args.Name.Substring(0, args.Name.IndexOf(',')) : args.Name.Replace(".dll", "");
    dllName = dllName.Replace(".", "_");
    if (dllName.EndsWith("_resources")) return null;
    System.Resources.ResourceManager rm = new System.Resources.ResourceManager(
    GetType().Namespace + ".Properties.Resources", System.Reflection.Assembly.
    GetExecutingAssembly());
    byte[] bytes = (byte[])rm.GetObject(dllName);
    return System.Reflection.Assembly.Load(bytes);
}

STEP3 在InitializeComponent()前構建事件委託

//主窗體構造函數
public MainForm()
{
   AppDomain.CurrentDomain.AssemblyResolve += new 
   ResolveEventHandler(CurrentDomain_AssemblyResolve);//一定要放在InitializeComponent前面
   
   InitializeComponent();
   //其他代碼
}

4. 仍然存在的問題

  • 對於含多張表格、且”課程名“-“學分”-"成績"列順序錯亂的表格導入時可能會出現異常
  • 對於Excel較低版本的.xlsx文件不兼容,導入時可能出現異常,這可能是由於我下載的NPOI版本過高所致。
  • 。。。

5. 完整工程獲取

這個程序本身沒啥難度,作者水平有限,做得不好的地方請見諒~
點我獲取完整項目資源

參考文獻

這裏列出的文獻無一不是在我開發過程中給我提供了關鍵性啓發的,伴隨每一個文獻查找的便是開發過程中遇到的相關疑難,這也是我爲什麼在文章中基本只列出代碼而不嘗試作詳細敘述的原因(可以說你基本可以在以下文獻中找到你想要的),在此十分慶幸能找到它們,並對作者大大們表示感謝。

(以下參考資料將按照開發流程先後列出)
[1] DataGridView綁定List對象時,利用BindingList來實現增刪查改
[2] TextBox換行,滾動到最後一行
[3] 使用NPOI導出DataGridView數據到Excel表格
[4] C#中list綁定datagridview爲什麼不顯示數據 xiangjuan314的回答
[5] C# 使用NPOI 實現Excel的簡單導入
[6] DataGridView 輸入不正確格式值時發生的錯誤,處理DataError事件
[7] DatagridView報"對象的當前狀態使該操作無效"(雖然方法不能解決問題,但其對於Error引發原因的解釋給予了我啓發)
[8] Excel導入異常Cannot get a text value from a numeric cell解決

(最後附上初期界面設計時參考的文章)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章