☰
C# WinForms 日程提醒器设计:Timer调度、重复规则与托盘驻留
2026/10/30 2:50:06 网站建设 项目流程

简介:这是一份面向C#入门与Windows桌面开发学习者的WinForms日程提醒器完整项目源码,围绕事件添加、事件浏览、提醒开关、倒计时显示与到期通知等核心功能展开,可用于课程设计、练手项目或二次开发参考。压缩包共47个文件、约317KB,以15个cs源文件为主体,配合5个resx与resources界面资源、sln与csproj工程文件、app.config配置、可执行exe及dll依赖,另含mdf与ldf数据库文件,覆盖登录、注册、事件管理等窗体模块,工程结构完整,可直接用Visual Studio打开调试。目前已有395人学习下载。读者可从中参考WinForms控件布局、事件数据结构设计、Timer周期检查与时间差计算、MessageBox提醒触发等实现思路,也能借鉴多窗体跳转、数据集与配置文件的组织方式,适合按模块拆解学习,并在此基础上扩展搜索过滤、Toast通知等功能。

1. 从贴在显示器边上的便签说起:日程提醒器真正要解决的问题

去年帮一位同学看毕设,他的选题是「C# 用 WinForm 制作的日程提醒器」。我第一反应是这东西太简单——一个 Timer,一个 DateTime 比较,弹个 MessageBox 就完事了。等他演示的时候,第一个提醒准点弹出,第二个早了两分钟,第三个在他把窗口最小化之后彻底没响。这就是典型的“功能列表能写满一页,真正跑起来全是漏洞”。

日程提醒器真正的难点不在“提醒”这两个字,而在时间:日期怎么存、倒计时怎么算、重复规则怎么展开、系统休眠或改时间之后怎么不丢提醒、托盘驻留时程序怎么不被误关。这些恰恰是课程设计和毕设答辩里最容易被追问的部分。这篇文章面向的是要交一份完整设计的人——某高校的课程设计、毕业设计,或者刚入门想做一个能真正用起来的桌面小工具。我会把需求、选型、架构、核心代码到验收清单按顺序摆开,你可以照着改成自己的版本。

2. 需求拆解与技术选型:WinForms、Timer 与本地存储的三方权衡

动手写代码之前,先要把“提醒器”这个词拆开。很多人一上来就新建窗体、拖控件,写到一半发现重复规则没法表达,只能推倒重来。我一般会先花半天时间把功能边界划出来,再决定技术栈,最后才动键盘。

2.1 功能边界:提醒、倒计时、重复规则三条主线

把需求按用户实际动作来分,日程提醒器只有三条主线。

第一条是一次性提醒。用户选一个具体日期和时间点,比如 6 月 18 日 14:30 交材料,到点弹窗、响铃、托盘闪一下。这条最简单,但也最容易出问题,因为它是“绝对时间”,跟系统时钟强相关。

第二条是倒计时展示。界面上要能显示“距离下次提醒还有 3 天 4 小时 12 分”,而且这个数字每秒都在变。倒计时不是独立功能,它是提醒记录的派生视图——所有未触发、已启用的记录,取最小的那个触发时间,减去当前时间。理解了这一点,你就不会去单独维护一个“倒计时对象”,而是老老实实从数据算。

第三条是重复规则。每天 8:30 提醒打卡、每周一三五 19:00 提醒跑步、每月 1 号提醒交房租、工作日早上提醒看邮件。这条是整套系统里唯一有设计含量的部分,也是答辩时最能拉开差距的地方。规则的表达方式、下次触发时间的计算方式,直接决定了后面会不会出现“一天弹八次”或者“隔天就不提醒了”这种问题。

边界划完之后,还可以顺手砍掉一批不该做的:多用户、云同步、日历视图拖动、与系统日历打通。这些在课程设计里纯属给自己挖坑,工作量翻倍但答辩时并不加分。我一般会明确写一句“本系统为单用户本地应用,数据不离开本机”,把范围框死。

2.2 为什么是 WinForms:三条选型理由和一个反例

有同学会问,现在都 2025 年了,为什么还用 WinForms?这个问题答辩时一定有人问,得提前准备好答案。

