上周还在跟
Console.ReadLine()和属性封装较劲,这周直接跨到了有按钮、有图片、能点能拖的桌面程序。做完八个项目回头看,WinForm 其实就三件事:摆控件、设属性、写事件。所有效果都是在这三件事上做加法。
开发环境:Visual Studio 2022 / .NET 9
目录
一、这周学了什么
二、面向对象深入
三、重载 vs 重写
四、异常处理
五、Dictionary 字典
六、WinForm 入门
七、界面布局
八、WinForm 里的四个技巧
九、键盘和定时器
十、DataGridView 数据绑定
十一、总结
一、这周学了什么
| 日期 | 主题 | 项目 |
|---|---|---|
| 10-19 | 方法、继承、多态 | 动物管理器、银行账户、学校人员 |
| 10-20 | 重写、异常、字典 | 世界杯查询、字符统计、学员管理 |
| 10-21 | WinForm 入门 | QQ 登录、注册窗口、圆形头像、笔记本界面 |
| 10-22 | WinForm 进阶 | 坦克大战、投票程序、颜色切换器 |
| 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 常用窗体属性
| 属性 | 常用值 | 作用 |
|---|---|---|
Text | QQ登录 | 标题栏文字 |
ClientSize | 553, 403 | ★内部可画区域(不含边框) |
FormBorderStyle | FixedSingle | ★ 不允许拉伸 |
StartPosition | CenterScreen | 启动位置 |
Icon | xxx.ico | ★只认 .ico |
DoubleBuffered | true | 减少闪烁 |
KeyPreview | true | ★★ 让窗体优先拿到按键 |
AcceptButton | btnLogin | 回车 = 点这个按钮 |
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.Timer | UI 线程 | ✔ |
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(最后) | 8 | 0 | ★ 回到第一个 |
这周用了三次%:约瑟夫环绕圈、卡片网格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但死活不变色。
但正是这些"卡住 → 查资料 → 顿悟"的过程让我真正记住了。光看代码记不住,必须自己敲一遍、错一遍。
如果这篇博客帮到你,欢迎点赞收藏。有任何问题或指正