C# WinForms计算器源码解析:从事件分发到状态机设计

发布时间:2026/10/24 0:09:08
C# WinForms计算器源码解析:从事件分发到状态机设计

简介:这份资源是一套基于C#与Windows Forms开发的桌面计算器完整源代码,面向正在学习C#窗体编程的初学者与进阶者,帮助读者通过一个可运行的小型项目理解GUI界面搭建、控件使用与事件驱动编程的核心思路。压缩包共56个文件,约1.21MB,以cs源代码文件为主,配合resx资源、ico图标、jpg界面截图及exe可执行程序,另含sln解决方案与csproj项目文件,可直接在Visual Studio中打开调试。项目围绕数字与加减乘除按钮、文本框显示、运算符状态保存及等号求值等逻辑展开,并涉及除零等异常处理,还附带关于计算器、按键说明、日历与联系方式等多个窗体模块,目录结构清晰,便于按功能拆分阅读。已有549人学习下载,适合作为课程设计、入门练手或窗体编程参考案例,帮助读者快速掌握从界面设计到事件处理函数的完整开发流程。

1. 从一份 WinForms 计算器源码说起:它到底能帮你解决什么

很多人第一次接触 Windows 桌面开发,都是从拖控件开始的。打开设计器,把按钮一个个摆上去,双击写事件,跑起来能点,但一旦想加个连续运算、处理除零、支持键盘输入,代码就开始乱成一锅粥。这份 C# 计算器源代码就是冲着这个痛点来的:一个完整的 Windows 窗体程序,把界面布局、按钮事件分发、表达式状态管理、异常处理这几件事拆得清清楚楚。它适合两类人——刚学完 C# 语法、想找一个能跑起来的小项目练手的同学,以及需要一份干净 WinForms 骨架去做二次开发的老手。整个工程不依赖第三方库,纯 .NET 窗体技术栈,下载解压用 Visual Studio 打开就能编译,属于那种“拿来就能改”的源程序。

2. 工程结构与技术选型:一个 WinForms 计算器由哪些文件撑起来

2.1 目录与文件清单

拿到源码包,第一件事不是急着按 F5,而是先看清楚文件都是干什么的。一个标准的 C# Windows 窗体计算器,目录结构通常长这样:

Calculator/ ├── Calculator.sln # 解决方案文件,VS 双击入口 ├── Calculator/ │ ├── Calculator.csproj # 项目文件,记录目标框架和引用 │ ├── Program.cs # 程序入口,Application.Run 启动主窗体 │ ├── Form1.cs # 主窗体逻辑,按钮事件和运算核心都在这 │ ├── Form1.Designer.cs # 设计器自动生成的控件布局代码 │ ├── Form1.resx # 窗体资源文件 │ └── Properties/ │ ├── AssemblyInfo.cs # 程序集元信息 │ └── Settings.settings # 用户设置(可选) └── .gitignore

这里有个血泪经验:Form1.Designer.cs是设计器自动生成的,手改容易在下次打开设计器时被覆盖。真正要写的逻辑全部放在Form1.cs里,两个文件通过partial class合并成同一个类。很多人第一次改计算器,把按钮的 Click 事件写进了 Designer 文件,结果一拖控件就全丢了,翻车翻得很彻底。

.csproj里最关键的是目标框架。老项目可能是.NET Framework 4.7.2,新一点的用.NET 6/8的net8.0-windows。打开项目如果提示框架不匹配,改这一行就行:

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net8.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <Nullable>disable</Nullable> </PropertyGroup> </Project>

OutputType为WinExe表示这是窗口程序,不会弹出黑色控制台;UseWindowsForms是 .NET Core 之后必须显式开启的开关,漏了会报找不到System.Windows.Forms。如果你机器上只装了 .NET Framework,把TargetFramework换成net472,同时去掉UseWindowsForms那一行即可。

2.2 界面布局:TableLayoutPanel 才是计算器的正确打开方式

