news 2026/10/30 8:33:12

C# WinForm 桌面日程提醒器:Timer调度与SQLite本地存储

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# WinForm 桌面日程提醒器:Timer调度与SQLite本地存储

简介:面向C#初学者与Windows桌面开发练习者的日程提醒器项目源码,围绕事件录入、列表查看、提醒设置、倒计时显示和弹窗通知等日常时间管理需求展开,适合用来熟悉WinForms界面布局、事件数据类设计、Timer周期检查、时间计算与简单异常处理。压缩包共47个文件,约317KB,以15个cs源码文件为核心,配合resx与resources界面资源、sln和csproj工程文件、config配置以及mdf/ldf数据库文件,另含编译产物和说明文本,工程结构相对完整,可在Visual Studio中直接打开学习。已有395人学习下载,读者可对照登录、注册、日程管理等窗体代码理解各模块协作方式,并借鉴提醒触发、倒计时格式化、事件保存和MessageBox通知的实现思路。对于课程设计、期末项目或自学练手,也可参考其窗体划分、数据保存与定时检查做法,减少从零搭建界面的时间。

1. 到点就弹窗的日程提醒器:一个 C# WinForm 小工具能干什么

上周某实验室的 A 同学跟我吐槽,他每天要盯三件事:下午三点的数据备份、周五下班前交周报、每月十号的设备巡检。手机日历他从来不看,微信收藏夹里躺着一堆过期链接。我给他做的就是这么一个东西——C# + WinForm 写的桌面日程提醒器,开机自启、常驻托盘、到点弹窗,主界面上每一行都带着实时倒计时。这类工具的定位很朴素:不联网、不登录、不占内存,双击 exe 就能跑,数据落在本地一个 SQLite 文件里,拷走就能换机器。适合谁?适合那些"提醒必须长在电脑上"的人:写代码写到忘记吃饭的、值班要按点记录的、手里管着几台机器要定时巡检的。下面按我拆这个项目的顺序,从工程骨架、调度核心、界面交互一路讲到能上手改的地方。

2. 工程骨架与数据层:从 Program.cs 到 SQLite 建表

拿到一个 WinForm 小工具,我习惯先看Program.cs和目录结构,因为这两处基本决定了后面所有代码的组织方式。这个项目没什么花哨的框架,WinForm 自带的消息循环就是它的运行时,调度靠 Timer,存储靠本地文件数据库。看起来简单,但恰恰是这类"简单项目"最容易在启动顺序和表结构上翻车——顺序错了会出现窗体起来了但数据没加载,表结构糊了则后面每加一个功能都要改一次 SQL。

2.1 从 Program.cs 看启动顺序:先建库,再开窗,最后启动调度

入口文件里真正重要的不是那行Application.Run,而是它前面做了什么事。我一般的顺序是:单实例检查 → 初始化数据库 → 构造主窗体 → 进消息循环。调度器不在这里启动,而是交给主窗体的Load事件,原因是调度器要往界面上推数据,窗体没准备好就启动,第一次触发很容易打到空引用上。

