ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C# 第二周学习总结:从控制台到 WinForm 桌面开发

C# 第二周学习总结:从控制台到 WinForm 桌面开发

上周还在跟Console.ReadLine()和属性封装较劲,这周直接跨到了有按钮、有图片、能点能拖的桌面程序。

做完八个项目回头看,WinForm 其实就三件事:摆控件、设属性、写事件。所有效果都是在这三件事上做加法。

开发环境:Visual Studio 2022 / .NET 9

目录

  • 一、这周学了什么

  • 二、面向对象深入

  • 三、重载 vs 重写

  • 四、异常处理

  • 五、Dictionary 字典

  • 六、WinForm 入门

  • 七、界面布局

  • 八、WinForm 里的四个技巧

  • 九、键盘和定时器

  • 十、DataGridView 数据绑定

  • 十一、总结


一、这周学了什么

日期主题项目
10-19方法、继承、多态动物管理器、银行账户、学校人员
10-20重写、异常、字典世界杯查询、字符统计、学员管理
10-21WinForm 入门QQ 登录、注册窗口、圆形头像、笔记本界面
10-22WinForm 进阶坦克大战、投票程序、颜色切换器
10-23数据绑定学生信息管理(搜索 + 增删改)

转折点是理解了事件驱动:程序不再从上往下一行行走完,而是"你点一下,我才跑那一小段"。


二、面向对象深入

2.1 继承:把重复代码往上提