新手摆计算器按钮,最常见的做法是绝对坐标定位,一个个设 Location 和 Size。窗口一拉大,按钮全挤在左上角,难看还难维护。这份源码用的是TableLayoutPanel,把键盘区切成 4 列 5 行的网格,按钮的Dock设为Fill,跟着格子自动伸缩。

设计器里生成的布局代码大致是这样:

private void InitializeComponent() { this.tableLayoutPanel1 = new TableLayoutPanel(); this.btn7 = new Button(); // ... 其余按钮省略 this.tableLayoutPanel1.ColumnCount = 4; this.tableLayoutPanel1.RowCount = 5; // 四列等宽,按百分比分配 this.tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25F)); this.tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25F)); this.tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25F)); this.tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25F)); // 五行等高 for (int i = 0; i < 5; i++) this.tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 20F)); this.btn7.Dock = DockStyle.Fill; this.btn7.Text = "7"; this.btn7.Click += new EventHandler(this.NumberButton_Click); this.tableLayoutPanel1.Controls.Add(this.btn7, 0, 2); // 第0列第2行 }

ColumnStyles用Percent而不是Absolute,是为了窗口缩放时按钮跟着变;Controls.Add(控件, 列, 行)的两个整数参数就是网格坐标,改按钮位置只动这两个数字,不用碰像素。数字按钮统一挂NumberButton_Click,运算符按钮挂OperatorButton_Click,这是后面事件分发的基础。

2.3 事件分发:一个处理器管十个数字键

十个数字按钮如果每个都写一个 Click 方法,代码量翻十倍还容易漏。源码的做法是共用一个处理器,通过sender反查是哪个按钮被按下:

private void NumberButton_Click(object sender, EventArgs e) { Button btn = sender as Button; if (btn == null) return; string digit = btn.Text; // 按钮上的文字就是数字 if (isNewEntry) // 上一次运算刚结束,准备输入新数 { txtDisplay.Text = digit; isNewEntry = false; } else { if (txtDisplay.Text == "0") // 避免出现 007 这种前导零 txtDisplay.Text = digit; else txtDisplay.Text += digit; } }

sender as Button是安全转换,转失败返回 null 而不是抛异常;btn.Text直接当数字用,前提是按钮文字和数字一致,这也是为什么设计器里按钮文本不能随便改。isNewEntry是个布尔状态位,按下等号或运算符后置 true,下一个数字键就会覆盖显示而不是追加。这个标志位是整个计算器状态机里最容易出错的地方,后面避坑章节会专门讲。

2.4 运算核心:用状态机而不是 eval

有些计算器源码图省事,把整个表达式拼成字符串丢给DataTable.Compute去算,看着聪明,实际坑很多:除零异常信息不友好、运算符优先级和用户预期不一致、小数点处理容易出玄学问题。这份源码走的是经典的两操作数状态机,字段就四个:

private double currentValue = 0; // 当前累积值 private double pendingValue = 0; // 待运算的右操作数 private string pendingOperator = ""; // 挂起的运算符 private bool isNewEntry = true; // 是否开始输入新数

按下运算符时的处理逻辑:

private void OperatorButton_Click(object sender, EventArgs e) { Button btn = sender as Button; string op = btn.Text; if (pendingOperator != "") // 已有挂起运算,先算掉 CalculatePending(); pendingValue = double.Parse(txtDisplay.Text); pendingOperator = op; isNewEntry = true; } private void CalculatePending() { double right = double.Parse(txtDisplay.Text); switch (pendingOperator) { case "+": currentValue = pendingValue + right; break; case "-": currentValue = pendingValue - right; break; case "*": currentValue = pendingValue * right; break; case "/": if (right == 0) { txtDisplay.Text = "除数不能为零"; return; } currentValue = pendingValue / right; break; } txtDisplay.Text = currentValue.ToString(); }

CalculatePending在按下新运算符和等号时都会被调用,保证1 + 2 + 3这种连续运算按顺序结算,而不是等全部输入完再算。除零判断放在除法分支里,直接改显示文本并返回,不抛异常也不崩程序。double.Parse这里有个隐患:如果显示区是错误提示文字,Parse 会抛FormatException,所以实际工程里我一般用double.TryParse兜底,这一点在改造章节会展开。

3. 把项目跑起来:从打开解决方案到第一个可点的按钮

3.1 环境准备与编译

跑这个项目需要的东西不多:Windows 系统、Visual Studio 2019 或 2022(社区版够用),安装时勾选“.NET 桌面开发”工作负载。如果不想装完整 VS,用dotnetCLI 也能编译运行,前提是项目是 SDK 风格(.NET 6/8):

# 进入解决方案所在目录 cd Calculator # 还原依赖并编译 dotnet build Calculator.sln # 直接运行(会自动弹出窗体) dotnet run --project Calculator/Calculator.csproj

dotnet build会先做 NuGet 还原,这个项目没有外部包,还原很快;dotnet run的--project参数指向具体的 csproj,因为解决方案目录下可能不止一个项目。如果编译报CS0246 找不到类型或命名空间名 Windows,八成是UseWindowsForms没开或者目标框架写成了不带-windows的net8.0。

用 Visual Studio 的话更简单:双击.sln,等解决方案资源管理器加载完,按 Ctrl+Shift+B 编译,F5 启动。第一次打开如果提示“此项目与当前 Visual Studio 版本不兼容”,右键项目 → 属性 → 应用程序 → 目标框架,改成你机器上装了的版本。

3.2 第一次运行该验证什么

程序起来后别急着点,按顺序验证几件事,能快速判断源码是否完整:

  1. 窗口标题和尺寸是否正常,按钮有没有重叠或缺失——验证 Designer 文件完整。
  2. 依次点1 + 2 =,显示区应出现3——验证基本运算链路。
  3. 点5 / 0 =,应显示错误提示而不是崩溃——验证异常处理。
  4. 点1 . 5 + 2 . 5 =,应得4——验证小数点输入。
  5. 连续点2 + 3 + 4 =,应得9——验证连续运算状态机。

这五步走完,基本能确认这份源程序是能用的,而不是缺胳膊少腿的半成品。哪一步不对,就对应到上一章讲的具体模块去查,比漫无目的地读代码高效得多。

3.3 键盘输入支持

鼠标点按钮只是及格线,真正好用的计算器要支持键盘。WinForms 里处理键盘有两个层次:窗体级KeyPreview = true加KeyDown事件,或者重写ProcessCmdKey。源码用的是前者,在构造函数里打开预览:

public Form1() { InitializeComponent(); this.KeyPreview = true; // 让窗体先于控件收到按键 this.KeyDown += Form1_KeyDown; } private void Form1_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode >= Keys.D0 && e.KeyCode <= Keys.D9) { // 数字键 0-9,转成对应按钮的点击 string key = ((int)(e.KeyCode - Keys.D0)).ToString(); PressButtonByText(key); e.Handled = true; } else if (e.KeyCode == Keys.Enter) { PressButtonByText("="); e.Handled = true; } else if (e.KeyCode == Keys.Escape) { PressButtonByText("C"); e.Handled = true; } else if (e.KeyCode == Keys.Add) { PressButtonByText("+"); e.Handled = true; } else if (e.KeyCode == Keys.Subtract) { PressButtonByText("-"); e.Handled = true; } else if (e.KeyCode == Keys.Multiply) { PressButtonByText("*"); e.Handled = true; } else if (e.KeyCode == Keys.Divide) { PressButtonByText("/"); e.Handled = true; } }

KeyPreview = true是关键,不开的话焦点在按钮上时按键会被按钮吞掉。e.Handled = true防止按键继续冒泡触发默认行为,比如回车触发某个按钮。PressButtonByText是个辅助方法,遍历tableLayoutPanel1.Controls找到文本匹配的按钮并调用PerformClick(),这样键盘和鼠标走的是同一条逻辑,不会出现两套代码行为不一致的问题。

4. 核心代码逐段拆解:显示区、小数点与清除逻辑

4.1 显示区:TextBox 还是 Label

源码用的是TextBox,设成只读:

this.txtDisplay.ReadOnly = true; this.txtDisplay.TextAlign = HorizontalAlignment.Right; this.txtDisplay.Font = new Font("Consolas", 20F, FontStyle.Bold);

用 TextBox 而不是 Label 的理由很实际:TextBox 自带文本选中和滚动,数字太长时不会像 Label 那样被硬裁掉;ReadOnly = true阻止用户直接改内容,但代码里仍然可以赋值。TextAlign右对齐符合计算器习惯,Consolas等宽字体让数字宽度一致,显示区不会因为数字变化而“跳动”。如果你二次开发想换成 Label,记得把AutoSize关掉并设Dock,否则长数字会把布局撑变形。

4.2 小数点输入:一个按钮三种状态

小数点按钮看着简单,实际要处理三种情况:当前数字已有小数点、显示区是整数、显示区是刚算完的结果。源码的处理:

private void btnDot_Click(object sender, EventArgs e) { if (isNewEntry) { txtDisplay.Text = "0."; // 新输入以 0. 开头 isNewEntry = false; return; } if (!txtDisplay.Text.Contains(".")) txtDisplay.Text += "."; // 没有小数点才追加 }

Contains(".")判断防止出现1.2.3这种非法输入;isNewEntry为 true 时直接给0.,避免用户按了运算符后直接按小数点得到.5这种不规范的显示。这里有个细节:如果显示区是错误提示文字(比如“除数不能为零”),Contains判断不会命中,会拼出乱码,所以更稳妥的做法是先判断显示内容是否为合法数字,这一点在改造章节给方案。

4.3 清除键:C 和 CE 的区别

很多简易计算器只有一个 C 键,把所有状态清零。这份源码区分了 C(全清)和 CE(清除当前输入):

private void btnClear_Click(object sender, EventArgs e) // C { txtDisplay.Text = "0"; currentValue = 0; pendingValue = 0; pendingOperator = ""; isNewEntry = true; } private void btnClearEntry_Click(object sender, EventArgs e) // CE { txtDisplay.Text = "0"; isNewEntry = true; // 注意:不清 pendingOperator 和 pendingValue }

C 把四个状态字段全部复位,相当于重新开始;CE 只清当前显示,挂起的运算符和左操作数保留,用户可以5 + 输错 → CE → 3 =得到 8。这个区分是计算器好不好用的分水岭,也是面试里常被问的细节。改代码时如果只改 C 忘了 CE,用户输错一个数就得从头来,体验直接掉档。

4.4 等号与结果回填

等号的处理要兼顾“有挂起运算”和“没有挂起运算”两种情况:

private void btnEquals_Click(object sender, EventArgs e) { if (pendingOperator == "") return; // 没有待运算,按等号无意义 CalculatePending(); pendingOperator = ""; pendingValue = currentValue; // 结果可作为下一次运算的左操作数 isNewEntry = true; }

pendingValue = currentValue这一行让2 + 3 =得到 5 之后,再按* 4 =能接着算出 20,符合真实计算器的连算习惯。isNewEntry = true保证下一个数字键覆盖显示而不是追加。如果漏了这行,用户按完等号再按数字,会得到53这种拼接结果,属于典型的状态机 bug。

5. 避坑与排查:这类计算器源码最容易翻车的五个地方

5.1 现象:连续运算结果不对,2 + 3 * 4得 20 而不是 14

原因:两操作数状态机是严格从左到右结算的,按下*时已经把2 + 3算成了 5,再乘 4 自然得 20。这不是 bug,是设计取舍——Windows 自带计算器的标准模式也是这个行为。

解决:如果确实需要运算符优先级,就不能用两操作数状态机,得改成表达式解析(调度场算法或递归下降)。改造时把输入先缓存成 token 列表,按等号时统一求值。但要注意,这样改完 CE 键的语义也要跟着调整,工作量比想象中大。

5.2 现象:输入0.1 + 0.2显示0.30000000000000004

原因:double是二进制浮点,0.1 和 0.2 都无法精确表示,相加误差就露出来了。这是 IEEE 754 的固有特性,不是代码写错。

解决:显示时做格式化,currentValue.ToString("G15")保留 15 位有效数字能消掉大部分尾巴;如果做财务类计算器,直接把字段类型换成decimal,代价是运算稍慢但精度可控。我一般会在显示前统一走一个FormatNumber方法,避免每处ToString各写各的。

5.3 现象:按了除零后,再按任何数字都显示异常或程序卡住

原因:除零分支只改了txtDisplay.Text就return,没有复位isNewEntry和pendingOperator。下一个数字键进来时,double.Parse去解析“除数不能为零”这串中文,抛FormatException。

解决:除零分支里把状态字段一并复位,或者更彻底地在所有 Parse 前用double.TryParse判断:

if (!double.TryParse(txtDisplay.Text, out double val)) { txtDisplay.Text = "0"; isNewEntry = true; return; }

5.4 现象:窗体拉大后按钮错位,或者高 DPI 屏幕上文字模糊

原因:布局用了绝对坐标,或者项目没有声明 DPI 感知。WinForms 默认按 96 DPI 渲染,在高分屏上会被系统拉伸导致模糊。

解决:布局改用TableLayoutPanel加Dock;在Program.cs的Main里加一行Application.SetHighDpiMode(HighDpiMode.SystemAware);(.NET Core 以上),或者在 app.manifest 里声明 DPI 感知。老 .NET Framework 项目改 manifest 更稳。

5.5 现象:改了 Designer.cs 里的代码,下次打开设计器全被覆盖

原因:InitializeComponent方法由设计器托管,手动修改会在设计器重新生成时丢失。

解决:所有业务逻辑写进Form1.cs,Designer 文件只放控件声明和布局。确实需要动态创建控件,就在窗体构造函数里InitializeComponent()之后用代码加,不要塞进 Designer 文件。这个坑我踩过一次,丢了半小时的布局调整,从那以后 Designer 文件我基本不手碰。

6. 二次开发改造:把它变成一个能记住历史的计算器

源码跑通只是起点,真正体现价值的是改。我拿这个计算器做过一次小改造,加了个运算历史面板,过程不复杂但很能说明 WinForms 项目的扩展套路。

第一步,界面上加一个ListBox放在右侧,Dock = DockStyle.Right,宽度 180。第二步,在CalculatePending算出结果后追加一条记录:

private void AppendHistory(string expr, double result) { // 最多保留 50 条,防止长时间使用内存膨胀 if (lstHistory.Items.Count >= 50) lstHistory.Items.RemoveAt(0); lstHistory.Items.Add($"{expr} = {result:G15}"); lstHistory.TopIndex = lstHistory.Items.Count - 1; // 自动滚到最新 }

第三步,在CalculatePending里拼出表达式字符串再调用它。这里有个顺序问题:pendingValue在结算后会被覆盖,所以要在覆盖前把$"{pendingValue} {pendingOperator} {right}"存下来。我第一版就是先算后拼,历史里全是错的操作数,排查了十几分钟才反应过来。

再进一步,可以把历史记录持久化到本地文件,用File.AppendAllText每次追加一行,启动时读回来。验证方法很直接:算几笔、关程序、重开,历史还在就说明落盘成功。注意路径别写死成绝对路径,用Path.Combine(Application.StartupPath, "history.txt"),否则换台机器就找不到文件。

还有一个值得改的点是错误处理统一化。原码里除零、Parse 失败各写各的提示,改造时抽一个ShowError(string msg)方法,内部复位状态并显示提示,所有异常路径都走它。这样以后加新运算(比如开方负数)只需要调一次,不会漏复位某个字段。

从那以后我每次拿到这类小项目源码,都强制先把状态字段列成一张表,标清楚每个操作会改哪几个,再动手加功能——状态机类的代码,想清楚比写快更重要。希望这份拆解能帮到你,把这份 C# 计算器源代码跑起来,再改成自己顺手的样子。

本文还有配套的精品资源,点击获取