第一条理由是启动快、部署简单。日程提醒器这种小工具,用户期待的是双击就开、开机常驻。WinForms 编译出来的程序启动通常在几百毫秒量级,内存占用小,做一个托盘常驻的后台工具非常合适。WPF 当然也能做,但 XAML 绑定、MVVM 那一套对这类表单驱动的界面来说是过度设计。

第二条理由是控件模型直接。一个 ListView 展示提醒列表,几个 DateTimePicker 选时间,ComboBox 选重复类型,Timer 控件拖上去就能用。C# 里System.Windows.Forms.Timer的 Tick 事件跑在 UI 线程上,这一点在后面讲线程安全时会反复用到,它省掉了很多 Invoke 的麻烦。

第三条理由是与 Windows 系统集成成本低。托盘图标用NotifyIcon,提示音用System.Media.SystemSounds或者SoundPlayer,开机自启用注册表 Run 键,都是一二十行代码的事。

反例也说一个:不要用控制台程序冒充。有人图省事写个 Console 程序,靠Thread.Sleep轮询,弹窗还得引用 WinForms 的 MessageBox。这种作业在演示环节问题不大,但只要用户关掉黑窗口,程序就没了,托盘、置顶、最小化全都没有,答辩时被问一句“用户怎么让它一直在后台运行”就答不上来。

技术栈最终定下来:.NET(6 或 8 的 Windows 桌面目标框架)+ WinForms + System.Text.Json(后期可换 SQLite)+ 单 Timer 轮询调度。这几个选择后面每一章都会落到具体代码。

2.3 数据落地:JSON 起步、SQLite 收尾的两段式方案

提醒数据存哪里,是个需要提前想清楚的问题。常见三种:文本/JSON 文件、SQLite、SQL Server 之类的关系库。

我的建议是两段式:课程设计级别、记录量在几百条以内,直接用 JSON 落盘到用户目录,System.Text.Json序列化一个List<ReminderItem>就够,调试时还能用记事本打开看,非常直观。等到记录上千条、需要按标题模糊搜索、需要统计时,再换 SQLite。

JSON 落盘的路径要注意,不要写在程序目录。程序装在C:\Program Files下面时,普通用户对那个目录没有写权限,一保存就抛UnauthorizedAccessException,这是新手最常见的翻车点之一。正确做法是写到%AppData%:

// 数据文件放在当前用户的 AppData\Roaming 下,避免程序目录无写权限的问题 public static class AppPaths { public static readonly string DataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ScheduleReminder"); public static readonly string DataFile = Path.Combine(DataDir, "reminders.json"); public static void EnsureDir() { if (!Directory.Exists(DataDir)) Directory.CreateDirectory(DataDir); } }

Environment.SpecialFolder.ApplicationData会解析成当前用户的漫游目录,每个用户各存各的,不需要管理员权限,重装程序也不会误删数据。EnsureDir在程序启动时调一次,后面所有读写都建立在目录已存在的前提上。

如果后期升级到 SQLite,表结构大致是这样,字段跟 JSON 模型一一对应,迁移时只是换个序列化层:

-- 提醒记录主表,RepeatType 与 WeekMask 配合表达重复规则 CREATE TABLE Reminder ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Title TEXT NOT NULL, -- 提醒标题 Content TEXT, -- 备注内容 TriggerAt TEXT NOT NULL, -- 首次触发时间,ISO8601 字符串 RepeatType INTEGER NOT NULL DEFAULT 0, -- 0不重复 1每天 2每周 3每月 4工作日 WeekMask INTEGER NOT NULL DEFAULT 0, -- 位掩码,bit0=周一 … bit6=周日 DayOfMonth INTEGER NOT NULL DEFAULT 1, -- RepeatType=3 时使用 AdvanceMinute INTEGER NOT NULL DEFAULT 0, -- 提前多少分钟提醒 IsEnabled INTEGER NOT NULL DEFAULT 1, -- 是否启用 LastFiredAt TEXT -- 上次实际触发时间,防重复的关键字段 );

注意TriggerAt和LastFiredAt都存成 ISO8601 字符串而不是时间戳。原因很实际:调试时打开数据库能直接看懂,出问题时不用心算。LastFiredAt这个字段是整个防重复机制的锚点,第 5 章会专门讲它没落盘会导致什么后果。

3. 总体架构设计:三层结构与四个模块的职责划分

