简介:这是一份 C# 面试高频考点整理文档,适合准备技术笔试或面试的 .NET 开发者与初级程序员。内容覆盖 string 与 StringBuilder、GC 与内存回收、const/readonly、接口 vs 抽象类、struct vs class、finally 与 return 执行时序等经典问题,并附有内存分配示意图与简洁代码示例,便于快速理解底层机制。包内为 1 个 doc 文件,约 316KB,集中收录 9 道常见面试题及解答思路,题目与解析直接对应,示例代码可复制运行,可离线阅读或按需打印。该资料已有 372 人学习下载,题解侧重从内存与编译角度解释概念差异,例如 string 三种声明的堆栈分配、StringBuilder 的预留空间原理等,能帮助读者在面试中对基础题做出结构化回答,减少临场卡壳。适合在冲刺复习阶段用于自查知识盲区。
1. 聊起C#面试题,大家的第一反应都是背答案
聊起C#面试题,多数人的第一反应是找一份「面试题大全」背答案。但我见过太多准备C#面试的开发者,把题册背得滚瓜烂熟,结果面试官把「struct 和 class 有什么区别」换个角度一追问,立刻卡壳。C#面试题和学校考试题最大的区别是:它很少考确凿的定义,多数在考工程判断——值类型该用的时候用了引用类型、await 之后代码跑在哪个线程上、事件该不该注销,这些才是面试官真正写下「通过」或「不通过」的地方。
这篇笔记按一线面试里反复出现的题目来拆:从值类型与引用类型、委托与事件、异步与并发,到最容易翻车的几个细节,每道题都给出推荐作答结构、代码示例、面试官追问方向和避坑记录。适合正在准备 C# 后端或客户端岗位面试的开发者,也适合需要出题的技术面试官——前者能拿它检验回答深度,后者可以把它当追问提纲。下面这些题没有一道是偏题怪题,都是最常见的、你必须答到「第二层」才能过关的题。
2. 值类型与引用类型:一张图装不下的边界题
2.1 struct 和 class 的区别:先答表象,再答本质,最后答例外
这道题十场面试有九场出现。我的建议是不要一上来就背「struct 是值类型存在栈上,class 是引用类型存在堆上」,这句话只值一分,因为它马上会被追问。
推荐回答分三层。第一层说赋值语义:struct 是值拷贝,赋值后两个变量彼此独立;class 是引用共享,赋值后两个变量指向同一对象。第二层说内存位置:作为局部变量的 struct 通常分配在栈上,class 实例始终在托管堆上。第三层主动抛出例外:struct 作为 class 的字段时,它不在栈上,而是作为对象的一部分存在于托管堆中。能自己说出第三层的人,面试官基本不会再纠缠这道题。
public class Order { public int Id { get; set; } public PriceInfo Price { get; set; } // struct 类型字段,但它实际在托管堆上 } public struct PriceInfo { public decimal Amount; public string Currency; }这里PriceInfo是 struct,但当它作为Order的成员时,它跟随Order实例一起分配在托管堆上。代码注释里那句「实际上在托管堆上」就是这个题的核心加分点。回答时顺带说明:结构体作为局部变量时通常可以用更高效的方式分配,但「通常」不等于「绝对」,这才是面试官想听的精确性。
2.2 一个 struct 放进 List ,改字段为什么不生效
这是 2.1 的升级题,也是真正常见的翻车现场。很多人写过类似代码:从List<MyStruct>里取一个元素,修改它的属性,结果再读回去发现没变。原因不在结构体本身,而在索引器的返回值。
public struct Stock { public decimal Price; } var stocks = new List<Stock> { new Stock { Price = 10m } }; stocks[0].Price = 20m; // 编译错误 CS1612:不能修改“变量”的成员 // 正确写法:先取出,整体赋值 var temp = stocks[0]; temp.Price = 20m; stocks[0] = temp;List<T>的索引器返回的是元素本身,但 struct 是值类型,这个返回动作会生成一个副本。stocks[0].Price = 20m试图修改的是临时副本,编译器直接拦截,报错 CS1612。明白这一点之后,对「为什么不生效」的解释就顺理成章:不是代码写得不对,而是对值类型语义下的索引器行为理解不到位。面试官如果继续追问,你可以补充:用数组就没有这个问题,因为数组索引器返回的是托管引用;或者用CollectionsMarshal.AsSpan拿到底层 span 来修改。
这道题的完整回答里最好带一句设计判断:如果你的数据容器需要频繁原地修改成员,struct 配合数组或 span 是可行的;但如果集合要频繁增删、要素能被多方引用,class 的引用语义更省心。这样就从「语法是否正确」上升到了「场景选型」。
2.3 string 是引用类型,但表现得像值类型
string 是 C# 里最容易被误判的类型。它是如假包换的引用类型,但==比较的是内容而不是引用,赋值时看起来像值拷贝。这个「表里不一」的点,是面试官验证候选人有没有真正读过类型系统的好材料。
string a = "hello"; string b = "hello"; object objA = a; object objB = b; Console.WriteLine(a == b); // True,内容相等 Console.WriteLine(objA == objB); // True,因为编译后 a、b 引用同一个字符串实例 Console.WriteLine(ReferenceEquals(a, b)); // True,字符串拘留池生效解释这段代码时,要注意object objA == objB的 True 并不是引用类型比较的常规结果,而是因为两个字面量在编译期被「拘留」成了同一个实例。如果其中一个是运行时拼接出来的字符串,结果就会变成 False。这里有三个层次:一是 string 的==是重载后的内容比较;二是引用层面到底等不等要看实例化方式;三是字符串不可变性带来的设计意义——正因为不可变,才能被安全共享、才能进拘留池。
面试追问常常落在「频繁拼接字符串为什么慢」上。答到 StringBuilder 只是第一步,能说出「string 拼接每次都会创造新对象,原本的字符串变成垃圾,循环拼接 N 次产生 O(N²) 的拷贝成本」才算把原理讲透。顺带可以提一句:某些场景下用string.Create或ValueStringBuilder能进一步减少分配,这是加分项,但不要强行展开。
2.4 装箱与可空类型:null 不是「空」,而是「有」
可空类型int?在面试题中的出场率不低,但很多人只答到「可以赋 null」,而不知道它本质上是Nullable<T>这个结构体。当面试官问「可空类型装箱后是什么」,很多人就愣住了。
int? normal = 5; int? empty = null; object boxed = normal; // 装箱成包含值 5 的盒 object boxedNull = empty; // 装箱后是 null 引用,而不是一个带 hasValue=false 的盒 Console.WriteLine(boxed == null); // False Console.WriteLine(boxedNull == null); // TrueNullable<T>是一个 struct,包含hasValue和value两个成员。装箱时规则很特别:hasValue为 false 的实例直接装箱成 null;hasValue为 true 的实例只把内部值装箱,不再套一层Nullable<T>。这意味着(int?)null装箱后你就无法再通过is int?判断它曾是「可空的 null」——它已经变成了纯粹的 null 引用。这个细节在写泛型、反射、序列化边界时都会遇到。
回答这道题时,我一般建议主动说一句:可空类型是值类型家族的一员,所以它同样有拷贝开销;而??运算符本质是检查hasValue后再决定取值路径。这样就把可空类型和上面讲的值类型知识串起来了,面试官会认为你对相关知识点有结构性的理解。
3. 委托与事件:面试官嘴里的「解耦」到底在考什么
3.1 从委托到事件:最小可运行的订阅模型
委托和事件是 C# 面试的常客,但问法高度一致:「事件和委托的区别是什么」「为什么有了委托还要事件」。回答这两题的关键不是定义,而是封装:委托是一个可以持有方法引用的类型,事件是对委托字段的一层受限封装——外部只能+=和-=,不能直接调用,也不能整体覆盖。
public class StockTicker { public event EventHandler<decimal> PriceChanged; public void UpdatePrice(decimal newPrice) { Console.WriteLine($"新价格: {newPrice}"); PriceChanged?.Invoke(this, newPrice); // 触发事件 } } public class Subscriber { public Subscriber(StockTicker ticker) { ticker.PriceChanged += OnPriceChanged; // 订阅 } private void OnPriceChanged(object? sender, decimal price) { Console.WriteLine($"订阅者收到: {price}"); } }# 运行预期 新价格: 12.5 订阅者收到: 12.5这里有两个关键点。第一,event修饰符只改变了字段的访问级别:类内部可以Invoke,类外部只能+=或-=。如果没有event,外部代码可以把PriceChanged直接赋成别人的方法,把原有订阅者全部冲掉——这是发布者最不能接受的破坏。第二,PriceChanged?.Invoke(...)这个问号判空能避免在没有订阅者时抛出NullReferenceException,它看起来像「安全调用」,实际上是「先取快照,再判空,再调用」,在并发场景下能避免竞态。
回答时最好补一句为什么要用 EventHandler 而不是自定义委托类型:EventHandler 是 BCL 里的标准约定,object sender承担发布者标识,事件参数承载数据,这套约定让生态里的所有代码能互相理解。能说出「事件是委托的应用模式,而不是另一种独立语言特性」这句,基本就能让面试官点头。
3.2 Action、Func 与自定义委托:到底该怎么选
面试题里另一种常见问法是「什么时候用 Func,什么时候自己定义 delegate」。这个问题的标准答案很短,但回答时的论证过程才是加分点。Action 和 Func 是通用委托,参数和返回值由类型参数表达;自定义委托可以把参数名写进类型签名,可读性更好,但也会让 API 表面更繁重。
Func<int, int, int> add = (x, y) => x + y; Action<string> log = msg => Console.WriteLine(msg); Predicate<int> isPositive = x => x > 0; // 自定义委托:参数带名字,调用处意图一目了然 public delegate bool TryConvert<TInput, TOutput>(TInput input, out TOutput output);选型原则其实三条就够:只有一个返回值且无复杂语义时用 Func;没有返回值用 Action;判断条件用 Predicate;当参数包含out、ref,或者多个参数含义需要用名字表义时,自定义委托更合适。注意 Predicate 本身就是 Func,它只是 BCL 里为「断言」语义保留的历史名称,面试里能把这点说清楚也很加分。
顺带一提,委托支持逆变和协变。Func<object>可以赋给Func<string>的参数位置吗?这类问题很多面试官喜欢拿出来压场。正确答案是:方法的返回类型支持协变,参数类型支持逆变,所以参数更宽泛的方法可以赋值给参数更具体的委托。光背结论容易忘,我的记忆方式是:只输出更具体的没问题(协变),只接受更宽泛的没问题(逆变),两个方向都是「丢出去更窄、收进来更宽」。
3.3 闭包陷阱:for 与 foreach 里的捕获差异
闭包捕获这道题是 C# 面试里的老牌陷阱,但至今还有大量候选人答错。题目通常长这样:一个 for 循环,循环体内注册回调,回调里使用循环变量 i,问最终输出结果是什么。
var actions = new List<Action>(); for (int i = 0; i < 3; i++) { actions.Add(() => Console.Write(i + " ")); } foreach (var action in actions) action(); Console.WriteLine(); // 输出:3 3 3 // 修正写法:复制一份局部变量 var actions2 = new List<Action>(); for (int i = 0; i < 3; i++) { int captured = i; actions2.Add(() => Console.Write(captured + " ")); } foreach (var action in actions2) action(); Console.WriteLine(); // 输出:0 1 2初学的人看到输出「3 3 3」都觉得是玄学,其实规律很清楚:闭包捕获的是变量,不是变量在某一时刻的值。三个 lambda 引用的是同一个 i,循环结束后 i 的值为 3,所以全部输出 3。修正方法把captured变成每次循环的新变量,三个闭包各捕获各的,自然输出 0 1 2。
如果面试题换成了 foreach,回答要谨慎:C# 5.0 之前,foreach 的迭代变量在每次循环中也会被复用,同样需要局部拷贝;C# 5.0 以后编译器为 foreach 的每次迭代创建新变量,直接捕获就是安全的。面试官会拿「新旧版本行为差异」来探你的工程经验,能明确说出分水岭版本的人不多。讲完这个例子,最好追一句工程判断:不要依赖「我记得新版是安全的」这种模糊记忆,在循环里捕获变量时一律做局部拷贝,这个习惯能省掉大量排查时间。
3.4 多播委托的返回值与异常:GetInvocationList 的用武之地
委托用+=挂多个方法之后形成调用链,这个机制叫多播委托。面试贪深一点的面试官会问:多播委托的返回值是什么?被串联的多个方法里有一个抛异常会怎样?
答案是:多播委托返回的是最后一个方法的结果,前面方法的结果被丢弃;当某个方法抛出异常时,调用链中断,后面的方法不再执行。这两个特性在生产里经常造成隐蔽 bug——你以为所有订阅者都收到了通知,实际上第一个人抛异常,后面的人全没执行。
public delegate int Calculate(int x); var chain = (Calculate)(x => x + 1); chain += x => { throw new InvalidOperationException("订阅者2炸了"); }; chain += x => x * 10; try { chain(5); } catch (InvalidOperationException) { Console.WriteLine("调用链中断,第三个方法未执行"); } // 可靠做法:逐个调用,确保每个订阅者都有机会执行 foreach (var handler in chain.GetInvocationList()) { try { handler.DynamicInvoke(5); } catch (Exception ex) { Console.WriteLine($"单个订阅者异常: {ex.Message}"); } }代码注释里已经点明:GetInvocationList返回每个方法各自包装的委托,可以逐个try/catch执行,让某个订阅者的失败不影响其他人。回答这题时,能主动说出「事件系统在多播场景下没有内置隔离能力,发布者要注意订阅者的异常行为」的人,是真正写过多订阅者系统的。这条经验在 UI 事件、消息总线这类场景里非常适用。
4. async/await 与多线程并发:半小时追问的答题节奏
4.1 先问本质:await 之后线程是「等待」还是「释放」
async/await 题是 C# 面试的重头戏,因为几乎所有候选人都会用,但绝大多数说不清机制。常见说法是「async 方法开了一个新线程」,这话严格来说是错的。应该这样理解:async只是让方法体内可以使用await,它本身不开线程;await在遇到尚未完成的任务时,会立即返回,让调用线程继续做别的事——这才是异步释放线程资源的关键。
public async Task<string> FetchDataAsync() { Console.WriteLine($"开始: 当前线程 {Environment.CurrentManagedThreadId}"); var result = await Task.Delay(1000).ContinueWith(_ => "模拟网络耗时"); Console.WriteLine($"结束: 当前线程 {Environment.CurrentManagedThreadId}"); return result; }这个例子里的Task.Delay(1000)不占用任何线程,它是用计时器回调模拟的等待。await 之前说的「当前线程」和 await 恢复后的线程很可能不同,这一点在控制台程序里体现得最直观。解释机制时用三个词就够了:状态机、返回、继续。编译器把 async 方法改写成状态机结构,每次 await 都是「挂起点」,当任务未完成就先返回,让出控制权;任务完成后,状态机从挂起点继续执行。
面试官接下来大概率会问「那多线程呢」。回答方向是:异步不等于并发,更不等于多线程。如果你的任务本身是 CPU 密集计算,await不会让它跑得更快;这时要考虑Task.Run把计算丢到线程池,或者说明这个操作应该用同步方法。面试里能区分 IO 密集和 CPU 密集,并且给出不同策略的人非常少,这两个词一出口,印象分就上来了。
4.2 那个像玄学一样的死锁:SynchronizationContext 与 .Result
UI 程序里async方法配合.Result造成死锁,是 C# 异步面试题中最经典的场景。很多候选人把它当玄学背结论:UI 上别用.Result。面试官想听的其实是机制:await 默认会捕获当前线程的同步上下文,任务完成后要回到这个上下文继续执行;如果你的调用线程用.Result把自己阻塞住了,回到上下文这个动作就没有可用的线程,于是互相等待。
public async Task<string> LoadAsync() { await Task.Delay(500); // 需要回到 UI 线程 return "完成"; } // 错误调用:在 UI 线程上同步阻塞 // string result = LoadAsync().Result; // 死锁风险极高 // 修正方案一:一路 async/await,不用同步阻塞 public async Task DoWorkAsync() { string result = await LoadAsync(); Console.WriteLine(result); } // 修正方案二:不依赖上下文,后半段在线程池执行 public async Task<string> LoadNoContextAsync() { await Task.Delay(500).ConfigureAwait(false); return "完成"; }代码注释已经标出两个修正方向。ConfigureAwait(false)的核心含义是:await 恢复时不必回到捕获的上下文,继续在完成的线程上跑即可。它在类库代码里尤其重要,因为类库作者不知道调用方有没有同步上下文。但面试里不要无脑推荐它——如果后续代码确实要操作 UI 控件,回到 UI 线程是必需条件,这时反而不能加。
这题的完整回答要带出两个工程判断:应用层代码保持全程 await 是最省心的;类库层默认加 ConfigureAwait(false) 是性能友好写法。能讲到这一层,面试官就能确认你不是只会背结论。
4.3 并发原语怎么选:从 lock 到 ConcurrentDictionary
并发题在 C# 面试里的地位逐年上升,尤其是「多个线程同时写一个字典」这类题。先问自己:是要保护一段代码,还是要让并发容器直接扛。保护代码用lock,保护资源单一、逻辑简单;如果并发读写的是集合,优先看ConcurrentDictionary<TKey, TValue>、ConcurrentQueue<T>这类无锁容器,它们把复杂度封装在内部。
| 场景 | 首选方案 | 说明 |
|---|---|---|
| 保护临界区代码 | lock或Monitor | 简单可靠,但粒度要小,避免嵌套锁 |
| 高频读低频写集合 | ConcurrentDictionary<TKey, TValue> | 分桶加锁,读写吞吐优于全程大锁 |
| 生产者消费者队列 | Channel<T>或ConcurrentQueue<T> | Channel 支持等待、取消和背压,推荐优先 |
| 限制并发数量 | SemaphoreSlim | 常见用法:控制同时访问数据库的连接数 |
| 异步场景下的互斥 | SemaphoreSlim.WaitAsync | lock 不能在 async 方法体内 await 中保持 |
private readonly SemaphoreSlim _gate = new SemaphoreSlim(3); public async Task ProcessAsync(string item) { await _gate.WaitAsync(); try { // 模拟并发受限的数据库写入 await Task.Delay(200); Console.WriteLine($"处理: {item}"); } finally { _gate.Release(); } }SemaphoreSlim初始计数 3,表示最多允许 3 个任务同时进入临界区。WaitAsync和Release必须在 try/finally 里配对,否则一旦异常,信号量就永久少一个,之后所有任务都会卡在等待上——这个坑在面试答完代码后再提一句,面试官会认为你有真实排障经验。另外注意lock在await前后使用是反模式,因为编译器不允许在某些上下文中跨 await 持有锁,真需要就换成上表里的SemaphoreSlim。
4.4 async void 为什么是禁区,唯一的例外是什么
面试官爱问的最后一层:async void 到底能不能用。答案是有且仅有一个合法场景——事件处理器。UI 事件的标准签名是void,为了在事件里写异步逻辑,语言才允许 async void;除此之外,任何用 async Task 的地方都不应该换成 async void。
public static async void DangerousAsync() { await Task.Delay(100); throw new InvalidOperationException("异常没人能接住"); } // 正确用法:返回 Task 并交给调用方 await public static async Task SafeAsync() { await Task.Delay(100); throw new InvalidOperationException("异常交给调用方处理"); }async void方法抛出的异常不会传回调用方,而是被直接递交给当前同步上下文;如果你在类库或服务端代码里这样写,异常很可能直接崩掉进程或变成一条难以追踪的日志。相比之下,async Task 方法的异常被封装在 Task 里,caller 通过 await 捕获,通过 ContinueWith 处理,通过同步阻塞观察——无论如何都可控。把「唯一的例外是事件处理器」这句话放在回答最后,面试官会记下你的边界意识。
5. C#面试避坑笔记:五个最常被追问翻车的细节
5.1 async void 异常让进程直接退出
现象:程序运行一段时间后突然退出,日志里什么都没有,只在系统事件查看器里找到一条异常记录。再查代码,发现某个按钮点击事件用了 async void,里面抛了异常。
原因:async void 的异常不归调用方管,它直接飞向 SynchronizationContext;在带 UI 的进程里表现为 UI 线程未处理异常,在没有上下文的进程里直接终止进程。
解决:把事件处理器改成 async Task 配合await,如果必须保持 void 签名,方法体最外层加 try/catch,且一定在 catch 里做完整记录。我给团队定的规矩是:async void 方法里只做两件事——await 一个 Task 方法,以及兜底 catch 日志。
5.2 循环变量被闭包捕获,批量发请求全部拿错参数
现象:一个 for 循环里发起 10 个 HTTP 请求,回调里拿到的 index 全是 9。
原因:闭包捕获的是同一份 i,循环已经结束了,i 停在最终值 9,所有回调读到的都是它。
解决:循环体里第一行声明临时变量int captured = i;,闭包捕获 captured。这条规则不用思考,直接养成习惯。新版 foreach 虽然安全,但为了代码风格统一,我在 for 循环里一直写局部拷贝。
5.3 struct 字段改了不生效,序列化回去还是旧值
现象:从List<MyStruct>取出元素,设置属性成功,但再序列化检查发现值没变。
原因:索引器返回值生成副本,修改落在副本上。这个问题在前面 2.2 已经详细分析,此处再强调一次工程影响——它不会报错,只在输出阶段暴露,属于最难查的那类「静默错误」。
解决:要么用数组替代 List,要么整存整取。顺手说一句:遇到这种问题先怀疑值类型语义,再怀疑序列化器,排查顺序能省不少时间。
5.4 事件订阅不注销,对象永远回收不了
现象:内存持续增长,强分析 dump 后发现一堆已「处理完业务」的对象还活着。
原因:事件发布者持有订阅者的委托引用,只要发布者还活着,订阅者就被引用链一直拽住,GC 无法回收。被回收的对象必须没有任何到达根的强引用,而事件订阅就是最常见的「隐形强引用」。
解决:实现 IDisposable,在 Dispose 里给事件做-=注销;或者使用弱事件模式。面试里主动说出「事件是反向的引用关系,不注销等于内存泄漏」这句话的人,写 UI 或消息系统的经验应该都不浅。
5.5 LINQ 延迟执行,Where 回调里做日志看不到记录
现象:筛选用 LINQ 写好后打日志,符合条件的数据很多,日志却一条没有。
原因:LINQ 查询是延迟执行的。Where、Select只是构建表达式,真正的遍历发生在调用ToList()、foreach、Count()等触发执行的方法那一刻。如果你只写了个查询变量没消费它,回调根本不会运行。
解决:调试时在消费端打日志,或者临时加个ToList()强制执行。工程上记住一条:IQueryable/IEnumerable 只是「查询的蓝图」,不是结果本身。理解了延迟执行,也就理解了为什么大查询链里的缓存位置要反复斟酌。
6. 一套自测方法:把C#面试题变成30分钟的压力自查
6.1 计时作答与三层回答法
光看题目觉得「我会」和面试时「讲清楚」之间隔着一条巨大的鸿沟。我的自测方法是:每天挑 5 道题,每题计时 10 分钟,按「结论 → 原理 → 边界」三层结构作答。结论部分 30 秒讲完,原理部分用自己话解释,边界部分主动说出例外情况。三层都讲满才算过,讲不满就对着参考资料重写一遍。
# 自测五题清单 1. struct 和 class 的本质差异是什么?什么场景必须用 struct? 2. string 的相等性比较有哪些坑? 3. event 与 delegate 的关系是什么?为什么外部不能直接 Invoke 事件? 4. async/await 在 UI 线程上的死锁是怎么产生的? 5. 多线程并发写一个 Dictionary 有什么风险?备选方案是什么?这五题覆盖类型系统、委托事件、异步并发三个高频板块,刚好够一次 30 分钟的自测。作答时不要看编译器,直接用注释把预期输出写在代码旁,跑分后再核对答案。
6.2 从「答对」到「答透」的自我追问
每次自测之后,再多问自己三个问题:这个特性为什么存在?边界条件在哪里?我过去的项目里哪里踩过它的坑?这三个问题能筛掉「背题」型候选人和「理解」型候选人。比如 string 这道题,答对「== 比较的是内容」只是第一层;能说出「字符串拘留」「不可变性」「拼接开销」三个维度的关系,才算真正理解。面试官通常不会把所有追问一次抛完,但自测时把这三个问题过一遍,上场就能保持「被追问也不慌」的节奏。
6.3 把教训留在自测记录里
我第一次准备这类岗位面试时,把所有精力放在刷题数量上,结果栽在一道看似简单的值类型题上——不是不知道答案,而是答完定义后接不住追问。从那以后,我改变策略:每道题都主动写一句「我上下文里遇到过的问题是……」,把理论和自己的踩坑经验绑在一起。这个习惯让我的回答听起来不像背书,更像一个用过这些特性的工程师在陈述事实。如果你也在准备 C# 面试,不妨从这篇笔记里的题开始,按三层回答法逐个过一遍,希望帮到你。
本文还有配套的精品资源,点击获取