十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C#内嵌脚本编辑器实战:AvalonEdit与Roslyn的完美结合

C#内嵌脚本编辑器实战:AvalonEdit与Roslyn的完美结合 简介这是一套面向C#开发者的代码脚本编辑器实现方案适合在视觉软件等产品中嵌入可灵活扩展的脚本功能也适合希望了解编译运行机制的读者参考无论是做独立工具还是嵌入现有系统都能提供清晰的实现思路。方案内含主程序与类库两个工程支持编写脚本后直接编译运行输出运行结果并自动提示编译错误同时可引入第三方库相当于一个迷你版Visual Studio。资源文件共156个以dll库文件71个、C#源文件23个和exe可执行程序为主辅以pdb调试符号、config配置、resx资源等按功能可分为源码、依赖库与运行示例压缩包整体约30.37MB结构紧凑。目前已有937人学习下载代码可直接导入项目中二次改造也可根据项目需要调整界面与功能对需要快速集成脚本编辑功能的桌面应用开发场景具有实用参考价值。1. 为什么要在系统里内嵌一个脚本编辑器不只是给程序员用做C#开发这些年我遇到过不少编辑器需求上位机项目里客户希望不重新编译就能调整通信协议参数管理系统的BOM表规则经常变每次都要发新版本还有做自动化测试的测试用例想跑的时候自己改但又不能让他们碰Visual Studio。这些需求最终都指向同一个东西——在软件里提供一块能写代码、能运行代码的区域。我第一次做这个功能的时候以为拿个TextBox就能顶住结果给领导演示时被当场质疑客户要的不是记事本是像Visual Studio那样的界面。从那以后我就明白了所谓的C#实现代码脚本编辑器功能核心能力其实就四块文本编辑体验、语法高亮、智能提示、脚本执行。而每一块都有不少坑等着你。接下来的经验以C# WinForms为主来分享因为做上位机和管理系统的同学大多数还是WinForms阵营但WPF和Web方案我也会给出选型对比方便不同方向的读者参考。如果你正准备给自己的项目加一个脚本编辑器这篇文章应该能帮你省下不少调研时间。2. 编辑器的地基选对组件后面的事才谈得上2.1 最常见的错误拿RichTextBox硬扛先说一个真实教训。我最初想省事用WinForms自带的RichTextBox做编辑器配合SelectionColor做着色头两天看着效果还行。但脚本一长问题立刻暴露RichTextBox的着色方式是先匹配文本再逐段设置颜色对1000行以上的脚本性能急剧下降。更要命的是撤销重做会把你手动设置的SelectionColor一起清掉需要花大量精力去维护自己的撤销栈。另一个大坑是RichTextBox的键盘事件和处理中文输入法时非常不配合。一旦你的脚本里含有中文字符串或者用户通过输入法输入注释里的中文SelectionChange事件会频繁触发主题色块会乱跳甚至导致光标回到行首。我调试了整整一个下午最后把RichTextBox方案全删了只保留了一个经验别在这种底层控件上硬抠代码编辑器功能除非你的场景真的就是几十行配置脚本。2.2 AvalonEdit目前最靠谱的免费开源方案选型了一圈之后我把目光锁定在AvalonEdit上。这个编辑器是SharpDevelop团队从IDE里独立出来的本身就是用来做完整代码编辑的不是普通文本控件的替代品。它支持语法高亮、折叠、行号、括号高亮、代码补全扩展点而且是MIT协议商业项目可以放心用。在WinForms里用AvalonEdit需要多走一步通过ElementHost承载WPF控件。做法不复杂先安装NuGet包Install-Package AvalonEdit然后在窗体代码里初始化using System.Windows.Forms.Integration; using ICSharpCode.AvalonEdit; using ICSharpCode.AvalonEdit.Highlighting; public partial class ScriptForm : Form { private TextEditor _editor; public ScriptForm() { InitializeComponent(); var host new ElementHost { Dock DockStyle.Fill }; _editor new TextEditor(); _editor.FontFamily new System.Windows.Media.FontFamily(Consolas); _editor.FontSize 14; _editor.ShowLineNumbers true; // 启用C#语法高亮 _editor.SyntaxHighlighting HighlightingManager.Instance.GetDefinition(C#); host.Child _editor; this.Controls.Add(host); } }代码量不大但功能已经有了质的飞跃。TextEditor这个控件内部用了虚拟化渲染处理上万行脚本也不卡撤销重做、复制粘贴、查找替换这些编辑器的基本体验都齐了。注意ElementHost需要随窗体一起释放否则可能在关闭窗口时出现WPF的Dispatcher未关闭警告这个虽然不影响业务但看着很烦。2.3 为什么不建议用ScintillaNET提到插件式编辑器很多人会想到ScintillaNET。我不能说它不行但结合我自己的维护经验Scintilla的原生版很强.NET封装则有一段时间没大更新了在.NET Framework 4.8和x64环境下经常遇到本机DllNotFound的坑。就算能用它走的是Win32控件路线API风格和C#的受控环境差别比较大要自己管理词法分析器的配置和样式映射学习成本并不低。所以我对团队的选型建议很简单如果是全新项目直接用AvalonEdit如果团队已经有一块成熟的Scintilla封装且跑得好好的就别折腾着换了稳定压倒一切。技术选型最怕的不是选错而是中途换。2.4 WebView2 Monaco的另类路线如果需求不是内嵌编辑器而是内嵌一个开发环境你可以考虑WebView2加载Monaco Editor。这条路线的好处是界面体验直接对标VS Code智能提示、多光标编辑这种高级功能开箱即用代价是它本质上是个浏览器页面和C#主程序的数据交互全部要通过postMessage之类的桥接方案脚本编辑器的保存、执行、错误定位都要自己设计消息协议。我的建议是你只是给用户一个临时脚本输入框用AvalonEdit如果你要做的是一个可视化规则编排平台用户要在里面大量编辑代码片段甚至小型项目那Monaco值得投入虽然通信成本高一点但值。3. 语法高亮让用户一眼看出这是代码3.1 内置的C#高亮定义怎么用AvalonEdit自带C#语法高亮定义这是它极大的便利之处。不用自己写正则规则拿过来就有效关键字、字符串、注释、数字的颜色都有现成的主题。刚才的初始化代码已经启用了C#高亮如果你想让脚本更接近Visual Studio的深色主题可以加载自定义HighlightingManager_editor.Background new System.Windows.Media.SolidColorBrush( System.Windows.Media.Color.FromRgb(30, 30, 30)); _editor.Foreground new System.Windows.Media.SolidColorBrush( System.Windows.Media.Color.FromRgb(220, 220, 220));不过需要提醒的是颜色只是外观真正决定识别效果的是一组规则包括关键字列表、字符串匹配模式、注释模式。AvalonEdit默认的C#高亮对常见的语法都覆盖了。我实测过从网上下载一份几百行的C#脚本粘贴进去高亮几乎没有识别错误这点让人省心。3.2 想支持自定义脚本语言用xshd文件定义很多项目里的脚本不是标准C#而是公司内部定义的一套类似C的规则语言。你照样可以让它高亮AvalonEdit支持通过XML格式的xshd文件来定义语言规则。xshd里可以声明关键字、数字格式、字符串规则、注释规则也可以定义嵌套规则和正则表达式匹配。我第一次编写自定义语言时踩过一个规则匹配的坑把注释规则定义在字符串规则之后导致注释中的引号被当成字符串起始符一整段后面的着色全乱了。后来调换声明顺序并给字符串规则加上不支持跨行的约束问题才解决。xshd的规则顺序很讲究匹配优先级是按照声明顺序来的把特殊规则放在前面通用规则放后面是这个文件编写的基本功。3.3 高亮性能大数据量脚本为什么还是会卡AvalonEdit虽然不容易被长文本卡死但如果你的脚本编辑器要支持实时校验每敲一个字符都做一次全量解析那性能依然会出问题。我把一次编译结果错误列表和语法高亮联动时频繁触发TextChanged事件导致界面一顿一顿的。方法很简单给事件节流。敲击停止350毫秒之后再去做编译或校验而不是每次击键都处理System.Windows.Threading.DispatcherTimer _debounceTimer; void Editor_TextChanged(object sender, EventArgs e) { _debounceTimer?.Stop(); _debounceTimer new System.Windows.Threading.DispatcherTimer(); _debounceTimer.Interval TimeSpan.FromMilliseconds(350); _debounceTimer.Tick (s, args) { _debounceTimer.Stop(); DoLint(); // 这里做真正的解析和校验 }; _debounceTimer.Start(); }这些经验虽然不是AvalonEdit官方文档里的核心内容但实际做编辑器功能时几乎每个项目都会碰到。特别是想做脚本错误提示这种进阶功能节流是必须迈过去的第一道坎。4. 智能提示与自动补全编辑器体验的分水岭4.1 一个够用的关键字补全实现智能提示是代码脚本编辑器里最显专业感的功能。AvalonEdit没有开箱即用的自动补全需要我们自己扩展。思路不算复杂监听输入事件在当前光标前提取正在输入的关键字然后将匹配项展示在弹出列表中。我用代码来演示一个基础方案先定义一个补全数据类public class CompletionData : ICompletionData { public CompletionData(string text, string description ) { Text text; Description description; } public System.Windows.Media.ImageSource Image null; public string Text { get; private set; } public object Content Text; public object Description { get; private set; } public double Priority 0; public void Complete(ICSharpCode.AvalonEdit.Document.TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) { textArea.Document.Replace(completionSegment, Text); } }然后挂载补全窗口using ICSharpCode.AvalonEdit.CodeCompletion; private CompletionWindow _completionWindow; private ListICompletionData _keywords new ListICompletionData { new CompletionData(if, 条件判断), new CompletionData(for, 循环), new CompletionData(foreach, 遍历集合), new CompletionData(var, 隐式类型), new CompletionData(async, 异步) }; void OnTextEntering(object sender, TextCompositionEventArgs e) { if (e.Text.Length 0) return; if (char.IsLetterOrDigit(e.Text[0])) { string word GetCurrentWord(); if (word.Length 2) // 至少两个字符再触发 { ShowCompletions(word); } } else { _completionWindow?.Close(); } } void ShowCompletions(string word) { if (_completionWindow ! null) return; var matched _keywords .Where(k k.Text.StartsWith(word, StringComparison.OrdinalIgnoreCase)) .ToList(); if (matched.Count 0) return; _completionWindow new CompletionWindow(_editor.TextArea); var data _completionWindow.CompletionList.CompletionData; foreach (var item in matched) data.Add(item); _completionWindow.Show(); _completionWindow.Closed (s, args) _completionWindow null; }这里面有个容易忽视的点弹出窗口可能连续触发所以每次弹出前要检查_completionWindow是否为空否则会出现多个补全层叠的情况。4.2 补全弹窗的时机为什么不能太激进我初版实现是只要输入一个字母就弹出补全马上被客户吐槽说太吵。输入注释、复制粘贴、甚至输入纯文本的时候都会跳出来体验很差。后来我加了几个约束条件再触发弹出当前不在注释区域内当前不在字符串字面量中光标前的单词长度大于等于2个字符且当前输入的语言上下文允许代码补全判断是否在注释里可以通过SyntaxHighlighting高亮信息反向检查在TextEntering事件里检查光标位置当前的颜色是否等于注释颜色。这个小技巧很管用虽然算不上完美但能让误弹率降低一大半。4.3 与CtrlSpace快捷键的配合鼠标点击和手动按键都应该能触发补全。我在Editor的PreviewKeyDown里挂了CtrlSpace强制弹出补全窗口。这里又有个坑CtrlSpace在中文输入法里一般是切换输入法的快捷键WinForms里有时会抢不过输入法。解决方法是改用KeyUp事件里检查Key.Space加上Control修饰键同时用ModifierKeys判断实测大部分输入法都拦不住了。如果你的项目允许可以直接禁掉输入法切换冲突或者给用户提供一个关闭系统输入法切换的设置避免用户敲快捷键时反复横跳。5. 让脚本真正跑起来C#脚本执行引擎的接入方案5.1 Roslyn脚本引擎微软官方能力编辑器只是壳真正的核心是脚本能执行。C#脚本执行最正规的方案是Roslyn提供的Microsoft.CodeAnalysis.CSharp.Scripting包。安装后几步就能跑起来Install-Package Microsoft.CodeAnalysis.CSharp.Scripting基础的执行代码using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; public async Taskobject RunScript(string code) { var script CSharpScript.Create(code); var state await script.RunAsync(); return state.ReturnValue; }但实际项目里你不可能就这么一跑至少要考虑宿主对象传入、程序集引用、异常处理这三件事。5.2 脚本与宿主程序之间如何共享数据脚本往往需要访问你程序里的变量和对象。比如上位机里脚本要读实时温度、控制电机启停。Roslyn提供了globals参数让你把宿主对象传给脚本public class ScriptGlobals { public double Temperature { get; set; } public ListDeviceState Devices { get; set; } public ILogger Logger { get; set; } } var options ScriptOptions.Default .WithReferences( typeof(ScriptGlobals).Assembly, typeof(System.Text.Json.JsonSerializer).Assembly, typeof(System.Threading.Tasks.Task).Assembly) .WithImports(System, System.Linq, System.Collections.Generic); var result await CSharpScript.EvaluateAsync( code, options, new ScriptGlobals { Temperature 36.5 });脚本里可以直接写if (Temperature 40) { Logger.LogWarning(温度过高); }这个能力很强大但要注意globals对象里不要暴露过多内部敏感对象脚本安全边界要提前设计好。你的脚本编辑器的使用者如果是内部技术人员安全要求可以宽松一点如果用户是第三方就必须控制可访问的成员防止脚本越权操作。5.3 脚本执行超时、取消和错误定位把执行和编辑器整合在一起时最容易忽略的细节是超时和取消。用户的脚本可能死循环也可能阻塞等待网络如果不管你的主程序就完了。我在初版上线后就碰到过一次用户脚本里while(true)空转直接卡死上位机只能强杀进程。后来我加了CancellationTokenSource并设定超时时间var cts new CancellationTokenSource(TimeSpan.FromSeconds(30)); try { var state await CSharpScript.RunAsync(code, options, globals, cts.Token); if (state.ReturnValue ! null) OutputBox.AppendText(state.ReturnValue.ToString() Environment.NewLine); } catch (OperationCanceledException) { OutputBox.AppendText(脚本执行超时已自动终止。 Environment.NewLine); } catch (CompilationErrorException ex) { foreach (var diagnostic in ex.Diagnostics) { OutputBox.AppendText(diagnostic.ToString() Environment.NewLine); // 把diagnostic的LineSpan映射到编辑器行号实现错误定位 } } catch (Exception ex) { OutputBox.AppendText(运行时异常: ex.Message Environment.NewLine); }CompilationErrorException里的Diagnostic对象自带行号列号信息把LineSpan信息转成编辑器行号用户点错误信息就能跳转到对应行这一步会极大提升脚本编辑器的可用性。如果你用的是AvalonEdit调用editor.TextArea.Caret.Line lineNumber;就能定位。关于C#脚本编译有一个容易被忽略的细节Roslyn脚本引擎的编译输出默认是在内存中第一次运行会加载大量元数据首条脚本的执行可能会有数百毫秒的延迟后续脚本会快很多。所以如果你的编辑器支持频繁执行小脚本可以在初始化阶段预编译一个空脚本预热环境用户体感会好很多。6. 踩过的坑输入法、异步和UI线程的纠缠6.1 中文输入法导致补全窗口乱跳WinForms里承载AvalonEdit最常见的问题是中文输入法对补全窗口的影响。用户在脚本注释里输入中文拼音候选框弹出的瞬间AvalonEdit的TextEntering事件也会收到e.Text此时e.Text不是完整汉字而是拼音字母。如果不加处理拼音字母会被当成补全触发字符弹出英文关键字列表非常影响体验。我最终的处理策略是在TextEntering事件里检查e.Text是否为ASCII字母非ASCII则直接禁止触发补全。同时在处理TextCompositionEventArgs时优先判断输入法相关状态。这里的核心原则是凡是和用户中文输入相关的编辑器功能都尽量把补全触发和拼音输入隔离。类似的问题在WPF版本里也存在推荐提前做个输入法兼容性测试不要等到上线后让客户提bug。6.2 脚本执行结果更新UI的跨线程问题脚本是异步执行的更新UI只能在UI线程。很多开发新手在这里会踩System.InvalidOperationException这个坑。我的做法是封装一个UIAction方法统一调度到UI线程private void UIAction(Action action) { if (this.IsHandleCreated this.InvokeRequired) { this.BeginInvoke(action); } else { action(); } }执行线程里所有涉及控件更新的操作比如输出日志、刷新进度条、更新当前运行状态都走这个方法。虽然听起来是很基础的事但异步脚本执行线程不像普通事件线程它的调用栈更深更容易被忽略。如果遇到调用很频繁的场景记得用BeginInvoke而不是Invoke能避免潜在的跨线程死锁。6.3 编辑器加载大脚本时的等待体验如果用户打开一个几百KB的脚本文件即便AvalonEdit不卡文件读取和编码检测也会造成界面短暂失去响应。我建议在打开文件时把读取操作放到Task.Run里读完之后再在UI线程填充编辑器文本。但在填充长文本时不要逐字符插入那样会触发大量重排。合理做法是使用BeginChange和EndChange包裹批量写入private void LoadScript(string path) { var lines File.ReadAllLines(path); _editor.Document.BeginUpdate(); try { var text string.Join(Environment.NewLine, lines); _editor.Document.Text text; } finally { _editor.Document.EndUpdate(); } }BeginUpdate模式下撤销栈和渲染只记录一次完整的变更性能提升非常明显。如果在加载过程中还要做语法高亮初始化尽量等文本填充完成后再设置SyntaxHighlighting否则会重复触发高亮计算。7. 收尾一套可复用的开发顺序建议最后给想动手的同学一个从零到一的顺序建议这也是我第二次做类似功能时的推进顺序先验证AvalonEdit在目标平台上的运行表现确认字体、行号、滚动条在WinForms里没有明显问题。接入语法高亮把demo脚本放进去观察着色和性能。实现基本的打开、保存、文件编码处理再考虑代码补全。接入Roslyn脚本引擎先保证能执行、能输出、能报错。最后再做错误定位、快捷键、输入法适配、历史记录这些完善体验的功能。我个人第二次做这个功能时比第一次省了至少三分之二的时间因为避开了RichTextBox、Scintilla封装坑和输入法误区。脚本编辑器这件事核心难点不在编辑器本身而在编辑器和你的业务系统如何协作。把这个协作做顺了这个功能就成功了大半。本文还有配套的精品资源点击获取
返回列表