简介:一套面向.NET开发者的编辑器功能集成示例,基于AvalonEdit完成文本编辑区域,结合NRefactory提供代码提示,并通过Roslyn实现动态编译。解决CSharpCodeProvider在NetCore下不可用、需要跨Framework与NetCore运行的编译需求,适合有WinForms或WPF基础的桌面应用开发者,也适合准备为项目增加轻量编辑器能力的团队参考。压缩包共620个文件、约13.69MB,源码结构清晰:271个cs文件承担核心逻辑,89个dll为运行时依赖,22个xshd用于语法高亮规则,8个xaml负责界面布局,另含editorconfig、targets等工程配置,便于按模块拆解学习。示例涵盖文本输入、复制粘贴、撤回撤销,以及补全列表、InsightWindow智能提示、SearchPanel查找等交互,Roslyn部分演示了动态编译并输出程序集,可直接沿用。已有746人学习下载,是快速搭建带智能提示与动态编译能力的文本编辑器、或从Framework迁移到NetCore时值得参考的完整方案。
1. 把 AvalonEdit 文本编辑器、NRefactory 代码提示、Roslyn 动态编译三件套拼到一起,做成一个能跑 C# 脚本的本地宿主
做过内部工具的人大概率遇到过这个尴尬场景:老板说「给运维写个小脚本面板」,你打开 Visual Studio 建个 WPF 工程,才发现通用编辑器控件要么太重、要么压根没有代码补全。更麻烦的是脚本要实时编译执行,传统 CodeDom 在 .NET Core 下已经半残。我拆过一套组合方案:AvalonEdit 充当文本编辑外壳,NRefactory 负责解析和补全数据,Roslyn 把编辑区里的字符串变成可执行程序集。它能在 NetCore 下稳定跑起来,适合做本地 IDE 原型、教学演示工具、内部脚本宿主这一类项目。这一篇把三层拆开讲,参数、接口、坑都摆在明面上。
2. AvalonEdit 初始化:行号、高亮与折叠背后的三个设置项
2.1 编辑器外壳:从 XAML 挂载到 Options 参数
AvalonEdit 本质上不是「一个文本框」,它是一套完整的文档模型加渲染管线。最外层是TextEditor控件,内部拆成TextArea(负责交互)、Document(负责文本存储和变更事件)、TextView(负责渲染)。这三个对象是后续所有功能的地基,NRefactory 的补全要拿Document做快照,Roslyn 编译要拿Text做源码,折叠要拿Document的线段信息。
常见做法是在 XAML 里先挂一个控件,然后到后台代码统一设置参数,我一般这样初始化:
editor = new ICSharpCode.AvalonEdit.TextEditor { ShowLineNumbers = true, FontFamily = new System.Windows.Media.FontFamily("Consolas"), FontSize = 14, Text = "using System;\nclass Demo { static void Main() { Console.WriteLine(\"ok\"); } }" }; editor.TextArea.TextView.Options.HighlightCurrentLine = true; editor.Options.ConvertTabsToSpaces = true; editor.Options.IndentationSize = 4; editor.Options.EnableHyperlinks = false; editor.Options.EnableEmailHyperlinks = false;ShowLineNumbers打开左侧行号栏,这个看似无关紧要的设置会影响文本区域的宽度计算,当行号超过三位数(比如脚本文件到上千行)时,AvalonEdit 会自动重排行号栏宽度,如果后续补全窗口的定位代码写死了固定偏移,这里就是第一个翻车点。ConvertTabsToSpaces和IndentationSize是配套的,代码提示返回的缩进信息按空格计算,如果编辑器里实际存的是 Tab,NRefactory 算出来的位置会整体右偏。EnableHyperlinks和EnableEmailHyperlinks这两个选项很少被人注意,但默认开启时,鼠标悬停到 URL 上会出现手型光标,和补全窗口的鼠标事件抢焦点,内部工具里一般直接关掉。
还有一个关键参数是editor.TextArea.TextView.LineHeight,这属于渲染层的缩放因子,高分屏下如果系统缩放比例不是 100%,补全弹窗的定位锚点会漂移。AvalonEdit 本身做了一定的 DPI 适配,但是CompletionWindow是浮在编辑器上层的独立窗口,锚点计算用的是物理像素和逻辑像素混合的值,这个后面避坑章节再展开。
2.2 语法高亮:xshd 文件是怎么被加载的
AvalonEdit 的高亮不是正则匹配那么简单,它底层有一套基于语法的状态机,定义在.xshd文件里。C# 的 xshd 定义了关键字、字符串、注释、预处理指令等规则,而且支持嵌套规则——比如注释里的 TODO 标签可以单独上色。加载方式是:
using System.Xml; var manager = ICSharpCode.AvalonEdit.Highlighting.HighlightingManager.Instance; using (var stream = File.OpenRead("CSharp.xshd")) using (var reader = XmlReader.Create(stream)) { editor.SyntaxHighlighting = ICSharpCode.AvalonEdit.Highlighting.Xshd.HighlightingLoader.Load( reader, manager); }HighlightingManager.Instance是一个全局单例,它维护了一套内置高亮方案。HighlightingLoader.Load接收两个参数,第一个是XmlReader,第二个是HighlightingManager,后者用于解析 xshd 里的Color引用——比如Color="Comment"这种写法需要从 manager 的预置颜色表里取实际画刷。如果直接传null,遇到这类引用会抛 NullReferenceException,而且报错位置很隐蔽,只提示Color解析失败。
部署时不要把 xshd 放在工作目录下裸读,Windows 下没问题,打成单文件发布或者放到 Linux 容器里就经常找不到路径。我常用的做法是把 xshd 编译成嵌入资源,用程序集读取:
var assembly = System.Reflection.Assembly.GetExecutingAssembly(); using (var stream = assembly.GetManifestResourceStream("YourNamespace.CSharp.xshd")) { editor.SyntaxHighlighting = HighlightingLoader.Load( XmlReader.Create(stream), HighlightingManager.Instance); }注意嵌入资源的名称是「命名空间.文件名」的完整路径,少一层命名空间都会直接抛NullReferenceException。如果你自定义了高亮颜色,比如把字符串改成自定义色号,xshd 里就要写成<Color name="String" foreground="#FFAA00" />,加载后要手动调用editor.TextArea.TextView.Redraw()才会刷新当前已打开的文档。
2.3 代码折叠与编辑器的交互钩子
代码折叠在 AvalonEdit 里不像高亮那样开箱即用,需要自己挂FoldingManager。核心逻辑不是 UI 层,而是文档结构层——你要先知道哪些行是{到}的范围,然后注册给折叠管理器:
var foldingManager = ICSharpCode.AvalonEdit.Folding.FoldingManager.Install(editor.TextArea); var foldingStrategy = new ICSharpCode.AvalonEdit.Folding.XmlFoldingStrategy(); foldingStrategy.UpdateFoldings(foldingManager, editor.Document);XmlFoldingStrategy是官方示例里常用的实现,但它只适用于 XML 结构。C# 代码要自己做括号匹配,NRefactory 的解析器能输出AstNode的范围信息,转换后逐段注册折叠标记。折叠标记FoldingSection的Tag属性可以挂任意对象,我一般挂一个TextDocument的区间对象,这样折叠标题可以显示第一行内容,用户体验更接近 Visual Studio。
折叠更新的触发时机最好放在editor.TextChanged事件里,但要加防抖。C# 脚本文件动辄几百行,每次击键都全量重算括号匹配,UI 线程会明显掉帧。我一般用DispatcherTimer做 300ms 的延迟刷新,这个值要自己试,机器性能不同体感差异很大。
3. NRefactory 代码提示:解析器与 CompletionData 的协作
3.1 提示引擎的工作原理:文本快照与解析树
NRefactory 在 5.x 版本之后是独立维护的代码分析库,它做的事情是把 C# 源码解析成语法树,再基于语法树的位置信息计算某个光标位置「可能出现的成员」。这跟 Roslyn 的思路很像,但 NRefactory 是纯独立实现,不依赖编译器后端,加载速度比 Roslyn 快很多。代价是它对最新 C# 语法的支持停在 C# 7 附近,record、global using这种新语法会直接解析失败。
补全引擎的核心接口是CSharpCompletionEngine,它需要三个依赖:ITextSource(文本源)、ICompletionDataFactory(补全项工厂)、IEntityCompletionData等辅助接口。我第一次接的时候在这里卡了两天,因为官方接口在不同小版本里变化很大,5.4 和 5.5 的命名空间就挪过位置。这里给出一个能跑的简化版:
var textSource = new StringTextSource(editor.Text); var completionEngine = new CSharpCompletionEngine( new DefaultCompletionContextProvider(textSource), new CompletionDataFactory(), textSource, new DefaultTypeResolveContext());大意是:StringTextSource把编辑器的字符串包成 NRefactory 需要读取的文本快照,DefaultCompletionContextProvider提供命名空间和 using 上下文信息,DefaultTypeResolveContext负责解析类型引用。这三个是 NRefactory.Analysis 程序集里的基础类,不需要额外写实现。
然后通过GetCompletionData拿补全列表:
var completions = completionEngine.GetCompletionData(editor.CaretOffset, false);第一个参数是光标所在文档偏移量,第二个参数是「是否按 Ctrl+J 强制触发」。传false时只有在句点后面或using关键字后面等明确位置才返回结果,传true则不管光标在哪都给出候选。实际使用中前者适合自动弹补全,后者适合手动唤出。
3.2 实现 ICompletionDataFactory:把提示项喂回编辑器
ICompletionDataFactory是 NRefactory 补全系统里最啰嗦的接口,它有一大堆方法要重写,CreateEntityCompletionData、CreateKeywordCompletionData、CreateImportCompletionData等等。实现类长这样:
public class CompletionDataFactory : ICompletionDataFactory { public ICSharpCode.AvalonEdit.CodeCompletion.ICompletionData CreateEntityCompletionData( IEntity entity) { return new TextCompletionData(entity.Name, entity.Documentation?.Sumarize()); } public ICompletionData CreateKeywordCompletionData(string keyword) { return new TextCompletionData(keyword, "关键字"); } // 其余方法类似,按需返回或返回 null }这里有个陷阱:NRefactory 的ICompletionData命名空间和 AvalonEdit 的ICompletionData是两个完全不同类型,而CreateEntityCompletionData的返回值是 NRefactory 的接口。要实现联动,需要让 AvalonEdit 的补全窗口去适配 NRefactory 返回的数据——AvalonEdit 的CompletionWindow接收的是ICompletionData,所以TextCompletionData这个类要自己写,实现 AvalonEdit 接口,内部持有 NRefactory 返回的文本和描述。
AvalonEdit 的补全项渲染默认只显示文本和图标,图标部分ICompletionData.Image通常返回null,列表里会留一块空白,视觉上不美观。我一般用内置的SegmentDisplayString之类的实现或者干脆自己做DataTemplate。描述信息Description属性会在选中项时显示在 tooltip 里,没有实现就一片空白,这对新手不太友好。
3.3 提示触发时机:输入、Ctrl+J 与事件队列
补全触发不能只监听TextChanged,因为输入句点的时候光标是紧跟在后面的,需要延迟一点拿最终的文档快照。我用的方案是键盘事件 + 定时器:
editor.TextArea.TextEntered += (s, e) => { if (e.Text == "." || e.Text == "(") { timer.Stop(); timer.Start(); } }; editor.TextArea.KeyDown += (s, e) => { if (e.Key == System.Windows.Input.Key.Space && e.KeyboardDevice.Modifiers == ModifierKeys.Control) { ShowCompletion(); } };延迟一般设 200ms 到 400ms。TextEntered的e.Text是当前输入的单个字符,通过判断它来触发不是绝对严谨——比如在字符串里输入句点也会弹补全。要做文本语义判断,就得看 NRefactory 的解析结果,但那又太慢。折中方案是判断editor.CaretOffset前后的字符,如果前一个字符是引号或注释标记,就跳过不弹。
强制 Ctrl+J 触发还有一个细节:补全窗口打开后焦点会跳到窗口内部,如果用户这个时候点击编辑器,原来的TextEntered事件会被中断,导致补全窗口残留。需要在窗口的Closed事件里做清理,把定时器和临时状态都重置掉。
4. Roslyn 动态编译:从 CSharpCompilation 到 AssemblyLoadContext
4.1 为什么用 CSharpCompilation 而不是 CodeDom
.NET Core 里原来的CodeDomProvider已经不提供动态编译,微软官方把能力迁移到了 Roslyn 的CSharpCompilation。这是个好事,因为 Roslyn 的编译管线可以完全在内存中操作,不需要生成临时项目文件。它把「源码字符串 → 语法树 → 编译对象 → 程序集字节流」整个链路都暴露给开发者,每一个环节都可插拔。
如果你只是执行一段简单表达式,CSharpScript.EvalAsync是更快的路径,但它是解释式执行,每次调用都要重编,循环里反复调用性能会很难看。CSharpCompilation一次编译输出完整程序集,同一份编译结果可以反复调用,适合脚本宿主的场景。
var syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); var compilation = CSharpCompilation.Create( assemblyName: "Script_" + Guid.NewGuid().ToString("N"), syntaxTrees: new[] { syntaxTree }, references: references, options: new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release, allowUnsafe: true));LanguageVersion.Latest表示用当前 Roslyn 版本支持的最高语法,C# 12 的集合表达式在这也能过。DynamicallyLinkedLibrary指定输出为 DLL,比ConsoleApplication更灵活,因为你还要在宿主进程里调用它。allowUnsafe默认是false,写惯了指针代码的人容易在这里翻车——编译报错信息是「不安全代码只会在 /unsafe 下出现」,排查半天才发现是选项没开。
4.2 编译选项与引用程序集收集
引用程序集是另一个重灾区。Roslyn 编译不是说你using System;它就知道System.Console在哪,你必须显式传入MetadataReference集合。最容易想到的做法是把当前进程加载的所有 DLL 都加进去:
var references = AppDomain.CurrentDomain.GetAssemblies() .Where(a => !a.IsDynamic) .Select(a => MetadataReference.CreateFromFile(a.Location)) .ToList();这在桌面宿主里能用,但在 .NET Core 单文件发布场景下有隐患:a.Location可能返回空字符串,因为单文件发布把所有程序集打包进了宿主可执行文件,MetadataReference.CreateFromFile就抛异常了。更稳定的做法是直接从AppContext.BaseDirectory扫描:
var refs = new List<MetadataReference>(); foreach (var dll in Directory.GetFiles(AppContext.BaseDirectory, "*.dll")) { try { refs.Add(MetadataReference.CreateFromFile(dll)); } catch { /* BadImageFormat 等,单个失败不影响整体 */ } }这里不建议盲目把几百个 DLL 全引进去——Roslyn 编译时会做程序集绑定解析,重复引用相同名字但不同路径的程序集会报冲突警告,甚至让你看到「引用不明确」的诡异错误。另一个问题是编译速度:引用对象越多,编译越慢。我一般只扫描System.*、Microsoft.*以及功能相关的自定义 DLL,这样一次编译耗时能从 800ms 降到 200ms 左右。实测数字供参考:200 个引用程序集编译 300 行脚本约 600ms;精简到 60 个约 180ms;如果脚本里没用到什么特殊 API,只引System.Console所在程序集再加System.Runtime,能把编译压到 80ms 附近。
4.3 在 .NET Core 下加载并执行编译结果
Emit之后把程序集字节流直接写进MemoryStream,然后要么Assembly.Load到当前上下文,要么放进自定义AssemblyLoadContext。不要在路径上落盘再Assembly.LoadFile,那是把问题往自己身上揽——文件锁、清理、重新生成,每一步都是坑。
using var ms = new MemoryStream(); var emitResult = compilation.Emit(ms); if (!emitResult.Success) { var firstError = emitResult.Diagnostics .First(d => d.Severity == DiagnosticSeverity.Error); // 把 firstError 展示给用户 return; } ms.Seek(0, SeekOrigin.Begin); var loadContext = new ScriptLoadContext(); var assembly = loadContext.LoadFromStream(ms); var entry = assembly.GetType("ScriptEntry")?.GetMethod("Main"); entry?.Invoke(null, new object[] { args });ScriptLoadContext的核心是隔离:
public class ScriptLoadContext : AssemblyLoadContext { protected override Assembly Load(AssemblyName assemblyName) { // 优先从默认上下文解析,避免重复加载基础库 var defaultAssembly = AssemblyLoadContext.Default.Assemblies .FirstOrDefault(a => a.GetName().Name == assemblyName.Name); return defaultAssembly ?? base.Load(assemblyName); } }自定义上下文的好处有两个:一是每次重新编译可以重新LoadFromStream,旧版本程序集不会覆盖新版本,没有任何文件锁问题;二是可以随时Unload()释放旧版本占用的内存。但注意Unload()不能立即生效,要等下一次 GC 触发。调试脚本宿主的老手会说「玄学」——其实原理是调试器持有程序集引用,禁用了Unload的核心能力,所以线上发布一定要去掉调试器挂载。
5. 三层联调避坑记:五个最常见的翻车现场
5.1 提示不弹出来:NRefactory 版本 API 不匹配
现象:代码写好了,运行不报错,但输入句点后补全窗口死活不出来,加断点发现GetCompletionData返回空集合。
原因:NRefactory 5.5 之后把CSharpCompletionEngine的构造函数改过,从「直接传ITextSource」变成「传CSharpCompletionContext」,旧代码编译通过是因为某些重载存在,但运行时解析环境的上下文是空的,导致GetCompletionData永远返回空。
解决:把工程里的ICSharpCode.NRefactory稳定锁定在 5.3.0,或者按新 API 走迁移方案。我一般直接锁版本,packages.lock.json里把版本单一化,避免传递依赖把版本顶上去。
5.2 编译后的程序集被锁死:无法重新生成
现象:脚本第二次编译报「文件被另一个进程占用」,或者第一次执行后 DLL 文件删不掉。
原因:Assembly.LoadFile或者Assembly.LoadFrom会把文件锁住,垃圾回收也不一定释放,特别是执行完还没有卸载上下文。
解决:统一走MemoryStream+ 自定义AssemblyLoadContext,不要落盘。每次编译都 new 一个上下文,旧上下文在确认不再使用后调用Unload(),再强制GC.Collect(),等待下次执行自然回收。
5.3 UI 卡死:Roslyn 编译把界面线程整崩溃
现象:脚本行数超过 300 行或引用程序集特别多时,界面冻结三五秒,拖动窗口跟拉锯一样。
原因:compilation.Emit是 CPU 密集操作,直接跑在 UI 线程里,占满了线程导致渲染和消息队列全部停滞。
解决:把编译和加载部分包进Task.Run,编译完再切回Dispatcher更新 UI。同时把ConcurrentBuild打开(默认就是 true),多核机器上能明显缩短编译时间。注意LoadFromStream不要在子线程里做后被AssemblyLoadContext对象被 GC 收掉,引用要提前存好。
5.4 AvalonEdit 的光标与补全窗口抢焦点
现象:补全窗口弹出时,上下键选不中候选,方向键全被编辑器吞了,或者鼠标在列表上滚动时会连带滚动编辑器内容。
原因:CompletionWindow本身监听键盘事件,但它挂载到的TextArea同时也在处理相同按键。两个输入管道竞争,结果取决于事件路由顺序。
解决:初始化补全窗口时把TextArea.InputHandler里无关的处理器临时卸掉,只保留补全需要的那段逻辑。代码里要做防御,给CompletionWindow.Closed事件把处理器重新挂回去。这个坑不设防就会出现「用了一次补全之后编辑器方向键失灵」的诡异现象,很影响口碑。
5.5 高亮文件路径在打包后失效
现象:开发机跑得好好的,发布成单文件后语法高亮没了,控制台报DirectoryNotFoundException。
原因:xshd 文件被打进单文件包后,原来的相对路径访问方式失效,File.OpenRead("CSharp.xshd")指向的工作目录中没有这个文件。
解决:改成嵌入资源,用Assembly.GetManifestResourceStream读取,路径写全。如果不想改代码,也可以把 xshd 放到一个「不打包」的独立目录并在启动时检测路径存在性,但嵌入资源是唯一不需要处理当前工作目录的方案。
6. 联调验证与进阶:把零散模块拧成一个可用的脚本宿主
6.1 一条完整的执行链路
把前三章的东西串起来,一个最小可用的脚本宿主链路是:用户在TextEditor里输入源码,按下 Ctrl+Enter,触发编译执行;输入过程中 AvalonEdit 做高亮和折叠,NRefactory 提供补全。链路里的关键事件点是TextChanged和KeyDown,每次编译都要把editor.Text完整传给 Roslyn,然后拿到输出结果回显到下方一个只读的TextEditor里。
补全的延迟参数我习惯这样调:TextEntered后开 300ms 的DispatcherTimer;如果用户连续输入,每次击键都重置定时器——这也意味着快速敲代码时不会频繁弹补全,体验更流畅。手动触发 Ctrl+J 不受定时器限制,立刻弹。
6.2 性能参数与调优取舍
下面是三个必须盯紧的数字,拿一台普通 i5 笔记本测试:
| 环节 | 参数 | 建议值 |
|---|---|---|
| 补全触发延迟 | 自动弹出 | 200ms~400ms |
| 编译耗时 | 100 行脚本 | 100ms~200ms |
| 编译耗时 | 1000 行脚本 | 800ms~1200ms |
| 程序集加载 | 单次执行后内存 | 20MB~80MB |
如果编译耗时超过 1 秒,先把OptimizationLevel调成Debug会显著缩短,缺点是执行速度慢一点;对脚本宿主的交互来说通常是编译等待时间更敏感。另有一个容易忽略的点:Compilation对象本身也会占用内存,大量重复编译后要主动置空引用,否则内存曲线会一直往上爬。
6.3 给脚本宿主加扩展点:NuGet 引用与调试接口
进阶玩法是把 NuGet 包引用接进来。做法是本地维护一个包缓存目录,脚本里通过#r "MyLib.dll"指令引用自定义库,宿主解析#r指令,从缓存目录收集 DLL 并加入references。这样使用者可以往脚本里塞自己的公共库。再加一个「附加调试器」的开关,如果检测到调试器已附加,就把CSharpCompilationOptions里的EmitDebugInformation打开,并输出 PDB 到临时目录,让断点能打进脚本代码里——这一步是内部工具提效最明显的功能,但那个 PDB 与程序集字节流的配对关系,不同 Roslyn 版本有细微差异,新版本里我用Emit(ms, pdbStream)双重流输出,旧版本则得另存磁盘。
从那以后我每接一个编辑器工具类需求,都会先强制走一遍「编辑器初始化 → 补全引擎接控件 → Roslyn 编译链路 → 内存隔离验证」这四步,确认每一层的边界再写业务代码。前期多花半小时校准接口,后期少熬几个深夜,这套三层组合的坑基本就这些。希望帮到你。
本文还有配套的精品资源,点击获取