ARTICLE DETAIL

资讯详情

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

C# DX控件画线性能测试:从DrawLine到基准优化

C# DX控件画线性能测试:从DrawLine到基准优化 简介基于C#与DirectX的直线绘制性能测试项目通过SlimDX或SharpDX封装调用DirectX接口测量画直线的具体耗时适合C#图形编程学习者、游戏开发入门者以及关注渲染性能的开发者。资源共20个文件涵盖5个C#源码、3个可直接运行的exe、2个pdb调试符号、2个缓存文件以及解决方案sln、工程csproj、窗体资源resx等配置压缩包仅38KB结构轻量便于快速研读和二次开发。目前已有141人学习下载。项目完整演示了DirectX设备初始化、直线绘制与着色器调用、Stopwatch计时、窗体交互及渲染循环等关键环节可帮助读者直观掌握C#环境中调用非托管图形接口的流程并据此定位绘制性能瓶颈。通过阅读源码还能学习顶点缓冲区构建与计时测试写法并将相关代码迁移到自己的C#图形项目中。1. 为什么C#的DX控件画一条线还要单独测速度CSharp-test-DX-draw-a-line-speed 这个标题看着像某个测试源码包但说到底它就是一件事在 C# 的 DXDevExpress控件上画一条线并且把“画得快不快”量化出来。DX 控件的绘制层和普通 WinForms 控件不一样自带缓存、皮肤和坐标转换一句 DrawLine 在不同版本和 DPI 下耗时能差出好几倍。这适合两类人新手想知道这线到底画在哪、怎么让 DX 控件响应绘制熟手则想把单线耗时做成基准用来评判绘图方案改不改。下文直接从绘制模型讲到测速和避坑每个步骤都能复制到本地跑通。2. 先把绘制模型搞清楚DX控件里的画线发生在哪一步2.1 DX控件的绘图栈真正执行DrawLine的到底是谁先分清一个事情C# 圈子里的 DX 几乎都是指 DevExpress 那套 WinForms 控件不是 DirectX。我之前看到有人拿“dx修复工具”当关键词搜到这个标题结果越看越不对。DevExpress 的控件以外观统一、功能覆盖出名最常见的有 PanelControl、GridControl、ChartControl它们不是同一个绘制实现但都统称为 DX 控件。真正执行 DrawLine 的仍然是你熟悉的 Graphics 对象区别在于这个 Graphics 是从哪里来的。普通控件在 Paint 事件里拿到e.Graphics而 DX 控件除了 Paint还有一层 CustomDraw 的管线例如 GridView 的自定义绘制、TileView 的 ItemCustomize。它们会在控件内部把自己的图元画完后再把剩余绘制交给你所以你说“在 DX 控件上画线”先要回答一个问题是在控件自身的层上画还是在 DX 内部某个元素单元格、图表坐标区上画。两条路径的执行时机不同测出来的数值没有可比性。最常见的做法也是大多数项目里最省事的方式是拿一个 DX 的 PanelControl 当自定义绘制容器挂它的 Paint 事件在里面画直线。这条路径最接近普通 WinForms 的绘制经验也比较容易和标题里的“draw a line speed”对应起来。下面我给出的示例都围绕这条路径展开。2.2 最小可运行示例在DX的PanelControl上画一条线下面这个类继承DevExpress.XtraEditors.PanelControl在构造函数里挂 Paint。代码在 .NET Framework 4.8 和 .NET 6 上都能跑前提是项目已经安装了 DevExpress WinForms 套件并引用了对应程序集。版本号我没有写死以你项目实际安装的 DX 版本为准。using System; using System.Drawing; using DevExpress.XtraEditors; public class DxLinePanel : PanelControl { public DxLinePanel() { Paint OnDxLinePaint; } private void OnDxLinePaint(object sender, PaintEventArgs e) { using (var pen new Pen(Color.Red, 1.5f)) { e.Graphics.DrawLine(pen, 10, 10, Width - 10, Height - 10); } } }这段代码的逻辑很直接每次控件需要重绘时系统触发 Paint 事件e.Graphics是当前可用的绘制表面DrawLine在这个表面上画一条从左上到右下的红线。using保证 Pen 被及时释放避免在循环重绘时产生 GDI 句柄泄漏。有一个细节要注意Pen的宽度是 1.5f。如果你写的是Pen(Color.Red, 1)在默认坐标系下是一条物理像素宽的线如果写0则是 GDI 里特殊的 hairline它表示“最细的线”绘制方式和其他宽度不同。这个值会影响渲染速度后面测速时建议固定。2.3 不要在Paint之外随手CreateGraphics闪烁和层覆盖的根源很多人拿到这个标题后第一件事就是拖一个 DX 控件到窗体上然后写panel.CreateGraphics().DrawLine(...)。这样写通常也能画出线但你会发现几个问题窗口一刷新线就消失拖动窗体时线会拖出残影和 DX 自带皮肤叠加后还会出现明显的层级错乱。原因在于CreateGraphics()拿到的是“临时绘制表面”它不进入控件的持久化绘制流程。Windows 在窗口需要重绘时只重发 WM_PAINT 消息你之前用 CreateGraphics 画上去的内容不会被保留。正确的做法是把绘制指令放在 Paint 事件或OnPaint重写里系统每次重绘都会重新执行线才能稳定显示。如果你只是想测速不想让屏幕一闪一闪那也不该用 CreateGraphics。更合理的方式是直接在内存 Bitmap 上画这样能测出 GDI 指令本身的耗时和屏幕刷新完全解耦。这个区分关系到后面测出来的数据到底代不代表真实性能下面一章专门讲。3. 把“画一条线”做成可靠基准计时方式、循环次数和三个必调参数3.1 先预热再计时否则第一轮DrawLine慢得离谱直接写一个循环从Stopwatch.StartNew()开始连续画一万次直线然后算平均耗时。这个做法本身没问题但有一个坑第一轮绘制往往比后面慢很多。原因有三层第一DevExpress 程序集是懒加载的第一次调用会触发程序集初始化第二GDI 第一次创建 Pen、Brush 时会加载内部缓存第三JIT 需要把绘制方法和循环体编译成机器码。所以我的习惯是正式计时前先跑一段预热循环。预热结果不记录只让绘制环境热起来。using System; using System.Diagnostics; using System.Drawing; public class LineSpeedBench { private readonly Pen _pen new Pen(Color.Black, 1f); public double Measure(int width, int height, int count) { using (var bmp new Bitmap(width, height)) using (var g Graphics.FromImage(bmp)) { // 预热先画 100 次不参与统计 for (int i 0; i 100; i) g.DrawLine(_pen, 0, 0, width, height); // 正式计时 var sw Stopwatch.StartNew(); for (int i 0; i count; i) g.DrawLine(_pen, i % width, 0, width - i % width, height); sw.Stop(); return sw.Elapsed.TotalMilliseconds / count; } } }这里有几个参数值得说清楚。width和height决定绘制表面的大小建议固定成你目标控件实际尺寸比如 800x600。count是循环次数不要设成 1单次 DrawLine 的耗时通常在微秒级直接测会受到计时器分辨率和线程调度干扰我一般取 1000 或 10000最后返回平均每线毫秒数。_pen是成员字段只用创建一个实例避免循环内反复创建。还有一个容易被忽略的点内存 Bitmap 默认不带透明度且初始内容是未定义的。绘图前最好调用g.Clear(Color.White)刷一遍否则第一次绘制结果可能叠加脏数据。虽然对耗时测量影响不大但能保证你把测试代码改成截图保存时输出图像是干净的。3.2 离屏Bitmap与控件DrawToBitmap两种测法测的不是一回事用Graphics.FromImage(bmp)测的是纯 GDI 绘制指令耗时它测不到 DX 控件自己那层主题绘制、缓存管理、坐标变换。换句话说它只回答“C# 画一条线最低要多少时间”回答不了“DX 控件上画一条线用户要等多久”。如果你的目标是对比不同控件或不同 DX 版本就需要另一种方法把真实控件渲染到 Bitmap 上。using (var bmp new Bitmap(panel.Width, panel.Height)) { panel.DrawToBitmap(bmp, new Rectangle(0, 0, panel.Width, panel.Height)); // 之后对 bmp 做像素读取或保存 }DrawToBitmap会向控件发送一条绘制消息让控件把当前完整界面画到位图上包括 DX 的皮肤、边框和你在 Paint 里画的内容。它的优点是接近真实显示效果缺点也很明显运行时间比单纯 DrawLine 长得多因为它要把整个控件树递归绘制一遍另外部分 DX 自定义绘制在 DrawToBitmap 路径下可能被跳过所以结果要标注“通过 DrawToBitmap 采样”。实际做性能验收时我会把两者分开记录一个叫GdipLineMs一个叫DxPaintMs。前者用来判断 GDI 是否存在明显退化后者用来判断 DX 控件的呈现链路是否变慢。两者结合才能定位到是绘图指令本身慢还是 DX 控件的外层处理拖了后腿。3.3 SmoothingMode、Pen.Width和Clip区域三个参数的敏感性同样的 DrawLine改一个参数耗时可能翻倍。最容易踩的就是抗锯齿开关。Graphics.SmoothingMode默认是None画出来的线边缘会有锯齿一旦设置成AntiAliasGDI 会对线段做边缘混合涉及更多像素采样斜线尤其明显。水平或垂直线受抗锯齿影响相对小因为边缘对齐像素网格所以如果你测的是斜线抗锯齿带来的差异会非常大。第二个参数是Pen.Width。常规宽度下线越宽光栅化阶段要填充的像素越多耗时越高。但最特殊的是Width 0f它代表 hairline会使用硬件支持的最小绘制宽度绘制逻辑和普通宽度不一样在某些显卡驱动上反而比1f更快。为了让测试结果可对比我建议固定宽度不要混用。第三个是裁剪区域。e.Graphics.VisibleClipBounds表示当前可见的绘制范围。如果你的控件有一部分被遮挡GDI 会自动裁剪掉不可见区域实际绘制像素减少耗时也会下降。这不是好事因为你的业务代码还在跑只是屏幕显示被省了。测速时要把控件完整显示出来或者用全屏 Bitmap避免裁剪目录干扰数据。下面是我常用的参数参考表目的是让同一项目的不同版本跑出来的数值可以直接对比。参数固定值说明绘制表面内存Bitmap 或控件尺寸不要一会儿宽一会儿窄循环次数1000 或 10000取平均耗时抗锯齿明确固定 None 或 AntiAlias不能混跑Pen.Width1f 或 0f0f 是hairline单独说明直线方向斜线/水平线分开测抗锯齿对斜线影响更大4. DX控件画线和测速最常见的四个坑与排查思路4.1 画好的线闪一下就没或者被控件默认皮肤盖住现象你在 Paint 事件里画了一条粗线运行后一闪而过或者根本看不到但窗口在调试时能命中断点。换成普通 UserControl 后同样代码却正常。原因DX 的 PanelControl 不是普通面板它内部有边框、背景和皮肤绘制层。某些主题下控件先把皮肤画完再触发你的 Paint随后又有一次内部 Border 绘制覆盖在上面。类似的情况也出现在给 Panel 做圆角的时候你会发现 Paint 里画的东西会被边框或背景吃晕。解决优先把画线内容放到一个普通 UserControl 上外层再套 DX 控件如果坚持用 PanelControl就在OnPaint重写里绘制并调用base.OnPaint不要只用事件。另外一个保险做法是把线画到图片上再用控件的背景图属性显示绕开绘制层级。顺带提一句WinForms 控件属性大全里UserControl 有一个受保护的 DoubleBuffered 属性不少闪烁问题就是靠它解决的。4.2 循环里new Pen和Brush越画越慢现象测速脚本前几十次正常后面肉眼可见变卡任务管理器里 GDI 对象数不断上升最后整窗白屏或绘制抛出OutOfMemoryException。原因Pen、Brush、Font 都属于 GDI 对象每次new都占用一个句柄。循环里写new Pen(Color.Red)却不 Dispose句柄很快被撑满。很多测速代码只注意了 Stopwatch没注意对象释放于是把“性能问题”测成了“内存泄漏问题”。解决在循环外创建、循环内复用用完统一释放。上面的LineSpeedBench示例里我特意把_pen设为成员字段就是这个原因。如果你需要多种颜色、多种宽度也要预先建好对应的 Pen 集合不要边画边 new。这条对生产代码比测速更重要我见过不止一个报表模块因为在这里贪省事上线几小时就句柄耗尽。4.3 DPI从96切到120坐标就不准了现象在 100% 缩放下线是准的把 Windows 缩放改成 125% 或 150%线明显偏移或者整条线变粗变糊。截图比对时绘制位置和预期坐标对不上。原因WinForms 的 PerMonitorV2 DPI 感知下DX 控件内部会缩放字体和布局但你自己用Width、Height做像素运算时拿到的可能是逻辑像素。GDI 默认按物理像素绘制二者一混合坐标就错了。解决统一用e.Graphics.PageUnit GraphicsUnit.Pixel并显式获取DeviceDpi。画线前先算一个缩放系数float scale e.Graphics.DpiX / 96f; e.Graphics.DrawLine(_pen, 10f * scale, 10f * scale, Width - 10f * scale, Height - 10f * scale);如果你的项目已经开启了 DPI 感知DX 控件内部会自动处理缩放这种手动乘系数的方式反而可能造成二次缩放要注意区分项目里有没有现成的ScaleFactor工具类。笔者的排查习惯是先把窗体的AutoScaleMode固定为Dpi画线坐标全部改成浮点数再在不同缩放级别下截屏对比。4.4 跨线程调用DrawLine直接崩溃现象后台线程或System.Timers.Timer里直接调用panel.CreateGraphics().DrawLine(...)程序时好时坏偶尔抛InvalidOperationException提示“线程间操作无效”。原因WinForms 控件只能在创建它的 UI 线程上操作跨线程访问会被 .NET 的控件安全检查拦截。很多测速脚本为了不卡界面会把绘制循环丢进Task.Run结果撞上这个限制。解决把绘制指令切回 UI 线程。最轻量的写法是panel.BeginInvoke(new Action(() ...))但高频绘制下每次都 Invoke 仍然会积压消息。更稳的做法是后台线程只负责把要画的坐标写入线程安全队列UI 线程在Application.Idle或定时器里统一取出并绘制。这套模型也适用于后面的批量画线场景。5. 从一条线到批量矢量什么时候该用DX控件什么时候自绘5.1 10万条线的场景DrawLine不是为批量而生把标题里的“画一条线”改成画一万条线你会立刻发现效率瓶颈不在 DX 控件而在 GDI 的立即绘制模式。每条DrawLine都是一次独立调用调用开销和状态切换比光栅化更明显。常见做法是合并成一次DrawLines把所有线条的端点放进一个PointF[]数组让 GDI 一次提交。using System.Drawing; public static void DrawManyLines(Graphics g, PointF[] points, Pen pen) { // points 数组长度为线段数的两倍每一对表示一条线的起点和终点 g.DrawLines(pen, points); }这段代码的关键在points格式它不是点的集合是“线段的端点”集合数组里每两个点组成一条线。如果你原来的逻辑是逐条判断颜色、宽度那就不能合并需要按相同绘制状态分段合并。DrawLines 只支持同一条 Pen 和同一种线型想每根线不同颜色要么拆开要么用渐变画刷或逐段绘制这一步省不掉。当线条量继续涨到十万级以上GDI 已经不适合作为主渲染路径。此时要么用 GraphicsPath 把线条预编译成路径再填充和描边要么直接把绘制迁移到 DirectWrite 或 SkiaSharp。DX 控件在这里帮不了太多忙因为它自身的绘制最终也走 GDI。5.2 DX自有绘制接口值得用的两个场合单元格Tile和图表坐标面如果你的“画线”不是画在空白面板上而是要画进 GridControl 的单元格或者 ChartControl 的坐标区域思路就要换成 DX 的 CustomDraw 接口。拿 Grid 举例单元格自定义绘制时要拿e.Cache.Graphics而不是用CreateGraphics绘制前先调用e.Appearance.FillRectangle填充背景再画你的内容否则单元格背景会被你的线盖住。ChartControl 场景里真正做部门常用的是 ConstantLine 或者 GridLine这些是 DX 图表内置功能不需要自绘。很多搜“C# dx 控件画线”的人其实是想在图表上加一条均值线或阈值线这时候自绘反而绕远路。优先查控件自带能力确定没有再回到 Paint 事件手绘。5.3 什么时候放弃DX自绘回到自定义UserControl当你的绘制层需要平移、缩放、局部放大、动态更新坐标时DX 控件那层皮肤和布局管线反而碍事。PictureBox 那一套重绘思路更合适自己接管 Paint自己管理一张离屏 Bitmap。做法是创建一个继承 UserControl 的类把DoubleBuffered设为 true每次交互只重新计算坐标并要求Invalidate()。public class LineViewer : UserControl { public LineViewer() { DoubleBuffered true; } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); e.Graphics.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; e.Graphics.Clear(BackColor); // 这里有局部放大时先取缩放矩阵再画线 } }注意DoubleBuffered在 UserControl 里是受保护属性你只有在子类里才能直接设置。如果是在窗体设计器里放一个普通 PictureBox也可以把它的BackColor设成纯色在 Paint 事件里全权接管。这个方案牺牲了 DX 的皮肤统一性但换来了绘制逻辑的完全可控遇到局部放大、坐标换算、橡皮筋框选这些需求时改起来非常快。6. 把测速结果留档把一次手工测试变成可回归的验证6.1 跑完测速后把配置、结果和DX版本一起写进文本性能测试最怕结果记在脑子里。今天改了一点代码觉得快了下周改回来你根本不知道动了什么。我现在的做法是让测速函数直接返回一个字符串包含测试时间、DX 版本、循环次数、抗锯齿开关和平均耗时然后写进项目根目录下的bench.txt。File.AppendAllText( bench.txt, ${DateTime.Now:yyyy-MM-dd HH:mm:ss} | DX {typeof(PanelControl).Assembly.GetName().Version} | ${count} lines | avg {avgMs:F3} ms);这套记录的意义不在于精准而在于同一份脚本跑出的数字可以前后对比。改完绘图代码跑一遍看平均值是上升还是下降比肉眼判断靠谱得多。如果你的项目已经接了 CI也可以把它变成命令行工具在每次提交后自动输出一组数值。6.2 我的习惯不做“肉眼快慢”判断我自己在改 DX 绘制代码之前都会先看一眼项目里的基准值有没有变化而不是凭感觉说“好像变快了”。指标的名字就叫LineDrawMs/1000Lines固定格式固定循环次数。曾经有一次只是把 Pen 缓存起来就快了四倍我说不清具体是哪一环省下来的但基准数据帮我把结论定了下来。后来每次有人提议换渲染方案我都让去跑一遍同款基准数据说话。希望这个习惯对你也有用也希望上面的参数和排查能帮你少走几步弯路。本文还有配套的精品资源点击获取
返回列表