上位机开发需要的 C#/.NET 核心基础
这一页补的是上位机和 Avalonia 开发会反复遇到的底层基础:运行时、项目结构、内存、空值、集合、分层、校验、调试、Git、进程、线程、任务、异步、反射、特性、序列化、文件、网络、数据库、配置、依赖注入、日志、测试和安全。
这些内容不是为了背概念,而是为了以后看到 Avalonia、MVVM、设备通信、后台采集、依赖注入、序列化和单元测试时,知道底层为什么能工作。Web 中也会使用这些基础,但 Web 不是当前学习主线。
先看整体地图
C# 源码
-> 编译成 dll/exe
-> .NET 运行时加载程序集
-> 操作系统启动进程
-> 进程里有线程
-> 线程执行代码
-> Task 管理异步或并发工作
-> async/await 把等待拆成可恢复的状态机
-> Attribute 写入元数据
-> Reflection 在运行时读取类型、方法、属性和特性
-> 框架根据这些信息自动绑定、路由、序列化、测试或创建对象如果这张图能看懂,后面写 Avalonia 界面、后台采集任务和设备通信代码时,就不会只停留在照抄 API。
还需要知道的基础主题
这些主题不要求一次学很深,但必须知道它们是什么、以后在哪里出现。
| 主题 | 先这样理解 | 上位机里会遇到 | 以后 Web 里也会遇到 |
|---|---|---|---|
| .NET Runtime / CLR | C# 程序运行的环境 | Avalonia 程序和采集服务启动 | ASP.NET Core 服务启动 |
| SDK / Runtime | SDK 用来开发和编译,Runtime 用来运行 | 工控机运行要有 Runtime 或自包含发布 | 服务器部署也要 Runtime |
| 程序集 Assembly | 编译后的 dll/exe | 加载 View、ViewModel、通信模块 | 加载 Controller、Service |
| NuGet 包 | 第三方依赖包 | Avalonia、图表、串口、Modbus、日志库 | 数据库、JWT、Swagger |
| 命名空间 Namespace | 给类型分组,避免重名 | MyApp.Devices、MyApp.ViewModels | MyApp.Services |
| 访问修饰符 | 控制谁能访问 | ViewModel、Service、通信类的边界 | Controller 和 Service 的边界 |
| 栈和堆 | 值和对象存放位置的基础模型 | 窗口、设备对象、采集数据 | 请求对象、DTO、Service |
| GC 垃圾回收 | 自动回收不用的对象 | 大列表、曲线数据、窗口和订阅释放 | 长请求、缓存、内存泄漏 |
| Nullable 空值体系 | 区分可能为空和必须有值 | 表单、选中设备、通信结果 | 请求参数、数据库字段 |
| 泛型 | 一套代码处理多种类型 | IRepository<T> | ObservableCollection<T> |
| 集合选择 | 不同集合适合不同查询方式 | 缓存、去重、权限集合 | 列表、搜索、选中集合 |
| LINQ | 对集合查询、过滤、排序 | 查询 DTO、内存筛选 | 列表搜索、过滤、排序 |
| 序列化 | 对象和 JSON/文本互转 | API 请求和响应 | 本地配置、缓存文件 |
| HTTP | 客户端和服务端通信协议 | Controller、接口、状态码 | 桌面调用后端 API |
| DTO / Entity / ViewModel | 不同层使用不同数据形状 | 请求响应和数据库实体分开 | 界面显示模型和业务模型分开 |
| 分层架构 | 把界面、业务、数据访问拆开 | Controller / Service / Repository | View / ViewModel / Service |
| 数据校验 | 把错误挡在业务执行前 | 参数校验、模型校验 | 表单校验、命令可执行状态 |
| 数据库 / SQL | 持久保存业务数据 | 用户、订单、权限 | 本地 SQLite、缓存数据 |
| 配置 | 把可变参数放到代码外 | 连接字符串、环境变量 | 本地路径、主题、偏好 |
| 依赖注入 DI | 对象不自己 new 依赖,由容器提供 | Controller 注入 Service | ViewModel 注入 Service |
| 日志 Logging | 记录程序运行过程 | 排查线上接口问题 | 排查用户电脑上的错误 |
| 单元测试 | 用代码验证代码 | 测 Service、校验规则 | 测 ViewModel、业务逻辑 |
| 安全基础 | 输入不可信,权限要校验 | 登录、权限、参数校验 | 本地文件权限、用户输入 |
| 认证授权 | 先确认是谁,再确认能做什么 | JWT、Cookie、Role、Policy | 本地账号、功能权限 |
| 调试诊断 | 用断点、日志、堆栈定位问题 | 500 错误、慢接口 | 卡顿、崩溃、绑定错误 |
| Git | 记录代码历史和协作 | 分支、PR、回滚 | 版本管理、打包前追踪 |
| 设计模式 | 常见问题的稳定写法 | Repository、Middleware、Options | MVVM、Command、Observer |
| 发布部署 | 把程序交给别人运行 | 部署到服务器 | 打包给用户安装 |
如果只记一句话:上位机开发不是只会拖控件,它同样离不开类型、对象、异步、IO、网络、配置、日志、测试和发布。
全站名词词典
这一节把后面反复出现的词集中解释。遇到陌生词时,先回到这里查。
| 名词 | 直接解释 | 在项目里怎么看 |
|---|---|---|
| SDK | 开发工具包,包含编译、模板、命令 | 能执行 dotnet new、dotnet build |
| Runtime | 运行环境,只负责运行程序 | 用户电脑运行 .NET 程序需要它 |
| 项目 | 一组能一起编译运行的文件 | 一个 .csproj 对应一个项目 |
| 解决方案 | 管理多个项目的容器 | .sln 里放 App、Core、Tests |
| NuGet | .NET 包管理系统 | 安装 Avalonia、xUnit、日志库 |
| 命名空间 | 给类型分组,避免重名 | ProductApp.ViewModels |
| 类 | 描述一种对象的结构和行为 | Product、Order |
| 对象 | 类创建出来的一份具体数据 | 一个具体商品 |
| 属性 | 对象暴露的数据 | Name、Price、Stock |
| 方法 | 有名字的一段逻辑 | CalculateTotal() |
| 接口 | 规定一组能力 | IProductRepository |
| 实现 | 接口的具体做法 | JsonProductRepository |
| 泛型 | 一套代码适配多种类型 | List<T>、Task<T> |
| 异常 | 程序无法按正常流程继续 | 文件不存在、参数非法 |
| Task | 表示一个异步任务 | Task<List<Product>> |
| async/await | 写异步代码的关键字 | 等文件、网络、数据库 |
| DTO | 层与层之间传输数据的对象 | API 请求和响应模型 |
| Entity | 更接近数据库的数据对象 | 数据库里的商品记录 |
| ViewModel | 给界面使用的数据和命令 | MainWindowViewModel |
| Binding | 把界面属性和 ViewModel 属性连接 | Text="{Binding ProductName}" |
| Command | 按钮触发 ViewModel 方法 | SaveCommand |
| Service | 放业务规则的类 | 校验商品、计算金额 |
| Repository | 放数据读写的类 | 读写 JSON、SQLite、数据库 |
| DI | 依赖注入,对象依赖由外部提供 | Service 注入 Repository |
| 配置 | 放在代码外的可变参数 | 文件路径、开关、连接字符串 |
| 日志 | 记录程序运行过程 | 保存失败、导入失败 |
| 单元测试 | 用代码验证代码 | xUnit 测服务层规则 |
| 发布 | 生成给别人运行的程序目录 | dotnet publish |
| 验收 | 按清单确认版本是否合格 | 操作、期望、实际、结论 |
完整项目演进图
后面的项目不是突然出现的。它是一步一步长出来的:
控制台商品计算器
-> 控制台商品列表
-> CSV 清洗器
-> 订单域模型
-> 异步文件批处理器
-> 工程化商品服务
-> Avalonia 商品表单
-> MVVM 商品管理页面
-> JSON 持久化商品管理
-> 商品管理 MVP
-> 商品管理增强版
-> 最终交付包
-> 串口与网络调试助手
-> Modbus / OPC UA 设备监控器
-> 多设备采集引擎
-> SQLite 历史数据库
-> 实时趋势与报警中心
-> Avalonia 工业操作台
-> 测试、诊断与现场交付每一步都在增加一种能力:
| 项目阶段 | 新增能力 |
|---|---|
| 控制台商品计算器 | 输入、转换、校验、计算 |
| 控制台商品列表 | 多条数据、循环、统计 |
| CSV 清洗器 | 外部文本、错误行、数据清理 |
| 订单域模型 | 类、封装、状态规则 |
| 异步文件批处理器 | Task、并发、取消、异常汇总 |
| 工程化商品服务 | 分层、接口、DI、测试 |
| Avalonia 商品表单 | 窗口、控件、事件 |
| MVVM 商品管理页面 | Binding、Command、ViewModel |
| JSON 持久化商品管理 | 文件保存、读取、错误提示 |
| 商品管理 MVP | 需求到完整功能闭环 |
| 商品管理增强版 | 权限、审计、导入导出、后台任务 |
| 最终交付包 | 测试、文档、版本、发布、验收 |
| 串口与网络调试助手 | 字节、报文、异步收发、拆包、重连 |
| Modbus / OPC UA 监控器 | 工业协议、节点、寄存器、订阅、质量码 |
| 多设备采集引擎 | 驱动、调度、队列、缓存和 UI 节流 |
| SQLite 历史数据库 | 批量写入、时间查询、迁移、保留和备份 |
| 实时趋势与报警中心 | 降采样、报警延时、死区、确认和恢复 |
| Avalonia 工业操作台 | 生命周期、UI 线程、工业控件、联锁和回读 |
| 现场交付 | 模拟测试、诊断包、权限、升级、回滚和联调 |
IDE 操作专题
不管用 VS Code、Rider 还是 Visual Studio,都要掌握这些操作。工具不同,动作相同。
新建和运行
| 目标 | 命令行做法 | IDE 里对应动作 |
|---|---|---|
| 新建控制台项目 | dotnet new console -n Demo | 新建 Console 项目 |
| 新建 Avalonia 项目 | dotnet new avalonia.mvvm -o HmiApp | 选择 Avalonia MVVM 模板 |
| 还原依赖 | dotnet restore | Restore / Reload Project |
| 构建项目 | dotnet build | Build |
| 运行项目 | dotnet run | Run |
| 运行测试 | dotnet test | Test Explorer / Unit Tests |
看报错
IDE 里看报错时,不要只看红线。按这个顺序:
- 看 Problems / Error List 的第一条错误。
- 双击跳到文件和行号。
- 先修第一条,再重新构建。
- 如果一堆错误同时出现,优先找最上面的语法错误。
打断点
断点适合放在这些位置:
| 位置 | 看什么 |
|---|---|
| 读取输入后 | 输入值到底是什么 |
TryParse 后 | 转换是否成功 |
| 进入 Service 前 | ViewModel 传入的数据是否正确 |
| Repository 保存前 | 保存路径和 JSON 内容 |
catch 里面 | 异常类型和错误消息 |
看变量
调试时重点看:
| 变量 | 为什么看 |
|---|---|
| 输入文本 | 判断输入是否真的进入程序 |
| 转换后的数字 | 判断 TryParse 是否成功 |
| 集合数量 | 判断列表有没有数据 |
| 选中项 | 判断编辑或删除对象是否为空 |
| 文件路径 | 判断保存和读取是不是同一个位置 |
格式化代码
格式化不是为了好看,是为了看出结构。
如果缩进乱了,很难看清:
- 哪个
if包住哪几行。 - 哪个方法结束了。
- 哪个 XAML 标签没有闭合。
- 哪个
try/catch/finally是一组。
每次写完一段代码,都应该格式化一次。
基础常用 API 和方法速查
这一节专门解决“类型知道了,但不知道能用哪些方法”的问题。
后面做 Avalonia、Web API、数据库、测试时,经常不是不会写大框架,而是卡在这些基础操作上。
文本 string
| 需求 | 方法/属性 | 例子 |
|---|---|---|
| 判断空白 | string.IsNullOrWhiteSpace(text) | 表单输入是否为空 |
| 去掉前后空格 | text.Trim() | 商品名保存前清理 |
| 转大写 | text.ToUpper() | 统一编号格式 |
| 转小写 | text.ToLower() | 搜索时忽略大小写 |
| 是否包含 | text.Contains("key") | 商品搜索 |
| 开头判断 | text.StartsWith("A") | 编号前缀 |
| 结尾判断 | text.EndsWith(".json") | 文件类型判断 |
| 替换 | text.Replace(" ", "-") | 生成简单 slug |
| 分割 | text.Split(',') | 解析 CSV 一行 |
| 拼接 | string.Join(", ", names) | 把列表合成文本 |
| 长度 | text.Length | 限制输入长度 |
string raw = " Keyboard Pro ";
string clean = raw.Trim();
Console.WriteLine(clean.ToUpper());
Console.WriteLine(clean.ToLower().Contains("key"));
Console.WriteLine(clean.Replace(" ", "-"));
Console.WriteLine(clean.Length);常见场景:
- 输入框保存前先
Trim()。 - 搜索时把输入和目标都转成小写再比较。
- 保存前检查
Length,避免超长文本。
数字 int、decimal、double
| 需求 | 写法 | 例子 |
|---|---|---|
| 整数计算 | count + 1、count * 2 | 库存、数量 |
| 取余数 | number % 2 | 判断奇偶、分页余数 |
| 金额计算 | price * count | 单价乘数量 |
| 保留两位显示 | price.ToString("F2") | 金额输出 |
| 四舍五入 | Math.Round(value, 2) | 金额或比例 |
| 最大值 | Math.Max(a, b) | 最低库存保护 |
| 最小值 | Math.Min(a, b) | 限制最大数量 |
| 绝对值 | Math.Abs(value) | 差值计算 |
| 转整数 | int.TryParse(text, out int value) | 数量输入 |
| 转金额 | decimal.TryParse(text, out decimal value) | 价格输入 |
decimal price = 199.9m;
int count = 2;
decimal total = price * count;
Console.WriteLine(total.ToString("F2"));
Console.WriteLine(Math.Round(total * 0.8m, 2));
Console.WriteLine(Math.Max(count, 1));注意:
- 金额优先用
decimal。 19.9m里的m不能省。- 用户输入永远先当
string,再用TryParse转换。
真假值 bool
| 需求 | 写法 | 例子 |
|---|---|---|
| 取反 | !isBusy | 非保存中才允许点击 |
| 并且 | nameOk && priceOk | 多个条件都通过 |
| 或者 | isAdmin || isOwner | 任意权限满足 |
| 相等 | role == "Admin" | 判断角色 |
| 不相等 | status != "Deleted" | 排除删除状态 |
bool nameOk = true;
bool priceOk = true;
bool isSaving = false;
bool canSave = nameOk && priceOk && !isSaving;
Console.WriteLine(canSave);桌面里常见:
IsBusy:是否正在忙
CanSave:能不能保存
IsEmpty:列表是否为空
IsSelected:是否选中了数据Web 里常见:
IsActive:账号是否启用
IsAdmin:是否管理员
HasPermission:是否有权限日期时间 DateTime
| 需求 | 写法 | 例子 |
|---|---|---|
| 当前时间 | DateTime.Now | 创建时间、日志时间 |
| 今天日期 | DateTime.Today | 日期筛选 |
| 加天数 | date.AddDays(7) | 过期时间 |
| 加月份 | date.AddMonths(1) | 会员到期 |
| 年月日 | date.Year、date.Month、date.Day | 报表分组 |
| 格式化 | date.ToString("yyyy-MM-dd") | 显示日期 |
| 转换 | DateTime.TryParse(text, out DateTime date) | 输入日期 |
DateTime createdAt = DateTime.Now;
DateTime expiredAt = createdAt.AddDays(7);
Console.WriteLine(createdAt.ToString("yyyy-MM-dd HH:mm"));
Console.WriteLine(expiredAt.ToString("yyyy-MM-dd"));集合 List<T>、Dictionary<TKey,TValue>、HashSet<T>
| 需求 | 推荐写法 | 例子 |
|---|---|---|
| 保存多条数据 | List<T> | 商品列表 |
| 添加一条 | items.Add(item) | 新增商品 |
| 删除一条 | items.Remove(item) | 删除商品 |
| 清空 | items.Clear() | 重置列表 |
| 数量 | items.Count | 判断是否为空 |
| 是否存在 | items.Any(...) | 是否有重复名称 |
| 查第一条 | items.FirstOrDefault(...) | 按 Id 找商品 |
| 过滤 | items.Where(...) | 搜索和筛选 |
| 排序 | items.OrderBy(...) | 按价格排序 |
| 转列表 | .ToList() | 固定查询结果 |
| 按 key 查 | Dictionary<TKey,TValue> | 按商品 Id 查 |
| 去重 | HashSet<T> | 权限集合、标签集合 |
var products = new List<Product>
{
new Product(1, "Keyboard", 199m),
new Product(2, "Mouse", 99m),
new Product(3, "Monitor", 899m)
};
List<Product> expensiveProducts = products
.Where(product => product.Price >= 100)
.OrderBy(product => product.Price)
.ToList();
Product? keyboard = products.FirstOrDefault(product => product.Name == "Keyboard");
Console.WriteLine(expensiveProducts.Count);
Console.WriteLine(keyboard?.Name);
public sealed record Product(int Id, string Name, decimal Price);判断集合怎么选:
- 要顺序展示,用
List<T>。 - 要界面列表自动刷新,用
ObservableCollection<T>。 - 要按编号快速查,用
Dictionary<TKey,TValue>。 - 要去重或快速判断存在,用
HashSet<T>。
文件和路径
| 需求 | 写法 | 例子 |
|---|---|---|
| 拼路径 | Path.Combine(...) | 跨平台路径 |
| 判断文件存在 | File.Exists(path) | 读取前检查 |
| 读全部文本 | File.ReadAllText(path) | 读小文件 |
| 写全部文本 | File.WriteAllText(path, text) | 保存配置 |
| 读所有行 | File.ReadAllLines(path) | 读 CSV |
| 写所有行 | File.WriteAllLines(path, lines) | 导出 CSV |
| 创建目录 | Directory.CreateDirectory(path) | 保存前保证目录存在 |
| 判断目录存在 | Directory.Exists(path) | 检查输出目录 |
string folder = Path.Combine(Environment.CurrentDirectory, "data");
Directory.CreateDirectory(folder);
string path = Path.Combine(folder, "products.txt");
File.WriteAllText(path, "Keyboard");
string text = File.ReadAllText(path);
Console.WriteLine(text);注意:
- 不要手写
/或\拼路径,优先用Path.Combine。 - 文件读写可能失败,要准备
try/catch。 - 大文件不要一次性全部读入内存,后面再学 Stream。
JSON
| 需求 | 写法 | 例子 |
|---|---|---|
| 对象转 JSON | JsonSerializer.Serialize(obj) | 保存配置 |
| JSON 转对象 | JsonSerializer.Deserialize<T>(json) | 加载数据 |
| 格式化输出 | new JsonSerializerOptions { WriteIndented = true } | 文件更易读 |
| 属性改名 | [JsonPropertyName("name")] | 对接接口字段 |
using System.Text.Json;
var product = new Product("Keyboard", 199m);
string json = JsonSerializer.Serialize(product, new JsonSerializerOptions
{
WriteIndented = true
});
Product? loaded = JsonSerializer.Deserialize<Product>(json);
Console.WriteLine(json);
Console.WriteLine(loaded?.Name);
public sealed record Product(string Name, decimal Price);常见错误:
- JSON 字段类型和 C# 类型不匹配。
- JSON 文本格式坏了。
- 反序列化结果可能是
null,后面要判断。
异步 Task、async、await
| 需求 | 写法 | 例子 |
|---|---|---|
| 等待异步操作 | await SomeAsync() | 保存文件、请求接口 |
| 模拟耗时 | await Task.Delay(500) | 练习保存中状态 |
| 返回结果 | Task<Product> | 异步读取商品 |
| 不返回结果 | Task | 异步保存 |
| 取消操作 | CancellationToken | 取消导入 |
Console.WriteLine("开始保存");
await SaveAsync();
Console.WriteLine("保存结束");
static async Task SaveAsync()
{
await Task.Delay(500);
Console.WriteLine("保存完成");
}桌面端里,耗时操作不要直接卡住界面线程。Web 里,数据库和 HTTP 请求也经常用异步。
HTTP
| 需求 | 写法 | 例子 |
|---|---|---|
| GET 字符串 | GetStringAsync(url) | 读取接口数据 |
| GET 响应 | GetAsync(url) | 判断状态码 |
| POST JSON | PostAsJsonAsync(url, obj) | 新增数据 |
| 读响应文本 | ReadAsStringAsync() | 查看返回内容 |
using System.Net.Http;
using var client = new HttpClient();
string json = await client.GetStringAsync("https://example.com/api/products");
Console.WriteLine(json);实际项目要处理:
- 网络失败。
- 状态码不是
200。 - 返回 JSON 格式不符合预期。
- 请求超时。
常见报错字典
报错不是敌人。报错是在告诉你:代码、运行环境、数据或绑定关系里,有一处不符合规则。
处理报错时先按这个顺序:
- 先看第一条错误。
- 找文件名和行号。
- 判断是编译错误、运行时异常、命令错误,还是 XAML 绑定错误。
- 只改最可能的一处。
- 重新运行,看错误是否变化。
C# 编译错误
| 报错关键词 | 直接意思 | 常见原因 | 修法 |
|---|---|---|---|
; expected | 这里应该有分号 | 一行语句结尾忘了 ; | 在语句末尾补 ; |
} expected | 这里应该有右大括号 | { 和 } 数量不匹配 | 从当前方法开始检查括号成对 |
The name 'xxx' does not exist in the current context | 当前范围找不到这个名字 | 变量名拼错、变量定义在别的作用域 | 对照变量声明,确认名字和作用域 |
Cannot implicitly convert type | 不能自动转换类型 | 把 string 当成 int 或 decimal | 用 TryParse 或改变量类型 |
Use of unassigned local variable | 变量可能还没有赋值 | 只声明了变量,没有保证每条路径都赋值 | 声明时给默认值,或补完整分支 |
A local variable named ... is already defined | 同一作用域重复定义变量 | 同一个代码块里写了两次 int count | 第二次只赋值,不再写类型 |
No overload for method ... takes ... arguments | 方法参数数量或类型不匹配 | 调用方法时多传、少传、传错类型 | 回到方法定义,按参数列表传值 |
错误例子:
int count = "3";问题是左边是 int,右边是 string。
正确写法:
string text = "3";
if (int.TryParse(text, out int count))
{
Console.WriteLine(count);
}运行时异常
| 报错关键词 | 直接意思 | 常见原因 | 修法 |
|---|---|---|---|
NullReferenceException | 访问了空对象 | 变量是 null,却调用属性或方法 | 使用前判断 is null 或给默认值 |
IndexOutOfRangeException | 下标超出范围 | 数组下标小于 0 或大于最后下标 | 访问前检查长度 |
ArgumentOutOfRangeException | 参数超出允许范围 | RemoveAt、Substring 等传了非法位置 | 先判断范围 |
FormatException | 格式不对 | 用 Parse 转换非法文本 | 改用 TryParse |
FileNotFoundException | 文件不存在 | 路径错、文件没创建、工作目录不对 | 输出路径并检查文件 |
UnauthorizedAccessException | 没有权限 | 写入系统目录、文件被限制 | 改到应用数据目录或检查权限 |
InvalidOperationException | 当前状态不允许操作 | 集合为空却取值、重复启动任务 | 先检查状态再执行 |
错误例子:
List<string> names = new();
Console.WriteLine(names[0]);问题是列表为空,不能读取第一个元素。
正确写法:
List<string> names = new();
if (names.Count > 0)
{
Console.WriteLine(names[0]);
}
else
{
Console.WriteLine("列表没有数据。");
}dotnet 命令错误
| 报错关键词 | 直接意思 | 常见原因 | 修法 |
|---|---|---|---|
Couldn't find a project to run | 当前目录没有可运行项目 | 没进入 .csproj 所在目录 | 先 pwd、ls,再 cd 到项目目录 |
The template ... could not be found | 找不到模板 | 模板没安装或名字写错 | 检查模板名,必要时安装模板 |
Project file does not exist | 项目文件不存在 | 路径写错、文件名写错 | 用 ls 确认真实文件名 |
Package restore failed | 包还原失败 | 包版本、网络、源配置问题 | 先执行 dotnet restore 看详细错误 |
Build FAILED | 编译失败 | C# 或 XAML 有错误 | 回到第一条编译错误 |
排查目录的固定命令:
pwd
ls
dotnet --version
dotnet buildAvalonia / XAML 常见问题
| 现象或报错 | 直接意思 | 常见原因 | 修法 |
|---|---|---|---|
| XAML 标签报错 | 标记结构不合法 | 标签没闭合、属性引号丢失 | 检查最近修改的标签 |
Unable to resolve type | 找不到控件或类型 | 命名空间、类名、程序集写错 | 检查 xmlns 和类名 |
| 绑定没有效果 | View 找不到属性或命令 | 属性名拼错、DataContext 没设置 | 检查 ViewModel 属性和 DataContext |
| 按钮命令不执行 | 找不到 Command | Save 生成的是 SaveCommand | XAML 绑定 SaveCommand |
| 列表显示类型名 | 没有显示模板 | ListBox 不知道如何显示对象 | 写 DataTemplate 或绑定显示字段 |
| 修改属性界面不刷新 | 没有通知变化 | 属性没有实现变化通知 | 使用 [ObservableProperty] |
绑定问题固定排查:
XAML 里绑定的名字
-> ViewModel 里是否真的有这个属性或命令
-> View 是否拿到了正确 DataContext
-> 属性变化后是否通知界面错代码和对代码对照
这一节专门训练“看见不稳定写法,就知道哪里可能出问题”。
输入数字
错代码:
Console.Write("价格: ");
string? priceText = Console.ReadLine();
decimal price = decimal.Parse(priceText);
Console.WriteLine(price);问题:用户输入 abc 时会直接抛异常。
对代码:
Console.Write("价格: ");
string? priceText = Console.ReadLine();
if (!decimal.TryParse(priceText, out decimal price))
{
Console.WriteLine("价格必须是数字。");
return;
}
Console.WriteLine(price);判断空文本
错代码:
if (name == "")
{
Console.WriteLine("名称不能为空。");
}问题:null 和全空格没有处理。
对代码:
if (string.IsNullOrWhiteSpace(name))
{
Console.WriteLine("名称不能为空。");
return;
}
string cleanName = name.Trim();访问列表
错代码:
Console.WriteLine(products[0].Name);问题:列表为空时会出错。
对代码:
if (products.Count == 0)
{
Console.WriteLine("没有商品。");
return;
}
Console.WriteLine(products[0].Name);LINQ 查询
错代码:
Product product = products.First(p => p.Id == id);问题:找不到商品时会抛异常。
对代码:
Product? product = products.FirstOrDefault(p => p.Id == id);
if (product is null)
{
Console.WriteLine("商品不存在。");
return;
}异步调用
错代码:
Task<string> task = File.ReadAllTextAsync("data.json");
Console.WriteLine(task);问题:打印的是任务对象,不是文件内容。
对代码:
string text = await File.ReadAllTextAsync("data.json");
Console.WriteLine(text);并发限流
错代码:
await semaphore.WaitAsync();
await ProcessAsync();
semaphore.Release();问题:ProcessAsync 出错时不会释放名额。
对代码:
await semaphore.WaitAsync();
try
{
await ProcessAsync();
}
finally
{
semaphore.Release();
}MVVM 绑定
错代码:
<Button Content="保存" Click="OnSaveClick" />问题:逻辑仍然留在 View 里,不利于测试和复用。
对代码:
<Button Content="保存"
Command="{Binding SaveCommand}" />[RelayCommand]
private void Save()
{
Message = "保存成功。";
}保存数据
错代码:
await File.WriteAllTextAsync("products.json", json);问题:当前工作目录可能变化,文件不一定写到预期位置。
对代码:
string folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ProductApp");
Directory.CreateDirectory(folder);
string path = Path.Combine(folder, "products.json");
await File.WriteAllTextAsync(path, json);调试入门专题
调试不是随便改代码。调试是按证据一步一步缩小范围。
第一步:先复现
先写清楚:
我输入了什么?
我点击了什么?
我期望看到什么?
实际看到了什么?
错误出现在第几步?如果不能稳定复现,就先把输入和操作固定下来。
第二步:定位错误类型
| 类型 | 先看哪里 |
|---|---|
| 编译错误 | 终端第一条错误的文件名和行号 |
| 运行异常 | 异常类型、异常消息、调用堆栈 |
| 结果不对 | 关键变量的值 |
| 绑定不生效 | XAML 绑定名、ViewModel 属性名、DataContext |
| 文件保存失败 | 实际文件路径、权限、文件内容 |
第三步:用输出定位变量
控制台项目可以直接打印:
Console.WriteLine($"name = {name}");
Console.WriteLine($"priceText = {priceText}");
Console.WriteLine($"products.Count = {products.Count}");打印不是最终方案,但它能快速确认数据有没有进入你以为的位置。
第四步:用断点看流程
断点适合看这些问题:
- 方法有没有进入。
if条件走了哪个分支。- 变量当前到底是什么值。
- 集合里有几条数据。
- 异常是在哪一行抛出的。
断点优先打在:
读取输入后
转换类型后
进入 Service 前
Repository 保存前
catch 里面第五步:把问题缩小
不要一口气改很多文件。
按这个顺序缩小:
整个项目
-> 某个页面
-> 某个 ViewModel
-> 某个方法
-> 某个 if 条件
-> 某一行变量值第六步:XAML 绑定排查模板
绑定不生效时,按这个清单检查:
| 检查项 | 问题例子 |
|---|---|
| ViewModel 是否创建 | DataContext 没设置 |
| 属性名是否一致 | ProductNam 少了一个 e |
| 命令名是否一致 | Save 生成的是 SaveCommand |
| 属性是否通知变化 | 没有 [ObservableProperty] |
| 集合类型是否正确 | 用 List 而不是 ObservableCollection |
| 是否绑定到正确对象 | 列表项模板里上下文变了 |
第七步:文件保存排查模板
文件保存失败或读不到时,先打印路径:
Console.WriteLine(filePath);再检查:
| 检查项 | 说明 |
|---|---|
| 文件夹是否存在 | 不存在就先 Directory.CreateDirectory |
| 文件是否存在 | 第一次运行不存在是正常情况 |
| JSON 是否为空 | 空文件要按空列表处理 |
| 是否有权限 | 不要写系统目录 |
| 保存和读取路径是否一致 | 两边必须使用同一个路径 |
第八步:提交前自检
每次改完代码,至少做四件事:
dotnet build
dotnet test
dotnet run如果是文档站点,再执行:
npm run build.NET 运行时、SDK、程序集、NuGet
.NET Runtime / CLR
.NET Runtime 是 C# 程序真正运行的环境。CLR 可以先理解成 .NET 的执行引擎,负责加载程序集、执行代码、管理内存、处理异常。
C# 源码
-> dotnet build
-> dll/exe
-> .NET Runtime 加载
-> CLR 执行代码SDK 和 Runtime 的区别
SDK:开发用,包含编译器、模板、命令行工具
Runtime:运行用,只负责运行已经编译好的程序开发机器要装 SDK;用户机器或服务器只运行程序时,通常只需要 Runtime。如果使用自包含发布,可以把 Runtime 一起打进发布包。
程序集 Assembly
程序集就是编译后的 .dll 或 .exe。一个项目通常会编译成一个程序集。
MyApp.csproj
-> dotnet build
-> MyApp.dll反射、依赖注入、测试框架、插件扫描,很多时候都是在扫描程序集里的类型。
NuGet
NuGet 是 .NET 的包管理系统。你安装 Avalonia、JSON、数据库、日志库,通常都是加 NuGet 包。
dotnet add package Newtonsoft.Json包会记录在 .csproj 里,后续 dotnet restore 会还原依赖。
命名空间和访问修饰符
命名空间
命名空间用来给类型分组,避免重名。
namespace MyApp.Services;
public class ProductService
{
}以后你会常见这些分层:
MyApp.Models
MyApp.Services
MyApp.Repositories
MyApp.ViewModels
MyApp.Views访问修饰符
访问修饰符控制谁能访问你的类、方法、属性。
| 修饰符 | 直接理解 |
|---|---|
public | 外部可以访问 |
private | 只有当前类内部能访问 |
protected | 当前类和子类能访问 |
internal | 当前项目程序集内部能访问 |
常见写法:
public class ProductService
{
private readonly List<string> _logs = new();
public void Add(string name)
{
_logs.Add(name);
}
}这里 Add 是公开能力,_logs 是内部细节,不应该让外面随便改。
栈、堆、值类型、引用类型、GC
这部分不用一开始学得很深,但要知道基本方向。
值类型和引用类型
值类型:int、double、decimal、bool、DateTime、struct
引用类型:string、class、object、List<T>、数组值类型通常保存的是值本身;引用类型变量保存的是对象地址。
int a = 10;
int b = a;
b = 20;
Console.WriteLine(a); // 10var p1 = new Product { Name = "A" };
var p2 = p1;
p2.Name = "B";
Console.WriteLine(p1.Name); // B第二段里 p1 和 p2 指向同一个对象,所以改 p2.Name 会影响 p1.Name。
栈和堆
可以先这样理解:
栈:方法调用、局部变量、执行路径相关的数据
堆:new 出来的对象主要放这里你不需要手动管理这两个区域,但理解它能帮助你看懂“对象引用”“共享状态”“内存泄漏”。
GC 垃圾回收
GC 会自动回收不再使用的对象。
void Create()
{
var product = new Product();
}方法结束后,如果没有其他地方引用 product,它将来会被 GC 回收。
注意:GC 回收的是托管对象内存,不等于所有资源都会自动安全释放。文件流、数据库连接、网络连接这类资源通常要 Dispose。
using var file = File.OpenRead("data.txt");using 能保证用完后释放资源。
Nullable 空值体系
空值问题是 C# 项目里最常见的错误之一。很多异常不是算法复杂,而是代码假设“这里一定有值”,实际运行时却拿到了 null。
先分清两种写法:
string name = "Tom";
string? nickname = null;可以这样理解:
string:按设计不应该为空。
string?:这个变量允许没有值。如果一个值可能来自用户输入、数据库、配置、HTTP 返回、文件读取,就要认真判断它是否可能为空。
常见错误:
Product? product = repository.FindById(id);
Console.WriteLine(product.Name);FindById 找不到数据时可能返回 null,此时直接访问 product.Name 会出现空引用异常。
更稳的写法:
Product? product = repository.FindById(id);
if (product == null)
{
throw new InvalidOperationException("Product not found.");
}
Console.WriteLine(product.Name);也可以在方法入口就把无效数据挡住:
public void Rename(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Name is required.");
}
Console.WriteLine(name.Trim());
}空值处理不要只依赖 !:
Console.WriteLine(product!.Name);! 的意思是“我告诉编译器这里不会为空”,它不会真的检查运行时数据。如果判断错了,程序仍然会崩。
判断顺序可以固定成这样:
这个值从哪里来?
来源是否可信?
有没有可能为空?
为空时应该返回错误、给默认值,还是停止流程?
后面的代码是否已经拿到了非空结果?Web 里常见:
请求参数为空
数据库查不到记录
配置项没有填写
第三方 API 返回缺字段桌面里常见:
输入框为空
没有选中列表项
配置文件不存在
打开文件对话框被取消桌面选中项例子:
Product? selected = SelectedProduct;
if (selected == null)
{
return;
}
Delete(selected.Id);这里不是“程序太笨”,而是界面状态本来就允许“什么都没选中”。代码要把这种状态当成正常分支处理。
集合选择和复杂度
C# 里不是所有集合都一样。集合选错,代码能跑,但后面数据一多就慢,或者逻辑变复杂。
先记住这几个:
| 集合 | 适合什么 | 不适合什么 |
|---|---|---|
List<T> | 保持顺序、遍历、按下标访问 | 频繁按 key 查找 |
Dictionary<TKey, TValue> | 按 key 快速查找 | 只需要简单顺序列表 |
HashSet<T> | 去重、判断是否存在 | 需要保存重复数据 |
Queue<T> | 先进先出任务 | 随机访问 |
Stack<T> | 后进先出撤销、回退 | 按顺序展示全部数据 |
ObservableCollection<T> | 桌面绑定列表变化 | 复杂查询和大量批处理 |
例子:如果你经常按商品编号查商品,不要每次都遍历列表。
var products = new List<Product>
{
new Product { Id = 1, Name = "Keyboard" },
new Product { Id = 2, Name = "Mouse" }
};
Product? found = products.FirstOrDefault(p => p.Id == 2);数据少时没问题,数据多时每次都要从头找。
更适合的写法:
var productMap = products.ToDictionary(p => p.Id);
if (productMap.TryGetValue(2, out var product))
{
Console.WriteLine(product.Name);
}去重不要手写很多判断,可以用 HashSet<T>:
var names = new HashSet<string>();
names.Add("Admin");
names.Add("Admin");
Console.WriteLine(names.Count); // 1复杂度先不用背很多符号,但要有直觉:
List 里查一个指定条件:通常要一个个找。
Dictionary 按 key 查:更适合快速定位。
HashSet 判断是否存在:适合去重和权限集合。Web 常见场景:
用 Dictionary 做缓存
用 HashSet 判断用户角色
用 List 返回分页结果桌面常见场景:
用 ObservableCollection 绑定列表
用 List 做临时计算
用 Dictionary 保存控件状态或数据索引集合选择的判断方式:
我要不要顺序?
我要不要重复?
我要不要按 key 快速查?
我要不要通知界面列表变化?
数据量会不会变大?LINQ 和延迟执行
LINQ 是 C# 处理集合的重要基础。
var names = products
.Where(p => p.Price > 100)
.Select(p => p.Name)
.ToList();要知道一个关键点:很多 LINQ 查询是延迟执行的。
var query = products.Where(p => p.Price > 100);
products.Add(new Product { Name = "Mouse", Price = 120 });
var result = query.ToList();query 定义时不一定马上执行,ToList() 时才真正遍历。
直接判断:
想固定结果 -> ToList()
想继续拼查询 -> 保留 IEnumerable<T>
结果不对 -> 检查 Where 条件和 ToList 时机文件、路径、Stream
以后做桌面和 Web 都会处理文件。
string text = File.ReadAllText("data.txt");
File.WriteAllText("out.txt", text);小文件可以这样读写;大文件更适合用 Stream。
using var stream = File.OpenRead("big-file.dat");路径要注意:
相对路径:相对于程序当前工作目录
绝对路径:从磁盘根路径开始
跨平台路径:优先用 Path.Combinestring path = Path.Combine("data", "products.json");JSON 序列化
序列化就是对象和文本互相转换。
var product = new Product { Name = "Keyboard", Price = 299 };
string json = JsonSerializer.Serialize(product);反序列化:
Product? product = JsonSerializer.Deserialize<Product>(json);Web API 的请求响应、本地配置文件、缓存文件都会用到 JSON。
常见问题:
字段名不一致 -> 用 JsonPropertyName
类型不匹配 -> 检查 int/decimal/string
空值异常 -> 用可空类型并做校验HTTP 和 API
HTTP 是客户端和服务器通信的协议。Web 服务靠它,桌面端调用后端接口也靠它。
常见方法:
GET:读取
POST:新增或提交
PUT:整体更新
PATCH:局部更新
DELETE:删除桌面端调用 API:
using var client = new HttpClient();
string json = await client.GetStringAsync("https://example.com/api/products");Web API 返回状态码:
200:成功
400:请求参数错误
401:未登录
403:无权限
404:找不到
500:服务器错误DTO、Entity、ViewModel
项目变复杂后,不建议一个类从数据库一路用到界面或接口返回。不同层关心的数据不一样,混在一个类里会让代码越来越难改。
先记住三类对象:
Entity:数据库里的业务实体,通常对应表。
DTO:接口传输对象,通常用于请求和响应。
ViewModel:界面显示和交互对象,通常用于桌面绑定。例如数据库实体:
public class ProductEntity
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal CostPrice { get; set; }
public decimal SalePrice { get; set; }
public DateTime CreatedAt { get; set; }
}接口返回 DTO 不一定要暴露全部字段:
public class ProductResponse
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal SalePrice { get; set; }
}桌面 ViewModel 可能还要包含界面状态:
public class ProductItemViewModel
{
public int Id { get; set; }
public string DisplayName { get; set; } = "";
public bool IsSelected { get; set; }
}转换通常放在 Service 或专门的 Mapper 里:
public ProductResponse ToResponse(ProductEntity entity)
{
return new ProductResponse
{
Id = entity.Id,
Name = entity.Name,
SalePrice = entity.SalePrice
};
}为什么要分开?
数据库字段不一定能给前端看。
接口字段不一定等于界面字段。
界面状态不应该存进数据库。
数据库结构变化不应该直接影响 API 和界面。Web 里常见:
CreateProductRequest:新增商品时前端传入的数据。
ProductResponse:接口返回给前端的数据。
ProductEntity:数据库保存的数据。桌面里常见:
ProductEntity:本地 SQLite 保存的数据。
ProductItemViewModel:列表上显示和选中的数据。
ProductEditViewModel:编辑窗口里的表单数据。分层架构
分层不是为了显得复杂,而是为了让每段代码只负责一件事。
Web 常见分层:
Controller:接收 HTTP 请求,返回 HTTP 响应。
Service:处理业务规则。
Repository:读写数据库。
DTO:请求和响应数据。
Entity:数据库实体。桌面常见分层:
View:界面。
ViewModel:界面状态和命令。
Service:业务规则。
Repository:文件或数据库读写。
Model / Entity:业务数据。不要把所有逻辑写在按钮点击里:
private void SaveButton_Click(object? sender, EventArgs e)
{
File.WriteAllText("products.txt", NameTextBox.Text);
}更好的方向是让界面只触发动作:
public class ProductViewModel
{
private readonly ProductService _service;
public ProductViewModel(ProductService service)
{
_service = service;
}
public void Save()
{
_service.Save(Name);
}
}业务规则放到 Service:
public class ProductService
{
private readonly ProductRepository _repository;
public ProductService(ProductRepository repository)
{
_repository = repository;
}
public void Save(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Name is required.");
}
_repository.Save(name.Trim());
}
}这样做的好处:
界面换了,业务规则还能复用。
数据库换了,界面不需要大改。
Service 可以写测试。
错误更容易定位在哪一层。遇到功能需求时,可以按这个顺序拆:
界面/API 收到什么输入?
Service 要执行什么规则?
数据要保存到哪里?
返回给用户什么结果?
失败时在哪一层处理?数据校验
校验的目标是把错误数据挡在业务执行前。不要等数据进了数据库,或者界面已经进入下一步,才发现字段不对。
常见校验:
必填
长度
范围
格式
重复
权限
业务状态是否允许操作简单校验可以写在方法入口:
public void ChangePrice(decimal price)
{
if (price <= 0)
{
throw new ArgumentException("Price must be greater than zero.");
}
Price = price;
}Web DTO 可以用特性表达基础规则:
public class CreateProductRequest
{
[Required]
[StringLength(50)]
public string Name { get; set; } = "";
[Range(0.01, 999999)]
public decimal Price { get; set; }
}桌面表单也要做校验:
public bool CanSave()
{
return !string.IsNullOrWhiteSpace(Name) && Price > 0;
}校验分两层看:
输入校验:字段有没有填、格式对不对。
业务校验:当前状态能不能做这个动作。例子:订单已经支付后不能随便删除,这不是字段格式问题,而是业务状态问题。
if (order.Status == OrderStatus.Paid)
{
throw new InvalidOperationException("Paid order cannot be deleted.");
}数据库、SQL、ORM
数据库用来长期保存数据。
SQL 是查询数据库的语言:
SELECT * FROM Products WHERE Price > 100;ORM 是把对象和数据库表关联起来的工具,例如 Entity Framework Core。
Product 类
-> Products 表
-> Product.Name 对应 Name 列桌面应用可能用 SQLite 保存本地数据;Web 应用通常连接 PostgreSQL、SQL Server、MySQL 等数据库。
最低要知道:
内存集合:程序关了就没了
文件保存:适合简单本地数据
数据库:适合可查询、可维护、多人或长期数据配置、环境变量、密钥
配置就是把会变化的参数放到代码外面。
{
"ApiBaseUrl": "https://api.example.com",
"LogLevel": "Information"
}Web 常见配置:
数据库连接字符串
JWT 密钥
第三方 API 地址
日志级别
运行环境 Development / Production桌面常见配置:
窗口大小
主题
本地数据路径
用户偏好设置密钥不要写死在源码里。真实项目里用环境变量、用户密钥或部署平台的密钥管理。
依赖注入 DI
依赖注入解决的是“对象怎么拿到它依赖的东西”。
不推荐到处 new:
public class ProductViewModel
{
private readonly ProductService _service = new ProductService();
}更好的方向:
public class ProductViewModel
{
private readonly ProductService _service;
public ProductViewModel(ProductService service)
{
_service = service;
}
}这样测试、替换实现、分层都会更容易。
Web 里 Controller 注入 Service;桌面里 ViewModel 注入 Service,本质一样。
日志、异常、测试
日志
日志是给开发者和维护者看的运行记录。
logger.LogInformation("Product saved: {Name}", product.Name);
logger.LogError(ex, "Save product failed");不要只写“出错了”,要写清楚哪个动作、哪个对象、哪个异常。
异常
异常表示正常流程无法继续。
if (string.IsNullOrWhiteSpace(product.Name))
{
throw new ArgumentException("Product name is required");
}用户输入错误优先用校验;文件、网络、数据库失败通常需要 try/catch 和日志。
测试
测试用代码验证代码。
[Fact]
public void Add_ShouldRejectEmptyName()
{
Assert.Throws<ArgumentException>(() => service.Add(""));
}Web 主要测 Service、Controller、权限;桌面主要测 ViewModel、业务规则、数据转换。
安全基础
安全不只是 Web 才需要。桌面也要处理用户输入、文件路径、权限和敏感数据。
最低要记住:
用户输入不可信
文件路径不可信
接口参数不可信
权限必须在服务层校验
密钥不能写进源码
错误信息不要泄露敏感数据例子:
if (!currentUser.CanDeleteProduct)
{
throw new UnauthorizedAccessException("No permission");
}不要只在按钮上隐藏删除入口,服务层也要检查权限。否则换个调用入口仍然可能绕过限制。
认证和授权
认证和授权经常被混在一起,但它们不是一件事。
认证 Authentication:确认你是谁。
授权 Authorization:确认你能做什么。例子:
登录成功,系统知道你是 Alice:认证成功。
Alice 能不能删除商品:授权判断。Web 里常见方式:
Cookie:浏览器网站常用。
JWT:前后端分离或移动端接口常用。
Role:按角色判断,例如 Admin、User。
Policy:按更细规则判断,例如 CanDeleteProduct。桌面里也会遇到:
本地账号登录
根据角色显示不同菜单
某些按钮只有管理员能点
访问本地加密配置或敏感文件授权不要只写在界面层:
public void DeleteProduct(User user, int productId)
{
if (!user.HasPermission("Product.Delete"))
{
throw new UnauthorizedAccessException("No permission.");
}
repository.Delete(productId);
}界面可以隐藏按钮,但服务层仍然要校验。因为界面只是入口之一,测试代码、脚本、接口调用都可能绕过界面。
常见错误判断:
401:没有登录,系统不知道你是谁。
403:已经登录,但没有权限做这件事。设计权限时按这个顺序想:
这个动作是谁发起的?
系统怎么确认这个人身份?
这个动作需要什么权限?
权限判断写在哪一层?
失败时返回什么错误?发布和部署
发布就是把程序交给别人运行。
桌面常见问题:
目标系统不一致
缺少资源文件
数据目录不存在
Runtime 没装
日志路径没权限Web 常见问题:
环境变量没配
数据库连接失败
端口没开放
反向代理配置错误
HTTPS 证书问题最小验收:
在一个干净目录或干净环境里运行一次
确认配置、日志、数据文件、接口或窗口都能正常工作调试和诊断
会写代码还不够,还要会找到代码为什么不对。调试的核心不是乱试,而是缩小范围。
常用工具:
断点:看代码有没有执行到这里。
单步执行:看每一行执行后的变量变化。
调用堆栈:看当前代码是谁调用过来的。
日志:记录线上或用户电脑上的运行过程。
异常信息:看错误类型、错误消息和堆栈位置。排错时先问 5 个问题:
错误能不能稳定复现?
输入数据是什么?
期望结果是什么?
实际结果是什么?
第一处和预期不同的变量在哪里?空引用错误可以这样查:
Console.WriteLine(product == null);
Console.WriteLine(product?.Name);更常用的是断点查看:
在出错行前一行打断点
运行到断点
检查每个变量是否为空
继续单步执行
找到第一个变错的位置Web 里排查 500 错误:
看服务端日志
看异常堆栈
看请求参数
看数据库连接
看配置是否缺失桌面里排查卡顿:
看是否在 UI 线程做了耗时工作
看是否没有 await 异步方法
看是否一次性加载过多数据
看日志里有没有重复异常日志不要只写“失败”,要写动作和上下文:
logger.LogError(ex, "Delete product failed. ProductId={ProductId}", productId);这样以后看到日志时,知道是哪个动作、哪个对象、发生了什么异常。
Git 基础
Git 是代码历史管理工具。它不是只给团队用,单人项目也需要 Git,因为它能让你安全地回到之前的版本。
最小工作流:
git status
git add .
git commit -m "Add product validation"常见概念:
工作区:正在修改但还没提交的文件。
暂存区:准备进入下一次提交的文件。
提交 commit:一次有说明的代码快照。
分支 branch:一条独立开发线。
合并 merge:把一条分支的修改合进另一条。提交不要太大。一个提交最好只做一类事情:
好:Add product validation
好:Fix product list empty state
差:Update code
差:Fix many things查看当前改了什么:
git status
git diff为什么基础课也要会 Git?
改错了可以找回。
报错时能知道最近改了哪里。
做实验可以开分支。
项目交付时能留下清晰历史。排错时 Git 很有用:
昨天能运行,今天不能运行。
先看 git diff。
再看最近提交。
把问题缩小到某几个文件。常见设计模式速览
设计模式不是必须背定义,而是看到代码结构时知道它为什么这么写。
Repository
Repository 把数据访问封装起来,Service 不直接关心数据来自文件、SQLite 还是服务器。
public interface IProductRepository
{
Product? FindById(int id);
void Save(Product product);
}Service 只依赖接口:
public class ProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
}好处是以后换数据库或写测试时,不需要大改业务逻辑。
Options
Options 模式把配置绑定成对象,避免代码里到处读取字符串 key。
public class AppOptions
{
public string ApiBaseUrl { get; set; } = "";
public int TimeoutSeconds { get; set; }
}这样写比到处写 "ApiBaseUrl" 更容易维护。
Observer
Observer 是“一个对象变化,其他对象收到通知”。桌面绑定里经常会遇到这个思想。
ViewModel 属性变化
-> 发出通知
-> View 更新界面Avalonia、WPF、很多 MVVM 框架都离不开这个思想。
Command
Command 把“用户点击按钮后执行什么”封装成对象或属性。桌面 MVVM 里经常使用。
按钮点击
-> 执行 SaveCommand
-> SaveCommand 调用 ViewModel 的保存逻辑这样界面不需要直接写大量事件处理代码。
Factory
Factory 负责创建对象,适合创建过程复杂、需要根据条件选择不同实现的场景。
public IExporter Create(string type)
{
return type switch
{
"csv" => new CsvExporter(),
"json" => new JsonExporter(),
_ => throw new NotSupportedException(type)
};
}判断是否需要模式,不要看名字高级不高级,而是看问题:
对象创建太复杂 -> Factory
数据访问想隔离 -> Repository
配置项越来越多 -> Options
界面要响应属性变化 -> Observer
按钮动作想从界面分离 -> Command进程、线程、Task、协程式异步
1. 进程是什么
进程是操作系统启动的一个程序实例。你运行一个 Avalonia 应用,通常就是启动一个进程。
进程有自己的内存空间、资源句柄、加载的 dll。一个进程崩溃,通常不会直接把另一个进程的内存弄坏。
MyApp.exe
-> 一个进程
-> 加载 .NET Runtime
-> 加载你的 dll
-> 创建线程执行代码Web 里的一个 ASP.NET Core 服务也是进程;桌面里的一个 Avalonia 应用也是进程。
2. 线程是什么
线程是进程里真正执行代码的路线。一个进程里可以有多个线程。
线程共享同一个进程的内存,所以多个线程同时改同一个变量时,会出现竞争问题。
int count = 0;
var t1 = new Thread(() => count++);
var t2 = new Thread(() => count++);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine(count);这段代码看起来应该输出 2,但多线程里共享变量不是这么简单。真实项目里要用锁、线程安全集合,或者避免多个线程同时改同一份状态。
3. UI 线程是什么
桌面应用里通常有一个 UI 线程。窗口、按钮、文本框这些界面对象要在 UI 线程上更新。
错误理解是:开一个后台线程,然后直接修改界面。
后台线程直接改 TextBlock.Text
-> 可能报错
-> 也可能出现不可预测问题正确理解是:后台线程做耗时工作,UI 线程负责更新界面。
UI 线程:响应点击、刷新界面
后台任务:读文件、算数据、请求接口
完成后:切回 UI 线程更新状态Web 服务没有桌面 UI 线程,但也有线程池和请求处理线程。不要把桌面 UI 线程和 Web 请求线程混成一件事。
4. Task 是什么
Task 表示一个未来会完成的工作。它不等于线程。
Task 可能代表:
- 一个在线程池里执行的 CPU 任务。
- 一个正在等待的 IO 操作。
- 一个组合任务,例如
Task.WhenAll。
Task<int> task = GetCountAsync();
int count = await task;这里的重点不是“创建线程”,而是“我有一个未来会拿到结果的工作”。
5. async/await 到底做了什么
async/await 不是让代码自动变快,也不是一定创建新线程。
它更像把方法拆成两段:
await 之前的代码
-> 遇到等待,先把控制权让出去
-> 等任务完成
-> 从 await 后面继续执行可以把它理解成“可暂停、可恢复”的写法。编译器会把 async 方法改造成状态机。
async Task LoadAsync()
{
Console.WriteLine("开始读取");
string text = await File.ReadAllTextAsync("data.txt");
Console.WriteLine(text.Length);
}这段代码不是一直占着线程等文件读完。文件 IO 等待期间,线程可以去做别的事。
6. 协程是什么,和 C# async 有什么关系
协程可以先这样理解:一种能暂停、之后再继续执行的函数。
C# 里最常见的“协程式体验”是 async/await 和 yield return:
IEnumerable<int> CountToThree()
{
yield return 1;
yield return 2;
yield return 3;
}yield return 让方法不是一次性跑完,而是每次取一个值时继续往下走。
async/await 让方法在等待异步结果时暂停,等结果回来后继续往下走。
yield return:按需吐出数据
async/await:等待期间暂停,完成后继续
Unity Coroutine:游戏循环里按帧暂停和继续所以在 C# 里,不要把“协程”简单等同于“线程”。协程式写法强调暂停和恢复;线程强调并行执行路线。
什么时候用哪个
| 场景 | 应该优先想到 |
|---|---|
| 启动一个独立应用 | 进程 |
| 桌面界面卡住 | UI 线程被阻塞 |
| CPU 密集计算 | Task.Run 或后台线程 |
| 文件、网络、数据库等待 | async/await |
| 同时跑多个异步任务 | Task.WhenAll |
| 限制同时执行数量 | SemaphoreSlim |
| 多线程共享状态 | lock、线程安全集合、不可变数据 |
| 一边生成一边读取数据 | yield return 或 IAsyncEnumerable<T> |
多线程最常见的坑
1. 把异步当成多线程
async/await 解决等待问题
多线程解决并行执行问题读文件、请求接口通常是等待问题;大量图片处理、复杂计算通常是并行执行问题。
2. 在 UI 线程做耗时工作
private void OnClick(object? sender, EventArgs e)
{
Thread.Sleep(5000);
}这会让界面 5 秒没有响应。
更好的写法是:
private async void OnClick(object? sender, EventArgs e)
{
await Task.Delay(5000);
}真实耗时计算可以放到后台:
private async void OnClick(object? sender, EventArgs e)
{
var result = await Task.Run(() => CalculateReport());
ResultText.Text = result;
}3. 多个线程同时改同一个变量
private readonly object _lock = new();
private int _count;
void Increase()
{
lock (_lock)
{
_count++;
}
}lock 的作用是:同一时间只允许一个线程进入这段代码。
反射是什么
反射就是程序运行时查看类型信息的能力。
平时你写代码是这样:
var user = new User();
user.Name = "Tom";反射可以让程序在运行时问:
这个对象是什么类型?
它有哪些属性?
它有哪些方法?
属性上有没有特性?
能不能动态读取或设置这个属性?最小例子:
public class User
{
public string Name { get; set; } = "";
public int Age { get; set; }
}
Type type = typeof(User);
foreach (var property in type.GetProperties())
{
Console.WriteLine(property.Name);
}输出:
Name
Age特性是什么
特性是写在类、方法、属性上的元数据。它本身不直接执行业务逻辑,但框架可以通过反射读取它。
[Obsolete("Use NewMethod instead")]
void OldMethod()
{
}常见特性:
[Serializable]
[Obsolete]
[JsonPropertyName("user_name")]
[Fact]
[HttpGet]
[Required]这些特性为什么有用?
Json 序列化:读取 [JsonPropertyName]
单元测试框架:读取 [Fact]
Web 路由:读取 [HttpGet]
参数校验:读取 [Required]
依赖注入或扫描:读取类和接口信息
Avalonia/XAML:通过类型和属性信息连接界面与代码反射 + 特性的完整例子
先定义一个自己的特性:
[AttributeUsage(AttributeTargets.Property)]
public class CsvColumnAttribute : Attribute
{
public string Name { get; }
public CsvColumnAttribute(string name)
{
Name = name;
}
}给属性加特性:
public class Product
{
[CsvColumn("product_name")]
public string Name { get; set; } = "";
[CsvColumn("price")]
public decimal Price { get; set; }
}运行时读取特性:
Type type = typeof(Product);
foreach (var property in type.GetProperties())
{
var attr = property
.GetCustomAttributes(typeof(CsvColumnAttribute), false)
.FirstOrDefault() as CsvColumnAttribute;
if (attr != null)
{
Console.WriteLine($"{property.Name} -> {attr.Name}");
}
}输出:
Name -> product_name
Price -> price这就是很多框架的底层思路:你写特性,框架扫描类型,读取元数据,然后自动完成绑定、路由、序列化、测试发现。
Web 和桌面分别怎么用到这些基础
Web 方向
ASP.NET Core 常见底层关联:
[HttpGet]、[HttpPost]:通过特性声明路由动作。[FromBody]、[FromQuery]:告诉框架参数从哪里来。- DTO 把外部请求和内部实体隔开。
- Service 放业务规则,Repository 放数据访问。
- 校验先挡住错误输入,授权再判断能不能执行动作。
- DI 容器:通过类型和构造函数创建对象。
- JSON 序列化:通过属性、类型和特性决定字段名。
- 请求处理:线程池处理并发请求,IO 用异步避免浪费线程。
- 日志、异常堆栈、Git diff 用来缩小问题范围。
桌面方向
Avalonia 常见底层关联:
- UI 线程负责界面响应。
- 后台任务负责耗时工作。
- 绑定依赖属性名、类型和通知。
- ViewModel 属性变化要通知界面。
- ViewModel 保存界面状态,Service 保存业务规则。
ObservableCollection<T>适合界面列表变化通知。- Command 把按钮动作从界面事件里拆出来。
- XAML 加载时要找到类型、控件、属性和事件。
- 空值、选中项、文件路径、权限都要明确处理。
做题检查顺序
遇到线程、异步或反射题,先按这 5 个点检查:
- 当前代码运行在哪个进程里?
- 哪些代码在 UI 线程、请求线程或线程池里?
- 有没有等待文件、网络、数据库或耗时计算?
- 框架是否通过特性或反射自动发现了类型、方法、属性?
- 出错时从哪里查:线程阻塞、未 await、共享状态、特性没写、类型没扫描到?
如果题目不是线程或反射,而是普通项目基础,就按这 5 个点检查:
- 这个功能的数据从哪里来:用户输入、文件、HTTP、数据库还是配置?
- 数据在内存里是什么类型:值类型、对象、集合、DTO 还是 ViewModel?
- 数据要不要跨进程保存:不用保存、写文件、写数据库还是发给 API?
- 出错时怎么处理:校验、异常、日志、测试还是权限判断?
- 最后怎么交付:运行命令、配置、发布包、服务器部署还是本地安装?
必须掌握的最低标准
- 能说清进程和线程的区别。
- 能说清线程和
Task不是同一件事。 - 能说清
async/await是“等待时暂停,完成后继续”的状态机写法。 - 能说清协程式写法强调暂停和恢复,不等于多线程。
- 能写一个
Task.Run的后台计算例子。 - 能写一个
await File.ReadAllTextAsync的 IO 等待例子。 - 能用反射列出一个类的属性。
- 能写一个简单自定义特性,并用反射读取。
- 能解释 SDK、Runtime、程序集、NuGet 分别解决什么问题。
- 能解释命名空间和访问修饰符的作用。
- 能说出值类型、引用类型、GC、
using释放资源的基本关系。 - 能判断什么时候要写
string?,什么时候必须先做空值检查。 - 能根据需求选择
List<T>、Dictionary<TKey, TValue>、HashSet<T>、ObservableCollection<T>。 - 能解释 LINQ 查询什么时候需要
ToList()。 - 能说明 JSON 序列化、HTTP、数据库分别解决什么问题。
- 能区分 DTO、Entity、ViewModel 的职责。
- 能按 Controller/View、Service、Repository 拆一个功能。
- 能说明输入校验和业务校验的区别。
- 能说明配置、依赖注入、日志、测试、安全校验在项目里放在哪里。
- 能区分认证和授权,知道 401 和 403 的区别。
- 能用断点、调用堆栈、日志、
git diff定位问题。 - 能说出 Repository、Options、Observer、Command、Factory 大概解决什么问题。
- 能说出 Web 和桌面里这些基础分别用在哪里。
作业完整答案
下面这些就是本页基础补强作业的完整答案。每一题都给出可直接提交的解释或代码。
作业 1:解释 Task 和线程
线程是执行代码的路线。
Task 是 .NET 用来表示一件未来会完成的工作。
Task 可能用线程池执行,也可能只是代表一次 IO 等待。
所以 Task 不等于 Thread。作业 2:写一个反射读取属性名的程序
public class Customer
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
}
foreach (var property in typeof(Customer).GetProperties())
{
Console.WriteLine(property.Name);
}作业 3:写一个自定义特性
[AttributeUsage(AttributeTargets.Class)]
public class ModuleAttribute : Attribute
{
public string Name { get; }
public ModuleAttribute(string name) => Name = name;
}
[Module("Sales")]
public class SalesService
{
}读取:
var attr = typeof(SalesService)
.GetCustomAttributes(typeof(ModuleAttribute), false)
.FirstOrDefault() as ModuleAttribute;
Console.WriteLine(attr?.Name);作业 4:解释 SDK、Runtime、程序集、NuGet
SDK:开发和编译用,包含 dotnet CLI、模板、编译器。
Runtime:运行已编译程序用。
程序集:编译出来的 dll 或 exe。
NuGet:第三方依赖包管理系统。作业 5:判断数据应该放哪里
临时显示结果:内存变量或集合。
用户偏好:配置文件。
本地长期数据:JSON 文件或 SQLite。
多人共享数据:服务器数据库。
接口传输数据:DTO + JSON。作业 6:解释日志、异常、测试
异常:正常流程无法继续时抛出。
日志:记录运行过程,方便排查问题。
测试:用代码验证业务规则是否正确。
常见顺序:
先校验用户输入
再执行业务逻辑
失败时抛异常或返回错误
关键错误写日志
核心规则写测试作业 7:处理可能为空的数据
public Product GetRequiredProduct(int id)
{
Product? product = repository.FindById(id);
if (product == null)
{
throw new InvalidOperationException("Product not found.");
}
return product;
}说明:
FindById 可能查不到,所以返回 Product?。
后面代码要使用 Product,就必须先判断 null。
判断完成后再 return,调用方拿到的就是明确存在的 Product。作业 8:选择合适的集合
商品列表要按顺序显示:List<Product>。
商品要按 Id 快速查找:Dictionary<int, Product>。
用户角色不能重复:HashSet<string>。
Avalonia 列表要通知界面变化:ObservableCollection<ProductItemViewModel>。代码例子:
var roles = new HashSet<string> { "Admin", "User" };
if (roles.Contains("Admin"))
{
Console.WriteLine("Can open admin page.");
}作业 9:区分 Entity、DTO、ViewModel
Entity:贴近数据库,保存完整业务数据。
DTO:贴近接口,只传外部需要的数据。
ViewModel:贴近界面,包含显示状态和命令状态。例子:
public class ProductEntity
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal CostPrice { get; set; }
public decimal SalePrice { get; set; }
}
public class ProductResponse
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal SalePrice { get; set; }
}这里不把 CostPrice 返回出去,因为接口使用者不一定应该看到成本价。
作业 10:按分层拆保存商品
View 或 Controller:接收输入。
Service:校验名称和价格,执行业务规则。
Repository:保存到文件、SQLite 或数据库。
DTO 或 ViewModel:承载输入和显示数据。示范:
public class ProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public void Create(string name, decimal price)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Name is required.");
}
if (price <= 0)
{
throw new ArgumentException("Price must be greater than zero.");
}
_repository.Save(new Product(name.Trim(), price));
}
}作业 11:解释认证和授权
认证:确认当前用户是谁。
授权:确认当前用户能不能执行某个动作。
401:没有登录或身份无效。
403:已经登录,但权限不够。服务层示范:
public void DeleteProduct(User user, int productId)
{
if (!user.HasPermission("Product.Delete"))
{
throw new UnauthorizedAccessException("No permission.");
}
repository.Delete(productId);
}作业 12:写出排错步骤
先复现问题。
记录输入数据。
确认期望结果和实际结果。
在关键位置打断点。
检查变量是否为空、集合是否有数据、条件是否进入。
看异常堆栈和日志。
用 git diff 查看最近改动。
把问题缩小到具体文件、具体方法、具体一行。这个顺序比随机修改代码更可靠,因为每一步都在缩小范围。