小工具也要有结构,不然写到第三个功能就开始互相打架。我习惯把它切成三层四个模块,每层职责单一,层与层之间只通过数据模型通信。

3.1 分层落地:UI 层只做展示,调度层只管时间

UI 层(主窗体、编辑窗体、提醒弹窗)负责显示和收集输入。它不允许直接读写文件,也不允许自己算下次触发时间,只调用服务层的方法,拿回结果刷新界面。

调度层(ReminderScheduler)是整套系统的心脏。它持有一份内存中的提醒列表,靠一个 Timer 每秒扫描一次,判断有没有到期的记录,有就抛事件出来。它不关心弹窗长什么样,只负责“什么时候该响”。

数据层(ReminderRepository)只做两件事:把List<ReminderItem>读出来、存回去。JSON 也好 SQLite 也好,对上层暴露的接口不变。

四个模块对应:提醒管理(增删改查)、调度引擎(时间计算与触发)、提醒展示(弹窗、托盘气泡、声音)、系统集成(托盘图标、开机自启、单实例运行)。

这样切的好处是,答辩时被问“如果我想改成服务端推送提醒怎么办”,你可以答:把调度层的 Timer 换成一个网络监听,UI 层和数据层完全不动。这种回答比“我没想过”强太多。

3.2 调度策略:单 Timer 轮询 + 内存优先,而不是一任务一 Timer

新手最容易犯的设计错误是一条提醒配一个 Timer。十条提醒十个 Timer,一百条就一百个,关掉提醒还得记得把 Timer Dispose 掉,忘了就是内存泄漏,而且系统休眠唤醒后多个 Timer 同时触发,界面直接卡死。

正确做法是全局单 Timer + 内存列表轮询。Timer 的间隔设成 1 秒,每次 Tick 做一件极轻的事:遍历内存中的启用记录,找出NextTriggerTime <= Now的那些,抛给上层。因为列表已经在内存里,几百条记录的遍历是微秒级操作,完全不会卡界面。

如果担心记录量大,间隔可以放宽到 5 秒或 10 秒。对日程提醒来说,误差在 10 秒内用户完全无感,但 CPU 占用会降到几乎为零。我在课程设计里一般用 1 秒,因为倒计时显示需要每秒刷新,正好一举两得。

这里有个关键设计点:内存列表是唯一事实来源,磁盘只是持久化副本。所有增删改先改内存,再异步或同步落盘。这样调度层完全不用碰 IO,逻辑干净,也不会有“读文件读到一半用户删了记录”的并发问题。

3.3 数据模型与表结构:八个字段撑起全部业务

数据模型的设计决定了后面代码的复杂度。字段太少,重复规则表达不了;字段太多,每加一个功能都要改一堆地方。我的经验是八个字段刚好。

字段名类型是否必填说明
IdGuid 或 int是主键,JSON 方案里用 Guid 免去自增管理
Titlestring是提醒标题,列表里显示这一列
Contentstring否备注,弹窗里显示详细信息
TriggerAtDateTime是首次触发时间,重复规则的起点
RepeatTypeenum是None / Daily / Weekly / Monthly / Workday
WeekMaskint否位掩码,bit0=周一,bit6=周日,仅 Weekly 使用
AdvanceMinuteint是提前提醒分钟数,0 表示准点
LastFiredAtDateTime?否上次实际触发时间,防重复的核心

