资讯动态

C# WinForms 日程提醒器设计:Timer调度、重复规则与托盘驻留

发布时间:2026/10/30 2:50:20 来源:尧图企业网站定制
简介这是一份面向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。这种作业在演示环节问题不大但只要用户关掉黑窗口程序就没了托盘、置顶、最小化全都没有答辩时被问一句“用户怎么让它一直在后台运行”就答不上来。技术栈最终定下来.NET6 或 8 的 Windows 桌面目标框架 WinForms System.Text.Json后期可换 SQLite 单 Timer 轮询调度。这几个选择后面每一章都会落到具体代码。2.3 数据落地JSON 起步、SQLite 收尾的两段式方案提醒数据存哪里是个需要提前想清楚的问题。常见三种文本/JSON 文件、SQLite、SQL Server 之类的关系库。我的建议是两段式课程设计级别、记录量在几百条以内直接用 JSON 落盘到用户目录System.Text.Json序列化一个ListReminderItem就够调试时还能用记事本打开看非常直观。等到记录上千条、需要按标题模糊搜索、需要统计时再换 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, -- RepeatType3 时使用 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只做两件事把ListReminderItem读出来、存回去。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 / WorkdayWeekMaskint否位掩码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是 double3 天 4 小时会得到 76.0直接取整会变成 76 小时显示就不对了。Days、Hours、Minutes是 TimeSpan 拆解后的分量这才是想要的。界面上刷新倒计时同样用那个 1 秒的 TimerTick 里找到“最近一条未触发的记录”算倒计时写进 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\...这种带空格的路径不 quoteWindows 启动时会把空格前那段当成 exe 名直接启动失败而且没有任何提示特别难查。throwOnMissingValue: false是为了让“取消自启”操作幂等没写过也不报错。自启还有个细节开机启动时程序应该直接最小化到托盘不要弹主窗口。启动参数里加一个--silentProgram.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:PublishSingleFiletrue \ -p:IncludeNativeLibrariesForSelfExtracttrue--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 就是这样平时看不见一换环境就现原形。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