[STAThread] static void Main() { // 单实例:第二次双击 exe 时不再开第二份进程,否则两个 Timer 会重复弹窗 using var mutex = new Mutex(true, "ScheduleReminder.SingleInstance", out bool createdNew); if (!createdNew) { MessageBox.Show("提醒器已经在托盘里跑着了。", "提示"); return; } ApplicationConfiguration.Initialize(); // .NET 6+ 模板生成,负责 DPI 感知和视觉样式 Db.Init(); // 建库建表,幂等,重复执行不报错 Application.Run(new MainForm()); // 主窗体负责创建托盘图标、启动调度器 }

这段代码里三个参数值得说清楚。Mutex的名字是全局唯一的字符串,只要进程还活着,第二个实例就拿不到它,createdNew为 false 直接退出——这是桌面小工具防重复启动最省事的做法。Db.Init()必须是幂等的,因为它每次启动都跑,内部用CREATE TABLE IF NOT EXISTS,不能写成"检测到文件不存在才建",那样一旦表结构升级就会漏掉。最后那句Application.Run决定了整个程序的生命周期绑在主窗体上,窗体隐藏不等于退出,这一点在第 4 章的托盘部分还会再碰一次。

2.2 目录分层:UI、Model、Data、Service 四个文件夹各管什么

WinForm 项目最容易写成一个 Form 里两千行代码,改一个字段要在里面翻半天。我在这个项目里做的第一件事就是分文件夹,不是为了好看,是为了让"加一个功能"能定位到唯一一个文件去改。

路径职责常见改动点
Program.cs入口、单实例、启动顺序加全局异常兜底、加启动日志
Forms/MainForm.cs主列表、托盘图标、调度器宿主加筛选、加列、加右键菜单
Forms/EditForm.cs新增/编辑单条提醒加字段、加校验规则
Models/Reminder.cs实体类 + 倒计时只读属性加优先级、加分类标签
Data/Db.cs连接串与建表脚本换存储、加表结构迁移
Data/ReminderRepo.cs增删改查,只输出实体加分页、加软删除
Services/Scheduler.csTimer 轮询与触发判断换调度策略、加触发前钩子
Services/AutoStart.cs注册表自启的读写换计划任务、换启动参数

分层的关键约定只有一条:Forms里不写 SQL,Data里不碰控件。看着像废话,但真到二次开发时,这条约定能让你把"把 SQLite 换成别的存储"这件事控制在一个文件里。剩下的两个文件夹里,Models允许带一点点展示逻辑(比如倒计时文案),因为它要绑定到 DataGridView,属性变更通知也得在这里发。

2.3 表结构设计:一张 Reminder 主表加一张 FireLog 触发记录表

这个项目里我用了两张表,而不是一张。Reminder存提醒本身,FireLog存"某条提醒在某时刻被触发过"的记录。第二张表看着多余,实际上它是解决重复弹窗的唯一可靠依据——光靠内存里的标记,程序重启一次就丢,用户就会在重启后被同一个提醒再弹一遍。

CREATE TABLE IF NOT EXISTS Reminder ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Title TEXT NOT NULL, Content TEXT, RemindTime TEXT NOT NULL, -- 本地时间,统一存 yyyy-MM-dd HH:mm:ss RepeatType INTEGER NOT NULL DEFAULT 0, -- 0 不重复 1 每天 2 每周 3 每月 4 每 N 分钟 RepeatValue INTEGER NOT NULL DEFAULT 0, -- 配合 RepeatType=4 使用,单位分钟 Enabled INTEGER NOT NULL DEFAULT 1, -- 0 表示已完成或手动停用 CreatedAt TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS FireLog ( Id INTEGER PRIMARY KEY AUTOINCREMENT, ReminderId INTEGER NOT NULL, PlanTime TEXT NOT NULL, -- 这一轮"计划触发"的时间点,用来去重 FiredAt TEXT NOT NULL, -- 实际弹出的时间,用来排查延迟 UNIQUE (ReminderId, PlanTime) -- 同一轮只允许插一条,从数据层兜底 );

时间字段用 TEXT 存yyyy-MM-dd HH:mm:ss,不用 SQLite 的日期类型。原因是这个格式定长、字典序等于时间序,ORDER BY RemindTime直接就是按时间排,省掉一次转换;而且从数据库里读出来是字符串,用DateTime.ParseExact转一次就完了,不会踩到区域设置导致MM/dd和dd/MM打架的坑。FireLog上的UNIQUE (ReminderId, PlanTime)是这套设计里唯一的后悔药:调度器判断失误、用户手动点了两次"立即提醒",插入冲突直接把第二条挡掉,弹窗就只有一次。

数据访问层我一般手写 SQL 而不是上 ORM,这个体量的项目引入 EF Core 只会让启动变慢、发布包变大。仓储类里最典型的是这一个查询:

public static List<Reminder> GetEnabled() { var list = new List<Reminder>(); using var conn = new SqliteConnection(Db.ConnStr); conn.Open(); using var cmd = conn.CreateCommand(); // 一次性提醒过了点就不查出来,减少调度器每轮要遍历的行数 cmd.CommandText = @"SELECT Id, Title, Content, RemindTime, RepeatType, RepeatValue, Enabled, CreatedAt FROM Reminder WHERE Enabled = 1 ORDER BY RemindTime"; using var reader = cmd.ExecuteReader(); while (reader.Read()) { list.Add(new Reminder { Id = reader.GetInt32(0), Title = reader.GetString(1), Content = reader.IsDBNull(2) ? "" : reader.GetString(2), RemindTime = DateTime.ParseExact(reader.GetString(3), "yyyy-MM-dd HH:mm:ss", null), Repeat = (RepeatType)reader.GetInt32(4), RepeatValue= reader.GetInt32(5), Enabled = reader.GetInt32(6) == 1, CreatedAt = DateTime.ParseExact(reader.GetString(7), "yyyy-MM-dd HH:mm:ss", null) }); } return list; }

这里按列序号取值而不是按列名,因为SELECT的列顺序是自己写的,序号取值少一次字符串匹配,十万行级别以下差别不大,但养成习惯没坏处。DateTime.ParseExact的第三个参数传null表示用当前区域格式,但因为我们给了确定的格式串,区域影响被消掉了。连接串指向AppContext.BaseDirectory下的reminder.db,不要用Environment.CurrentDirectory——后者会跟着快捷方式的"起始位置"变,用户在桌面双击和从命令行启动会落到两个不同的数据库文件上,这是我在别的项目里真栽过的坑。

3. 调度核心拆解:Timer 轮询、时间比较与倒计时文案

整个项目里真正有技术含量的部分就在这一个文件里:什么时候检查、怎么判断"该弹了"、弹完以后下次什么时候再弹。写错了轻则不弹,重则一分钟弹六十次。这里没有复杂的算法,全是时间边界上的细节。

3.1 选哪个 Timer:WinForms Timer、Timers.Timer 和计划任务的取舍

.NET 里叫 Timer 的东西有好几个,选错了会出现"回调跑到非 UI 线程上,一碰控件就抛跨线程异常"。这个项目我用的是System.Windows.Forms.Timer,间隔 1000 毫秒。理由很直接:它的 Tick 回到 UI 线程执行,弹窗、改列表、更新托盘文本都不需要Invoke,代码量少一半;代价是精度只有几十毫秒级,界面卡住时 Tick 会被推迟。对一个"分钟级"的提醒器来说完全够用。

System.Timers.Timer和System.Threading.Timer走线程池回调,精度高,但每次碰控件都要封送,一旦回调里抛异常还没人接,进程可能直接没了。至于 Windows 计划任务,那是另一个思路:由系统负责唤起,程序可以不常驻。缺点是没法做"还剩 3 天 4 小时"这种实时倒计时展示,用户也看不到列表。所以这个项目的定位决定了它必须是常驻 + 轮询,Timer 是合适的。

public class Scheduler { private readonly System.Windows.Forms.Timer _timer = new() { Interval = 1000 }; private readonly Func<List<Reminder>> _loader; // 从仓储取数据的委托,方便测试时替换 private readonly Action<Reminder, DateTime> _onFire; // 触发后交给主窗体弹窗 public Scheduler(Func<List<Reminder>> loader, Action<Reminder, DateTime> onFire) { _loader = loader; _onFire = onFire; _timer.Tick += Tick; } public void Start() => _timer.Start(); public void Stop() => _timer.Stop(); private void Tick(object? sender, EventArgs e) { var now = DateTime.Now; foreach (var r in _loader()) { var plan = NextFireTime(r, now); // 算出这条提醒"本轮"应该触发的时间点 if (plan is null) continue; if (plan > now) continue; // 还没到,跳过 if ((now - plan.Value).TotalSeconds > 90) continue; // 错过太久不补弹,避免开机时炸一屏 if (!FireLog.TryAppend(r.Id, plan.Value, now)) continue; // 已触发过,静默跳过 _onFire(r, plan.Value); } } }

三个参数决定了它的行为:Interval = 1000是检查频率,不是触发精度,真正的触发点由NextFireTime算出来;90秒是容忍窗口,程序休眠、机器卡顿导致晚了几十秒仍然补弹,但晚了十分钟就不弹了——否则笔记本合盖一晚上,第二天开机瞬间弹二十个窗口,用户第一个想法就是卸载。_loader用委托注入而不是直接 new 仓储,是为了让这个类能脱离数据库单独跑单元测试。

3.2 时间比较与 NextFireTime:跨天、重复规则的算法

NextFireTime是整个调度器的核心,输入一条提醒和当前时间,输出"这条提醒下一次该响的时刻",没有则返回 null。一次性提醒最简单,过了点就是 null;重复提醒要按规则往后推,推到第一个大于当前时间的点。

public static DateTime? NextFireTime(Reminder r, DateTime now) { if (!r.Enabled) return null; var baseTime = r.RemindTime; switch (r.Repeat) { case RepeatType.None: // 一次性:到点前返回原时间,到点后永远返回 null return baseTime > now ? baseTime : null; case RepeatType.Daily: { // 把提醒的"时分秒"投影到今天,今天已经过了就推明天 var today = now.Date + baseTime.TimeOfDay; return today > now ? today : today.AddDays(1); } case RepeatType.Weekly: { // 先对齐到本周的同一天同一时刻,再按 7 天步进 var candidate = now.Date + baseTime.TimeOfDay; var diff = ((int)baseTime.DayOfWeek - (int)now.DayOfWeek + 7) % 7; candidate = candidate.AddDays(diff); return candidate > now ? candidate : candidate.AddDays(7); } case RepeatType.Monthly: { // 月末钳制:31 号的提醒在 2 月落到 28/29 号,不能直接 AddMonths 之后越界 var day = Math.Min(baseTime.Day, DateTime.DaysInMonth(now.Year, now.Month)); var candidate = new DateTime(now.Year, now.Month, day) + baseTime.TimeOfDay; if (candidate > now) return candidate; var nextMonth = now.AddMonths(1); var day2 = Math.Min(baseTime.Day, DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month)); return new DateTime(nextMonth.Year, nextMonth.Month, day2) + baseTime.TimeOfDay; } case RepeatType.EveryMinutes: { // 固定间隔:从上一次计划点往后推,保证节奏稳定而不是"检查时才累加" var step = TimeSpan.FromMinutes(Math.Max(1, r.RepeatValue)); var anchor = baseTime; if (anchor > now) return anchor; var passed = (now - anchor).Ticks / step.Ticks; return anchor.AddTicks(step.Ticks * (passed + 1)); } default: return null; } }

几个容易写错的地方在这里一次性说清。每日重复用now.Date + baseTime.TimeOfDay而不是baseTime.AddDays(1),因为后者在程序连续运行几天后会把时间越推越偏(每次都在旧值上加一天,一旦某天没触发就永远落后)。每周重复先算星期差再步进,注意DayOfWeek是枚举,直接相减在跨周时会得到负数,所以要+7再取模。每月重复必须做月末钳制,DateTime.AddMonths本身会帮你钳,但如果你自己拼new DateTime(y, m, 31)就会抛ArgumentOutOfRangeException,这是血泪经验。固定间隔那一段用"锚点 + 整数倍"的方式算,而不是每次now.AddMinutes(n),否则程序每次重启节奏都会漂移。

3.3 倒计时文案:TimeSpan 到「还剩 3 天 4 小时」

主列表上那一列倒计时是用户感知最强的部分。它不需要精确到毫秒,但必须每秒都在动,而且不能出现"还剩 0 天 0 小时"这种让人困惑的显示。

public static string Humanize(TimeSpan span) { if (span.TotalSeconds <= 0) return "已到点"; // 注意 Days 是"天部分",TotalDays 才是总天数,两者别混用 if (span.TotalDays >= 1) return $"还剩 {(int)span.TotalDays} 天 {span.Hours} 小时"; if (span.TotalHours >= 1) return $"还剩 {(int)span.TotalHours} 小时 {span.Minutes} 分"; if (span.TotalMinutes >= 1) return $"还剩 {(int)span.TotalMinutes} 分 {span.Seconds} 秒"; return $"还剩 {span.Seconds} 秒"; }

span.Days和span.TotalDays的区别是这个函数唯一的坑点:TimeSpan.FromHours(50).Days是 2,而TotalDays是 2.08,取整后都是 2,看起来一样;但TimeSpan.FromHours(23).Days是 0,TotalDays是 0.958,一旦你写成if (span.Days >= 1),23 小时的提醒就会掉到"还剩 22 小时"的分支里。所有跨档判断都必须用Total*,显示时才用不带 Total 的分量。另外这个函数返回的是纯字符串,绑定到 DataGridView 的只读属性上,靠INotifyPropertyChanged每秒通知一次刷新,具体做法在第 4 章。

3.4 触发去重:用触发记录表兜住重复弹窗

重复弹窗的成因有三类:Timer 在同一秒内 Tick 了两次(少见但存在)、用户手动改了系统时间又改回来、以及程序重启后内存标记丢失。前两类靠内存标记能挡,第三类挡不住,所以最终判定要落到数据库上。

public static bool TryAppend(int reminderId, DateTime planTime, DateTime firedAt) { using var conn = new SqliteConnection(Db.ConnStr); conn.Open(); using var cmd = conn.CreateCommand(); // INSERT OR IGNORE 配合 UNIQUE 约束:插得进去说明是第一次,插不进去说明已经弹过 cmd.CommandText = @"INSERT OR IGNORE INTO FireLog (ReminderId, PlanTime, FiredAt) VALUES ($rid, $plan, $fired)"; cmd.Parameters.AddWithValue("$rid", reminderId); cmd.Parameters.AddWithValue("$plan", planTime.ToString("yyyy-MM-dd HH:mm:ss")); cmd.Parameters.AddWithValue("$fired", firedAt.ToString("yyyy-MM-dd HH:mm:ss")); return cmd.ExecuteNonQuery() == 1; // 返回受影响行数,1 表示插入成功 }

INSERT OR IGNORE加唯一约束是这套去重的全部秘密,返回值就是"我是不是第一个到的"。这里有个顺序问题必须注意:先写日志再弹窗。如果反过来,弹窗成功了但写库失败(比如磁盘满),下次检查还会再弹一遍。先写后弹的代价是极端情况下"记了但没弹出来",用户少看一次弹窗,比被弹十次好接受。FireLog也需要定期清理,我在启动时顺手删掉 30 天前的记录,不然这个表会一直长。

4. 界面与常驻交互:DataGridView 绑定、编辑窗与托盘图标

WinForm 的界面代码写起来快,但写不好就是一坨事件处理。这个项目的界面其实只有两个窗体,复杂度全在"数据怎么流到界面上"和"程序怎么赖在后台不走"这两件事上。前者决定了你改一个字段要不要刷新整个表格,后者决定了用户会不会觉得这程序"关不掉"。

4.1 DataGridView 绑定 BindingList:改一个字段界面就跟着变

列表控件有两种用法:手动Rows.Add和绑定数据源。这个项目用后者,因为倒计时每秒都在变,手动刷行会闪屏、会丢选中状态、会在排序后错位。

private readonly BindingList<Reminder> _items = new(); private void BindGrid() { _items.Clear(); foreach (var r in ReminderRepo.GetAll()) _items.Add(r); grid.AutoGenerateColumns = false; // 关掉自动生成,列顺序和标题自己定 grid.DataSource = _items; grid.Columns["colTitle"].DataPropertyName = nameof(Reminder.Title); grid.Columns["colTime"].DataPropertyName = nameof(Reminder.RemindTime); grid.Columns["colTime"].DefaultCellStyle.Format = "MM-dd HH:mm"; grid.Columns["colCountdown"].DataPropertyName = nameof(Reminder.CountdownText); grid.Columns["colRepeat"].DataPropertyName = nameof(Reminder.RepeatText); grid.Columns["colId"].Visible = false; // 主键不显示,但选中行时还要用 } // 每秒刷新一次倒计时:不重建列表,只让绑定层重新读属性 private void RefreshCountdown() { foreach (var r in _items) r.RaiseCountdownChanged(); grid.InvalidateColumn(grid.Columns["colCountdown"].Index); }

BindingList<T>的价值在于它同时实现了IBindingList和INotifyPropertyChanged的转发:集合增删会自动反映到表格,元素属性变更只要在 setter 里发了通知也会刷新对应单元格。所以Reminder上那个CountdownText必须写成只读属性,并且在RaiseCountdownChanged里显式OnPropertyChanged(nameof(CountdownText))——它没有 setter,属性变更不会自动发生,得靠外部触发。

public class Reminder : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; private void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); public DateTime RemindTime { get; set; } // 只读计算属性,不参与序列化,也不进数据库 public string CountdownText => ReminderTimeHelper.Humanize(RemindTime - DateTime.Now); public void RaiseCountdownChanged() => OnPropertyChanged(nameof(CountdownText)); }

grid.InvalidateColumn而不是grid.Refresh(),前者只重绘那一列,几十行数据下肉眼看不到差别,但行数上千时滚动会明显更顺。另外AutoGenerateColumns = false必须写,否则每次换数据源它都会按属性名重新生成一遍列,你之前调好的列宽、隐藏列、格式化全被冲掉。

4.2 编辑窗体的校验与「重复规则」的表达

编辑窗体是用户输入的唯一入口,脏数据基本都从这里进来:时间填成过去、间隔填 0、标题全是空格。校验不能只放在保存按钮里,最好在控件上就近拦。

private void btnSave_Click(object sender, EventArgs e) { var title = txtTitle.Text.Trim(); if (title.Length == 0) { errorProvider1.SetError(txtTitle, "标题不能为空"); return; } // 一次性提醒允许设置过去时间吗?不允许,否则保存后立刻触发,用户以为是 bug if (cmbRepeat.SelectedIndex == 0 && dtpTime.Value <= DateTime.Now) { errorProvider1.SetError(dtpTime, "一次性提醒的时间必须晚于当前时间"); return; } // 固定间隔必须大于 0,0 会让 NextFireTime 里除零或者死循环 if (cmbRepeat.SelectedIndex == 4 && numInterval.Value < 1) { errorProvider1.SetError(numInterval, "间隔至少 1 分钟"); return; } _editing.Title = title; _editing.RemindTime= dtpTime.Value; _editing.Repeat = (RepeatType)cmbRepeat.SelectedIndex; _editing.RepeatValue = (int)numInterval.Value; DialogResult = DialogResult.OK; }

ErrorProvider比MessageBox适合这里:错误提示挂在控件旁边,用户改完自动消失,不用点确定。重复规则的 UI 就是一个ComboBox加一个NumericUpDown,后者只在选"每 N 分钟"时启用,其他时候Enabled = false,避免用户填了间隔却选了每天,保存后困惑为什么没生效。这里有个小细节:cmbRepeat.SelectedIndex直接强转成枚举,前提是枚举值和下拉项顺序严格对应,所以下拉项的Items顺序不要随手调整,改的话两边一起改。

4.3 托盘驻留与开机自启:让程序真的活在后台

提醒器如果被用户顺手点叉关掉,那它就没有存在意义。所以主窗体的关闭按钮要"骗"一下用户:不是退出,是缩到托盘。

private bool _reallyExit; private void MainForm_FormClosing(object? sender, FormClosingEventArgs e) { // 只拦截用户主动点叉的情况,系统关机、任务管理器结束进程照常放行 if (e.CloseReason == CloseReason.UserClosing && !_reallyExit) { e.Cancel = true; Hide(); notifyIcon.ShowBalloonTip(1500, "仍在后台运行", "提醒服务没有停止,双击托盘图标可以打开", ToolTipIcon.Info); } } private void menuExit_Click(object? sender, EventArgs e) { _reallyExit = true; // 先立旗标,再关窗,否则又被上面拦下来 Close(); Application.Exit(); }

CloseReason.UserClosing这个判断不能省。如果无条件e.Cancel = true,系统关机时程序会拒绝退出,卡住关机流程,用户看到的是"关机半天没反应"。同理,任务管理器结束进程走的是TaskKill,FormClosing根本不会触发,所以不能指望着靠它做数据保存,所有写库操作必须在弹窗前就落盘。

开机自启走注册表的当前用户 Run 键,比写计划任务轻量,也不需要管理员权限:

public static void SetAutoStart(bool enable) { using var key = Registry.CurrentUser.OpenSubKey( @"Software\Microsoft\Windows\CurrentVersion\Run", writable: true)!; if (enable) key.SetValue("ScheduleReminder", $"\"{Application.ExecutablePath}\""); // 引号包住带空格的路径 else key.DeleteValue("ScheduleReminder", throwOnMissingValue: false); }

路径外面的双引号是必须的。C:\Program Files\xxx\app.exe这种带空格的路径不加引号,Windows 解析时会把它当成C:\Program.exe加参数,结果就是开机什么也没启动,用户以为自启坏了。throwOnMissingValue: false保证重复取消自启不会抛异常。

5. 避坑与排查:五类跑起来才暴露的问题

代码编译通过只是开始,这类常驻小工具的问题基本都在"跑了一下午之后"才露头。下面这几条是我自己踩过的,现象、原因、解决都按实际排查顺序写。

5.1 现象:时间到了不弹窗,重启程序就弹了

原因:调度器读的是启动时缓存的那份列表,用户在编辑窗改了时间,数据库更新了,但内存里的_items没同步。Tick 里遍历的还是旧对象,NextFireTime算出来永远是旧时间点,自然对不上现在的时钟。

解决:编辑保存后不要只写库,要同步更新内存对象并触发通知,或者干脆让调度器每轮从仓储重新读一次。这个项目选的是后者,因为提醒量很小(几十条以内),每秒一次GetEnabled的查询成本远低于维护两套状态的复杂度。如果数据量大到上千条,就该反过来做:内存为主,写库为副,配一个脏标记定期落盘。

5.2 现象:点了关闭,托盘图标还在,但再也点不开主界面

原因:Hide()之后主窗体的Visible是 false,托盘双击事件里写的是Show(),看着没问题,实际是Show()在窗体已经Close的情况下会抛ObjectDisposedException。这种情况多发生在用户点了托盘菜单的"退出"之后又双击图标——窗体已经 Dispose,引用还在。

解决:双击托盘时先判断窗体状态,if (WindowState == FormWindowState.Minimized) WindowState = Normal; Show(); Activate();,并且在真正退出时把notifyIcon.Visible = false; notifyIcon.Dispose();一起做掉。托盘图标不 Dispose 会在任务栏留下一具"僵尸图标",鼠标划过去才消失,用户会以为程序还在跑。

5.3 现象:把 exe 拷到另一台机器,双击闪一下就没了

原因:数据库连接串用了Environment.CurrentDirectory,从快捷方式启动时工作目录是快捷方式所在位置,可能导致路径不存在或者写入被拒。更隐蔽的是发布时漏了Microsoft.Data.Sqlite的原生依赖(e_sqlite3.dll),本机因为全局装了运行时能跑,目标机就崩。

解决:连接串一律基于AppContext.BaseDirectory拼,这是 exe 所在目录,跟启动方式无关。发布用dotnet publish -r win-x64 --self-contained,把运行时和原生库一起打进去,别指望目标机装了对应版本的 .NET。发布后在干净目录双击验证一次,这一步省不得。

5.4 现象:高分屏上字体糊、按钮挤在一起,或者拖到副屏后界面变形

原因:项目没声明 DPI 感知,系统做了位图缩放。WinForm 默认是 DPI Unaware,125% 缩放下整个窗口被拉伸,字就虚了。

解决:如果用 .NET 6+ 的模板,ApplicationConfiguration.Initialize()已经带了SetHighDpiMode(HighDpiMode.SystemAware),但要在项目文件里确认<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>,多显示器不同缩放时才能正确重排。另外AutoScaleMode保持Font,布局用TableLayoutPanel而不是手拖绝对坐标,否则缩放一变全部错位。

5.5 现象:日期显示成2024/5/1 下午 3:00,或者填了时间解析报错

原因:DateTime.ToString()不带格式串时吃当前线程的区域设置,同一份数据在不同机器上显示不一样,回读时用DateTime.Parse也可能失败。

解决:落库和显示都固定格式。落库统一yyyy-MM-dd HH:mm:ss,显示统一yyyy-MM-dd HH:mm,解析统一ParseExact。DataGridView 的列用DefaultCellStyle.Format控制显示,不要依赖ToString()。这条规矩听着啰嗦,但跨机器、跨区域的 bug 基本都出在这。

6. 二次开发:把提醒器改成自己的日程工作台

这个项目真正好玩的地方在于它的骨架足够薄,加功能不用动大手术。我按改造成本从低到高排了几条,都是我自己或朋友实际改过的方向。

改造点改动位置大致工作量
触发时播放提示音Scheduler的_onFire回调里加SystemSounds或SoundPlayer十分钟
加"提前 10 分钟"第二提醒Reminder加PreAlertMinutes字段,NextFireTime多算一个点半小时
加分类与筛选加Category字段 + 主界面加ComboBox过滤BindingList一小时
导出为日历文件新增IcsExporter,按模板拼文本写出.ics一小时
换成系统计划任务调度弃用Scheduler,写任务注册逻辑半天,且失去倒计时展示

最推荐先做的是"提前提醒",因为它能验证你对NextFireTime的理解是否到位。做法不是加一个 Timer,而是在同一个 Tick 里对每条提醒算两个时间点:

private void Tick(object? sender, EventArgs e) { var now = DateTime.Now; foreach (var r in _loader()) { // 主提醒和提前提醒共用同一套去重表,PlanTime 不同即为不同的一轮 foreach (var plan in NextFirePoints(r, now)) { if (plan > now || (now - plan).TotalSeconds > 90) continue; if (!FireLog.TryAppend(r.Id, plan, now)) continue; _onFire(r, plan); } } } private static IEnumerable<DateTime> NextFirePoints(Reminder r, DateTime now) { var main = NextFireTime(r, now); if (main is not null) yield return main.Value; // 提前提醒只在"主提醒还没到"的时候有意义,主提醒本身不重复计算 if (r.PreAlertMinutes > 0 && main is not null) { var pre = main.Value.AddMinutes(-r.PreAlertMinutes); if (pre > now) yield return pre; } }

这里的关键是PlanTime分别取两个不同的时间点,去重表的唯一约束天然把它们当成两轮,不会被误挡。验证方法很简单但必须走一遍:新建一条 3 分钟后的提醒、提前 2 分钟提醒,然后把系统时间往回调 10 分钟再调回来,观察是不是每轮只弹一次、FireLog里是不是正好两条记录。调系统时间来测是这类工具唯一靠得住的回归手段,光看代码看不出去重逻辑是否闭环。

另一个我常做的改造是给Scheduler加一个触发前的钩子,用来在弹窗之前做点别的事,比如写一条日志、给某个接口发个请求、或者干脆把弹窗换成托盘气泡。改法是给它加一个Func<Reminder, bool>? BeforeFire,返回 false 就跳过本次弹窗但依然记账。这样"静默提醒"和"强制弹窗"两种策略可以按提醒单独配,不用改调度器本身的逻辑。

最后说个教训。这个项目最早我把数据库路径写成了相对路径,本地跑了一星期完全正常,直到某天从任务栏固定图标启动,发现所有提醒都不见了——程序在另一个目录下新建了一个空库,我还以为数据库被删了,翻了半天回收站。从那以后我每次拿到任何带本地存储的桌面项目,第一件事就是把连接串打出来确认绝对路径,第二件事是发布后在干净目录双击跑一遍。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 8:30:05

基于 ESP32 的边缘网关断网续传实战:本地缓存、MQTT 离线消息与云端对账

基于 ESP32 的边缘网关断网续传实战:本地缓存、MQTT 离线消息与云端对账 做工业现场的设备接入,最容易被低估的一定是"网络"这件事。车间 WiFi 有覆盖死角,4G 模块在雨季掉信号,光纤被施工挖断,这些是常态而不是意外。于是会出现这样的场景:上午十点网关重启一…

作者头像 李华
网站建设 2026/10/30 8:28:37

经营分析会:不要本位主义,不要你好我好,要站在公司整体经营上看问题,敢拆墙、敢碰硬、敢动真格。

很多企业的经营分析会&#xff0c;开着开着就成了“成绩汇报会”。销售说收入增长了&#xff0c;采购说成本降低了&#xff0c;生产说计划完成了。各部门都有成绩&#xff0c;公司整体经营却未必改善。问题就在于&#xff1a;人人都在算部门账&#xff0c;却没人真正算公司账。…

作者头像 李华
网站建设 2026/10/30 8:27:22

电脑语音实时转文字工具,会议记录很省事

软件介绍 VoiceAssistant 是一款跑在电脑上的语音实时转文字工具&#xff0c;主打就是把听到的声音直接变成文字。像开会这种场景其实挺合适&#xff0c;软件开着&#xff0c;别人的发言就会被自动识别成文字记下来&#xff0c;后面要整理纪要、回顾内容都方便不少。 使用前的…

作者头像 李华
网站建设 2026/10/30 8:21:53

网络分层模型与数据封装/解封装全过程

IP地址&#xff1a;网络层定位一个主机端口&#xff1a;ip定位主机的&#xff0c;某一个进程协议分层1.OSI七层模型应用层-->表示层-->会话层-->传输层-->网络层-->数据链路层->物理层2.TCP/IP五层模型应用层-->传输层-->网络层-->数据链路层-->…

作者头像 李华
网站建设 2026/10/30 8:15:29

电动汽车数据分析平台:技术架构与实现解析

1. 项目概述&#xff1a;电动汽车市场分析系统的技术全景这个项目本质上是一个融合多技术栈的垂直领域数据分析平台&#xff0c;核心目标是通过网络爬虫抓取中国电动汽车行业数据&#xff0c;经过清洗加工后实现可视化呈现。从技术架构来看&#xff0c;它涉及了从数据采集&…

作者头像 李华