abstract class Animal { public string Name { get; set; } = ""; public Animal(string name) { Name = name; } public void Eat() { Console.WriteLine(Name + "在吃东西"); } public abstract void Speak(); // 只声明"必须会叫" } ​ class Dog : Animal { public Dog(string name) : base(name) { } // ★ base 调用父类构造 public override void Speak() { Console.WriteLine(Name + ":汪汪汪"); } }
概念说明
:继承,C#只支持单继承
base(...)调用父类构造函数
abstract抽象类不能new,抽象方法必须被子类实现
object所有类的根

2.2 多态:一个方法,各自实现

List<Animal> zoo = new() { new Dog("旺财"), new Cat("咪咪") }; ​ foreach (Animal a in zoo) a.Speak(); // ★ 调用的是各自子类的实现

变量类型是Animal,却能调到Dog.Speak()—— 因为方法调用是运行时根据对象实际类型决定的。

没有多态就得写if (a is Dog d) d.Bark(); else if (a is Cat c) ...,加个"鸭子"还要回来改循环。

2.3 virtual / override / new

关键词用在哪意思
virtual父类子类可以改
override子类我要改父类的虚方法
new子类藏起来父类同名方法(多态失效)
abstract父类子类必须改
class Base { public virtual void Show() { Console.WriteLine("Base"); } } class A : Base { public override void Show() { Console.WriteLine("A"); } } class B : Base { public new void Show() { Console.WriteLine("B"); } } ​ Base b1 = new A(); b1.Show(); // A ← 多态生效 Base b2 = new B(); b2.Show(); // Base ← ★ 多态失效

new只是编译期隐藏,用父类变量指向它时调的还是父类方法。

2.4 抽象类 vs 接口

抽象类接口
能有字段✔✘
能有实现方法✔C# 8 之前不能
能继承几个1 个多个
用在哪"是一种""能做什么"

三、重载 vs 重写

重载 Overload重写 Override
在哪同一个类父类 + 子类
方法名相同相同
参数必须不同必须相同
返回值可以不同必须相同
关键词不需要override
决定时机编译时(看参数)运行时(看对象)
// 重载:同一个类,参数不同 public int Add(int a, int b) { return a + b; } public double Add(double a, double b) { return a + b; } ​ // 重写:子类改父类虚方法 public override void Speak() { }

返回值不参与重载判断,只改返回值会报CS0111。


四、异常处理

4.1 编译错误 vs 运行时异常

编译错误运行时异常
发现时机写代码时(红波浪线)运行时
程序能启动✘✔,跑到那行才崩
例子int x = "abc";int.Parse("abc")

int.Parse("abc")编译能过,运行时才抛FormatException。

4.2 try-catch-finally

try { int age = int.Parse(Console.ReadLine()); } catch (FormatException) // 具体 { Console.WriteLine("输入的不是数字"); } catch (Exception ex) // 兜底放最后 { Console.WriteLine("出错:" + ex.Message); } finally { // 不管成功失败都执行,放资源释放 }

catch 顺序必须从具体到宽泛,Exception放前面会报CS0160。

4.3 抛异常

public void Withdraw(decimal amount) { if (amount <= 0) throw new ArgumentException("金额必须大于 0"); if (amount > Balance) throw new InvalidOperationException("余额不足"); Balance -= amount; }

五、Dictionary 字典

5.1 为什么用字典

查世界杯冠军,List要遍历(O(n)),Dictionary哈希直接定位(O(1)):

var dict = new Dictionary<int, string> { [2018] = "法国", [2014] = "德国", [2010] = "西班牙", }; ​ string result = dict[2014]; // O(1)

5.2 增删改查

// 增 dict.Add("张三", 90); // 键已存在会抛 ArgumentException dict["李四"] = 85; // ★ 索引器:不存在就新增,存在就覆盖 ​ // 查 int s = dict["张三"]; // 键不存在抛 KeyNotFoundException bool has = dict.ContainsKey("王五"); ​ // ★ 最安全:TryGetValue(一次哈希) if (dict.TryGetValue("王五", out int score)) Console.WriteLine(score); ​ // 删 dict.Remove("张三"); // 返回 bool dict.Clear(); ​ // 遍历 foreach (var kv in dict) Console.WriteLine($"{kv.Key} = {kv.Value}");
查法键不存在时推荐
dict[key]抛异常确定存在时
ContainsKey+dict[key]查两次一般
TryGetValue返回 false★最推荐

5.3 排序

Dictionary无序,排序用 LINQ:

using System.Linq; ​ var sorted = dict.OrderByDescending(kv => kv.Value).ToList();

OrderByDescending返回新序列,不修改原字典。


六、WinForm 入门

6.1 三个文件的分工

文件作用改不改
Program.cs入口,Application.Run(new Form1())基本不改
Form1.cs窗体逻辑(事件)★ 主要写这里
Form1.Designer.cs控件布局自动生成,别乱改

在「属性」窗口改的每一样,最后都变成 Designer 里的一行代码。所以"拖控件"和"写代码"是同一件事的两种做法。

6.2 开发三步

步骤做什么在哪
①设计界面工具箱拖控件
②设置属性属性窗口
③写事件代码双击按钮 → 写Click

6.3 常用窗体属性

属性常用值作用
TextQQ登录标题栏文字
ClientSize553, 403★内部可画区域(不含边框)
FormBorderStyleFixedSingle★ 不允许拉伸
StartPositionCenterScreen启动位置
Iconxxx.ico★只认 .ico
DoubleBufferedtrue减少闪烁
KeyPreviewtrue★★ 让窗体优先拿到按键
AcceptButtonbtnLogin回车 = 点这个按钮

ClientSizevsSize:Size含边框和标题栏,ClientSize是能摆控件的区域。设背景图、摆控件一律用ClientSize。


七、界面布局

7.1 绝对定位 vs 表格布局

绝对定位(给死Location)控件少了还行,一到注册窗口就出问题:标签长短不一对不齐、调间距要一个个改。

7.2 TableLayoutPanel

// ① 行列数量 tableLayout.ColumnCount = 4; tableLayout.RowCount = 4; ​ // ② 列宽/行高 tableLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 115F)); // 固定像素 tableLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); // 按比例 ​ // ③ ★ 放进单元格 —— 列在前、行在后 tableLayout.Controls.Add(lblUser, 0, 0); tableLayout.Controls.Add(txtUser, 1, 0); ​ // ④ 跨列 tableLayout.SetColumnSpan(btnRegister, 2);

7.3 SizeType 怎么选

值意思用在哪
Absolute固定像素标签(宽度由文字决定)
Percent按比例伸缩输入框(跟着窗口变)
AutoSize按内容自动内容不确定时

规律:标签固定宽、输入框按比例。

7.4 两个细节

中文标签对齐:密 码:中间加全角空格,让 2 个字和 4 个字("出生日期")一样宽,冒号才能对齐。

RadioButton 互斥范围 = 父容器:

this.Controls.Add(rdo1); // 全放窗体上 → 同一组,自动互斥 panelSex.Controls.Add(rdoMale); // 分两组就要用不同 Panel

八、WinForm 里的四个技巧

8.1 圆形头像

using System.Drawing.Drawing2D; // ★ GraphicsPath 在这 ​ GraphicsPath path = new GraphicsPath(); path.AddEllipse(0, 0, pb.Width - 1, pb.Height - 1); pb.Region?.Dispose(); // 释放旧的 pb.Region = new Region(path); // 当成"蒙版"

Region是控件的可见区域蒙版,设成圆形就只显示圆形那块,四个角被"挖掉"——不用处理图片像素。

★ PictureBox 宽高必须相等,AddEllipse画的是椭圆,外接矩形是正方形才是正圆。

8.2 无边框窗口拖动

this.FormBorderStyle = FormBorderStyle.None; this.MaximizedBounds = Screen.FromControl(this).WorkingArea; // 最大化不盖任务栏

去掉了系统标题栏就拖不动了,要自己实现:

private bool _dragging; private Point _dragOffset; ​ private void HookDrag(Control c) { c.MouseDown += (s, e) => { _dragging = true; _dragOffset = e.Location; }; c.MouseMove += (s, e) => { if (!_dragging) return; Point screen = c.PointToScreen(e.Location); // ★ 转屏幕坐标 this.Location = new Point(screen.X - _dragOffset.X, screen.Y - _dragOffset.Y); }; c.MouseUp += (s, e) => _dragging = false; }

必须用PointToScreen:窗口一动,控件的相对坐标系跟着动,直接算e.Location会抖动漂移。

标题栏上的每个子控件都要单独挂(鼠标事件不从子控件冒泡),但按钮不能挂(否则点不动)。

8.3 让 ProgressBar 变红

bar1.ForeColor = Color.Red; // ✘ 无效,还是绿色

ProgressBar包的是Windows 原生控件,启用视觉样式时颜色由主题决定,ForeColor被忽略。

解法:关掉这个控件的视觉样式:

using System.Runtime.InteropServices; ​ [DllImport("uxtheme.dll", CharSet = CharSet.Unicode)] private static extern int SetWindowTheme(IntPtr hWnd, string app, string idList); ​ private void Form1_Load(object? sender, EventArgs e) { SetWindowTheme(bar1.Handle, "", ""); // ★ 必须在 Load 里,Handle 才有效 bar1.ForeColor = Color.Red; // 现在生效 }

8.4 图标不能用字体

lbl.Text = "☰"/"⋮"会显示成方框(字体里没这个字形)。自己画:

lblMenu.Text = ""; lblMenu.Paint += (s, e) => { using (var pen = new Pen(Color.White, 2F)) { e.Graphics.DrawLine(pen, 5, 10, 31, 10); e.Graphics.DrawLine(pen, 5, 18, 31, 18); e.Graphics.DrawLine(pen, 5, 26, 31, 26); } };

判断标准:图标先粘到记事本,显示方框就自绘。←→✕—多数中文字体都有,可以直接用。


九、键盘和定时器

9.1 WASD 控制坦克

第一件事:

this.KeyPreview = true; // 让窗体优先拿到按键

WinForm 的键盘事件先发给有焦点的控件,焦点跑到按钮上,窗体的KeyDown就收不到了。

private const int Step = 8; ​ private void Form1_KeyDown(object? sender, KeyEventArgs e) { int dx = 0, dy = 0; ​ switch (e.KeyCode) { case Keys.W: dy = -Step; break; // 上:y 减小 case Keys.S: dy = +Step; break; // 下:y 增大 case Keys.A: dx = -Step; break; case Keys.D: dx = +Step; break; default: return; } ​ // ★ 边界限制:坐标是左上角,最大值要减控件宽高 int maxX = this.ClientSize.Width - picTank.Width; int maxY = this.ClientSize.Height - picTank.Height; ​ picTank.Location = new Point( Math.Clamp(picTank.Left + dx, 0, maxX), Math.Clamp(picTank.Top + dy, 0, maxY)); ​ e.Handled = true; // 防止按键"叮"声 }
ClientSize: 435 × 437 ┌───────────────────────────────┐ 0,0 │ ┌────┐ ← 坦克左上角在这里 │ │ │ 32 │ │ │ └────┘ │ └───────────────────────────────┘ (435,437) ​ 左上角 x 最大只能到 435 - 32 = 403

9.2 三个 Timer

类回调线程能更新 UI
★System.Windows.Forms.TimerUI 线程✔
System.Timers.Timer线程池✘
System.Threading.Timer线程池✘

WinForm 做界面动画必须用Forms.Timer,否则报"线程间操作无效"。

事件名也不同:Forms.Timer用Tick,另两个用Elapsed。

_loopTimer.Interval = 2000; // 毫秒,2000 = 2 秒 _loopTimer.Start(); _loopTimer.Stop();

Start()不会立即触发一次,要等一个Interval。

9.3 取模做循环

int next = (listBox1.SelectedIndex + 1) % listBox1.Items.Count;
当前+1% 8下一步
7(最后)80★ 回到第一个

这周用了三次%:约瑟夫环绕圈、卡片网格col = i % 4/row = i / 4、颜色轮播。

9.4 单一入口原则

private void LoopTimer_Tick(object? sender, EventArgs e) { int next = (listBox1.SelectedIndex + 1) % listBox1.Items.Count; listBox1.SelectedIndex = next; // ★ 只改选中项,不直接改颜色 } ​ private void ListBox1_SelectedIndexChanged(object? sender, EventArgs e) { // 颜色更新全在这里 }

改SelectedIndex会自动触发SelectedIndexChanged,手动点选和自动轮播走同一条路,逻辑只有一份。ListBox 高亮也是自动的。

原则:状态是唯一真相,界面是它的映射。


十、DataGridView 数据绑定

10.1 三步绑定

private List<Student> students = new(); ​ dgvStudents.AutoGenerateColumns = false; // ② 不自动生成列 dgvStudents.DataSource = students; // ③ 赋值

手工定义列:

colId.DataPropertyName = "Id"; // ★ 对应 Student.Id colId.HeaderText = "学号"; ​ colBirthday.DataPropertyName = "Birthday"; colBirthday.DefaultCellStyle.Format = "yyyy-MM-dd"; // 格式化显示

DataPropertyName必须和属性名完全一致(大小写敏感),拼错就整列空白。

10.2 ★ 重新绑定必须先设 null

private void BindDataSource() { dgvStudents.DataSource = null; // ★ 先清空 dgvStudents.DataSource = students; // 再赋值 }

DataSource存的是引用,内容变了但引用没变时,DataGridView 不刷新。

10.3 多条件搜索

students = allStudents.Where(stu => { bool isName = string.IsNullOrEmpty(name) || stu.Name.Contains(name); // 留空→全要 bool isAge = age == 0 || stu.Age == age; // 0→全要 bool isBirth = stu.Birthday.Date >= start && stu.Birthday.Date <= end; // 闭区间 return isName && isAge && isBirth; }).ToList();

日期比较必须.Date:不做的话,start = 14:30、birth = 当天 00:00,会判成00:00 >= 14:30为假,当天被错误排除。

10.4 增删改

// 添加 allStudents.Add(stu); // ★ 两个都要加:一个是数据源,一个是当前显示 students.Add(stu); BindDataSource(); ​ // 修改 —— ★ 属性拷贝,不是替换对象 Student? target = allStudents.FirstOrDefault(s => s.Id == edited.Id); if (target != null) { target.Name = edited.Name; target.Age = edited.Age; target.Birthday = edited.Birthday; } BindDataSource(); ​ // 删除 —— ★ 按 Id,不按行号 allStudents.RemoveAll(s => s.Id == id); students.RemoveAll(s => s.Id == id); BindDataSource();

三个关键点:

① 为什么两个集合都要动

只改allStudents只改students
表格不刷新下次搜索又冒出来

② 为什么按 Id 不按行号

allStudents: [1001, 1002, 1003, 1004, ...] ← 下标 0~7 搜索"张三"后: students : [张三(1001), 张三丰(1005)] ← 下标 0,1 ​ 表格第 1 行 = students[1] = 张三丰 但 allStudents 里 张三丰 的下标是 4

allStudents.RemoveAt(rowIndex)会删错人。

③ 为什么"属性拷贝"不"换对象"

students和allStudents可能是不同的 List,但元素是同一批引用:

  • 改属性→ 两边都能看到 ✔

  • 换引用→ 只有换的那处变,表格还是旧对象 ✘

10.5 编辑窗体:一个窗体干两件事

private readonly bool _isNew; public Student? EditStudent { get; set; } ​ public FrmEditStudent() { _isNew = true; } // 新增 public FrmEditStudent(Student stu) // 修改 { _isNew = false; EditStudent = stu; txtName.Text = stu.Name; // 回显 txtAge.Value = stu.Age; dtpBirthday.Value = stu.Birthday; } ​ // 保存 if (_isNew) EditStudent = new Student(); // ★ 只有新增才创建 EditStudent.Name = txtName.Text.Trim(); this.DialogResult = DialogResult.OK; this.Close();

调用方:

if (frm.ShowDialog(this) == DialogResult.OK) // ★ 阻塞,等结果 { Student stu = frm.EditStudent; }
Show()★ShowDialog()
阻塞✘✔ 等窗口关闭
拿返回值✘✔DialogResult
适合浮窗★对话框

.NET 9 里这个公开属性要加[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)],否则报WFO1000。


十一、总结

三个收获

① 面向对象的核心是"多态",不是"继承"

继承只是手段(代码复用),多态才是目的——一个List<Animal>装所有动物,循环里a.Speak()各叫各的。没有多态,继承就只是"少写几行"。

② WinForm 就三件事

摆控件 → 设属性 → 写事件

圆形头像、圆角卡片、无边框窗口、自定义绘制,都是在这三件事上做加法。先能跑,再好看。

③ 数据绑定是"引用 + 通知"的游戏

DataSource存引用,靠引用变化判断要不要刷新——所以才有"先设 null 再赋值"这种写法。

数据源和显示要分开:删除时两个都要动,修改时要改对象本身而不是换引用。

一点感受

这周最爽的时刻,是第一次做出圆形头像——原来图片还能这么裁。

最难受的时刻,是ProgressBar设了ForeColor = Red但死活不变色。

但正是这些"卡住 → 查资料 → 顿悟"的过程让我真正记住了。光看代码记不住,必须自己敲一遍、错一遍。


如果这篇博客帮到你,欢迎点赞收藏。有任何问题或指正

返回列表