简介:这是一份面向C#初学者与进阶学习者的实践型项目合集,聚焦通过动手开发夯实语法基础、掌握面向对象编程、GUI开发、数据库交互及异步编程等核心能力。资源包含30个由浅入深的经典小项目,如计算器、猜数字游戏、学生信息管理系统、图书管理CRUD应用、日历窗体程序及LINQ数据分析工具等,覆盖变量控制、类封装、事件委托、ADO.NET/EF操作、async/await并发处理等关键知识点。压缩包共598个文件,以148个.cs源码文件为核心,辅以31个.sln/.csproj工程文件构建可直接编译运行的完整解决方案,另有102个.exe可执行程序便于快速验证效果,68个.pdb调试符号与41个.resx本地化资源支持调试与多语言拓展,整体仅1.06MB,轻量易下载。目前已有1661人学习下载,项目结构清晰、命名规范(含实验章节标识如“第八章-习题二”),适合作为课堂实训补充、自学闭环练习或面试项目储备。
1. C#经典小项目:不是练手玩具,而是工程能力的压缩包
“C#一些经典的小项目”——这个标题看似平淡,实则藏着一线开发者最常复用的5类能力锚点:控制台交互逻辑闭环、文件IO与结构化数据落地、多线程资源协同、WinForms界面与业务解耦、以及.NET生态下轻量级HTTP服务封装。它不指向某个具体开源仓库,而是指代一批被反复验证过的最小可行范式:比如一个带进度反馈的批量重命名工具,表面是字符串操作,背后考的是路径安全校验、异常隔离和用户中断响应;再如一个本地JSON配置管理器,代码不到200行,却必须处理编码冲突、字段缺失回退、并发写入锁等真实场景。这类项目不是给新手“写个Hello World”的安慰剂,而是老手快速搭建原型、验证架构假设、或给新人布置可闭环评审任务的黄金粒度。适合三类人:刚学完语法想验证理解深度的初学者、需要在3天内交付内部工具的中阶工程师、以及负责技术面试题库建设的团队骨干。它们的价值不在炫技,而在把“C#能做什么”压缩成可触摸、可调试、可嵌入真实工作流的实体。
2. 从零构建控制台版“文件批量处理器”:命令行交互与健壮性设计
这类项目是C#入门后的第一道分水岭——它逼你直面真实世界的输入不确定性。一个看似简单的“按规则重命名当前目录下所有.jpg文件”,实际要处理:路径含中文乱码、文件正被其他进程占用、用户中途按Ctrl+C、目标名已存在冲突、甚至磁盘空间不足。我们不追求图形界面,而用System.CommandLine(.NET 6+推荐)替代原始args[]解析,让参数定义即文档。
2.1 定义可组合的命令结构:避免if-else地狱
// Program.cs using System.CommandLine; using System.CommandLine.Invocation; var rootCommand = new RootCommand("批量文件处理器:支持重命名、移动、添加前缀") { new Option<string>( alias: "--pattern", description: "匹配文件名的通配符,如 *.jpg 或 report_*.pdf") { IsRequired = true }, new Option<string>( alias: "--action", getDefaultValue: () => "rename", description: "操作类型:rename|move|addprefix") { IsRequired = true }, new Option<string>( alias: "--target", description: "目标路径或新名称模板,如 'new_{0}' 或 'D:\\backup\\'") { IsRequired = true } }; rootCommand.Handler = CommandHandler.Create<string, string, string>(HandleBatchOperation);提示:
System.CommandLine会自动生成--help输出,且参数校验(如必填项缺失)由框架拦截,无需手动if (args.Length < 2)。{0}占位符用于后续格式化,比字符串拼接更安全。
2.2 核心处理逻辑:用状态机思维管理文件生命周期
static async Task HandleBatchOperation(string pattern, string action, string target, InvocationContext context) { var currentDir = Directory.GetCurrentDirectory(); var files = Directory.GetFiles(currentDir, pattern, SearchOption.TopDirectoryOnly) .Where(f => !f.Equals(Assembly.GetExecutingAssembly().Location, StringComparison.OrdinalIgnoreCase)) .ToArray(); if (!files.Any()) { context.Console.WriteLine($"⚠️ 未找到匹配 '{pattern}' 的文件"); return; } // 关键:预检查阶段,提前暴露问题 var validationResults = ValidateFiles(files, action, target, currentDir); if (validationResults.Any()) { foreach (var msg in validationResults) context.Console.WriteLine($"❌ {msg}"); return; } // 执行阶段:逐个处理,失败不中断整体流程 var successCount = 0; var failedList = new List<(string file, string error)>(); foreach (var file in files) { try { await ProcessSingleFile(file, action, target, currentDir); successCount++; } catch (Exception ex) { failedList.Add((file, ex.Message)); } } // 结果汇总:比单纯抛异常更有生产价值 context.Console.WriteLine($"\n✅ 成功处理 {successCount}/{files.Length} 个文件"); if (failedList.Any()) { context.Console.WriteLine($"⚠️ 失败列表(共{failedList.Count}个):"); failedList.ForEach(f => context.Console.WriteLine($" - {f.file} → {f.error}")); } }参数说明:
pattern:使用Directory.GetFiles的原生通配符,不支持正则,避免过度复杂化;action:枚举值应硬编码为"rename"/"move"/"addprefix",而非开放任意字符串,防止误操作;target:对rename操作,target是格式化模板(如"IMG_{0:yyyyMMdd_HHmmss}"),需配合DateTime.Now.ToString()动态生成;对move操作,则必须是绝对路径,且需调用Path.IsPathRooted(target)校验。
2.3 文件操作的原子性保障:临时文件+重命名才是真安全
static async Task ProcessSingleFile(string sourcePath, string action, string target, string baseDir) { var fileName = Path.GetFileName(sourcePath); var dirName = Path.GetDirectoryName(sourcePath); switch (action) { case "rename": var newFileName = string.Format(target, Path.GetFileNameWithoutExtension(fileName)); var newFullPath = Path.Combine(dirName, $"{newFileName}{Path.GetExtension(fileName)}"); // 关键:不直接File.Move,先写临时文件再原子替换 var tempPath = Path.GetTempFileName(); try { File.Move(sourcePath, tempPath); // 先移出原位置 File.Move(tempPath, newFullPath); // 再移入新位置 } catch { if (File.Exists(tempPath)) File.Move(tempPath, sourcePath); // 失败则回滚 throw; } break; case "move": var destPath = Path.IsPathRooted(target) ? Path.Combine(target, fileName) : Path.Combine(baseDir, target, fileName); Directory.CreateDirectory(Path.GetDirectoryName(destPath)); File.Move(sourcePath, destPath); break; } }为什么不用File.Copy+File.Delete?
因为Copy可能因磁盘满失败,留下半截文件;而Move在同卷内是原子操作,但跨卷会退化为Copy+Delete。我们的方案用临时文件兜底,确保源文件不丢失——这是生产环境不可妥协的底线。
3. WinForms版“本地配置管理器”:UI线程与数据持久化的边界控制
当控制台项目开始需要“记住上次设置”或“用户勾选保存偏好”,就该引入WinForms了。但新手常犯的错误是:把所有逻辑塞进Button_Click事件,导致UI线程阻塞、配置文件读写无锁、JSON序列化忽略空值引发兼容性断裂。本节用一个真实场景重构:一个允许用户编辑JSON配置并实时验证格式的窗体。
3.1 分层设计:UI、业务逻辑、数据访问三者物理隔离
├── MainForm.cs // 仅负责控件绑定、事件转发、错误Toast ├── ConfigService.cs // 核心:加载/保存/校验配置,不依赖任何UI类 ├── AppConfig.cs // POCO模型,用[JsonProperty]控制序列化行为 └── JsonFileStorage.cs // 纯文件IO,含编码自动探测与BOM处理关键约束:ConfigService类构造函数不接受任何Form或Control实例,所有UI交互通过回调委托注入:
// ConfigService.cs public class ConfigService { private readonly Func<string> _getFilePath; private readonly Action<string> _showError; private readonly Action _onSaveSuccess; public ConfigService(Func<string> getFilePath, Action<string> showError, Action onSaveSuccess) { _getFilePath = getFilePath; _showError = showError; _onSaveSuccess = onSaveSuccess; } public async Task<bool> SaveAsync(AppConfig config) { try { var json = JsonConvert.SerializeObject(config, Formatting.Indented); await File.WriteAllTextAsync(_getFilePath(), json, Encoding.UTF8); _onSaveSuccess(); return true; } catch (UnauthorizedAccessException) { _showError("配置文件被其他程序占用,请关闭相关软件后重试"); } catch (Exception ex) { _showError($"保存失败:{ex.Message}"); } return false; } }注意:
Encoding.UTF8显式指定,避免Windows记事本默认的UTF-8+BOM导致某些解析器报错;Func<string>延迟获取路径,支持用户点击“浏览”按钮后动态变更。
3.2 防止UI假死:耗时操作必须异步+进度反馈
// MainForm.cs private async void btnSave_Click(object sender, EventArgs e) { // 1. UI层立即禁用按钮,防止重复提交 btnSave.Enabled = false; statusLabel.Text = "正在保存..."; // 2. 调用服务层异步方法 var success = await _configService.SaveAsync(GetCurrentConfig()); // 3. 回到UI线程更新状态(WinForms需Invoke) if (this.InvokeRequired) this.Invoke((MethodInvoker)delegate { UpdateSaveStatus(success); }); else UpdateSaveStatus(success); } private void UpdateSaveStatus(bool success) { btnSave.Enabled = true; statusLabel.Text = success ? "✅ 保存成功!" : "❌ 保存失败,请查看下方错误信息"; }为什么不用await后直接操作控件?
因为SaveAsync内部可能切换线程上下文(如File.WriteAllTextAsync),回到主线程必须用Invoke,否则抛InvalidOperationException。这是WinForms最经典的线程陷阱。
3.3 JSON模型设计:用特性驱动序列化行为
public class AppConfig { [JsonProperty("server_url", Required = Required.Always)] public string ServerUrl { get; set; } = "https://api.example.com"; [JsonProperty("timeout_ms")] [DefaultValue(30000)] public int TimeoutMs { get; set; } = 30000; [JsonProperty("log_level")] public LogLevel LogLevel { get; set; } = LogLevel.Information; [JsonIgnore] public bool IsDirty { get; set; } // 运行时状态,不存入JSON } public enum LogLevel { Debug = 0, Information = 1, Warning = 2, Error = 3 }参数说明:
Required = Required.Always:反序列化时若缺失server_url字段,JsonConvert.DeserializeObject直接抛JsonSerializationException,比运行时判空更早暴露问题;[DefaultValue]:配合JsonSerializerSettings.IgnoreNullValues = true,可让TimeoutMs=30000不写入JSON,减小配置体积;[JsonIgnore]:明确标记运行时状态,避免意外序列化导致循环引用。
4. 常见问题排查:那些让C#小项目在交接时集体翻车的坑
这类项目最大的风险不是功能缺陷,而是隐性耦合与环境假设。当A同学在自己机器上完美运行,B同学拉取代码后编译失败、运行报错、或行为诡异,90%源于以下五个血泪现场:
4.1 现象:项目在VS里能运行,但双击exe直接闪退
原因:未捕获顶层异常,且AppDomain.CurrentDomain.UnhandledException未订阅。控制台项目默认关闭控制台窗口,错误日志瞬间消失。
解决:在Program.Main开头添加全局异常钩子,并将错误写入日志文件:
AppDomain.CurrentDomain.UnhandledException += (sender, e) => { var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "error.log"); File.AppendAllText(logPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {e.ExceptionObject}\r\n"); };4.2 现象:中文路径下文件操作失败,报DirectoryNotFoundException
原因:Directory.GetFiles在.NET Framework下对Unicode路径支持不完善,且部分API(如File.OpenRead)在非UTF-8编码系统(如旧版Windows)会乱码。
解决:强制使用Encoding.UTF8读写所有文本文件,并用Path.GetFullPath标准化路径:
var safePath = Path.GetFullPath(@"C:\用户\文档\test.txt"); // 自动处理相对路径和编码 var content = File.ReadAllText(safePath, Encoding.UTF8); // 显式指定编码4.3 现象:WinForms窗体在高DPI显示器上文字模糊、布局错乱
原因:.NET Core/.NET 5+默认启用DPI感知,但WinForms需额外声明<Application xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">并设置EnableVisualStyles()。
解决:在Program.cs中Application.SetHighDpiMode(HighDpiMode.SystemAware),并在MainForm.Designer.cs中确保AutoScaleMode = AutoScaleMode.Dpi。
4.4 现象:JSON配置文件在另一台机器上解析失败,报Unexpected character
原因:文件以UTF-8+BOM(字节序标记)保存,而File.ReadAllText(path)在.NET Core中默认忽略BOM,但某些旧版解析器(如Python的json模块)会将其视为非法字符。
解决:统一用Encoding.UTF8读取,并在保存时禁用BOM:
var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); File.WriteAllText(path, json, utf8NoBom);4.5 现象:多线程操作List 时偶发IndexOutOfRangeException
原因:List<T>非线程安全,Add和Count同时调用可能引发竞态。新手常误以为lock住单个方法就安全,实则foreach遍历本身也需同步。
解决:改用ConcurrentBag<T>或ConcurrentQueue<T>,或对整个集合操作加锁:
private readonly object _listLock = new object(); private readonly List<string> _logEntries = new List<string>(); public void AddLog(string msg) { lock (_listLock) _logEntries.Add(msg); } public string[] GetAllLogs() { lock (_listLock) return _logEntries.ToArray(); // 必须锁住读取,避免遍历时被修改 }5. 进阶技巧:用Source Generator自动生成配置类,消灭手写POCO
当配置项超过10个字段,手写AppConfig.cs并维护[JsonProperty]特性就成了重复劳动。此时应引入.NET 6+的Source Generator——它在编译期生成代码,零运行时开销,且IDE能直接跳转到生成的类。
5.1 创建Generator项目:定义JSON Schema到C#类的映射规则
新建ConfigGenerator.csproj,引用Microsoft.CodeAnalysis.CSharp和Microsoft.CodeAnalysis.Analyzers。核心逻辑是扫描项目中所有.schema.json文件,生成对应C#类:
[Generator] public class ConfigSchemaGenerator : ISourceGenerator { public void Execute(GeneratorExecutionContext context) { // 查找所有以.schema.json结尾的嵌入资源 var schemaFiles = context.Compilation.EmbeddedResources .Where(r => r.FilePath.EndsWith(".schema.json")); foreach (var resource in schemaFiles) { var schemaContent = GetResourceContent(context, resource); var className = Path.GetFileNameWithoutExtension(resource.FilePath); var generatedCode = GenerateClassFromSchema(schemaContent, className); context.AddSource($"{className}.g.cs", SourceText.From(generatedCode, Encoding.UTF8)); } } private string GenerateClassFromSchema(string jsonSchema, string className) { // 简化版:解析JSON Schema的properties,生成public属性 // 实际项目中可用Newtonsoft.Json.Schema或NJsonSchema库 var props = ParseProperties(jsonSchema); var sb = new StringBuilder(); sb.AppendLine("using Newtonsoft.Json;"); sb.AppendLine("namespace GeneratedConfig {"); sb.AppendLine($"public partial class {className} {{"); foreach (var prop in props) { sb.AppendLine($" [JsonProperty(\"{prop.Key}\")]"); sb.AppendLine($" public {GetCSharpType(prop.Value)} {ToPascalCase(prop.Key)} {{ get; set; }}"); } sb.AppendLine("}}"); return sb.ToString(); } }5.2 在主项目中消费:零配置接入
在你的WinForms项目csproj中添加:
<ItemGroup> <ProjectReference Include="..\ConfigGenerator\ConfigGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> </ItemGroup> <ItemGroup> <EmbeddedResource Include="appconfig.schema.json" /> </ItemGroup>编写appconfig.schema.json:
{ "type": "object", "properties": { "server_url": { "type": "string" }, "timeout_ms": { "type": "integer", "default": 30000 }, "enable_logging": { "type": "boolean", "default": true } } }编译后,GeneratedConfig.AppConfig类自动生成,且AppDomain.CurrentDomain.GetAssemblies()能反射到它——这意味着你可以用JsonConvert.DeserializeObject<AppConfig>(json),完全无需手写类。
5.3 为什么不用第三方JSON to C#工具?
因为那些工具生成的代码是物理文件,每次修改schema都要手动重新生成、处理Git冲突、且无法参与编译期校验。Source Generator生成的代码存在于内存中,编译器视其为项目一部分,支持智能提示、重构、且修改schema后重新编译即生效——这才是现代C#工程化的正确姿势。
我坚持在所有新项目中用Source Generator替代手写配置类,哪怕只有3个字段。因为节省的5分钟手写时间不值钱,但避免未来3次因字段名拼错导致的线上故障,值回票价。希望帮到你。
本文还有配套的精品资源,点击获取