简介:这份C#学习项目——“背单词”源代码,适合初学C#、希望结合小型应用巩固文本处理与面向对象编程的读者。项目演示了如何利用StreamReader/TextReader读取txt单词库,将每行英文单词和中文释义解析为Word对象,并实现正序、倒序和随机三种显示模式,还支持通过界面自定义刷新速度,完整覆盖了从文件读取、数据解析到界面事件触发的典型流程。资源共37个文件,包含9个cs源文件、7个txt数据文件、3个exe可执行程序,以及sln工程文件、Resources资源文件、resx界面资源、config配置等,压缩包仅290KB,目录结构紧凑,便于直接打开、编译和运行。已有946人浏览学习。通过本工程可以系统练习文件操作、List集合与Random随机数、Windows窗体布局、按钮事件与try-catch异常处理等C#常用技能,是自学C#或期末课程设计时可直接参考的实用范例。
1. 项目概述:为什么拿C#写背单词工具
第一次冒出"用C#写个背单词程序"这个念头,其实是因为我自己备考时被各种花里胡哨的App折磨得够呛——弹窗广告、社交打卡、会员解锁,真正能安静背单词的功能反而被淹没。折腾了一个周末,我干脆用C#写了一个本地运行的背单词工具,纯控制台加一个简单的WinForms界面,源代码一共也就几百行,跑起来完全够用。
这个项目特别适合两类人:一类是刚学完C#语法基础、想找个完整项目练手的朋友,另一个就是真的需要一个清爽无广告的背词工具、又不想依赖在线服务的人。整个开发过程涉及文件读写、数据结构设计、计时器处理、UI刷新、简单算法调度等一系列C#核心知识点,麻雀虽小五脏俱全。而且因为是自己的工具,想加什么功能就加什么功能,词库想怎么换就怎么换,不受任何平台限制。
我先把项目源码结构和功能目录贴出来,再逐个模块拆解实现思路和踩坑记录。
2. 功能设计与技术选型:先想清楚再动手写代码
2.1 核心功能清单:一个背词程序必须有哪些能力
开始编码之前,我把背单词这个场景拆成了几个最基本的能力:
- 词库管理:支持读取外部单词本,格式简单可编辑,最好用txt或csv这类纯文本,便于随时增删。
- 学习模式:从词库中抽取单词,每次展示一个,让用户回忆词义后手动翻转显示答案。
- 记忆判断:用户自评"记得/模糊/忘记",程序根据判断结果决定该单词多久后再次出现。
- 进度追踪:记录已学数量、掌握数量、待复习数量,关闭程序后下次能继续。
- 复习调度:简单采用艾宾浩斯遗忘曲线思路或自研的复习间隔算法,避免所有单词无差别轮询。
这些功能放在一个C#控制台应用里就能跑通,但为了体验更好,我用了WinForms做界面版。控制台版的优点是好调试、代码逻辑清晰,WinForms版则能直观展示UI线程和耗时操作之间的协作关系。两个版本共用同一个核心类库,数据层完全复用。
2.2 技术方案对比:WinForms为何是首选而非WPF
C#做桌面工具有几个主流选项:WinForms、WPF、Avalonia、MAUI。我最终选了WinForms,核心原因就一个字——轻。WinForms上手门槛低,控件拖拽完成,事件模型直观,不需要学XAML,也不涉及MVVM模式。对于一个核心目标是练手C#语法和基础API的项目来说,WinForms能让人把注意力集中在业务逻辑上,而不是花大量时间跟绑定、模板、样式死磕。
WPF的优势是界面美观、数据绑定强大,但如果项目主体是控制台逻辑加简单列表展示,WinForms的ListView和Label组合完全够用。等哪天想换个皮肤,再迁移到WPF也不迟,C#的代码分离做得好,业务类是不需要大改动的。
词库文件我用的是UTF-8编码的csv,每一行一条记录,格式为"英文,中文,词性"。没用数据库是因为这个项目的数据量撑死几千条,SQLite反而要额外引入依赖,文件读写的方式最直接,也方便用户在记事本里调整词库。
2.3 项目结构规划:模块拆分让代码更好维护
整个项目我拆成了四个核心文件:
WordBook.cs // 单词实体类 WordRepository.cs // 词库加载与保存 StudyEngine.cs // 学习调度引擎 MainForm.cs // 界面交互(WinForms版)实体类只包含词条属性和记忆状态,仓储类只负责读写文件,调度引擎处理抽词和复习逻辑,界面只负责展示和接收用户操作。这样拆的好处是,以后想换成WPF界面,只需要重写MainForm,其余三个文件直接搬过去就能用。控制台版的学习程序也复用WordRepository和StudyEngine,只是把界面换成了命令行输出。
3. 核心代码实现:从词库加载到调度算法逐步拆解
3.1 词库文件格式与WordRepository实现
词库文件我命名为words.csv,放在程序运行目录下。如果文件不存在,程序会自动生成一个包含示例条目的模板文件,便于用户快速了解格式。下面这个是读取逻辑的核心部分:
public class WordRepository { private readonly string _filePath; public WordRepository(string filePath) { _filePath = filePath; } public List<WordBook> LoadWords() { var words = new List<WordBook>(); if (!File.Exists(_filePath)) { CreateTemplateFile(); return words; } foreach (var line in File.ReadAllLines(_filePath, Encoding.UTF8)) { if (string.IsNullOrWhiteSpace(line)) continue; var parts = line.Split(','); if (parts.Length < 2) continue; words.Add(new WordBook { English = parts[0].Trim(), Chinese = parts[1].Trim(), Pos = parts.Length > 2 ? parts[2].Trim() : string.Empty, LastReviewed = null, ReviewCount = 0, MemoryLevel = 0 }); } return words; } }这里有个细节值得注意:File.ReadAllLines会把整个文件一次性读入内存,对于几千条的词库完全没问题,但如果是几十万条的超大词库,就要改用StreamReader逐行读取。我建议初学者先从一次性读取入手,理解代码逻辑后再去优化性能。
CreateTemplateFile方法负责生成默认词库,里面放了几组常用的四级词汇作为示例,用户可以直接替换成自己的词库。文件生成时用File.WriteAllLines配合Encoding.UTF8,避免中文乱码。
3.2 单词实体类与记忆状态设计
WordBook实体类是数据流转的基础:
public class WordBook { public string English { get; set; } public string Chinese { get; set; } public string Pos { get; set; } public DateTime? LastReviewed { get; set; } public int ReviewCount { get; set; } public int MemoryLevel { get; set; } // 0=未学习 1=模糊 2=已掌握 }MemoryLevel的设计参考了记忆的递进逻辑——单词不是一次就能记住的,每次复习后根据用户的反馈调整等级。等级越高,间隔时间越长,重复出现次数越少。这个字段是调度算法的核心依据,也是进度统计的数据来源。
实体类不建议放太多业务方法,保持纯粹的"数据载体"角色就好。之前见过有人把读写文件逻辑也塞进实体类,后来词库格式一改动,整个类的所有方法都要跟着改,维护成本很高。
3.3 学习调度引擎:如何决定下一个展示哪个单词
调度引擎是背单词程序的灵魂。我做了一个简单但有效的优先级算法:
public class StudyEngine { private readonly List<WordBook> _words; private readonly Random _random = new Random(); public StudyEngine(List<WordBook> words) { _words = words; } public WordBook GetNextWord() { // 优先选取记忆等级低、距离上次复习时间长的单词 var candidate = _words .Where(w => w.ReviewCount < 3 || w.MemoryLevel < 2) .OrderBy(w => w.LastReviewed ?? DateTime.MinValue) .ThenByDescending(w => w.ReviewCount == 0 ? 1 : 0) .FirstOrDefault(); if (candidate == null) return _words[_random.Next(_words.Count)]; return candidate; } public void Evaluate(WordBook word, bool isKnown) { word.ReviewCount++; word.LastReviewed = DateTime.Now; if (isKnown) { word.MemoryLevel++; } else { word.MemoryLevel = Math.Max(0, word.MemoryLevel - 1); } // 记忆等级高且复习次数足够的单词,降低出场频率 if (word.MemoryLevel >= 2 && word.ReviewCount >= 5) { word.IsMature = true; // 标记为已熟记,进入长周期复习队列 } } }这个逻辑的核心思路是"越生疏的单词越优先出现"。排序条件里先按LastReviewed排序,确保很久没复习的单词能尽快重新出现;再按是否未复习过排序,保证新词能往前走。复习次数超过5次且记忆等级达到2级的词标记为"已熟记",在日常学习中只按低概率随机出现,避免反复刷熟悉的单词浪费时间。
3.4 复习间隔表与遗忘曲线模拟
光有优先级还不够,复习间隔得有更科学的安排。我参考艾宾浩斯遗忘曲线的几个时间节点做了一个简单映射:
public static class ReviewInterval { private static readonly Dictionary<int, int> IntervalMap = new Dictionary<int, int> { { 0, 1 }, // 首次学习:1分钟后复习 { 1, 10 }, // 第1次复习:10分钟后 { 2, 60 }, // 第2次复习:60分钟后 { 3, 1440 }, // 第3次复习:1天后 { 4, 7200 } // 第4次复习:2天后 }; public static bool IsDue(WordBook word, int elapsedMinutes) { if (word.LastReviewed == null) return true; int minutesSinceLast = (int)(DateTime.Now - word.LastReviewed.Value).TotalMinutes; int interval = IntervalMap.ContainsKey(word.ReviewCount) ? IntervalMap[word.ReviewCount] : 7200; return minutesSinceLast >= interval; } }每次用户复习完一个单词,StudyEngine会检查是否还有到期的单词。如果有,优先展示到期词;如果没有到期词,则从新队列中拿一个未学过的词。这样做能让整个背词过程既有紧迫感又不至于太密集。你完全可以根据自己的记忆习惯改IntervalMap的数值,比如把60分钟改成30分钟,让节奏更紧凑。
4. 界面交互与UI线程处理:别让卡顿毁了学习体验
4.1 WinForms界面布局与事件绑定
WinForms版本的界面不算复杂,我用了三个核心控件:一个Label展示英文单词、一个Label展示中文释义(默认隐藏,点击按钮后显示)、四个Button分别对应"记得""模糊""忘记""下一个单词"。另外用ProgressBar显示今天的进度,用一个ListView展示最近学习的单词列表。
界面初始化的关键代码大致是:
public partial class MainForm : Form { private WordRepository _repo; private StudyEngine _engine; private List<WordBook> _words; private WordBook _currentWord; public MainForm() { InitializeComponent(); _repo = new WordRepository("words.csv"); _words = _repo.LoadWords(); _engine = new StudyEngine(_words); LoadNextWord(); } private void LoadNextWord() { _currentWord = _engine.GetNextWord(); labelEnglish.Text = _currentWord.English; labelChinese.Text = string.Empty; btnShowAnswer.Enabled = true; btnKnown.Enabled = false; btnFuzzy.Enabled = false; btnForgot.Enabled = false; } private void btnShowAnswer_Click(object sender, EventArgs e) { labelChinese.Text = $"{_currentWord.Pos} {_currentWord.Chinese}"; btnShowAnswer.Enabled = false; btnKnown.Enabled = true; btnFuzzy.Enabled = true; btnForgot.Enabled = true; } private void btnKnown_Click(object sender, EventArgs e) { _engine.Evaluate(_currentWord, true); RefreshProgress(); LoadNextWord(); } }整个界面交互就是"显示单词 → 点击查看释义 → 自评 → 进入下一个",逻辑非常直白。初学者可以看着这个结构把代码敲一遍,理解事件驱动的UI模型是怎么运转的。
4.2 UI线程阻塞问题:计时器和数据处理别卡界面
热搜里有一条"循环数据采集和ui刷新卡顿",这我在开发过程中也踩过。最初的版本里,每加载一个新单词我都要遍历整个词库做排序和筛选,词库只有几百个词时感觉不明显,增加到3000个以后点"下一个"按钮明显感觉卡顿,大约有半秒到一秒的延迟。
排查后发现罪魁祸首是在UI线程里做的排序操作。C#的WinForms有个铁律:所有UI控件的操作必须在UI线程上执行,但耗时的逻辑不该塞进UI线程的事件处理器里。解决办法是让调度引擎和界面各干各的。
对于这个背单词场景,最有效的优化方案是"预加载缓存"。在程序启动时就把排序好的单词队列缓存起来,每次取词直接从内存队列里拿,不重复执行全量排序。具体做法是StudyEngine在初始化时就维护一个Queue,依次排出候选词:
private Queue<WordBook> _dueQueue; private void RebuildDueQueue() { _dueQueue = new Queue<WordBook>( _words .Where(w => !w.IsMature) .OrderByDescending(w => w.MemoryLevel == 0 ? 1 : 0) .ThenBy(w => w.LastReviewed ?? DateTime.MinValue) ); }取词时直接_dueQueue.Dequeue(),O(1)复杂度,再配合一个BackgroundWorker定期刷新进度数据,界面就能保持丝滑。这个经验可以平移到你写的任何C#桌面应用里——凡是UI卡顿,先检查是不是在事件回调里干了重活。
4.3 进度保存:退出后如何恢复到上次状态
进度追踪用的是一个名为progress.json的文件,程序退出时把当前所有词条的记忆状态序列化保存,启动时再反序列化还原。这里我用的是.NET内置的System.Text.Json,不需要额外装包:
public void SaveProgress(List<WordBook> words) { var json = JsonSerializer.Serialize(words, new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) }); File.WriteAllText("progress.json", json, Encoding.UTF8); } public List<WordBook> LoadProgress() { if (!File.Exists("progress.json")) return new List<WordBook>(); var json = File.ReadAllText("progress.json", Encoding.UTF8); return JsonSerializer.Deserialize<List<WordBook>>(json) ?? new List<WordBook>(); }注意序列化时Encoder要指定为UnicodeRanges.All,否则中文会被转成\uXXXX这种转义序列,文件看着头大,虽然功能不受影响但可读性差。保存时机我选在每次点击"下一个"的时候,频繁保存虽然IO开销大,但词库量小所以毫秒级完成,换来的是意外退出也不丢数据,值得。
4.4 从控制台版到WinForms版的迁移思路
先写控制台版本体会核心逻辑再套界面,是我强烈推荐的学习路径。控制台版的POC(概念验证)代码大概40行就能跑通:
// Program.cs - 控制台版 static void Main(string[] args) { var repo = new WordRepository("words.csv"); var words = repo.LoadWords(); var engine = new StudyEngine(words); while (true) { var word = engine.GetNextWord(); Console.WriteLine(word.English); Console.WriteLine("按任意键显示释义"); Console.ReadKey(); Console.WriteLine($"{word.Pos} {word.Chinese}"); Console.WriteLine("认识吗?(y/n)"); var input = Console.ReadLine(); engine.Evaluate(word, input?.ToLower() == "y"); } }控制台版跑通后再把相同逻辑套到WinForms上,你会发现自己的思路特别清晰——哪些方法要保留、哪些状态要提升为字段,都一目了然。不要一上来就拖控件,逻辑没想清楚就搭界面,最终会不停返工。
5. 常见问题与排查技巧:C#新手最容易踩的坑
5.1 中文乱码问题
词库文件和界面上中文显示乱码,这基本是编码问题。文件读取时一定要用Encoding.UTF8,保存时也要显式指定UTF-8编码。注意一些老旧的第三方库或者从Windows记事本另存的txt可能是ANSI编码,可以用StreamReader的构造函数自动检测编码,或者干脆统一用File.ReadAllLines(path, Encoding.UTF8)并确保词库文件保存为UTF-8格式。
如果已经乱码了怎么修复?用记事本打开词库文件,点"文件→另存为",把编码从ANSI改成UTF-8重新保存即可。
5.2 UI假死与卡顿
点按钮后窗口变成白色"无响应"状态,大概率是UI线程被死循环或耗时操作阻塞了。我刚才提到的预加载队列是一种方案,另一个常用做法是把耗时任务丢到Task.Run里,然后在await之后更新控件:
private async void btnLoadBigFile_Click(object sender, EventArgs e) { btnLoadBigFile.Enabled = false; var words = await Task.Run(() => _repo.LoadLargeWords()); _words.AddRange(words); btnLoadBigFile.Enabled = true; }注意如果程序里同时访问_words列表,多个线程并发修改会抛出InvalidOperationException,这时候需要加锁或者用ConcurrentBag。初学者可以先从"UI事件里不做重活"这个原则入手,遇到卡顿再逐步引入异步。
5.3 事件绑定重复导致方法执行两次
WinForms中按钮点击事件如果绑了两次,会导致每次点击执行两遍逻辑。这个坑一般是设计器代码和手动代码重复添加委托导致的。排查方法是在方法入口打个断点,快速判断执行次数;修复方法是在设计器的InitializeComponent里检查事件绑定行,只保留一处。
5.4 文件被占用无法保存
词库或进度文件被Excel或另一个程序实例占用时,写入会抛IOException。简单处理方式是catch异常后提示用户关闭其他程序,更健壮的做法是写入时先写入临时文件再替换原文件:
File.WriteAllText("progress.tmp", json, Encoding.UTF8); File.Copy("progress.tmp", "progress.json", true); File.Delete("progress.tmp");5.5 词库格式错误导致加载失败
用户手动编辑csv时容易出现格式错误:多一个逗号、少一个字段。我的处理方式是在LoadWords方法里做容错,parts.Length < 2的直接跳过并记录日志,而不是让程序崩溃:
catch (Exception ex) { // 记录到error.log,方便用户定位问题行 File.AppendAllText("error.log", $"{DateTime.Now}: {ex.Message}\n"); }5.6 C#字符串截断和拼接的细节
处理词性和释义时经常用Split和Trim,我踩过一个坑是csv字段内容本身包含逗号(比如释义里有"你好,世界"),导致Split后字段错位。解决办法是词库格式改用\t制表符分隔,或者引入CSV库做带引号解析。简单场景下用制表符最省事:
var parts = line.Split('\t');6. 深入扩展思路:从背单词工具到更复杂的C#应用
6.1 接入语音合成提升学习体验
单词光看不够,还得听发音。.NET自带的SpeechSynthesizer类可以实现文本转语音,只需要几行代码就能让程序朗读英文单词:
using System.Speech.Synthesis; var synthesizer = new SpeechSynthesizer(); synthesizer.SetOutputToDefaultAudioDevice(); synthesizer.Speak("apple");这是System.Speech命名空间下的类,记得在项目引用中加入System.Speech。注意不是所有Windows系统都自带英文语音包,安装时可能需要额外下载语音包,否则只有中文发音,念英文单词会很有喜感。
6.2 WebSocket在线对战背单词
如果你熟悉socket通信,可以扩展一个多人对战模式。用TcpListener做个简单的服务端,两个人同时背同一个词库,谁先答对得分。这里会用到C#的Socket、异步网络编程等知识点,正是热搜里那个"C# socket"的核心场景。
6.3 与上位机联动做智能硬件打卡
热搜里有"C# 上位机开发",这个背单词工具其实可以作为一个上位机Demo。比如用串口连接STM32板子,按板子上的按钮表示"记得"或"忘记",然后再用板载LED显示打卡状态。把学习引擎和硬件串口解耦后,核心代码不用动,只需要加一个串口事件监听模块。
6.4 用ECharts做学习统计
如果你想把每天的学习数据进行可视化展示,可以把progress.json导出给一个前端页面,用ECharts画折线图柱状图,展示记忆曲线和复习计划。C#这边只需要写一个导出接口,或者直接生成JSON文件放静态服务器上。
7. 最后分享点实际开发中的体会
背单词程序这个项目看起来小,但它几乎覆盖了C#桌面开发的常用知识点——文件处理、集合排序、事件驱动、UI线程、JSON序列化、异常处理。我后来面试时聊到项目经验,就是从背单词工具讲起的,招聘方对这个"小而完整"的实战项目评价很高,比空谈语法扎实得多。
如果你的目标是学C#而不是单纯背单词,我强烈建议你别用我写好的源代码一键运行,而是先看逻辑流程图,再自己动手一行行敲出来。照抄代码和亲手实现,中间差距很大。敲的过程中遇到问题,先断点调试、看调用堆栈、查官方文档,实在卡住了再回头看源码——这样至少能记住80%的知识点。
代码的全部源码我打包在文章关联的资源里了,记得配一个UTF-8编码的words.csv词库文件再启动。如果你把词库换成了自己的专业词汇表,建议顺手把复习间隔和时间周期也调一下,专业术语的记忆曲线和日常英语还是有区别的。祝编码愉快。
本文还有配套的精品资源,点击获取