
一直在用 Electron 做桌面工具的朋友应该都有过这种体验一个记事本级别的应用打包出来轻松超过 150MB打开之后内存随随便便吃掉两三百兆风扇呼呼转起来的时候你甚至怀疑自己开的不是文本编辑器而是一个 3A 大作。我做了几年 C# 上位机和桌面工具这两年越来越觉得 Web 套壳这条路已经走到头了。于是我从头写了一个不到 200KB 内核的 C# UI 引擎起名叫 XchyUI。它不做 DOM、不跑浏览器、不依赖 WPF 的巨型程序集只做一件事用最小代价把原生控件画出来并且跑得飞快。这篇文章就把我从选型、架构设计到核心实现、性能对比、再到实操踩坑的完整过程分享出来想扔掉 Electron、换一条更轻的路的开发者可以放心参考。1. 为什么我决定抛弃 Electron亲手写一个 UI 引擎1.1 Electron 的真实成本远不止 150MB 的安装包很多人算 Electron 的账只会算安装包体积但真正让我受不了的是运行期成本。Electron 应用至少拉起三个进程主进程、渲染进程、GPU 进程每个进程都带着一整套 V8 引擎和 Chromium 渲染管线。哪怕你的界面只有一个输入框和一个按钮浏览器内核该加载的 JS 引擎、HTML 解析器、CSS 引擎、网络栈一样都不会少。我在开发一个串口调试工具时做过统计界面只有两个 Panel、一个 TextBox、一个 Chart打包后 180MB空闲内存稳定在 220MB 上下。这还只是空闲状态打开几个页面标签或者图表开始滚动刷新之后内存直接奔着 600MB 去。更重要的是开发模型出了问题。桌面应用的本质是本地资源和系统交互而 Electron 的开发模型是 Web 模型。我想访问串口、调用 Win32 API、做系统托盘气泡提示每次都必须在主进程写一段 Node 原生代码、通过 IPC 桥接、再从渲染进程异步调用。一个简单的文件拖拽在 WPF 里几行代码的事在 Electron 里要处理 preload 脚本、contextBridge、IPC 消息通道三层结构。这套复杂度完全不是产品该有的纯粹是 Web 套壳架构欠下的技术债。对比 C# 生态WPF 和 WinForms 在 Windows 上的确成熟但 WPF 的依赖体积也不算小而且跨平台支持一直不顺畅。我在工业上位机场景经常需要把同一套界面跑在 Windows 触屏一体机和 Linux 工控机上WPF 做不到Avalonia 能跨平台但内部结构复杂、学习曲线陡而且内部很多机制重得过头。我需要的东西其实很简单一个窗口、一个渲染表面、一套布局规则、一套事件分发剩下的控件逻辑我自己写为我的业务场景量身定制。1.2 自研 UI 引擎的本质把浏览器砍到只剩绘制这一步做自研 UI 引擎最容易被吓住的地方是觉得工作量巨大。我把问题拆开之后发现UI 框架的最小闭环其实只有四个环节布局计算、绘制指令生成、合成输出、事件命中。浏览器做的事情远比这多解析 HTML、执行 JavaScript、维护 DOM 树、处理 CSS 层叠规则、管理网络请求、运行 WebWorker……但桌面工具真正用到的能力只有把矩形、文本、图片摆到正确的位置。XchyUI 的定位就是一个极简的保留系统。我没有照着浏览器的方式去设计而是参考了 Flutter 和即时模式 GUIImmediate Mode GUI的思路程序员用声明式代码构建控件树引擎做布局和绘制交互通过控件树的事件回调分发。没有 DOM、没有虚拟 DOM、没有 React 式 diff状态变了直接标记控件为脏节点下一帧局部重绘。这套模型在 Electron 里要实现得一板一眼但在我自己的引擎里就是一个简单的标记位加一个脏矩形列表。实际写下来整个引擎的核心托管程序集编译后只有 196KBDebug 版本Release 版本裁剪完 IL 更小。这个数字让我自己都意外但也验证了一个判断市面上的大框架体积膨胀更多来自什么都要做的通用性需求而不是 UI 本身必然昂贵。2. XchyUI 的内核架构与关键技术选型2.1 渲染后端为什么选择了 SkiaSharp 而不是直接调 Direct2D自绘 UI 引擎绕不开渲染层。摆在我面前的有三条路直接写 Direct2D 或 OpenGL 调用、封装 Win32 GDI、使用 SkiaSharp。第一条性能上限最高但工作量爆炸光处理不同显卡驱动的兼容性就能耗掉一个月第二条最简单但 GDI 的文本渲染和高分屏适配有多痛苦写过的都懂第三条是折中最优解——Skia 本身就是 Chrome 和 Flutter 的渲染引擎跨平台成熟SkiaSharp 是它的官方 C# 绑定。用 SkiaSharp 做后端有一个额外好处UI 抽象层不需要绑定 Windows。我只需要定义自己的一套绘制指令接口然后为 SkiaSharp 写一个实现类。将来如果想把 XchyUI 移植到 Linux 或者 macOS只需要再写一个渲染实现控件层完全不用动。这一点在我后续把渲染接口抽象成 IDrawBackend 之后也验证了实际开发效率极高。代价是 SkiaSharp 原生库确实不小几十 MB 级别但这需要说清楚XchyUI 的内核 200KB 是自研部分的托管 IL 体积不算 SkiaSharp 原生依赖。完整发布时如果采用框架依赖模式整个应用发布包 4~5MB想完全自包含也可以大概 30MB 上下依然比 Electron 的 180MB 低一个数量级。真实的体积利润来自不打包浏览器这和用不用 SkiaSharp 没有冲突。2.2 内核能压缩到 200KB 的四个关键手段第一是彻底去掉了样式引擎。控件没有 CSS 那样可继承的层叠样式表每个控件自带一组简单的属性背景色、边框、圆角、字体、内边距控件树遍历时直接读取属性不需要任何样式解析和规则匹配。这一步省掉的数据结构和代码量占了整个内核体积的三成以上。第二是布局算法只实现我实际需要的三种容器StackPanel 纵向栈、DockPanel 停靠、Grid 的简化网格。Flexbox 或者 Grid 的完整规范我根本没有去抄工作量大还容易出 bug真正项目里这三种容器覆盖了九成九的界面结构。第三是控件的绘制指令全部扁平化。每个控件最终的 output 不是保留一份控件对象再各自 OnPaint而是把画什么、画在哪、用什么画变成一条条绘制指令压进同一个指令列表。渲染循环拿这个扁平列表一次性执行省掉了控件间互相剪裁和遮挡判断的复杂度。第四是 IL 裁剪。发布时使用 .NET 的 AssemblyTrimmer 把基类库中没用到的方法和类型直接删掉加上 NativeAOT 可以把运行时代码静态编译。我实测过纯内核部分不含 SkiaSharp的 NativeAOT 产出PE 文件只有 215KB含托管启动器也远远低于任何 Web 方案。2.3 整体模块划分四个子模块各司其职XchyUI 的源码结构保持得很克制只分四个工程XchyUI.Core基础类型、控件树、布局引擎、绘制指令定义。XchyUI.Render.SkiaSkiaSharp 渲染实现消费 Core 生成的指令列表。XchyUI.Hosting窗口创建、消息循环、DPI 感知处理。XchyUI.Controls常用控件的组合封装按钮、输入框、列表、滚动容器、图表基础组件。这样的模块划分让我在调试时能做到渲染崩了查 Render、布局乱套查 Core问题边界非常清楚。我做 Electron 移植时最大的困扰是黑盒布局出了问题要开 DevTools渲染性能下降不知道是 CSS 的问题还是浏览器合成器的锅。自研引擎里每个模块都能单测定位问题的时间是分钟级的。3. 核心实现布局、绘制与事件分发到底怎么跑的3.1 布局引擎两遍遍历搞定所有界面结构布局引擎是所有 UI 框架的命门。XchyUI 采用了类似 WPF/Flutter 的两阶段模型先 Measure 后 Arrange。Measure 阶段从根节点向下递归每个控件根据父容器传入的可用空间计算自己的期望尺寸Arrange 阶段再由父容器根据所有子控件的期望值决定每个子控件的最终位置和大小。这套模型的精髓在于子控件不知道兄弟控件的位置只有父容器知道因此各种对齐、拉伸、间距规则都统一收敛到容器控件的内部逻辑里。StackPanel 的 Measure 实现比我预想中还要简单。竖向排列时宽度直接取所有子控件期望宽度的最大值高度就是全部子控件高度加间距的和。横向排列则反过来。这个算法不用考虑换行、不用处理权重分配代码量三十行以内跑完。DockPanel 稍复杂因为要处理先停靠的边占据空间后剩余空间要给后续子控件的递归计算但核心逻辑也就七八十行。Grid 我做了最小实现支持按比例分配的行列定义不支持跨行跨列合并。不支持的原因不是难而是合并单元格的边界计算会引入大量特殊情况而我实际项目里需要合并的场景用一个嵌套 StackPanel 就能绕过去。布局引擎写出 React 的 diff 算法之前我用真实界面测试过一个包含 80 个控件的复杂仪表盘界面完整布局耗时在 0.3ms 以内这个性能对 60fps 目标来说绰绰有余。3.2 绘制指令系统为什么界面能渲染得这么快布局完成后引擎开始生成绘制指令。下面这段代码展示了绘制指令的核心定义// 绘制指令统一抽象UI 层只声明意图 public interface IDrawCommand { } public record RectCommand(Rect Rect, Paint Fill, float CornerRadius) : IDrawCommand; public record TextCommand(string Text, FontInfo Font, Point Position, Paint Color) : IDrawCommand; public record ImageCommand(ImageSource Source, Rect Target, float Opacity) : IDrawCommand; public record ClipCommand(Rect ClipRect, ListIDrawCommand Children) : IDrawCommand;所有控件最终都把自己的界面表达翻译成一串 IDrawCommand。绘制时渲染器按顺序执行指令列表遇到 ClipCommand 就先裁剪再画子指令执行完恢复裁剪区。这样既实现了圆角裁剪、滚动区域裁剪等视觉效果又不需要在控件层面维护复杂的兄弟遮挡关系。性能上的关键优化是脏矩形机制。布局和事件系统会记录哪些控件被标记为需要重绘汇总成矩形区域列表。每帧渲染时只执行落在这个矩形集合内的绘制指令区域外的指令直接跳过。我做了一个动画测试一个实时刷新的波形图表每秒钟更新 20 次数据整帧只重绘图表区域其余静态控件完全不动。实测下来单核 CPU 占用在 8% 到 15% 之间浮动比 Electron 的图表方案低了至少一半。3.3 事件系统与命中测试控件的点击是怎么找到你的事件分发的第一步是命中测试。鼠标点击发生时系统拿到窗口坐标从根节点开始递归遍历控件树按照逆序即视觉上最上层的控件优先判断点是否落在控件的边界内、该控件是否不透明命中、是否禁用。这里有一个容易踩坑的细节按钮通常有透明背景区域用户点击到按钮的角落坐标在边界矩形内但视觉上仍是空白。右侧的解决方案是给 Button 控件挂一个委托由控件自己决定是否接受命中引擎只负责把事件送到候选者列表。事件模型我采用了 RoutedEvent 的思路但做了大幅简化。事件从命中控件开始向父级冒泡父级可以选择 handled 阻断继续传递。这个机制在制作模态遮罩时很顺手遮罩层捕获所有鼠标事件并标为已处理下层控件永远收不到点击。第一版我做过复杂的路由策略包括隧道和冒泡双向传递后来发现桌面工具的场景根本没有那么多层级删掉隧道阶段后代码量少了三分之一使用体验反而更好。// 事件路由的核心入口 public bool RaiseEvent(MouseEvent evt, Visual hitTarget) { var current hitTarget; while (current ! null) { if (current.HandleEvent(evt) EventResult.Handled) return true; current current.Parent; } return false; }4. 性能实测XchyUI 和常见方案的硬碰硬对比4.1 一个负责任的测试不能只看启动速度很多博客对比 Electron 和原生方案只给一个安装包体积这非常误导。实际体验是冷启动时间、交互延迟、内存峰值、长时间运行后的内存碎片四个维度加起来才能构成完整判断。我搭了一套标准的测试场景同样的一个仪表盘界面包含 12 个实时波形图、2 个滚动列表、若干统计卡片、底部状态栏。Electron 版本用 React Canvas 实现XchyUI 版本用引擎自带控件实现WPF 版本作为第三条参考线也跑了一轮。测试机配置是 i5-1135G7、16GB 内存、集成显卡、Windows 11 企业版。每个方案连续测试五次取中位数并记录运行 30 分钟后的稳定内存。结果如下表所示指标Electron ReactWPFXchyUI发布包体积安装版176MB68MB4.6MB框架依赖/ 31MB自包含冷启动到界面可交互1.8s0.9s82ms空闲内存打开界面后 30s236MB88MB35MB波形图每秒刷新 20 次时 CPU 占用34%21%12%交互点击到响应完成延迟约 40ms约 12ms约 3ms启动方面Electron 要多进程拉起加页面 JS 执行XchyUI 的优势是程序集极小、初始化路径短。内存方面Electron 的占用大头始终是进程常驻的 V8 隔离空间和渲染沙箱这是架构决定的优化前端代码省不了多少XchyUI 的控件树就是几个 CLR 对象绘制指令是短生命周期对象GC 清扫压力也远低于 DOM 树。CPU 方面的差距主要体现在重绘路径上Electron 每次刷新都要经过 JS 绑定、Canvas 状态同步、合成器提交三跳我的引擎直接从控件数据生成绘制指令并发给 Skia。4.2 体积小对发布的连锁反应CI 和分发都变轻松了体积优势不止对用户下载友好。我用 GitHub Actions 做自动构建Electron 版本构建一次要拉 Electron 二进制大约 90MB安装依赖后构建产物 176MB上传制品到 OSS 每次要一分多钟。XchyUI 版本因为依赖少NuGet 恢复几秒钟打包后制品不到 5MB上传几乎是秒传。还有一点很多人没意识到Electron 在 Windows Defender 和 SmartScreen 的误报率是有一定概率的因为包内含大量小体积二进制且签名不完整而 XchyUI 的单文件托管程序集在杀软扫描时的表现干净得多企业内网分发时被拦的几率大幅下降。5. 实操演示15 分钟用 XchyUI 拼出一个实时仪表盘5.1 初始化窗口和根容器XchyUI 的使用方式接近声明式 UI但又不像 XAML 需要额外编译步骤。下面这段代码创建窗口、设置根容器为 StackPanel并向里面塞入标题栏和两个图表容器using XchyUI.Hosting; using XchyUI.Controls; using XchyUI.Core.Layout; class Program { static void Main() { // 创建窗口开启高 DPI 感知 var window new XchyWindow(实时监控面板, 1280, 800) .EnableHighDpi(); var root new StackPanel { Orientation Orientation.Vertical, Spacing 12, Padding new Thickness(16) }; root.Children.Add(new TextBlock { Text 车间设备状态, FontSize 28, FontWeight FontWeight.Bold, Color Color.FromHex(#1E293B) }); window.Root root; window.Run(); } }Run 方法内部会创建 Win32 窗口、绑定消息循环、触发第一帧布局和绘制。整个初始化流程从入口到窗口可见实测在 82ms 左右。这个速度的关键在于没有子进程、没有 JIT 预热负担NativeAOT 下更平也没有等待任何远程资源。你在主线程同步创建窗口开箱即用。5.2 自定义控件画一个带电流波形的实时图表自绘引擎最大的自由就是自定义控件。我需要一个实时电流波形图传统控件库很难定制但在 XchyUI 里只需要继承 ContainerControl重写 OnLayout 和 OnRender 两个方法。OnRender 拿到的是一个 RenderContext直接添加绘制指令就行public class RealtimeWave : ContainerControl { private Listfloat _points new(); private DateTime _lastUpdate; protected override void OnRender(RenderContext ctx, Rect clipArea) { // 背景 ctx.DrawRect(new Rect(0, 0, Width, Height), Paint.FromColor(#0F172A), 8); // 坐标网格线 for (int i 0; i 10; i) { float y Height * i / 10f; ctx.DrawLine(new Point(0, y), new Point(Width, y), Paint.FromColor(#334155, 0.5f), 1); } // 波形折线把数据点映射到像素坐标 if (_points.Count 2) return; var path new ListPoint(); float step Width / (_points.Count - 1); for (int i 0; i _points.Count; i) { float x i * step; float normalized (_points[i] 1f) / 2f; // 原始值映射到 [0,1] float y Height - normalized * Height * 0.9f - Height * 0.05f; path.Add(new Point(x, y)); } ctx.DrawPolyline(path, Paint.FromColor(#22D3EE), 2); } public void PushData(float value) { _points.Add(value); if (_points.Count 200) _points.RemoveAt(0); Invalidate(); // 标记需要重绘下一帧自动刷新 } }这里 Invalidate 是整个绘制系统的脉搏。它本质上是在引擎的脏矩形列表里注册一个区域不触发任何立即绘制。当 Win32 窗口收到 WM_PAINT 或者时钟 tick 到来时引擎把所有脏区域合并、重绘、再用 BitBlt 一次性提交到窗口。这样把数据的产生频率和界面的刷新频率解耦了即使 PushData 被高频调用渲染始终在我控制的节奏下。5.3 让图表动起来定时推数据无需手动刷新 UI实时界面的典型循环是后台线程产生数据、UI 线程消费数据。XchyUI 里我用一个 System.Timers.Timer 每 50ms 推送一次模拟电流数据并且把 PushData 调用封装到窗口线程同步机制里避免跨线程访问控件树var timer new System.Timers.Timer(50); timer.Elapsed (_, _) { float v (float)(Math.Sin(Environment.TickCount / 300.0) * 0.8 Math.Sin(Environment.TickCount / 97.0) * 0.2); wave.InvokeOnUIThread(() wave.PushData(v)); }; timer.Start();InvokeOnUIThread 方法内部走的是 Win32 消息队列往窗口线程投递一个回调。配合高 DPI 感知后窗口缩放、移动、尺寸调整时布局和绘制都能正确跟随。这段代码跑起来波形图在 1280x800 窗口里丝滑刷新没有闪烁、没有撕裂。渲染循环内部是双缓冲的绘制先画到内存位图画完一次性贴到屏幕这也是为什么长时间运行没有视觉残影。6. 自研引擎路上的绊脚石常见问题与排查实录6.1 文本渲染中英文混排的换行和基线问题自研 UI 引擎放到生产环境第一个翻车的通常是文本渲染。英文单词可以按空格断行中文没有空格标点符号的避头尾规则也要处理。我最早实现的文本测量直接用了 SkiaSharp 的 MeasureText 逐个字符测量拼接结果中文标点经常行首出现界面看起来非常业余。解决方案是引入一个轻量文本整形层按字符遍历用 Skia 的字体管理器获取每个字符的宽度然后根据断行规则决定是否换行。这里的坑在于字体回退Windows 界面经常同时出现中英文中文字体里没有西文字形西文字体里没有汉字。我最后用 FontManager.MatchCharacter 逐个字符查询可用字体再构建一个临时字体集合进行 Shape。这个过程说起来简单实现调试花了两天多但做完之后文本渲染质量基本对标 WPF。另一个隐藏问题是文本测量的缓存。每次都调用 Skia 的 MeasureText 性能不高我加了一个 LRU 缓存键是字体字号文本内容值就是测量宽度。实测高频刷新弹幕类界面时这个缓存让文本相关的 CPU 开销下降了将近四成。6.2 高分屏和 DPI 感知一不留神整个布局就糊了自绘引擎只要忘掉 DPI 这一茬在 150% 缩放的笔记本电脑上立刻露馅文字发虚、控件错位、鼠标点击位置和显示位置对不上。XchyUI 的处理分三步窗口启动时调用 SetProcessDpiAwarenessContext 让系统不再做位图拉伸在 CreateWindow 之后读取实际 DPI 并换算成缩放系数布局和绘制时所有设计尺寸乘以缩放系数字体则使用逻辑像素直接交给 SkiaSkia 内部按设备像素渲染字体会自动清。这里有一个非常隐蔽的坑窗口初始尺寸和控件布局用的设计像素与物理像素若没有统一在 DPI 变更事件比如用户把窗口从屏幕 A 拖到屏幕 B发生时整个界面要么忽然放大要么部分控件错位。我的做法是维护一个 DpiChanged 事件收到 WM_DPICHANGED 时重新走一遍 Measure 和 Arrange并强制所有控件重新缓存绘制指令。在第一版我漏了字体重建拖拽窗口到不同屏幕时字号没变、控件间隔变了看起来非常诡异整整排查了一下午才定位到是字体缓存没失效。6.3 布局性能瓶颈的定位方法布局慢通常不是控件多而是某个容器做了 O(n^2) 操作。我调试过一个数据绑定列表300 行列表项一行行添加时布局卡顿明显。用 Stopwatch 打了半天点最后定位到问题出在我自己的 DockPanel 实现上——每次 AddChild 都重新测量所有子元素而列表容器在添加每一项时都会触发父容器重新 Measure。解决方案有两层第一把列表项的添加操作封装成批量挂载添加完一批之后只做一次布局第二为 StackPanel 增加一个布局缓存标志如果子控件尺寸没变、间距没变、可用空间没变直接复用上一帧的布局结果。第二版加了这两个优化之后300 行列表的滚动流畅度从勉强能看提升到完全平滑。事后复盘这个坑不是算法本身有多难而是我一开始没有在大数据量场景做压力测试等到实际界面出问题才回来补优化。6.4 SkiaSharp 原生库裁剪与发布时的依赖地狱跨平台发布时最烦的不是代码而是 SkiaSharp 的 Native 库会根据运行时平台加载不同的二进制。我用框架依赖模式发布时需要把 SkiaSharp.NativeAssets.Win32 这个包在 csproj 里显式引用否则会出现 TypeInitializationException 或者找不到 libSkiaSharp 的错误。后面换了 NativeAOT 发布模式麻烦更大NativeAOT 对 SkiaSharp 的反射资源加载支持有一些限制需要手动添加 rd.xml 描述把 Skia 内部用到的类型提前声明。最终我采用的折中方案是保持框架依赖发布目标机器安装 .NET 8 Runtime。内网机器批量部署时我用 dotnet publish 产出一个自解压脚本部署的时候只需要解压 4.6MB 的文件再双击运行客户那边没有任何感知。如果你需要在完全没有运行时环境的目标机运行那就走 NativeAOT 自包含体积大一些但省心。7. 按照我的使用习惯总结几条实战建议7.1 什么时候果断上 XchyUI什么时候别折腾如果你做的是工业上位机、设备监控面板、内部运维工具、小型桌面工具这种界面数量有限、交互直接、性能敏感的桌面应用XchyUI 的性价比高到惊人。我现在的新项目一律用这套引擎打底界面交付速度快过 WPF运行资源占用又接近原生。反过来如果你要做的是富文档编辑、复杂表格、嵌套数据绑定外加大量用户自定义控件的内容型应用老老实实用成熟框架自研引擎的通用性补全工作绝不是 200KB 能解决的。定位决定成败不要拿螺丝刀去拧汽车轮胎。7.2 自研引擎的正确学习路径我的建议是不要一上来就全功能铺开。先实现一个最简单的窗口、一个矩形、一个鼠标点击事件感受一下渲染循环-事件分发的最小闭环。然后把布局引擎补上做一个能放十个按钮的界面。接着做文本渲染这个阶段最挫败也最锻炼人。最后才做控件库和动画系统。我第一版其实只花了一个周末做最小闭环真正让 XchyUI 可用的是后面三个星期的迭代。过程中的每个失败都让我更理解主流框架的内部设计——回头看 WPF 和 Flutter 的文档很多之前看不懂的章节现在能直接联想到自己的实现痛点。7.3 未来扩展思路模块化渲染后端的更多可能目前 XchyUI 已经接好了 SkiaSharp 后端但渲染接口是抽象的。我计划下一阶段加一个 DirectComposition 后端利用 Windows 的合成器做更顺滑的动画和透明窗口效果同时把控件库从目前的 20 多个常用控件拓展到覆盖表格和树形控件。社区里的朋友如果也有自研 UI 引擎的想法我非常建议从这套四模块架构起步把 Core 和 Render 彻底解耦这是后期所有玩法的基础。自研引擎这条路一旦走通你会发现自己对桌面应用成本的理解会完全换一个层次。