TriggerAt存的是“起点”,不是“下次触发时间”。下次触发时间永远是从起点加上重复规则现算的,算完可以缓存到一个不序列化的运行时属性里(C# 里标[JsonIgnore]),避免每次 Tick 都重算。这个缓存字段不落盘,因为它是派生值,落盘只会带来“缓存和规则不一致”的新问题。

AdvanceMinute这个字段容易被忽略,但它直接影响调度逻辑:真正该触发的时间是NextTriggerTime - AdvanceMinute。如果设计时没预留,后期想加“提前 10 分钟提醒”就得改所有时间比较的地方,非常痛苦。

4. 核心模块实现:倒计时算法、重复规则与托盘驻留

这一章是能直接抄的部分。代码按“数据模型 → 时间计算 → 调度 → 系统集成”的顺序给,每段后面说清楚为什么这么写、参数怎么改。

4.1 下一次触发时间:TimeSpan 与 DateTime 的正确用法

倒计时的本质是两个DateTime相减得到一个TimeSpan。听起来简单,但有两个坑必须提前处理。

第一个坑:不要用DateTime.Now做持久化存储。DateTime.Now带Kind = Local,用户换时区、改系统时间之后,存下来的值语义就变了。存盘统一用本地时间字符串可以,但计算的时候要有意识地统一口径。我的做法是全程用本地时间,因为提醒是给人看的,用户在哪个时区就按哪个时区提醒,但在比较之前先取一次快照,避免一次 Tick 内多次调用DateTime.Now拿到不同值。

第二个坑:倒计时要处理负数。已经过期的记录如果还在列表里,直接相减会得到负的 TimeSpan,界面上就会显示“还有 -3 天”。要么过滤掉,要么显示“已过期”。

// 计算距离下次触发还有多久,返回值永远非负 public static TimeSpan GetCountdown(DateTime nextTrigger, DateTime now) { var span = nextTrigger - now; return span < TimeSpan.Zero ? TimeSpan.Zero : span; } // 把 TimeSpan 格式化成“3天4小时12分”这种中文倒计时 public static string FormatCountdown(TimeSpan span) { if (span.TotalDays >= 1) return $"{(int)span.TotalDays}天{span.Hours}小时{span.Minutes}分"; if (span.TotalHours >= 1) return $"{span.Hours}小时{span.Minutes}分{span.Seconds}秒"; if (span.TotalMinutes >= 1) return $"{span.Minutes}分{span.Seconds}秒"; return $"{span.Seconds}秒"; }

GetCountdown里那个三元判断看着不起眼,但它是防负数的唯一一道闸。FormatCountdown里用span.Hours而不是span.TotalHours,因为TotalHours是 double,3 天 4 小时会得到 76.0,直接取整会变成 76 小时,显示就不对了。Days、Hours、Minutes是 TimeSpan 拆解后的分量,这才是想要的。

界面上刷新倒计时同样用那个 1 秒的 Timer,Tick 里找到“最近一条未触发的记录”,算倒计时,写进 Label。如果列表为空,就显示“暂无提醒”。

4.2 重复规则展开:位掩码表示“每周一三五”

重复规则的计算是整个项目里唯一需要动点脑子的算法。目标很明确:给定一条记录和当前时间,算出严格大于当前时间的下一个触发时刻。注意是“严格大于”,等于当前时间也算已触发,否则同一秒内会反复触发。

先看位掩码怎么用。用 int 的低 7 位表示周一到周日,bit0 是周一,bit6 是周日。用户勾选“周一、周三、周五”,掩码就是1 | 4 | 16 = 21。判断某天是否命中,就是(mask & (1 << dayIndex)) != 0,其中dayIndex用(int)date.DayOfWeek换算。

// 根据重复规则计算下一次触发时间;返回 null 表示不再触发 public static DateTime? CalcNextTrigger(ReminderItem item, DateTime now) { // 不重复:只有起点晚于当前时间才有效 if (item.RepeatType == RepeatType.None) return item.TriggerAt > now ? item.TriggerAt : (DateTime?)null; // 起点还没到,直接用起点 if (item.TriggerAt > now) return item.TriggerAt; var start = item.TriggerAt; switch (item.RepeatType) { case RepeatType.Daily: { // 按整天数补齐,避免逐日循环在跨年时变慢 int days = (now.Date - start.Date).Days + 1; var candidate = start.AddDays(days); return candidate > now ? candidate : candidate.AddDays(1); } case RepeatType.Weekly: { // 最多向后找 14 天,一定命中至少一个勾选的星期 for (int i = 1; i <= 14; i++) { var candidate = start.AddDays(i); if (candidate <= now) continue; int idx = ((int)candidate.DayOfWeek + 6) % 7; // 周一=0 … 周日=6 if ((item.WeekMask & (1 << idx)) != 0) return candidate; } return null; } case RepeatType.Monthly: { // 从起点所在月开始逐月推进,日期取 DayOfMonth var baseDate = new DateTime(now.Year, now.Month, 1, start.Hour, start.Minute, start.Second); for (int i = 0; i <= 1; i++) { var month = baseDate.AddMonths(i); int day = Math.Min(item.DayOfMonth, DateTime.DaysInMonth(month.Year, month.Month)); var candidate = new DateTime(month.Year, month.Month, day, start.Hour, start.Minute, start.Second); if (candidate > now) return candidate; } return null; } case RepeatType.Workday: { // 工作日:周一到周五,跳过周末 var candidate = now.Date.AddDays(1).Add(start.TimeOfDay); while (candidate.DayOfWeek == DayOfWeek.Saturday || candidate.DayOfWeek == DayOfWeek.Sunday) candidate = candidate.AddDays(1); return candidate; } default: return null; } }

几个参数说明一下。now.Date - start.Date用的是日期差而不是时间差,避免“起点 23:59、当前 00:01”这种跨天场景下算出 0 天但实际已经过了两天的问题。周几的换算((int)DayOfWeek + 6) % 7是把 .NET 默认的“周日=0”转成“周一=0”,这样位掩码的顺序和用户勾选界面的顺序一致,改起来不容易错。

Monthly 分支里用Math.Min(DayOfMonth, DaysInMonth)处理 31 号在小月的情况。不做这个处理,设成每月 31 号提醒的用户在 2 月会直接抛异常,这是很典型的血泪教训。

AddDays会保留原时间的时分秒,这一点很重要。如果用now.Date.AddDays(n)再拼时间,容易把秒数丢掉,导致提醒时间在 8:30:00 和 8:30:59 之间漂移。

4.3 托盘驻留与开机自启:NotifyIcon 加注册表 Run 键

日程提醒器不能像普通程序那样关掉就没了。用户点右上角 × 的时候,正确行为是隐藏到托盘,程序继续跑;真要退出走托盘右键菜单。

// 主窗体关闭时取消关闭,改为隐藏,实现托盘驻留 private bool _realExit = false; private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { if (_realExit) return; // 托盘菜单点了退出才真关 e.Cancel = true; // 拦下关闭动作 this.Hide(); notifyIcon.Visible = true; notifyIcon.ShowBalloonTip(2000, "日程提醒器", "程序已最小化到托盘,提醒照常生效", ToolTipIcon.Info); } private void 退出ToolStripMenuItem_Click(object sender, EventArgs e) { _realExit = true; notifyIcon.Visible = false; // 先隐藏再关窗,避免托盘残影 notifyIcon.Dispose(); this.Close(); }

_realExit这个标志位是必须的,否则托盘菜单里的Close()又会被FormClosing拦下来,窗口永远关不掉,用户只能去任务管理器结束进程。notifyIcon.Visible = false要在Close()之前执行,这不是玄学——注销托盘图标有延迟,程序退得太快,图标会残留在托盘区,鼠标划过才消失,看起来像 bug。

开机自启走注册表当前用户分支,不需要管理员权限:

// 写入/移除 HKCU 的 Run 键,实现开机自启 private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; private const string AppName = "ScheduleReminder"; public static void SetAutoStart(bool enable) { using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true); if (key == null) return; if (enable) { // 路径必须加引号,否则装在“Program Files”这种带空格的目录会启动失败 key.SetValue(AppName, $"\"{Application.ExecutablePath}\""); } else { key.DeleteValue(AppName, throwOnMissingValue: false); } }

路径外面的双引号是重点。C:\Program Files\...这种带空格的路径不 quote,Windows 启动时会把空格前那段当成 exe 名,直接启动失败,而且没有任何提示,特别难查。throwOnMissingValue: false是为了让“取消自启”操作幂等,没写过也不报错。

自启还有个细节:开机启动时程序应该直接最小化到托盘,不要弹主窗口。启动参数里加一个--silent,Program.cs 里解析一下,有就Application.Run(new TrayContext())或者启动后立即 Hide。

4.4 提醒弹窗与线程安全:UI 线程 Timer 的边界在哪

System.Windows.Forms.Timer的 Tick 事件在 UI 线程上执行,这意味着两件好事:可以直接改控件属性,不需要Invoke;也意味着不能在 Tick 里做耗时操作,一卡整个界面就冻住。

// 调度器每秒 Tick 一次,只做内存比对,绝不在这里做 IO private void schedulerTimer_Tick(object sender, EventArgs e) { var now = DateTime.Now; // 一次 Tick 只取一次“现在” foreach (var item in _reminders.Where(r => r.IsEnabled)) { var next = item.CachedNextTrigger ?? CalcNextTrigger(item, now); if (next == null) continue; item.CachedNextTrigger = next; // 提前提醒:真正触发时刻要减去提前量 var fireAt = next.Value.AddMinutes(-item.AdvanceMinute); if (fireAt > now) continue; if (item.LastFiredAt.HasValue && item.LastFiredAt.Value >= next.Value) continue; // 已触发过 item.LastFiredAt = now; // 立刻打标记,防止下一秒重复触发 _repository.SaveAsync(_reminders); // 落盘异步,不阻塞 UI OnReminderDue(item); // 抛给弹窗模块处理 } }

这里的顺序非常关键:先写LastFiredAt,再弹窗,最后异步落盘。如果先弹窗再打标记,弹窗是模态的,用户不点确定就卡在那,下一秒 Tick 又判断为到期,再弹一个,叠成一堆窗口关不掉,这是第 5 章要展开的典型故障。

CachedNextTrigger是[JsonIgnore]的运行时字段,第一次算完缓存起来,后面每秒只做一次DateTime比较,开销可以忽略。记录被编辑时把这个缓存清空,让它重新算。

弹窗本身建议做成非模态的独立窗体,置顶显示,带一个“稍后提醒(5 分钟)”按钮和一个“知道了”按钮。稍后提醒的实现不是新建记录,而是把当前记录的TriggerAt临时往后推 5 分钟并单独存一个SnoozeUntil字段——这个字段我在第 3 章的表里没列,属于可选扩展,加上它答辩时是个加分项。

5. 避坑与常见问题排查:五类最容易翻车的场景

下面这五条,每一条我都在别人的课程设计里见过至少一次。现象、原因、解决按这个顺序写,出问题时可以对着排查。

5.1 到点不响:Timer 被阻塞或比较用了错误的时间源

现象:程序开着,提醒时间过了五分钟才弹,或者干脆不弹。调试时打断点又能正常触发。

原因:两种。一是把耗时操作放进了 Tick,比如每次 Tick 都去读一遍 JSON 文件,文件大了之后单次 Tick 超过 1 秒,Timer 消息排队,实际触发时间被推迟。二是时间比较用了DateTime.UtcNow去和本地时间字段比,差出 8 小时,永远不相等。

解决:Tick 里只做内存操作,IO 挪到保存动作里异步执行;全项目统一用DateTime.Now,在调度入口取一次快照往下传,不在函数内部反复调。另外加一行日志,每次 Tick 把当前时间和最近一条待触发时间写文件,出问题直接看日志就知道是没进 Tick 还是判断条件写错了。

5.2 重复提醒轰炸:LastFiredAt 没落盘或判断条件写反

现象:一条“每天 9:00”的提醒,9:00 弹了一次,用户点掉,9:00:01 又弹,一直弹到 9:01。

原因:LastFiredAt只改了内存没存盘,或者进程重启后从磁盘读回来的还是旧值;更常见的是判断条件写成LastFiredAt <= next,等于把“已触发”判成了“未触发”。

解决:LastFiredAt必须在触发的同时写内存并触发一次保存;比较用>=,语义是“上次触发时间不早于本次计划时间,说明已经处理过”。再加一层保险:触发后把该记录的CachedNextTrigger直接置空,强制下一次 Tick 重算,重算出来的时间一定大于当前,天然防重复。

5.3 休眠唤醒后时间错乱:时钟跳变没有兜底

现象:笔记本合盖一晚上,第二天打开,昨晚该响的提醒一个没响;或者用户手动把系统时间往前调了一天,结果所有提醒连弹几十个。

原因:Timer 在系统休眠期间是暂停的,唤醒后不会补发错过的 Tick。程序只认“当前时间点”,对“中间跳过了多长时间”一无所知。

解决:记录每次 Tick 的时间戳,唤醒后第一次 Tick 检查now - lastTick是否超过阈值(比如 2 分钟),超过就认为发生过休眠或时钟跳变,触发一次全量重算:遍历所有记录,重算下次触发时间,对于“本该在跳跃区间内触发”的记录,弹一个汇总提示“有 3 条提醒在你离开期间到期”,而不是逐条弹窗。这个兜底逻辑不到三十行,但没有它,笔记本用户基本用不了。

5.4 关窗后托盘残留、进程退不掉

现象:点了退出,托盘图标还在,鼠标划过去才消失;或者任务管理器里进程还在,得手动结束。

原因:NotifyIcon没有Dispose;窗体关闭时后台线程或 Timer 还在跑,主线程退不出去;还有一种是开了隐藏的子窗体,Application.Run的主消息循环没结束。

解决:退出流程固定成四步——停 Timer、notifyIcon.Visible = false、notifyIcon.Dispose()、Application.Exit()。另外在Program.cs里加单实例检查,用 Mutex 实现,第二次启动时激活已有窗口然后自己退出,避免起两个进程各自弹提醒。

5.5 换台电脑打不开:运行时依赖与相对路径

现象:自己电脑上跑得好好的,拷到答辩教室的机器上双击没反应,或者报“需要安装 .NET 运行时”。

原因:发布时用的是框架依赖模式,目标机没装对应版本的 .NET Desktop Runtime。另一种是程序里用了相对路径读配置,工作目录一变就找不到文件。

解决:发布命令加自包含和单文件参数:

# 自包含发布,目标机无需预装 .NET 运行时;单文件便于拷贝 dotnet publish -c Release -r win-x64 --self-contained true \ -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true

--self-contained true把运行时打包进去,体积会到六七十兆,但换来“拷过去就能跑”。-p:PublishSingleFile打成一个 exe,答辩时用 U 盘拷一个文件就行,不用担心漏了 dll。所有路径一律用Environment.SpecialFolder或AppDomain.CurrentDomain.BaseDirectory拼,绝不用Directory.GetCurrentDirectory()——后者是工作目录,从快捷方式启动和从命令行启动可能不一样。

6. 验收与答辩:一份能当场演示的验收清单

代码写完不等于项目完成。答辩现场最尴尬的不是被问住,而是演示到一半提醒不弹,你只能尴尬地说“它刚才还是好的”。我一般会在验收前把所有用例走一遍,下面这张表你可以直接拿去用。

编号验收项操作步骤通过标准
1一次性提醒新建一条 2 分钟后的提醒准点弹窗,误差不超过 5 秒
2每日重复建一条每日提醒,改系统时间到触发点触发后自动算出明天同一时刻
3每周多选勾选周一、周三、周五只在三天触发,周二不响
4每月 31 号设在 2 月验证不崩溃,按当月最后一天处理
5提前提醒设提前 10 分钟提前 10 分钟弹,不是准点弹
6托盘驻留点 × 关闭主窗口窗口隐藏,托盘图标在,提醒照常
7重启恢复建提醒后杀掉进程再启动提醒记录还在,时间正确
8防重复提醒弹出后不点确定,等 30 秒只弹一个窗口,不叠加
9换机运行拷到另一台没装 .NET 的机器双击直接运行
10退出干净托盘菜单退出进程结束,托盘无残留图标

这张表本身就是答辩材料。演示的时候按编号顺序走,讲到第 8 条的时候主动说一句“这里我特意做了防重复,因为早期版本点掉弹窗会再弹一次”,把踩过的坑主动讲出来,比被问出来强得多。

答辩常被追问的三个点,提前准备好答案。第一,“你的重复规则怎么存的?”——答位掩码,说清楚 bit0 到 bit6 对应周一到周日,为什么不用七个 bool 字段(数据库列膨胀、序列化麻烦)。第二,“程序关掉了提醒还会响吗?”——答托盘驻留加开机自启,说清楚注册表 Run 键的位置和为什么不用计划任务。第三,“如果用户改了系统时间怎么办?”——答第 5.3 节那套全量重算兜底,能说出“记录上次 Tick 时间戳”这个细节,基本就稳了。

最后说个我自己的习惯。这个项目我第一次做完,自测全过,交给同学演示当天翻车——他改完系统时间忘了改回来,第二天所有提醒提前了十几个小时全弹了一遍。从那以后我每次交付前都强制走一遍这张验收表,尤其是第 2、7、8 这三条跟时间状态相关的,一定要在改过系统时间之后再跑一遍。时间相关的 bug 就是这样,平时看不见,一换环境就现原形。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询