【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解决

(最后附上初期界面设计时参考的文章)

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