简介:这份资源是一套基于C#与WinForms开发的日程提醒器完整源码工程,面向具备一定C#基础、希望借助桌面小项目巩固WinForms界面设计与事件驱动编程的开发者。项目围绕事件添加、列表查看、提醒开关、倒计时显示与弹窗通知等核心功能展开,涵盖用户登录注册、密码管理、事件管理等模块,适合作为课程设计或练手参考。压缩包共47个文件,以15个cs源码文件为主体,辅以resx与resources界面资源、exe可执行程序、config配置、dll依赖及mdf/ldf数据库文件,另有sln解决方案与csproj工程文件,整体约317KB,结构完整可直接用Visual Studio打开研读。已有399人学习下载。读者可从中获取Timer定时检查、时间差计算与倒计时格式化、MessageBox通知等实现思路,并参考其模块划分与数据存储方式,快速理解一个可运行桌面提醒工具的完整搭建过程。
1. 桌面日程提醒器:一个 WinForm 小工具能解决的真实痛点
你有没有过这种经历:下午三点要开个线上会,两点五十还在改代码,等回过神来会议已经开始了十分钟。手机日历提醒不是没设,但写代码时手机常年静音扣在桌上,通知弹出来也看不见。这类“就在电脑前却错过提醒”的场景,恰恰是桌面端小工具最该发光的地方。这个用 C# 加 WinForm 做的日程提醒器,干的就是这件事:把日程和倒计时直接钉在屏幕上,到点弹窗、响铃、闪烁,不依赖任何外部服务,双击 exe 就能跑。它适合两类人——想找个能直接用的轻量提醒工具的人,以及想拿一个结构完整、代码量可控的 WinForm 项目练手或二次开发的人。整个项目围绕日期、提醒、倒计时三个核心词展开,技术栈朴素到不能再朴素,但正因为朴素,才看得清一个桌面应用从界面到持久化的完整骨架。
2. 技术选型与工程结构:WinForm 单进程应用的最小闭环
2.1 为什么是 WinForm 而不是 WPF 或控制台
先把这个选择说透,因为很多人一上来就问“都什么年代了还用 WinForm”。这个项目的定位决定了答案:它要的是一个启动快、依赖少、能直接操作 Windows 原生控件的小工具。WinForm 基于 GDI+ 绘制,打包出来单个 exe 几十 KB 到几 MB,双击即开,不需要额外运行时(.NET Framework 在 Windows 上基本自带)。WPF 的 XAML 数据绑定和样式能力更强,但学习曲线和工程复杂度对一个小提醒器来说属于杀鸡用牛刀;控制台程序倒是简单,可它没法做托盘图标、弹窗和界面交互。所以 WinForm 是这个场景下的合理选择,不是技术落后,是匹配需求。
技术栈上,项目用的是 C# + .NET Framework(常见做法是 4.7.2 或 4.8,这两个版本在 Win10/Win11 上覆盖率高),界面用 WinForm 设计器拖控件,数据持久化用本地文件(JSON 或 XML),定时逻辑用System.Windows.Forms.Timer或System.Timers.Timer。没有数据库、没有网络请求、没有第三方 NuGet 包,整个工程可以离线编译。
2.2 目录结构长什么样
一个典型的 WinForm 项目在 Visual Studio 里生成后,目录大致是这样组织的。这里按功能模块给出一个合理的工程结构,实际拿到源码时对照着看即可:
ScheduleReminder/ ├── ScheduleReminder.sln # 解决方案文件 ├── ScheduleReminder/ │ ├── Program.cs # 程序入口,Application.Run │ ├── Forms/ │ │ ├── MainForm.cs # 主界面:日程列表 + 倒计时显示 │ │ ├── MainForm.Designer.cs # 设计器生成的控件布局代码 │ │ ├── EditForm.cs # 新增/编辑日程的弹窗 │ │ └── EditForm.Designer.cs │ ├── Models/ │ │ └── ScheduleItem.cs # 日程数据模型 │ ├── Services/ │ │ ├── ScheduleStore.cs # 读写本地 JSON 文件 │ │ └── ReminderService.cs # 定时检查与触发提醒 │ ├── Utils/ │ │ └── TimeHelper.cs # 倒计时格式化等工具方法 │ ├── Resources/ │ │ └── alarm.wav # 提醒音效 │ └── App.config # 应用配置这个结构的关键在于把“界面”“数据模型”“业务服务”三层分开。很多新手写 WinForm 喜欢把所有逻辑塞进MainForm.cs,几百行之后就没法维护了。这里把定时检查和文件读写抽到 Services 层,主窗体只负责展示和事件响应,后面改提醒策略时不用动界面代码。
2.3 数据模型与持久化设计
日程条目需要存哪些字段?最少要有唯一标识、标题、提醒时间、是否已完成、是否已提醒过。用 C# 的类表示:
// Models/ScheduleItem.cs public class ScheduleItem { public string Id { get; set; } = Guid.NewGuid().ToString("N"); // 唯一标识,编辑时定位用 public string Title { get; set; } = ""; // 日程标题 public DateTime RemindTime { get; set; } // 提醒触发时间点 public bool IsDone { get; set; } // 是否已完成 public bool HasNotified { get; set; } // 是否已弹过提醒,防止重复触发 public string Note { get; set; } = ""; // 备注 }HasNotified这个字段是血泪经验:定时器每隔一秒检查一次,如果不记录“已提醒”状态,到点之后每一秒都会弹一次窗,用户会被烦死。加上这个标记,触发一次后置为 true,就只提醒一次。
持久化用 JSON 最省事,System.Text.Json在 .NET Framework 4.7.2 以上可以通过 NuGet 引入,或者直接用Newtonsoft.Json(老项目更常见)。如果不想引任何包,用XmlSerializer也行,代码稍多但零依赖。下面用 Newtonsoft.Json 的写法,这是 WinForm 老项目里最常见的组合:
// Services/ScheduleStore.cs public class ScheduleStore { private readonly string _filePath; public ScheduleStore() { // 数据文件放在用户目录下,避免程序目录无写权限 var dir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ScheduleReminder"); Directory.CreateDirectory(dir); _filePath = Path.Combine(dir, "schedules.json"); } public List<ScheduleItem> Load() { if (!File.Exists(_filePath)) return new List<ScheduleItem>(); var json = File.ReadAllText(_filePath); return JsonConvert.DeserializeObject<List<ScheduleItem>>(json) ?? new List<ScheduleItem>(); } public void Save(List<ScheduleItem> items) { var json = JsonConvert.SerializeObject(items, Formatting.Indented); File.WriteAllText(_filePath, json); } }这里有两个参数值得说:Environment.SpecialFolder.ApplicationData指向%AppData%目录,普通用户对该目录有写权限,而程序安装目录(如Program Files)往往没有,直接往 exe 旁边写文件在部分机器上会抛UnauthorizedAccessException。Formatting.Indented让 JSON 带缩进,方便调试时直接打开文件看内容。保存时机上,常见做法是每次增删改后立即Save,而不是等程序退出,这样即使进程被强杀也不丢数据。
3. 核心模块拆解:定时检查、倒计时与提醒触发链路
3.1 定时器怎么选、怎么起
提醒器的命脉是定时检查。WinForm 里有两个常用定时器:System.Windows.Forms.Timer和System.Timers.Timer。前者回调在 UI 线程上执行,可以直接操作控件,但精度较低(约 55ms 粒度,实际够用);后者回调在线程池线程上,精度高但不能直接碰控件,需要Invoke回 UI 线程。这个项目要更新界面上的倒计时文本,用System.Windows.Forms.Timer最省心,间隔设 1000 毫秒即可。
// MainForm.cs 片段 private System.Windows.Forms.Timer _tickTimer; private List<ScheduleItem> _items; private readonly ScheduleStore _store = new ScheduleStore(); private readonly ReminderService _reminder = new ReminderService(); private void InitTimer() { _tickTimer = new System.Windows.Forms.Timer(); _tickTimer.Interval = 1000; // 每秒触发一次 _tickTimer.Tick += TickTimer_Tick; _tickTimer.Start(); } private void TickTimer_Tick(object sender, EventArgs e) { RefreshCountdown(); // 刷新界面倒计时 _reminder.CheckAndNotify(_items); // 检查是否有到点日程 }Interval = 1000意味着每秒检查一次,对分钟级精度的日程提醒完全够用。如果追求秒级精准触发,可以把间隔调到 200~500 毫秒,代价是 CPU 占用略升,但现代机器上可以忽略。注意Tick事件里不要做耗时操作(比如读文件、发网络请求),否则界面会卡顿,因为它在 UI 线程上跑。
3.2 倒计时文本的格式化
界面上通常要显示“距离下一个日程还有 X 小时 Y 分 Z 秒”。这个格式化逻辑抽到工具类里,主窗体调用即可:
// Utils/TimeHelper.cs public static class TimeHelper { public static string FormatCountdown(TimeSpan span) { if (span.TotalSeconds <= 0) return "已到期"; if (span.TotalDays >= 1) return $"{(int)span.TotalDays}天 {span.Hours}小时 {span.Minutes}分"; if (span.TotalHours >= 1) return $"{span.Hours}小时 {span.Minutes}分 {span.Seconds}秒"; return $"{span.Minutes}分 {span.Seconds}秒"; } }逻辑说明:按剩余时间长度分三档显示,超过一天只显示到分钟(秒级跳动没意义还费眼),一小时内显示到秒,让用户有紧迫感。TimeSpan的Days、Hours、Minutes、Seconds属性是各分量而非总量,所以判断档位时用TotalDays、TotalHours这类总量属性,显示时用分量属性,这是新手最容易搞混的地方。
主窗体里找“下一个未完成且未过期的日程”,按提醒时间排序取第一个:
private void RefreshCountdown() { var next = _items .Where(x => !x.IsDone && x.RemindTime > DateTime.Now) .OrderBy(x => x.RemindTime) .FirstOrDefault(); lblCountdown.Text = next == null ? "暂无待办日程" : $"{next.Title} {TimeHelper.FormatCountdown(next.RemindTime - DateTime.Now)}"; }Where过滤掉已完成和已过期的,OrderBy按时间升序,FirstOrDefault取最近一条。这段每秒执行一次,列表规模在几百条以内性能无压力;如果日程上千条,可以在增删时缓存排序结果,不必每秒重排。
3.3 提醒触发:弹窗、声音与防重复
ReminderService负责判断哪些日程该提醒了。核心逻辑是遍历列表,找到RemindTime <= Now且HasNotified == false且IsDone == false的条目,触发提醒并置位标记:
// Services/ReminderService.cs public class ReminderService { public void CheckAndNotify(List<ScheduleItem> items) { var due = items.Where(x => !x.IsDone && !x.HasNotified && x.RemindTime <= DateTime.Now).ToList(); foreach (var item in due) { item.HasNotified = true; // 先置位,防止弹窗期间重复触发 ShowReminder(item); } } private void ShowReminder(ScheduleItem item) { System.Media.SystemSounds.Exclamation.Play(); // 系统提示音,零资源依赖 MessageBox.Show( $"日程提醒:{item.Title}\n时间:{item.RemindTime:yyyy-MM-dd HH:mm}\n{item.Note}", "到点了", MessageBoxButtons.OK, MessageBoxIcon.Information); } }参数说明:SystemSounds.Exclamation.Play()调用系统自带音效,不需要额外音频文件,如果项目里带了alarm.wav,可以用SoundPlayer循环播放,但记得在用户点确定后Stop(),否则声音会一直响。MessageBox.Show是模态弹窗,会阻塞当前线程直到用户点击,所以HasNotified = true必须放在弹窗之前,否则弹窗期间定时器虽然被阻塞,但逻辑上仍可能出问题。更讲究的做法是用非模态的自定义提醒窗体,支持“稍后提醒 5 分钟”按钮,这就留到二次开发部分展开。
3.4 主界面与编辑窗体的交互
主界面通常是一个DataGridView或ListView展示日程列表,加几个按钮(新增、编辑、删除、标记完成)和一个倒计时标签。编辑窗体EditForm负责录入标题、时间、备注,用DateTimePicker控件选时间。两个窗体之间的数据传递用属性或构造函数参数:
// 打开编辑窗体的写法 using (var edit = new EditForm(selectedItem)) // 传 null 表示新增 { if (edit.ShowDialog(this) == DialogResult.OK) { // 编辑窗体点确定后,把结果合并回列表 if (selectedItem == null) _items.Add(edit.Result); _store.Save(_items); BindGrid(); // 重新绑定表格 } }ShowDialog(this)以模态方式打开,父窗体在编辑期间不可操作,避免并发修改列表。DialogResult.OK是编辑窗体里“确定”按钮的DialogResult属性设置的,这是 WinForm 里窗体间通信的标准套路。using确保窗体关闭后资源释放。
4. 从源码到运行:编译、调试与打包的完整步骤
4.1 环境准备与首次编译
拿到源码后,第一步是确认开发环境。需要 Visual Studio 2019 或 2022(社区版即可),安装时勾选“.NET 桌面开发”工作负载,它会带上 .NET Framework 目标包和 WinForm 设计器。打开.sln文件,解决方案资源管理器里应该能看到项目节点,右键“生成解决方案”,如果一切正常,输出窗口显示“生成成功”。
常见的第一道坎是目标框架版本不匹配。项目文件里<TargetFrameworkVersion>可能写的是v4.7.2,而你的机器只装了 4.8 的开发包,这时 VS 会提示“未安装目标框架”。解决办法是在项目属性里把目标框架改成已安装的版本,或者去装对应的 Developer Pack。改完记得重新生成,别只改不编译就运行。
第二道坎是 NuGet 包还原。如果项目用了 Newtonsoft.Json,第一次打开时 VS 会自动还原,但如果网络受限或包源配置有问题,会报“未能找到类型或命名空间名 JsonConvert”。这时在“工具 → NuGet 包管理器 → 程序包管理器设置”里检查包源,或者在解决方案上右键“还原 NuGet 包”。实在不行,把 JSON 序列化换成XmlSerializer或手写简单的文本格式,零依赖也能跑。
4.2 调试技巧:让定时逻辑可观测
定时器和时间相关的 bug 最难调,因为“等一分钟看看”效率太低。调试时我一般会做两件事:一是在TickTimer_Tick里加Debug.WriteLine输出当前时间和下一个日程的剩余秒数,配合 VS 的“输出”窗口实时看;二是临时把Interval调小、把日程时间设成几十秒后,快速验证触发链路。
private void TickTimer_Tick(object sender, EventArgs e) { Debug.WriteLine($"[Tick] {DateTime.Now:HH:mm:ss} 待办 {_items.Count(x => !x.IsDone)} 条"); RefreshCountdown(); _reminder.CheckAndNotify(_items); }Debug.WriteLine只在 Debug 配置下输出,Release 打包时自动剔除,不影响性能。如果发现提醒不触发,先看输出窗口有没有 Tick 日志——没有说明定时器没启动(检查Start()是否被调用);有 Tick 但没弹窗,就检查RemindTime和HasNotified的值,多半是时间比较或标记位的问题。
4.3 发布打包:单文件 exe 与依赖处理
调试通了之后要给别人用,就得打包。最简单的做法是在 VS 里把配置切到 Release,生成后去bin\Release目录拿 exe。但如果有 NuGet 依赖(比如 Newtonsoft.Json.dll),需要把 dll 一起拷过去,否则目标机器上会报找不到程序集。
想做成单文件,可以用 ILMerge 或 Costura.Fody 这类工具把依赖合并进 exe。Costura.Fody 的用法是在项目里装这个 NuGet 包,然后在FodyWeavers.xml里配置,重新生成后bin\Release下就只有一个 exe。注意 .NET Framework 本身不能合并进去,目标机器仍需安装对应版本的 Framework,不过 Win10 1809 之后系统自带 4.7.2 以上,实际部署时基本不用操心。
提示:如果目标机器是 Win7 或更老的系统,需要确认 .NET Framework 版本,必要时在安装包里附带 Framework 安装程序,或者把项目目标框架降到 4.5.2 以扩大兼容范围。
5. 避坑与常见问题:那些让提醒器“失灵”的细节
5.1 提醒不触发或延迟很久
现象:日程时间到了,但弹窗过了几分钟才出现,甚至完全没出现。原因通常有三个:一是定时器Interval被设得过大(比如 60000 毫秒),检查粒度太粗;二是Tick事件里做了耗时操作,导致 UI 线程被占住,定时器回调排队;三是程序被最小化到托盘后,某些系统节能策略会降低定时器精度。解决办法:Interval保持 1000 毫秒左右,Tick里只做轻量判断,耗时逻辑(如写文件)放到Task.Run里异步执行。如果做了托盘功能,确认托盘模式下定时器仍在运行,别在Resize事件里误把定时器停了。
5.2 同一日程重复弹窗
现象:一个日程到点后,弹窗关掉又弹,反复好几次。原因就是前面说的HasNotified没置位,或者置位时机不对——如果先弹窗再置位,弹窗是模态的,用户点确定之前定时器虽然被阻塞,但某些写法下(比如用了非模态窗体)会重复进入。解决:在调用ShowReminder之前就把HasNotified = true,并且立即Save持久化,防止程序崩溃重启后重复提醒。这个顺序不能反,是踩过坑才记住的。
5.3 数据文件丢失或读不出来
现象:重启程序后日程全没了,或者启动时报 JSON 解析异常。原因:一是数据文件路径写在了程序目录,换机器或权限不足时读写失败;二是 JSON 文件被手动改坏,反序列化抛异常导致程序崩溃。解决:路径统一用%AppData%,Load方法里用try-catch包住反序列化,解析失败时返回空列表并备份坏文件(重命名为.bak),而不是让程序直接挂掉。用户数据无小事,宁可丢一次也不让程序起不来。
5.4 时间比较的时区与格式陷阱
现象:明明设的是下午三点的提醒,结果上午就弹了,或者日期差一天。原因:DateTime.Now和DateTime.UtcNow混用,或者从字符串解析时间时没指定格式,DateTime.Parse按机器区域设置解析,换台机器结果就变了。解决:全程用DateTime.Now(本地时间),存储时用 ISO 8601 格式(yyyy-MM-ddTHH:mm:ss),解析时用DateTime.ParseExact显式指定格式和CultureInfo.InvariantCulture。跨时区场景才需要 UTC,一个本地提醒器没必要引入这个复杂度。
5.5 打包后杀毒软件误报
现象:自己机器上跑得好好的 exe,拷到别人电脑上被杀毒软件拦截或删除。原因:单文件合并工具(如某些打包器)生成的 exe 特征容易被误判,或者程序没有数字签名。解决:优先用 Costura.Fody 这类成熟方案而不是冷门加壳工具;如果条件允许,给 exe 加数字签名(个人开发者可以用自签名证书,但会弹未知发布者提示);最稳妥的是分发 zip 包而不是裸 exe,让用户自己解压运行,降低误报概率。
6. 二次开发改造:从“能用”到“好用”的几个方向
拿到这个项目后,最值得先改的是提醒方式。现在的MessageBox是模态的,弹出来会挡住用户操作,而且只有“确定”一个选项。我一般会把它换成非模态的自定义提醒窗体,带“稍后提醒”和“标记完成”两个按钮。稍后提醒的实现是在ScheduleItem里加一个SnoozeUntil字段,点“稍后 5 分钟”时把RemindTime改成Now.AddMinutes(5)并把HasNotified重置为 false,这样下一轮检查会再次触发。这个改动不大,但体验提升明显。
第二个方向是托盘常驻。用NotifyIcon组件把程序缩到系统托盘,关闭窗口时不退出而是隐藏,双击托盘图标恢复。关键代码是在FormClosing事件里判断关闭原因:
private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { if (e.CloseReason == CloseReason.UserClosing) { e.Cancel = true; // 取消真正关闭 this.Hide(); // 隐藏到托盘 notifyIcon.Visible = true; } }e.Cancel = true阻止窗体关闭,Hide()隐藏界面,托盘图标保持可见。注意要提供“退出”菜单项,在里面设一个标志位再调Application.Exit(),否则用户没法真正关掉程序。
第三个方向是提醒策略的扩展。现在只支持“到点提醒”,可以加“提前 N 分钟提醒”,在ScheduleItem里加AdvanceMinutes字段,检查逻辑改成RemindTime.AddMinutes(-AdvanceMinutes) <= Now。还可以加重复日程(每天/每周),用RemindTime加周期推算下一次时间,触发后自动把RemindTime推到下一个周期并重置HasNotified。这些改动都集中在ReminderService里,界面只需加几个输入控件,分层设计的好处这时候就体现出来了。
验证改造是否成功,我的习惯是写一个简单的测试清单:设一个 30 秒后的日程,确认弹窗准时;点稍后提醒,确认 5 分钟后再次弹;关窗口到托盘,确认定时器仍在跑;强杀进程重启,确认数据没丢。这套流程走一遍,基本能覆盖主要路径。从那以后我每次改定时相关逻辑,都强制走一遍这个清单,因为时间相关的 bug 靠肉眼 review 根本看不出来,只有跑起来才知道。希望这个项目能帮你省下几次错过会议的尴尬,也帮你在 WinForm 这条老路上找到点新乐子。
本文还有配套的精品资源,点击获取