Skip to Content
C# 核心基础补强

上位机开发需要的 C#/.NET 核心基础

这一页补的是上位机和 Avalonia 开发会反复遇到的底层基础:运行时、项目结构、内存、空值、集合、分层、校验、调试、Git、进程、线程、任务、异步、反射、特性、序列化、文件、网络、数据库、配置、依赖注入、日志、测试和安全。

这些内容不是为了背概念,而是为了以后看到 Avalonia、MVVM、设备通信、后台采集、依赖注入、序列化和单元测试时,知道底层为什么能工作。Web 中也会使用这些基础,但 Web 不是当前学习主线。

先看整体地图

C# 源码 -> 编译成 dll/exe -> .NET 运行时加载程序集 -> 操作系统启动进程 -> 进程里有线程 -> 线程执行代码 -> Task 管理异步或并发工作 -> async/await 把等待拆成可恢复的状态机 -> Attribute 写入元数据 -> Reflection 在运行时读取类型、方法、属性和特性 -> 框架根据这些信息自动绑定、路由、序列化、测试或创建对象

如果这张图能看懂,后面写 Avalonia 界面、后台采集任务和设备通信代码时,就不会只停留在照抄 API。

还需要知道的基础主题

这些主题不要求一次学很深,但必须知道它们是什么、以后在哪里出现。

主题先这样理解上位机里会遇到以后 Web 里也会遇到
.NET Runtime / CLRC# 程序运行的环境Avalonia 程序和采集服务启动ASP.NET Core 服务启动
SDK / RuntimeSDK 用来开发和编译,Runtime 用来运行工控机运行要有 Runtime 或自包含发布服务器部署也要 Runtime
程序集 Assembly编译后的 dll/exe加载 View、ViewModel、通信模块加载 Controller、Service
NuGet 包第三方依赖包Avalonia、图表、串口、Modbus、日志库数据库、JWT、Swagger
命名空间 Namespace给类型分组,避免重名MyApp.DevicesMyApp.ViewModelsMyApp.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 / RepositoryView / ViewModel / Service
数据校验把错误挡在业务执行前参数校验、模型校验表单校验、命令可执行状态
数据库 / SQL持久保存业务数据用户、订单、权限本地 SQLite、缓存数据
配置把可变参数放到代码外连接字符串、环境变量本地路径、主题、偏好
依赖注入 DI对象不自己 new 依赖,由容器提供Controller 注入 ServiceViewModel 注入 Service
日志 Logging记录程序运行过程排查线上接口问题排查用户电脑上的错误
单元测试用代码验证代码测 Service、校验规则测 ViewModel、业务逻辑
安全基础输入不可信,权限要校验登录、权限、参数校验本地文件权限、用户输入
认证授权先确认是谁,再确认能做什么JWT、Cookie、Role、Policy本地账号、功能权限
调试诊断用断点、日志、堆栈定位问题500 错误、慢接口卡顿、崩溃、绑定错误
Git记录代码历史和协作分支、PR、回滚版本管理、打包前追踪
设计模式常见问题的稳定写法Repository、Middleware、OptionsMVVM、Command、Observer
发布部署把程序交给别人运行部署到服务器打包给用户安装

如果只记一句话:上位机开发不是只会拖控件,它同样离不开类型、对象、异步、IO、网络、配置、日志、测试和发布。

全站名词词典

这一节把后面反复出现的词集中解释。遇到陌生词时,先回到这里查。

名词直接解释在项目里怎么看
SDK开发工具包,包含编译、模板、命令能执行 dotnet newdotnet build
Runtime运行环境,只负责运行程序用户电脑运行 .NET 程序需要它
项目一组能一起编译运行的文件一个 .csproj 对应一个项目
解决方案管理多个项目的容器.sln 里放 App、Core、Tests
NuGet.NET 包管理系统安装 Avalonia、xUnit、日志库
命名空间给类型分组,避免重名ProductApp.ViewModels
描述一种对象的结构和行为ProductOrder
对象类创建出来的一份具体数据一个具体商品
属性对象暴露的数据NamePriceStock
方法有名字的一段逻辑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 restoreRestore / Reload Project
构建项目dotnet buildBuild
运行项目dotnet runRun
运行测试dotnet testTest Explorer / Unit Tests

