首页 / 资讯中心 / 文章详情

C#多人计时器设计:用时间戳与状态机实现精确计时与持久化

C#多人计时器设计:用时间戳与状态机实现精确计时与持久化 ★ FEATURED ARTICLE
简介基于C#的多人计时器项目面向已有C#入门知识、想进阶练习Windows窗体与多线程开发的读者解决比赛或课程中同时为多名参与者计时的需求。压缩包共31个文件、仅56KB核心内容为WindowsFormsApp1解决方案含7个.cs源码文件、.sln/.csproj工程文件可直接用Visual Studio打开另附.exe可执行程序、config配置、pdb调试符号及resx/resources资源文件便于直接运行、排查问题或二次修改。目前已有68人学习浏览。项目围绕多人计时的核心场景展开通过维护计时器对象列表、处理多线程下的时间记录与界面刷新完整演示了启动、停止、重置和成绩读取等操作。对于希望掌握C#桌面计时工具开发流程、理解Timer与线程协作机制的开发者而言这是一份轻量而完整的入门参考稍加改动即可适配更多计时场景。1. 基于 C# 的多人计时器先搞清楚它算的是什么时间我们一共看了几分钟了我也说不大清但这个计时的思路我在好几个 C# 项目里都用上了今天把拆过的这个“基于 C# 的用于计算多人时间的计时器”项目完整讲一遍。它解决的就是一个很具体的场景同一场活动里有多个人/多个任务要分别计时每人开始、暂停、继续、结束的时间点互不干扰最后还要把每个人的累计时长算清楚甚至导出成表格。核心就是一个基于 C# 的多计时器管理方案不算复杂但坑不少。先说结论这项目不是用一个 Timer 控件去凑而是用“时间戳 状态机 持久化”的方式管理每一个参与者的计时记录。界面上的 Timer 只是刷新显示真正的时间计算完全靠 DateTime 差值这样才能做到暂停后继续、跨窗体刷新、程序重启后还能恢复。适合谁适合要做会议计时、多人答题计时、活动分段统计、比赛耗时记录的 C# 开发者和相关从业者。2. 计时核心设计为什么不能用 Timer 累加秒数2.1 用 Timer 累加秒数会翻车很多初学者拿到“多人计时”需求第一反应是放一个 Timer每秒 tick 一次每个参与者维护一个 int 计数字段tick 的时候就count。表面看没问题但实际跑起来会翻车。比如你把窗口最小化或者电脑进入睡眠再唤醒Windows 的 Timer 事件会被挂起tick 次数就会少记甚至漏记。你看着界面停了 5 分钟但累积时间可能只增加了 2 分钟这数据根本没法用。我拆的这个项目里核心思路是用“时间点”而不是“时间量”。每个参与者只需要记录三个时间点本次计时的开始时间、最后暂停时间、累计已计时的秒数。暂停的时候把DateTime.Now - 开始时间加到累计值里然后把状态置为暂停继续的时候重新记录开始时间。页面上的 Timer 只负责每秒把累计值加上当前差值刷到界面上不负责累加逻辑。这样即使界面卡了 10 秒重新刷新时也是用真实时间差补回来不会少一秒。2.2 数据结构一个类管一个参与者的计时状态项目里定义了一个参与者计时类大概长这样public class PersonTimer { public string Name { get; set; } public bool IsRunning { get; set; } public DateTime StartTime { get; set; } public DateTime LastPauseTime { get; set; } public TimeSpan AccumulatedTime { get; set; } public void Start() { if (IsRunning) return; StartTime DateTime.Now; IsRunning true; } public void Pause() { if (!IsRunning) return; AccumulatedTime DateTime.Now - StartTime; LastPauseTime DateTime.Now; IsRunning false; } public TimeSpan GetCurrentTime() { if (!IsRunning) return AccumulatedTime; return AccumulatedTime (DateTime.Now - StartTime); } }解释一下AccumulatedTime存的是已经暂停过的累计时长StartTime是本次开始运行的时间点IsRunning表示当前是否正在计时。GetCurrentTime()是给 UI 刷新的方法正在跑的时候返回累计值加当前差值暂停了就只返回累计值。这里有几个参数说明DateTime.Now精确到 10 毫秒左右做多人计时完全够用如果你要更高精度可以用DateTime.UtcNow配合Stopwatch.GetTimestamp()那套但这个场景没必要。TimeSpan类型可以直接做加法和减法跨午夜也不会出错比用int存秒数靠谱得多。2.3 列表管理多人C# 集合操作的妙用多人计时就是用一个ListPersonTimer界面绑定到DataGridView或者ListView。添加一个人的时候就new PersonTimer()然后list.Add()移除就list.Remove()。整个项目我比较欣赏的一点是它把操作封装成了几个方法开始、暂停、继续、重置、删除全部围绕列表中的某个PersonTimer来做。ListPersonTimer timers new ListPersonTimer(); void StartTimer(string personName) { var person timers.FirstOrDefault(t t.Name personName); if (person null) { person new PersonTimer { Name personName }; timers.Add(person); } person.Start(); } void PauseAll() { foreach (var t in timers) { if (t.IsRunning) t.Pause(); } } void DisplayRefresh() { foreach (var t in timers) { var txt t.GetCurrentTime().ToString(hh\:mm\:ss); // 更新 UI 对应行 } }FirstOrDefault是个好东西但要注意如果找不到会返回null所以要先判空或者先判断Any()是否存在。这里的PauseAll是“一键暂停所有人”在比赛场景里很常用比如主持人喊停所有参赛者同时冻结。3. UI 层刷新与线程模型Timer 到底怎么挂才不卡3.1 Windows Forms 里 UI 刷新的正确姿势如果项目是基于 Windows Forms看代码风格很像虽然正文没明说但这个结构是典型的 WinForms 项目刷新界面用的System.Windows.Forms.Timer它的 tick 事件是在 UI 线程上执行的不会触发跨线程访问控件的问题。但要注意tick 间隔不要设得太短1000ms 刷新一次就够了设 100ms 反而会导致界面闪烁CPU 占用也上去了。有的同学喜欢用System.Threading.Timer或者System.Timers.Timer那个回调是在线程池里的直接操作 UI 控件会抛InvalidOperationException必须用Invoke或BeginInvoke转回 UI 线程。这个项目里我印象最深的就是它在刷新方法前面加了一个判断if (dataGridView1.InvokeRequired) { dataGridView1.Invoke(new Action(DisplayRefresh)); } else { DisplayRefresh(); }这个写法是个保险即使项目未来从 WinForms 迁移到其他 UI 框架或者有人把刷新逻辑放进了后台线程也不会炸。不过我们现在的场景直接用Form.Timer就完全够用因为这个网格控件并不需要频繁刷新。3.2 显示格式TimeSpan 转字符串的坑TimeSpan.ToString()默认返回类似01:02:03的格式但是小时数超过 24 的时候会变成1.02:03:04小数点前面是“天”这显然不是我们想要的。项目里用了自定义格式hh\:mm\:ss把:用转义符包起来这样小时数会显示为进制的余数而不是总天数。TimeSpan ts person.GetCurrentTime(); string display ts.ToString(hh\:mm\:ss);这里有个隐藏坑如果总共计时超过 24 小时hh这个格式只会显示“小时余数”比如 25 小时会变成01:00:00不是 25。如果你需要显示总小时数要用(int)ts.TotalHours拼字符串string display string.Format({0:D2}:{1:D2}:{2:D2}, (int)ts.TotalHours, ts.Minutes, ts.Seconds);我一般会写一个小的扩展方法统一处理这种显示逻辑避免每个界面写一遍。这个项目里好像没有做扩展方法但我在复现时会自己加上这个习惯帮了我不少。3.3 跨窗体共享计时状态逻辑别写在控件里如果一个解决方案里有多个窗体比如主窗体显示总览子窗体显示每个人的详情那么计时逻辑必须放在独立的类中而不能写在某个窗体的代码后面。这个项目把PersonTimer单独成一个类文件就是为了避免窗体关闭时计时状态丢失。窗体只是“看”数据数据本身在内存中由全局静态变量或者依赖注入容器持有。public static class TimerManager { public static ListPersonTimer AllTimers { get; set; } new ListPersonTimer(); public static void SaveToXml(string path) { // 序列化 AllTimers } public static void LoadFromXml(string path) { // 反序列化恢复状态 } }我拆的时候发现它的持久化采用的是 XML 序列化而不是数据库。适合人数不多、数据量小的场景毕竟一个 XML 文件可以直观地看到每个人的计时记录方便调试和排错。如果你要保存到 SQLite 或者 SQL Server只要把SaveToXml里的逻辑替换即可PersonTimer类加上[Serializable]特性就能直接用XmlSerializer。4. 完整复现步骤从建项目到跑起来四步走4.1 创建 WinForms 项目并引入核心类用 Visual Studio 新建一个.NET Framework 4.7.2或者.NET 6的 Windows Forms 项目都可以然后创建PersonTimer.cs和TimerManager.cs两个文件内容就是上面写的那些代码。这里要注意项目目标框架和 NuGet 包如果用了System.Text.Json需要安装对应的包如果全部用XmlSerializer则不需要额外引用。Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet6.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms /PropertyGroup /Project这段是.csproj文件UseWindowsForms必须设为true否则 WinForms 相关程序集不会被引用。注意用 SDK 风格的项目文件不需要额外手动添加System.Windows.Forms.dll引用SDK 会自动处理。如果你还在用旧的.NET Framework项目需要在“添加引用”里勾选System.Windows.Forms和System.Xml。4.2 设计主界面一表多按钮主界面上放一个DataGridView列设置如下姓名、状态运行/暂停、时长、操作开始/暂停/继续。再加四个按钮添加人员、一键暂停所有、一键继续所有、导出报表。布局简单但要注意DataGridView的AutoSizeColumnsMode设为Fill这样窗口大小变化时列能自动伸缩不然看起来很难看。添加人员按钮的逻辑private void btnAdd_Click(object sender, EventArgs e) { string name txtName.Text.Trim(); if (string.IsNullOrEmpty(name)) { MessageBox.Show(姓名不能为空); return; } if (TimerManager.AllTimers.Any(t t.Name name)) { MessageBox.Show(该人员已在列表); return; } var person new PersonTimer { Name name }; TimerManager.AllTimers.Add(person); // 刷新 DataGridView RefreshGrid(); }这段代码里Any和FirstOrDefault的用法是 LINQ 的基本操作。这里有个细节txtName.Text.Trim()是为了去掉用户输入的空格不然“张三 ”和“张三”会被认为是两个人。4.3 用 UI Timer 刷新显示在窗体的Load事件里创建一个Timer间隔设为 1000msTick 事件里调用RefreshGrid()。RefreshGrid按行更新每列的显示文本而不是每次重新绑定数据源这样可以避免滚动条跳动和焦点丢失。private void RefreshGrid() { for (int i 0; i TimerManager.AllTimers.Count; i) { var p TimerManager.AllTimers[i]; if (dataGridView1.Rows.Count i) { dataGridView1.Rows.Add(); } dataGridView1.Rows[i].Cells[ColName].Value p.Name; dataGridView1.Rows[i].Cells[ColStatus].Value p.IsRunning ? 运行中 : 已暂停; dataGridView1.Rows[i].Cells[ColTime].Value p.GetCurrentTime().ToString(hh\:mm\:ss); var btnCell dataGridView1.Rows[i].Cells[ColAction] as DataGridViewButtonCell; if (btnCell ! null) { btnCell.Value p.IsRunning ? 暂停 : 继续; } } }这里有个容易踩的坑DataGridViewButtonCell的Value属性不能直接赋字符串需要显示调用CellPainting事件来画按钮文本或者使用列模板里的UseColumnTextForButtonValue属性。一个比较省事的做法是用DataGridViewButtonColumn的Text属性为按钮设置默认文本然后在CurrentCellDirtyStateChanged事件里判断点击的是哪一行再执行对应操作。4.4 实现开始、暂停、继续的联动在CellClick事件里判断点击的列是否是操作列再获取当前行索引取出对应的PersonTimer调用它的Start或Pauseprivate void dataGridView1_CellClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex ! colAction.Index) return; var person TimerManager.AllTimers[e.RowIndex]; if (person.IsRunning) { person.Pause(); } else { person.Start(); } RefreshGrid(); }这里要注意e.RowIndex可能返回-1比如用户点击了列头必须先判断e.RowIndex 0。另外暂停和继续的触发逻辑就是靠person.IsRunning分支代码清晰明了。4.5 持久化XML 序列化与反序列化保存按钮调用TimerManager.SaveToXml(timers.xml)加载按钮调用LoadFromXml。序列化的方式很简单public static void SaveToXml(string path) { var serializer new XmlSerializer(typeof(ListPersonTimer)); using (var fs new FileStream(path, FileMode.Create)) using (var writer new StreamWriter(fs, Encoding.UTF8)) { serializer.Serialize(writer, AllTimers); } }注意XmlSerializer对ListT的支持很好但要求类型T具有无参构造函数并且所有需要序列化的属性必须是可读写的。PersonTimer类正好符合。反序列化时同理public static void LoadFromXml(string path) { if (!File.Exists(path)) return; var serializer new XmlSerializer(typeof(ListPersonTimer)); using (var fs new FileStream(path, FileMode.Open)) using (var reader new StreamReader(fs, Encoding.UTF8)) { AllTimers (ListPersonTimer)serializer.Deserialize(reader); } }这里的一个坑是如果 XML 文件被外部修改过或者字段名不对反序列化会抛异常。我一般会建议加一个 try-catch捕获InvalidOperationException提示用户文件损坏并保留当前内存中的计时状态避免崩溃。5. 数据导出与显示刷新Excel 报告和毫秒级精度的选择5.1 导出 CSV 或 Excel 的两种思路如果只是导出汇总时长用一个简单的 CSV 文件就够了using (var sw new StreamWriter(timers.csv, false, Encoding.UTF8)) { sw.WriteLine(姓名,状态,累计时长(秒),时长文本); foreach (var p in TimerManager.AllTimers) { var ts p.GetCurrentTime(); sw.WriteLine(${p.Name},{p.IsRunning},{(int)ts.TotalSeconds},{ts:hh\\:mm\\:ss}); } }注意 CSV 文件用 Excel 打开时中文容易乱码所以Encoding.UTF8前面要加上 UTF-8 BOM。最简单的方式是用new StreamWriter(path, false, new UTF8Encoding(true))这样 Excel 会自动识别。如果你要导出真正的.xlsx那需要引用一个开源库比如NPOI或ClosedXML这里就不展开代码了思路一样只是把StreamWriter换成Workbook操作。5.2 提高精度Stopwatch 和 DateTime 的取舍项目里用的是DateTime.Now精度是 10 毫秒左右对“多人计时”这类需求足够。但如果是做体育比赛计时需要 1 毫秒甚至 0.1 毫秒的精度就得上Stopwatch。Stopwatch底层用的是高精度计数器不是系统时钟不受系统时间调整影响。常见做法是在PersonTimer里持有一个StopwatchStart时调用sw.Start()Pause时sw.Stop()时长直接sw.Elapsed。但这样就不能把暂停逻辑写成累计制了因为Stopwatch本身会处理累计。两者各有优劣DateTime方案可以序列化恢复Stopwatch方案程序重启后会丢失精度。我拆的这个项目用的是DateTime方案我觉得很合理因为它的应用场景不是搞毫秒级比赛而是会议室里记录每个人发言时间、多人答题计时等场景。这类场景下UI 刷新本身有延迟10 毫秒精度绰绰有余。5.3 防止误操作计时状态的颜色区分项目里做到了运行中行背景色浅绿已暂停行背景色浅灰这个设计很实用。我会在CellFormatting事件里根据状态设置DefaultCellStyle而不是在RefreshGrid里逐行设置。这样当单元格内容被刷新时颜色不会闪烁。private void dataGridView1_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (e.RowIndex 0) return; var p TimerManager.AllTimers[e.RowIndex]; if (p.IsRunning) { dataGridView1.Rows[e.RowIndex].DefaultCellStyle.BackColor Color.LightGreen; } else { dataGridView1.Rows[e.RowIndex].DefaultCellStyle.BackColor Color.LightGray; } }通过颜色一眼就能看出哪个人还在计时这在多人并行计时的场景下非常有用。不然十几个人的列表靠“运行中”文字判断很容易失误。6. 踩坑记录多人计时实战中的五个现场问题6.1 最小化窗口后计时少了“一大截”现象把程序最小化或者锁屏后再回来界面上显示的时间比真实时间少了 5 分钟。原因UI Timer 在窗口最小化时被系统挂起tick 事件不触发每秒的累加没有被执行。解决用DateTime差值计算而不是在 tick 里累加。具体就是GetCurrentTime()每次用当前时间减开始时间和 Tick 事件无关。我自己的习惯是最小化后打开任务管理器看 CPU但这不是关键关键是时间计算不能依赖 tick。6.2 暂停后继续时间重复计算现象暂停 3 分钟后继续继续运行 1 分钟显示的总时长是 4 分钟而不是 1 分钟。原因暂停时把当前差值加到AccumulatedTime后没有把StartTime重置继续时又用旧的StartTime去算。解决在Start()方法里每次都要StartTime DateTime.Now;。这个细节我在拆项目时专门验证过很多人写暂停时会忘记重置开始时间导致时间重复累加。6.3 跨天计时hh格式显示成“天”现象计时超过 24 小时后单元格里出现了1.01:02:03这种带小数点的字符串。原因TimeSpan.ToString()的默认格式把超过 24 小时的部分显示为天数。解决使用自定义格式hh\:mm\:ss时如果总时长为 25 小时 3 分 4 秒显示的是01:03:04属于“小时余数”。要用TotalHours自己拼接字符串。6.4 DataGridView 按钮文本不刷新现象点击“暂停”按钮后按钮的文本仍然显示“暂停”而不是“继续”。原因DataGridViewButtonColumn没有自动根据Value更新显示文本需要手动处理。解决在CellPainting事件里绘制按钮文本或者在按钮列把UseColumnTextForButtonValue设为False然后在RefreshGrid里设置cell.Value p.IsRunning ? 暂停 : 继续;并调用dataGridView1.Invalidate()。6.5 反序列化时程序崩溃现象加载上次保存的 XML 文件程序直接报错退出。原因XML 文件被手工编辑过或者字段顺序不对、类型不匹配。解决加 try-catch捕获异常后弹出提示但不清空当前数据允许用户继续操作。我一般会再做一个“自动备份”功能每次保存时把旧文件重命名为.bak这样就算新版程序读不了还能用旧文件恢复。7. 进阶技巧一个人也可以秒变“会议计时小助手”除了基本的多人计时这个项目还可以扩展出两个很实用的功能一键轮询计时、定时统计报表。一键轮询计时就是为每个参与者分配一个固定时间段比如发言不超过 3 分钟到时间自动暂停。实现不复杂在RefreshGrid里检查GetCurrentTime()是否超过预设的阈值如果超过就自动调用Pause()并把该行闪烁提示。我这里加一个简单的倒计时显示public TimeSpan GetRemainingTime(TimeSpan limit) { var current GetCurrentTime(); return current limit ? TimeSpan.Zero : limit - current; }然后在刷新方法里var remain person.GetRemainingTime(TimeSpan.FromMinutes(3)); if (remain TimeSpan.Zero) { person.Pause(); MessageBox.Show(${person.Name} 时间到); }这里要注意不要弹MessageBox每秒弹一次这样会烦死人。我会设置一个标记只在第一次超过阈值时弹一次或者用一个bool alerted字段记录是否已经提示过。另一个进阶是定时生成统计报表每小时把当前计时状态写入 CSV或者按天生成汇总。这就和上一章的导出联动起来了。从那次以后我每做一个计时类工具都会强制自己写一个“状态恢复”功能不要因为程序意外关闭就丢掉所有数据。哪怕只是简单的每分钟自动保存到 XML都比全靠用户手动保存强得多。最后把PersonTimer里的逻辑尽量保持纯净不依赖任何 UI 控件这样以后想把它移植到控制台、WPF 或者 MAUI 都是几分钟的事。希望这个拆解对你有帮助你自己动手复现时可以直接从核心类开始先把计时逻辑跑通再慢慢套界面会顺畅很多。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站