ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

打造WPF迷你IDE:AvalonEdit+NRefactory+Roslyn集成指南

打造WPF迷你IDE:AvalonEdit+NRefactory+Roslyn集成指南 简介面向.NET开发者的一款完整文本编辑器实现方案整合AvalonEdit、NRefactory与Roslyn三项核心组件解决在Framework与NetCore环境下动态编译的兼容问题。适合需要自研编辑器、代码高亮及智能提示功能的桌面应用开发者参考。压缩包共620个文件以271个C#源码、89个DLL库、22个Xshd语法文件及8个XAML界面文件为主体配套配置文件与文档整体约13.69MB可直接编译运行。已有743人学习下载。资源不仅展示AvalonEdit的基础编辑操作高亮、复制粘贴、撤回还通过NRefactory实现代码提示并使用Roslyn完成跨平台动态编译相比CSharpCodeProvider更具通用性。目录结构清晰包含示例工程与依赖组件便于二次开发与集成学习。1. 先搞清楚这套迷你IDE组合到底解决了什么问题我在给一个内部工具做脚本编辑器的时候摸遍了.NET生态里能做代码编辑的控件最后落地的方案是 AvalonEdit NRefactory Roslyn 这一套组合——AvalonEdit负责文本编辑和语法高亮NRefactory负责IDE式的智能提示Roslyn把用户写的代码动态编译执行。整篇文章会把搭建过程、关键代码和踩过的坑完整写出来适合正在为WPF应用寻找代码编辑器方案、或者想给工具加上脚本能力的.NET开发者。很多人第一反应是放一个RichTextBox然后自己处理语法高亮。我做过一次之后果断放弃——高亮、缩进、智能提示、错误标记全都自己写工作量跟重造一个IDE差不多。后来又想过把VS Code嵌进来但重量级组件带来的集成成本、更新维护和UI风格统一问题对一个内部工具来说都不太划算。最终我回到WPF生态用SharpDevelop系的三件套拼出了一个带完整编辑体验的内嵌代码编辑器核心代码控制在几百行内效果接近一个迷你VS Code。为什么是这三个组件而不是别的分工非常清晰AvalonEdit是SharpDevelop项目独立出来的WPF编辑器控件自带高性能文本渲染、语法高亮、行号、折叠、缩进策略做编辑界面几乎不需要自己造轮子。NRefactory同样出身SharpDevelop专门负责解析C#代码并生成智能补全数据虽然Roslyn发布后这个库基本停止维护但单论给编辑器提供代码提示这个能力它依然轻量、够用。Roslyn则是.NET官方的编译器平台用它做动态编译能把用户写进编辑器里的C#代码变成真正可运行的程序集配合反射调用就完成了脚本执行。有一点需要提前说明如果只做执行代码这一个动作直接用Roslyn的CSharpScript就能搞定完全不需要NRefactory和AvalonEdit。但现实中用户不可能在记事本里写代码再切过来执行他们需要的是有高亮、有提示、有错误反馈的编写环境。三者缺一个这个工具就只能算半个成品。所以这篇文章的核心其实是讲清楚这三块各自完成什么、边界在哪里、怎么粘合而不是单纯堆API。2. AvalonEdit编辑器让文本编辑区先达到IDE手感先装包NuGet直接搜AvalonEdit命令行则是Install-Package AvalonEdit在WPF的XAML里放一个TextEditor控件非常直接avalonedit:TextEditor x:Nameeditor FontFamilyConsolas FontSize14 ShowLineNumbersTrue SyntaxHighlightingC# WordWrapFalse /光这样放进窗口已经能获得行号、C#语法高亮、文本选择、括号高亮这些基本能力。SyntaxHighlighting赋值C#是AvalonEdit内置的高亮规则底层用的是HighlightingManager.Instance里的定义你也可以换成XML、JavaScript等语言。但要让编辑区有IDE手感还有几个配置不能漏。第一个是智能缩进。默认情况下TextBox系列的控件是纯文本行为回车后不会自动补缩进。AvalonEdit提供了Options和IndentationStrategy可以这样设置editor.Options new TextEditorOptions { ConvertTabsToSpaces true, IndentationSize 4, EnableHyperlinks true }; editor.TextArea.IndentationStrategy new CSharpIndentationStrategy(editor.Options);设置之后在方法体、命名空间里回车会自动带上上一层级的缩进。CSharpIndentationStrategy会结合花括号位置做智能缩进这对C#代码尤其重要。没有缩进策略的话写花括号嵌套的代码会让人崩溃这段配置值得被放进你的初始化代码里。第二个是代码折叠。对于脚本工具来说用户写几百行代码时方法的折叠非常常用。AvalonEdit的折叠通过CodeFoldingManager完成_foldingManager editor.TextArea.InstallFoldingManager(); var foldingStrategy new BraceFoldingStrategy(); foldingStrategy.UpdateFoldings(_foldingManager, editor.Document);BraceFoldingStrategy是AvalonEdit Demo工程里提供的一个示例策略基于{和}配对做折叠。如果你的编辑器需要支持#region折叠需要自己写一个策略不过示例实现已经能覆盖大多数方法/类折叠场景。别忘了在TextDocument的TextChanged事件里再次调用UpdateFoldings否则折叠区域不会随内容变化。第三个是错误标记的基础设施。AvalonEdit本身没有内置红色波浪线但提供了TextMarkerService这样一个示例类核心原理是实现IBackgroundRenderer和IVisualLineTransformer两个接口在绘制层面给指定字符范围画一条下划线。初始化方式如下var markerService new TextMarkerService(editor.Document); editor.TextArea.TextView.BackgroundRenderers.Add(markerService); editor.TextArea.TextView.LineTransformers.Add(markerService);有了这个服务后面把Roslyn编译错误画成波浪线就轻松了。我在实际项目里用Dictionaryint, TextMarker管理错误标记每次标记数量变化时调用TextView.InvalidateLayer重绘。这个基础设施建议一开始就装上别等要做错误提示了再回头加。3. NRefactory代码提示从看懂代码到弹出补全NRefactory在NuGet上的包名就是NRefactory我用的版本是5.6.0这是这个库最后一代稳定版。它的作用是把编辑器里的C#代码解析成语法树和类型系统然后在某个光标位置计算出候选补全项。API设计虽然老但在代码提示这个单一功能上仍然够用。接入过程最大的坑是AvalonEdit的TextDocument和NRefactory期望的编辑器接口并不直接兼容。NRefactory的CSharpCompletionEngine需要一个ITextSource类型的文档对象而AvalonEdit的TextDocument实现的是自己的一套文本模型。解决办法是自己包一层适配器核心就一个方法按区间返回文本片段。网上各种版本很多我这里的实现是这样的public sealed class AvalonEditTextSource : ITextSource { private readonly TextDocument _doc; private readonly string _text; public AvalonEditTextSource(TextDocument doc) { _doc doc; _text doc.Text; } public string Text _text; public int TextLength _doc.TextLength; public ITextSource CreateSnapshot() this; public ITextSource CreateSnapshot(int offset, int length) new AvalonEditTextSource(_doc); public char GetCharAt(int offset) _doc.GetCharAt(offset); public string GetText(int offset, int length) _doc.GetText(offset, length); public void WriteTextTo(TextWriter writer) writer.Write(_text); public void WriteTextTo(TextWriter writer, int offset, int length) writer.Write(_doc.GetText(offset, length)); public event EventHandler TextChanged { add { } remove { } } }注意CreateSnapshot时我直接返回了完整文本的快照。NRefactory在解析时会基于快照做计算如果快照和实时文档不一致补全结果可能过期所以要在每次触发补全时重新new一个AvalonEditTextSource保证拿到的是最新文本。接下来是NRefactory补全引擎的初始化这是整个集成里最绕的部分var source new AvalonEditTextSource(editor.Document); var syntaxTree new CSharpParser().Parse(source, script.cs); var unresolvedFile syntaxTree.ToTypeSystem(); var typeSystem new SimpleTypeResolveContext(unresolvedFile); typeSystem.UsingScope unresolvedFile.RootUsingScope; var resolver new CSharpAstResolver(typeSystem, syntaxTree); var completion new CSharpCompletionEngine( source, new MyCompletionDataProvider(), typeSystem, syntaxTree, resolver ); var completions completion.GetCompletionData(offset, out string word);这里有两个关键点。第一个是SimpleTypeResolveContext必须设置UsingScope。不设置的话System、System.Collections.Generic这些命名空间的类型一个都补全不出来因为代码提示引擎依赖当前文件的using指令来解析类型。这是非常容易踩的坑我一上来没设置结果弹出来的补全全是空的排查了很久才意识到是这个问题。第二个是MyCompletionDataProvider它实现ICompletionDataProvider接口核心方法是CreateCompletionData。实际使用时NRefactory引擎把补全项抽象成ICompletionData我在这里包了一层把显示文本、描述、图标分离开public class CompletionData : ICompletionData { public CompletionData(string text, string description) { CompletionText text; Description description; } public string CompletionText { get; } public string Description { get; } public double Priority 0; public object Content CompletionText; public object DescriptionObject Description; public string Category null; public string Image null; public void InsertAction(ITextEditor editor, int offset) { } }补全列表的UI我用了一个WPF的Popup ListBox触发时机控制在两种用户按CtrlSpace主动触发或者用户输入了字母、下划线、点这些补全友好字符。每次触发时先拿到编辑器当前光标位置的Offset调用GetCompletionData拿到候选数据再定位到光标处把Popup弹出来。选择某个补全项后把CompletionText插入到编辑器当前光标位置即可。需要说明的是NRefactory支持配置程序集引用列表通过AddAssemblyReferences传入类型系统后补全结果会更准确。我在实际工具里做了基础版本补全主要覆盖当前代码内部符号和标准库类型日常脚本使用已经足够。如果你的项目需要引用自己程序集里的公共类可以考虑在这个阶段把相关MetadataReference对应的程序集加入类型系统。4. Roslyn动态编译把用户代码真正跑起来代码提示做好了编辑器里的代码已经能写得很舒服但下一步更关键——用户写完代码得能跑。这就是Roslyn登场的地方。Roslyn的全称是.NET Compiler Platform它把C#编译器本身做成了一套公开的API解析、编译、语义分析都能在代码里完成。做动态编译有两种路数先说简单的CSharpScript。Microsoft.CodeAnalysis.CSharp.Scripting包提供了CSharpScript.EvaluateAsync可以直接执行表达式或一段脚本var result await CSharpScript.EvaluateAsync(code, ScriptOptions.Default .WithReferences(assemblies) .WithImports(System, System.Linq, System.Text));这种方式在做计算器、规则引擎这类场景非常方便。但它有两个短板每次执行都要重新编译做不了编译一次多次执行也不方便拿到程序集做反射调用。所以我最终选择的是更传统的做法——CSharpCompilation编译到内存程序集再用反射执行。核心代码如下var tree CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(LanguageVersion.Latest)); var refs AppDomain.CurrentDomain.GetAssemblies() .Where(a !a.IsDynamic !string.IsNullOrWhiteSpace(a.Location)) .Select(a MetadataReference.CreateFromFile(a.Location)) .ToList(); var compilation CSharpCompilation.Create( ScriptAssembly, new[] { tree }, refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using var ms new MemoryStream(); var emitResult compilation.Emit(ms); if (!emitResult.Success) { foreach (var d in emitResult.Diagnostics.Where(d d.Severity DiagnosticSeverity.Error)) { errorMessages.Add(d.ToString()); } return; } ms.Seek(0, SeekOrigin.Begin); var assembly Assembly.Load(ms.ToArray()); var entryType assembly.GetType(Script.MainEntry); var runMethod entryType?.GetMethod(Run, BindingFlags.Public | BindingFlags.Static); runMethod?.Invoke(null, null);我约定用户代码的顶层必须包含一个静态类Script.MainEntry和静态方法Run这样反射调用的目标稳定。ParseText解析代码、Create创建编译单元、Emit输出到内存流然后Assembly.Load加载并反射调用入口方法。这种方式粒度可控编译错误可以通过emitResult.Diagnostics拿到文本描述和具体位置运行时可以在后台线程调用甚至能给Run方法传参。编译引用这块有个细节值得多说。用AppDomain.GetAssemblies().Select(a MetadataReference.CreateFromFile(a.Location))的方式本意是拿到当前进程已经加载的所有程序集作为编译引用让用户代码能使用应用里已有的类型。但实际会遇到个别程序集Location为空或者抛异常所以过滤掉IsDynamic是必要的。如果你的用户代码只需要基本库也可以完全自己指定最小引用集合比如System、System.Core、System.Linq这几个编译速度会快不少。动态编译在解释器型工具里属于高频操作Emit失败的编译会话代价就浪费了。我后来把编译结果做了缓存根据代码文本的哈希值缓存已编译成功的Assembly代码没变就直接复用。在用户没改代码但反复点运行的场景下基本没有任何额外开销这个优化做得很值。5. 三件套联调编辑、提示、编译如何顺畅协作三个组件各自跑通只是开始真正让它们像一个整体靠的是几个关键链路设计。第一步是事件流。AvalonEdit的TextDocument在内容变化时会触发TextChanged事件我在这个事件里做两件事标记缓存需要刷新并触发一个防抖定时器。代码提示更新不需要每次按键都做我设了300毫秒延迟用户连续输入时只触发最后一次解析var timer new DispatcherTimer { Interval TimeSpan.FromMilliseconds(300) }; timer.Tick (s, e) { timer.Stop(); RefreshCodeInfo(); }; editor.TextArea.TextView.DocumentChanged (s, e) { timer.Stop(); timer.Start(); };RefreshCodeInfo里同时做两件事用NRefactory重新解析文本并刷新补全数据的缓存同时把文本交给Roslyn做一次语法层面的快速检查完整编译留给运行按钮。这个输入完自动检查机制配合前面装的TextMarkerService就能在编辑区里实时标出语法错误。第二步是补全弹窗的定位和插入。Popup的定位用的是AvalonEdit的TextView.GetVisualPosition这个API把光标所在的文本位置换算成控件内的视觉坐标设置Popup的Left和Top就能把列表精确弹到光标附近。插入文本时直接操作editor.Documentvar caretOffset editor.CaretOffset; editor.Document.Insert(caretOffset, completionData.CompletionText); editor.CaretOffset caretOffset completionData.CompletionText.Length; editor.TextArea.Caret.BringCaretToView();第三步是错误定位与跳转。Roslyn的Diagnostic对象里每条错误都有Location带着SourceSpan从文本偏移量换算成行列位置就好var position tree.GetText().Lines.GetLinePositionSpan(span); var startLine position.Start.Line 1; var startCol position.Start.Character 1;然后通过TextMarkerService在对应位置画红色波浪线同时在下方错误列表里加一行。用户点击错误列表项时把编辑器光标定位到对应行列并让TextView滚动到可视区域。这套交互逻辑是参考VS的错误列表—点击跳转做的用户接受度很高。第四步是线程处理。NRefactory解析和Roslyn编译都比较耗时不能放在UI线程。我的做法是用后台Task跑解析编译拿到结果后通过Dispatcher回到UI线程更新界面。这里有个容易忽略的坑AvalonEdit的TextDocument不是线程安全的如果你在后台线程解析编译后直接修改Document会引发竞态。安全的做法是后台只处理数据所有对Document的插入、标记、折叠更新全部回到UI线程执行。我因为这个问题遇到过几次随机崩溃Debug时才发现都是文档状态不一致导致的。6. 实测踩坑记录那些文档里不会写的细节这套方案落地时真正花时间攻克的问题我整理成五个坑每个都附了最终结论。第一个坑是NRefactory的版本和环境兼容性。NRefactory 5.x目标框架是.NET Framework如果你的WPF项目是.NET 6之后的新版本直接引用会遇到依赖问题。我的做法是保留.NET Framework 4.8作为目标框架整个编辑器、提示、编译逻辑都基于这个版本。既然用AvalonEdit和NRefactory这套老方案最稳的就是待在.NET Framework 4.7.2或4.8上不要强行升级到.NET Core。第二个坑是NRefactory的补全结果偶发为空。除了上面提到的要设置UsingScope还有一个隐藏条件CSharpCompletionEngine在解析时依赖当前文件顶部的using语句如果用户代码一个using都不写引擎会倾向于只显示当前命名空间内部的符号。我的解决方案是在初始化时预设一批默认using导入把它放进SimpleProjectContent里作为默认全局using。这样即便用户不写using System补全里也照样有List 、Dictionary之类。第三个坑是Roslyn的Emit和Assembly.Load在WPF应用中的内存管理。第一次加载成功后程序集会锁进当前AppDomain代码修改后再编译出新Assembly加载进来两个版本的相同类型会同时存在反射调用时可能发生类型混淆。理论上只有重启应用或者用独立AppDomain才能卸载。我的折中方案是脚本编辑后必须重新点击运行按钮才触发新编译反射时通过GetTypes筛选最新版本避免旧类型干扰。如果以后想真正做到热更新建议每次运行在独立AppDomain执行完整体卸载代价是交互会变重。第四个坑是Console.WriteLine的输出去向。用户代码里写了Console.WriteLine输出默认进控制台窗口在WPF工具里看不到。我的解决方案是在调用Run方法前把这个进程的Console输出重定向到界面的文本框Console.SetOut(new ControlWriter(outputTextBox));ControlWriter继承TextWriter内部通过Dispatcher把文本追加到TextBox。Roslyn编译出的代码运行在同一个进程空间这个重定向对动态编译后的代码完全有效。第五个坑是字体和输入法体验。AvalonEdit默认字体建议用Consolas或JetBrains Mono中文注释才不会乱。输入中文时IME的兼容性需要显式开启editor.TextArea.TextView.Options.EnableImeMode true; editor.Options.AllowScrollBelowDocument true;不加这个配置在编辑器里打中文会出现光标漂移或候选框位置不对的情况。这个细节用户反馈里被提得最多比部分功能缺失还影响体验。整套方案从空荡荡的WPF窗口做到能写代码、有提示、能运行我花了两个多工作日。后来又陆续加了错误标记、输出重定向、折叠策略这些细节才变成内部工具里最常用的功能模块。如果你正在计划给应用加脚本能力AvalonEdit NRefactory Roslyn这套组合目前来看仍然是成本最低、可控性最好的路线之一。别指望它达到VS Code的完整体验但作为嵌入业务系统里的轻量脚本编辑器它的完成度和维护性已经足够让人满意。最后再提一句实际项目中别忘了给代码提示增加按键防抖和编译缓存这两个优化能明显提升连续输入和反复运行时的体验。本文还有配套的精品资源点击获取
返回列表