看报错

IDE 里看报错时,不要只看红线。按这个顺序:

  1. 看 Problems / Error List 的第一条错误。
  2. 双击跳到文件和行号。
  3. 先修第一条,再重新构建。
  4. 如果一堆错误同时出现,优先找最上面的语法错误。

打断点

断点适合放在这些位置:

位置看什么
读取输入后输入值到底是什么
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,避免超长文本。

数字 intdecimaldouble

需求写法例子
整数计算count + 1count * 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.Yeardate.Monthdate.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

需求写法例子
对象转 JSONJsonSerializer.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,后面要判断。

异步 Taskasyncawait

需求写法例子
等待异步操作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 JSONPostAsJsonAsync(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 格式不符合预期。
  • 请求超时。

常见报错字典

报错不是敌人。报错是在告诉你:代码、运行环境、数据或绑定关系里,有一处不符合规则。

处理报错时先按这个顺序:

  1. 先看第一条错误。
  2. 找文件名和行号。
  3. 判断是编译错误、运行时异常、命令错误,还是 XAML 绑定错误。
  4. 只改最可能的一处。
  5. 重新运行,看错误是否变化。

C# 编译错误

报错关键词直接意思常见原因修法
; expected这里应该有分号一行语句结尾忘了 ;在语句末尾补 ;
} expected这里应该有右大括号{} 数量不匹配从当前方法开始检查括号成对
The name 'xxx' does not exist in the current context当前范围找不到这个名字变量名拼错、变量定义在别的作用域对照变量声明,确认名字和作用域
Cannot implicitly convert type不能自动转换类型string 当成 intdecimalTryParse 或改变量类型
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参数超出允许范围RemoveAtSubstring 等传了非法位置先判断范围
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 所在目录pwdls,再 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 build

Avalonia / XAML 常见问题

现象或报错直接意思常见原因修法
XAML 标签报错标记结构不合法标签没闭合、属性引号丢失检查最近修改的标签
Unable to resolve type找不到控件或类型命名空间、类名、程序集写错检查 xmlns 和类名
绑定没有效果View 找不到属性或命令属性名拼错、DataContext 没设置检查 ViewModel 属性和 DataContext
按钮命令不执行找不到 CommandSave 生成的是 SaveCommandXAML 绑定 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); // 10
var p1 = new Product { Name = "A" }; var p2 = p1; p2.Name = "B"; Console.WriteLine(p1.Name); // B

第二段里 p1p2 指向同一个对象,所以改 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.Combine
string 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/awaityield 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 returnIAsyncEnumerable<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 个点检查:

  1. 当前代码运行在哪个进程里?
  2. 哪些代码在 UI 线程、请求线程或线程池里?
  3. 有没有等待文件、网络、数据库或耗时计算?
  4. 框架是否通过特性或反射自动发现了类型、方法、属性?
  5. 出错时从哪里查:线程阻塞、未 await、共享状态、特性没写、类型没扫描到?

如果题目不是线程或反射,而是普通项目基础,就按这 5 个点检查:

  1. 这个功能的数据从哪里来:用户输入、文件、HTTP、数据库还是配置?
  2. 数据在内存里是什么类型:值类型、对象、集合、DTO 还是 ViewModel?
  3. 数据要不要跨进程保存:不用保存、写文件、写数据库还是发给 API?
  4. 出错时怎么处理:校验、异常、日志、测试还是权限判断?
  5. 最后怎么交付:运行命令、配置、发布包、服务器部署还是本地安装?

必须掌握的最低标准

  • 能说清进程和线程的区别。
  • 能说清线程和 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 查看最近改动。 把问题缩小到具体文件、具体方法、具体一行。

这个顺序比随机修改代码更可靠,因为每一步都在缩小范围。