简介:基于C#与MySQL开发的WinForm背单词应用,围绕艾宾浩斯记忆曲线设计,将词典查询、题库练习和单词记忆三大功能整合在桌面客户端中,帮助学习者按照科学复习节奏提升背词效率,也适合作为C#课程设计或桌面数据库项目的参考。资源包共2000个文件、约94MB,以1919个JSON词库数据文件为核心,涵盖多类词库数据,可满足不同主题与场景下的单词积累需要;配合50个XML配置、21个TXT说明、Markdown笔记,以及需求与设计文档、开题报告和PPT演示文稿,数据与文档分层清晰,便于按用途查找。已有274人学习下载,适合备考英语、自研记忆工具或进行WinForm+MySQL课程设计的读者,在课程设计中可覆盖需求分析、开题、数据组织到答辩展示的完整链路。借助词库组织方式和文档框架,能够快速理解记忆曲线调度逻辑、单词分类维度与数据库交互思路;开题、需求文档与幻灯片又能为二次开发、功能扩展或答辩汇报提供完整素材。
1. 用C#+MySQL做背单词课程设计:先想清楚三个问题
一个基于C#+MySQL的WinForm背单词系统,核心玩法不是“背多少词”,而是“逼你在对的时间复习”——艾宾浩斯记忆曲线决定的复习点,往往和你自然想复习的时间正好错开。做这个课程设计,难点从来不在单词表有多大,而在于三个问题:数据表怎么建模,复习计划怎么调度,以及界面为什么一加载就卡白屏。这套编号100010304的资源把这几件事拆成了一套可直接运行的工程,WinForm前端、MySQL建库脚本、记忆曲线调度逻辑都齐了。适合正在找C#课程设计题目、准备WinForm项目案例的在校生,也适合想用现成案例快速跑通“CRUD+算法调度”完整流程的上班族。接下来按数据层、算法层、界面层依次拆,最后把踩过的坑一次说清。
2. 系统拆分与数据库设计:五张表撑起记忆曲线
2.1 功能模块怎么切:从单词管理到复习调度
背单词系统在功能上可以切成三块:单词管理、学习记忆、统计查询。单词管理负责词书和单词的增删改查;学习记忆是核心交互,用户看到一个单词、点击显示释义、再对自己判“认识”或“不认识”;统计查询则回答“今天有多少单词该复习”这类问题。对应到WinForm,至少有两个主要窗体:一个单词库管理窗体,一个学习窗体。数据流是一条闭环:学习窗体里判完一个单词,写一条复习记录,同时更新这个单词的“下次复习时间”。
这里有一个常见的坏习惯:很多同学一上来就把复习间隔直接写死在界面代码里,算法和界面耦合在一起,后面想调一个参数就要改好几处。我的习惯是单独抽一个MemoryService类,所有复习间隔计算和到期队列生成都走它,窗体只负责调用和展示。这样答辩现场演示时,改一下间隔数组、重新编译,效果立刻就能看得出来——老师对这个印象很深。
2.2 数据库五张表:字段、主外键与索引设计
这个项目的核心表是五张:用户表、词书表、单词表、复习记录表、记忆状态表。其中记忆状态表是本项目的关键——因为艾宾浩斯曲线关心的是“每个单词当前处于第几次复习、下次什么时候复习”,如果把状态散落在历史记录里,每次查询都要聚合一大堆ReviewRecord。单独拆一张MemoryState表后,“今日待复习列表”就变成只扫一张状态表的简单查询。
| 表名 | 核心字段 | 作用 |
|---|---|---|
| t_user | user_id, user_name, password_hash, created_at | 登录与多用户数据隔离 |
| t_word_book | book_id, book_name, word_count, description | 词书分组,可按四六级/考研区分 |
| t_word | word_id, book_id, spell, phonetic, meaning, example_sentence | 单词本体 |
| t_review_record | record_id, user_id, word_id, review_time, result, stage | 每次复习的历史流水 |
| t_memory_state | state_id, user_id, word_id, stage, next_review_time, review_count, dominance | 记忆状态与复习调度 |
t_memory_state里的stage是复习阶段,next_review_time是下次复习时间,review_count是累计复习次数,dominance是熟练度。把状态独立出来还有一个好处:用户误删了复习记录时,只要memory_state还在,下次启动程序就能按原有计划继续,不会变成一张白纸。
2.3 建库脚本:MySQL 8.0下的完整DDL
我在这个项目里用的数据库是MySQL 8.0,字符集直接选utf8mb4而不是utf8。原因很简单:utf8在MySQL里是utf8mb3的别名,存不了四字节的Emoji和部分生僻字,英文单词虽然用不上,但释义里如果带了个表情或者特殊符号,就会变成乱码。以下是完整的建库和建表脚本,直接放进Navicat或命令行执行即可。
CREATE DATABASE IF NOT EXISTS word_memory DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE word_memory; CREATE TABLE `t_user` ( `user_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `user_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', `password_hash` VARCHAR(64) NOT NULL COMMENT 'SHA256散列,不存明文', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB COMMENT='用户表'; CREATE TABLE `t_word_book` ( `book_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '词书ID', `book_name` VARCHAR(100) NOT NULL COMMENT '词书名', `word_count` INT NOT NULL DEFAULT 0 COMMENT '单词数量', `description` VARCHAR(255) DEFAULT '' COMMENT '词书描述', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='词书表'; CREATE TABLE `t_word` ( `word_id` INT PRIMARY KEY AUTO_INCREMENT, `book_id` INT NOT NULL COMMENT '所属词书', `spell` VARCHAR(100) NOT NULL COMMENT '单词拼写', `phonetic` VARCHAR(100) DEFAULT '' COMMENT '音标', `meaning` VARCHAR(255) NOT NULL COMMENT '释义', `example_sentence` VARCHAR(500) DEFAULT '' COMMENT '例句', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX `idx_book` (`book_id`), CONSTRAINT `fk_word_book` FOREIGN KEY (`book_id`) REFERENCES `t_word_book`(`book_id`) ) ENGINE=InnoDB COMMENT='单词表'; CREATE TABLE `t_review_record` ( `record_id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `word_id` INT NOT NULL, `review_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '复习时间', `result` TINYINT NOT NULL COMMENT '1认识 0不认识', `stage` TINYINT NOT NULL COMMENT '本次复习时所处的阶段', KEY `idx_user_time` (`user_id`, `review_time`), KEY `idx_word` (`word_id`) ) ENGINE=InnoDB COMMENT='复习记录表'; CREATE TABLE `t_memory_state` ( `state_id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `word_id` INT NOT NULL, `stage` TINYINT NOT NULL DEFAULT 0 COMMENT '当前复习阶段', `next_review_time` DATETIME NOT NULL COMMENT '下次复习时间', `last_review_time` DATETIME DEFAULT NULL COMMENT '上次复习时间', `review_count` INT NOT NULL DEFAULT 0 COMMENT '累计复习次数', `dominance` SMALLINT NOT NULL DEFAULT 0 COMMENT '熟练度0-100', UNIQUE KEY `uk_user_word` (`user_id`, `word_id`), KEY `idx_next_review` (`user_id`, `next_review_time`), CONSTRAINT `fk_state_word` FOREIGN KEY (`word_id`) REFERENCES `t_word`(`word_id`) ) ENGINE=InnoDB COMMENT='记忆状态表';代码段里有几个点需要解释。uk_user_word唯一键保证同一个用户对同一个单词只会有一行状态,避免了重复插入导致调度错乱。idx_next_review是高频查询索引,因为每天刷“今日待复习”时都要执行类似where user_id = ? and next_review_time <= now()的查询,把user_id放索引最前面正好符合最左前缀原则。两条t_review_record和t_memory_state的索引都是为了配合按时间和按用户维度的统计。另外我特意没给t_review_record加外键约束,只加了普通索引——历史流水只增不改,外键反而会在批量导入时拖慢速度,课程设计项目里这个取舍是可以讲给老师听的。
提示:如果你本机装的是MySQL 5.7,这个脚本同样能跑,因为用的都是5.7就支持的语法,8.0的utf8mb4默认行为略有不同,但显式声明后没有差异。
3. 艾宾浩斯算法落地:复习点计算与调度逻辑
3.1 复习间隔表:经典曲线如何转成工程参数
艾宾浩斯记忆曲线原版实验使用的间隔是5分钟、30分钟、12小时、1天、2天、4天、7天、15天。做成课程设计时完全照搬会让调度变得很复杂——分钟级的复习任务必须靠后台定时器,一关程序就丢。行业里做背单词App的通常会把第一次复习缩短、后续拉长。这个项目采用的简化策略是:初次学完立即记一次,之后按阶段逐步拉长到天级。
| Stage | 间隔 | 含义 |
|---|---|---|
| 0 | 1天 | 当天学完,次日第一次复习 |
| 1 | 2天 | 第二次复习 |
| 2 | 4天 | 第三次复习 |
| 3 | 7天 | 第四次复习 |
| 4 | 15天 | 第五次复习 |
| 5+ | 30天 | 巩固模式,进入长期记忆 |
这个间隔表不是唯一的答案。如果你想仿真原始曲线,可以改成数组{0.003, 0.02, 0.5, 1, 2, 4, 7, 15}天,但这样程序里就要处理小数天和分钟级任务,复杂度上升一个量级。课程设计的场景下,天级间隔已经足以演示“到期复习”的核心逻辑。
3.2 核心算法:下一个复习时间怎么计算
接下来是把算法落成C#代码。我用的MemoryService类只有两个关键方法:一个根据当前阶段算出下一次复习时间,一个根据判分结果返回下一个阶段。
public class MemoryService { // 间隔表:下标是stage,值是从该阶段到下一阶段所需间隔天数 private static readonly int[] IntervalDays = { 1, 2, 4, 7, 15, 30, 30 }; // 根据当前阶段和基准时间算出下次复习时间 public DateTime GetNextReviewTime(int stage, DateTime baseTime) { if (stage < 0) stage = 0; int maxStage = IntervalDays.Length - 1; if (stage > maxStage) stage = maxStage; return baseTime.AddDays(IntervalDays[stage]); } // 判分结果决定下一个阶段:答对进一阶,答错重置回0 public int GetNextStage(int currentStage, bool isCorrect) { if (!isCorrect) return 0; int maxStage = IntervalDays.Length; return Math.Min(currentStage + 1, maxStage); } }GetNextStage里“答错回0”是整个记忆曲线的关键行为。一次答错说明这条记忆断裂了,原有的间隔表不再可信,必须回到最初始的阶段重新走一遍。这也是艾宾浩斯法和普通“单词本”的本质区别:普通单词本只记录“背过没背过”,这套逻辑记录的是“这条记忆现在处于什么状态”。
3.3 到期队列生成:SQL怎么写才不会漏词
有了状态表之后,“今天该复习哪些词”就变成一个很朴素的查询:找出所有user_id匹配、且next_review_time已经小于等于当前时刻的单词。
SELECT w.word_id, w.spell, w.phonetic, w.meaning, w.example_sentence, m.stage, m.next_review_time FROM t_memory_state m INNER JOIN t_word w ON m.word_id = w.word_id WHERE m.user_id = @userId AND m.next_review_time <= NOW() ORDER BY m.next_review_time ASC LIMIT @limit;这里为什么用<=而不是=?因为程序不是一台永远开着的学习机,你昨晚该复习的单词可能今早才打开软件,用=就全漏掉了。ORDER BY next_review_time ASC把拖得最久没复习的排在最前面,正好符合“优先补旧债”的原则,这也是一个可以跟老师讲明白的细节。LIMIT @limit用于一次只取一组,避免积压几百个单词把学习窗体塞爆。我一般在WinForm里默认每次20个一组,背完一组再加载下一组。
3.4 判分后的状态更新:必须在一个事务里完成
背完一个单词后要做两件事:写一条复习记录,更新记忆状态。这两步操作不能分开执行,否则会出现“记录写了但状态没更新”的中间状态。我用一个事务把它们包起来。
public void SubmitResult(int userId, int wordId, int currentStage, bool isCorrect) { var memoryService = new MemoryService(); int nextStage = memoryService.GetNextStage(currentStage, isCorrect); DateTime nextReviewTime = memoryService.GetNextReviewTime(nextStage, DateTime.Now); string connStr = "Server=127.0.0.1;Port=3306;Database=word_memory;Uid=root;Pwd=******;Charset=utf8mb4;SslMode=None;"; string insertRecord = @" INSERT INTO t_review_record (user_id, word_id, review_time, result, stage) VALUES (@userId, @wordId, NOW(), @result, @stage)"; string updateState = @" UPDATE t_memory_state SET stage = @stage, next_review_time = @nextTime, last_review_time = NOW(), review_count = review_count + 1, dominance = LEAST(100, GREATEST(0, dominance + @delta)) WHERE user_id = @userId AND word_id = @wordId"; using (var conn = new MySqlConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { using (var cmd = new MySqlCommand(insertRecord, conn, tx)) { cmd.Parameters.AddWithValue("@userId", userId); cmd.Parameters.AddWithValue("@wordId", wordId); cmd.Parameters.AddWithValue("@result", isCorrect ? 1 : 0); cmd.Parameters.AddWithValue("@stage", currentStage); cmd.ExecuteNonQuery(); } using (var cmd = new MySqlCommand(updateState, conn, tx)) { cmd.Parameters.AddWithValue("@userId", userId); cmd.Parameters.AddWithValue("@wordId", wordId); cmd.Parameters.AddWithValue("@stage", nextStage); cmd.Parameters.AddWithValue("@nextTime", nextReviewTime); // 答对dominance +10,答错 -20,用LEAST/GREATEST限幅 cmd.Parameters.AddWithValue("@delta", isCorrect ? 10 : -20); cmd.ExecuteNonQuery(); } tx.Commit(); } catch { tx.Rollback(); throw; } } } }这个方法里的dominance用LEAST和GREATEST函数限幅,把熟练度压在0到100之间,不需要在C#端写边界判断。AddWithValue传参是参数化查询的标准写法,比起字符串拼接SQL,好处是避免注入,同时省去手动拼单引号的麻烦——这个习惯做C#项目时应该从一开始就强制保留。事务的Commit和Rollback成对出现,任何一条SQL异常,两条操作一起回滚,数据不会出现半截状态。
注意:事务只保护同步操作有效。如果以后要加“背单词时播放读音”这种耗时功能,别把网络请求或音频加载放进事务里,事务越短越好。
4. WinForm界面与交互:控件编排与异步刷新
4.1 主窗体布局:从单词表到学习窗体的跳转
整个项目至少包含两个窗体:登录窗体和主窗体。主窗体里左侧词书列表、右侧单词DataGridView、底部StatusStrip状态栏。用户在词书列表里点一本词书,右侧单词表就刷新成该词书的单词;点“开始背单词”按钮,打开学习窗体,学习窗体里一次展示一个单词卡片。
| 控件 | 类型 | 作用 |
|---|---|---|
| lstBooks | ListBox | 展示词书,绑定数据源 |
| dgvWords | DataGridView | 展示单词列表,列可配置 |
| btnLearn | Button | 打开学习窗体 |
| stsMain | StatusStrip | 展示“今日待复习/已完成”进度 |
| toolStripProgressBar | ToolStripProgressBar | 放在状态栏里的进度条 |
WinForm界面美化是这个项目容易被忽略但很加分的点。不需要引入第三方皮肤,只要把DataGridView开启BorderStyle=None、启用AlternatingRowsDefaultCellStyle交替行背景色、把表头字体加粗,观感就会比默认样式好很多。这类细节在“winform界面美化”检索里问的人很多,实际操作就几行属性配置。
4.2 DataGridView数据绑定:关闭自动生成列
绑定单词列表时最容易踩的坑是AutoGenerateColumns默认开着,导致每次查询返回的列顺序不稳定。我一般会先手动配置列,再绑数据源。
DataTable dt = new DataTable(); using (var conn = new MySqlConnection(connStr)) { string sql = @"SELECT word_id, spell, phonetic, meaning FROM t_word WHERE book_id = @bookId"; using (var cmd = new MySqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@bookId", selectedBookId); using (var adapter = new MySqlDataAdapter(cmd)) { adapter.Fill(dt); } } } dgvWords.AutoGenerateColumns = false; // 关键:关闭自动生成 dgvWords.Columns.Clear(); dgvWords.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "word_id", HeaderText = "ID", Width = 60 }); dgvWords.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "spell", HeaderText = "单词", Width = 150 }); dgvWords.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "phonetic", HeaderText = "音标", Width = 130 }); dgvWords.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "meaning", HeaderText = "释义", Width = 320 }); dgvWords.DataSource = dt;把AutoGenerateColumns关闭之后,列的顺序、表头文字、宽度完全由你控制,而不是跟着SQL的返回结果随机走。这个做法在数据列多、需要控制显示字段时非常有用。DataGridView还有几个常用属性:AllowUserToAddRows=false避免用户误加空行、ReadOnly=true让列表变成只读、SelectionMode=FullRowSelect让整行选中方便右键操作,做单词管理界面时建议一并设上。
4.3 学习窗体的判分交互:显示答案与判分拆成两步
学习窗体的核心是一个单词卡布局:上方显示拼写和音标,下方显示释义(默认隐藏),再往下是一排按钮。“显示答案”按钮点击后释义才出现,同时“认识”和“不认识”两个按钮才可用。这样的顺序设计保证了用户不会在没看到释义的情况下误判。
// 加载下一个单词 private void LoadNextCard(WordCard card) { lblSpell.Text = card.Spell; lblPhonetic.Text = card.Phonetic; lblMeaning.Text = ""; // 释义先隐藏 btnShowAnswer.Enabled = true; btnCorrect.Enabled = false; btnWrong.Enabled = false; } // 点击显示答案 private void btnShowAnswer_Click(object sender, EventArgs e) { lblMeaning.Text = _currentCard.Meaning; btnShowAnswer.Enabled = false; btnCorrect.Enabled = true; btnWrong.Enabled = true; } // 点击“认识” private void btnCorrect_Click(object sender, EventArgs e) { SubmitResult(_currentCard, true); LoadNextCard(_queue.Next()); } // 点击“不认识” private void btnWrong_Click(object sender, EventArgs e) { SubmitResult(_currentCard, false); LoadNextCard(_queue.Next()); }这里把“显示答案”和“判分”两个动作彻底拆开,从交互上杜绝了误触。判分按钮在显示答案之前强制处于禁用状态,哪怕用户连点也不会产生无效记录。_queue.Next()返回队列里下一个待复习单词,队列用完后自动加载下一批20个,这个后续逻辑放到调用处统一处理。
4.4 异步加载:别让UI线程卡在数据库查询上
WinForm程序最常见的翻车现场是启动时卡白屏。原因是Load事件里直接执行了同步数据库查询,UI线程被阻塞,窗体没法重绘。做过C#上位机的朋友对这个问题肯定不陌生——串口读数据、读扭矩值时的界面卡顿,和这里读数据库是同一个根源。解决方案是用async/await把查询扔到线程池。
private async void FrmMain_Load(object sender, EventArgs e) { btnLearn.Enabled = false; try { var wordList = await Task.Run(() => LoadWordsFromDb(0)); dgvWords.DataSource = wordList; } catch (Exception ex) { MessageBox.Show("加载失败:" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { btnLearn.Enabled = true; } } private DataTable LoadWordsFromDb(int bookId) { // 该方法内创建连接、执行查询、返回DataTable // 注意:不要在Task.Run里直接操作任何控件 }async void只在事件处理器里用,普通方法里不要这样写。Task.Run里的Lambda方法只负责查询和返回数据,不碰任何控件,跨线程操作控件会抛InvalidOperationException,这是WinForm开发里一个经典报错。控件赋值回到UI线程后再做,await后面的代码默认就在UI线程上下文里继续执行。
4.5 状态栏进度:先设Maximum再设Value
状态栏的ToolStripProgressBar用来展示今日进度。更新进度时有个顺序要求,先把Maximum赋好,再赋Value,否则Value超界会直接抛ArgumentOutOfRangeException。
private void UpdateProgress(int done, int total) { if (total <= 0) { toolStripProgressBar.Value = 0; toolStripStatusLabel.Text = "暂无待复习单词"; toolStripStatusLabel.Text = $"今日进度:{done}/{total}"; return; } toolStripProgressBar.Minimum = 0; toolStripProgressBar.Maximum = total; toolStripProgressBar.Value = Math.Min(done, total); toolStripStatusLabel.Text = $"今日进度:{done}/{total}"; }Value被限制在Maximum以内是为了避免进度条控件抛异常。实际项目中done值偶尔会因为并发或重复刷新超过total,Math.Min做一个兜底。状态栏文本和进度条同时更新,用户扫一眼就知道今天还剩多少个。
5. 避坑与排查:连接认证、中文乱码与日期边界
5.1 MySQL连接失败:caching_sha2_password认证不支持
现象:程序运行到conn.Open()直接抛异常,报错信息是“Authentication method 'caching_sha2_password' not supported”之类。
原因:MySQL 8.0默认的认证插件是caching_sha2_password,较老版本的MySql.Data连接器不认识它。课程设计环境里最常见的组合是MySQL 8.0配一个老版驱动,或者用的是5.7时代的配置文件。
解决:两种方式任选。要么升级连接器到MySqlConnector或MySql.Data 8.0以上版本,要么把本地用户的认证方式改回mysql_native_password,执行下面这条SQL后重连。
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码'; FLUSH PRIVILEGES;我的建议是优先换连接器,因为新版MySQL对native_password的支持会逐渐弱化,在本地开发环境改认证方式只图省事,答辩提问时不太好解释。连接字符串里最好显式加上SslMode=None,本地开发没有配置SSL证书时,这个选项能避免多余的SSL握手报错。
5.2 中文乱码:表、连接串、文件源头三处都要查
现象:从数据库读出来的中文释义显示成问号,或者Insert进去的中文变成乱码。
原因:三层里任何一层出问题都会乱码——表字符集不是utf8mb4、连接串没带Charset=utf8mb4、导入的源文件本身编码不是UTF-8。只改其中一处,另外两处还是会捣乱。
解决:建库脚本用utf8mb4;连接串里追加Charset=utf8mb4;如果用Excel或TXT导入单词数据,导入前把文件另存为UTF-8编码。下面这段连接串是我在这个项目里的标准写法。
string connStr = "Server=127.0.0.1;Port=3306;Database=word_memory;Uid=root;Pwd=******;Charset=utf8mb4;SslMode=None;";排查顺序建议是:先用Navicat或命令行直接SELECT看库里存的对不对,库没问题再看连接串,最后怀疑导入源文件。这个顺序能帮你快速定位乱码到底发生在哪一层。
5.3 今日待复习数不准:日期边界别用NOW()
现象:早上打开程序,今日待复习数量明显比预期少,有时甚至是0,但明明有昨天没复习完的单词。
原因:统计SQL里用了next_review_time < NOW()这类写法。NOW()包含当前时刻,昨天下午到期、但今天早上查询时它确实小于NOW(),理论上不会被漏;真正漏的是边界处理——如果今天截止时间写成NOW(),今天23:59之后才到期的单词会被提前算进去,第二天白天再打开时又刚好被排除。问题往往出在“今天”的起止边界不明确。
解决:明确“今日”的起止。今天到期的单词,条件是next_review_time处于今天零点和明天零点之间。零点和截止时间在C#端算好再传给SQL。
DateTime startOfToday = DateTime.Today; DateTime endOfToday = DateTime.Today.AddDays(1).AddSeconds(-1); string sql = @" SELECT COUNT(*) FROM t_memory_state WHERE user_id = @userId AND next_review_time >= @startOfToday AND next_review_time <= @endOfToday";用DateTime.Today而不是DateTime.Now来取零点,能避免“昨天23:59:59该复习的词在今天统计里消失”这类边界问题。下一次复习时间的计算也统一以DateTime.Now为基准,但统计时一定按天界来切。
5.4 数据不一致:复习记录和记忆状态不同步
现象:复习记录里有一条数据,但记忆状态的stage和next_review_time还是旧值;或者反过来,状态更新了但记录没写。
原因:两条SQL没有放在同一个事务里。程序在写记录和更新状态之间抛了异常,其中一条SQL执行成功并提交了,另一条没执行。
解决:把“插入复习记录”和“更新记忆状态”包进事务,要么一起成功,要么一起回滚。写法就是3.4节submitResult方法里的那段:同一个连接、同一个事务对象,commit放在最后。我在这个项目里给所有写操作都套了事务模板,包括“创建用户时插入用户信息+初始化默认词书”这种看似单条的操作——因为谁知道以后会不会在这个动作里加第二个写操作。
提示:事务不是越多越好。查询操作不要开事务,只有多个写操作需要保持原子性时才使用。事务范围控制在单个方法内,别跨窗体。
5.5 连接池耗尽:打开连接忘了释放
现象:程序运行一阵后,数据库操作突然变慢,最终报“Too many connections”。
原因:WinForm项目里每个窗体都用new MySqlConnection创建连接,用完后没有释放。MySQL默认max_connections只有151,连接池里堆积的连接很快被占满。
解决:所有连接对象都写在using语句里,让编译器保证释放。我习惯把“创建连接”封装成一个统一方法,调用处强制使用using。
public MySqlConnection OpenConnection() { var conn = new MySqlConnection(_connStr); conn.Open(); return conn; } // 调用处统一这样写: using (var conn = OpenConnection()) using (var cmd = new MySqlCommand(sql, conn)) { // 执行查询 }用using包围后,哪怕方法中途抛异常,连接也会在退出作用域时被释放。这条规则简单但有效,很多WinForm项目的卡顿和连接超时问题,根子就在这里。
6. 答辩演示加分:调试面板、打包安装与备份习惯
6.1 加一个Debug面板,让算法状态可见
课程设计答辩时,口头讲艾宾浩斯曲线远不如直接给老师看状态变化来得有说服力。我会在项目里加一个“记忆状态查询”小窗体,输入单词ID或用户ID,就能看到该单词当前的stage、next_review_time、last_review_time、review_count。现场演示时先背对一个词,再打开面板刷新,next_review_time从“刚刚”跳到“后天”,这个变化比任何流程图都直观。实现上就是t_memory_state表的简单查询,几十行代码的事。
6.2 打包成安装程序:几步出可执行文件
把项目交付出去时,打包是绕不开的一步。我的做法是直接用Visual Studio自带的发布功能,目标文件夹设置好以后生成安装包,也可以配合ClickOnce做一个双击安装的版本。打包时有两点必须提前处理:目标机器上要有.NET Framework 4.7.2以上运行时;MySQL数据库不打包进安装程序,而是把建库脚本单独交付,让使用方自己导入。
安装时连接字符串写在App.config里,用户拿到程序后可以通过改配置文件来指向自己的数据库。这比把密码硬编码在代码里更稳妥,也方便调试时切换本地库和演示库。别尝试在安装包里捆绑MySQL服务,那个复杂度对课程设计来说不值得。
6.3 数据备份:给自己留一颗后悔药
记忆状态数据是反复复习积累出来的,删了就很难重建。我建议在程序里加入“导出备份”和“导入恢复”两个按钮,导出时直接调用mysqldump命令。
mysqldump -h127.0.0.1 -uroot -p word_memory > backup_20250401.sqlmysql -h127.0.0.1 -uroot -p word_memory < backup_20250401.sql这两条命令可以用C#的Process类包装到按钮事件里,也可以直接用cmd手动执行。从那以后我每次改数据库结构都会先导出一份备份,再动手改字段——单词表丢了重导一遍就行,但记忆状态记录的是每个单词在你脑子里的真实遗忘规律,补不回来。希望这个习惯和这套设计,帮你在答辩和后续开发里都少踩几个坑。
本文还有配套的精品资源,点击获取