ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#自绘图表源码接入实践:GDI+坐标映射、交互与高DPI导出

C#自绘图表源码接入实践:GDI+坐标映射、交互与高DPI导出

简介:EeceChart.zip 是一份面向 C# 开发者与数据分析可视化学习者的实战项目包,围绕 Excel 数据读取、曲线绘制和交互式图形操作展开,可用于桌面端小型分析工具的原型搭建与技能练习。压缩包共 216 个文件,约 46.67MB,包含 cs 源码、dll 依赖库、xml/config 配置、nupkg 安装包、txt 说明与图片预览等,便于理解项目结构、复现实验流程并定位关键实现。项目涉及 Office Interop 读写 Excel、随机抽样、GDI+ 或 WPF 曲线映射、鼠标右键拖动缩放、曲线平均值计算、图形重绘与异常处理等知识点,并演示将处理结果写回表格。已有 245 人学习,适合希望把 Excel 处理、统计计算与图形交互串成完整案例的读者对照实践,也可供课程设计参考。

1. 解压 EeceChart.zip 之前:先分清「自己画图」和「调库画图」的边界

前阵子有人问我,一个纯 C# 的桌面小工具要画三条折线、一根柱状、带图例、能导出 PNG,装第三方图表组件要授权,发布时还得跟着一串 DLL,能不能自己写。能,而且这类需求里最省事的路径,往往就是拿一份现成的 C# 自绘图表源码改。EeceChart.zip 就是这一类东西:基于 System.Drawing 的图表绘制源码包,把坐标轴、刻度、折线/柱状/饼图的绘制、图例和导出这几件事封在若干类里,不依赖外部组件,放进 WinForms 工程就能跑。

它适合内部报表、采集上位机、测试工装、打印单据附页这种「图不复杂但不能没有」的场景,也适合拿来当学习 GDI+ 坐标系和绘制的脚手架;不适合做交互复杂的大屏、实时百万点渲染或者需要 3D 的场合。先认清这个边界,后面每一步才不会走偏。

2. 拆包与工程接入:把 zip 变成能 F5 跑起来的 C# 工程

拿到一个源码包,最忌讳的是双击 .sln 看到能跑就以为接入完成了,真正要拿走的是「绘图核心」那一层,示例窗体只是说明书。这一章按解压、看结构、选接入方式、跑通第一张图的顺序走一遍。

2.1 解压后先看三样东西:解决方案、绘图核心、示例窗体

解压出来的目录结构各家不一样,但负责的事情大同小异。下面这张表是我拆这类 C# 图表包时的固定检查清单,你对照着自己那份看一眼就知道哪部分是「要拿走的」、哪部分是「看完就删的」。

位置职责处理方式
根目录 .sln / .csproj把核心库和示例串起来,保证能编译只用来验证环境,不带走
Chart / Drawing 之类目录坐标轴、序列、绘制、图例的真正实现这是要拿走的全部
Demo / Form 之类目录演示数据怎么灌、属性怎么设当文档读,读懂了删
Properties / Resources默认图标、字体、配色注意字体授权,能删就删
bin / obj上次编译产物直接删,别混进你的仓库

值得多花十分钟的是绘图核心里的命名空间划分。常见做法是按「模型 + 渲染 + 交互」三层拆:模型层放数据点和序列,渲染层放坐标轴和画笔,交互层放命中测试和缩放。如果解压后这三层混在一个几千行的 Form 里,说明这份源码是「能跑优先」的风格,你接入时最好先自己分层,否则改到第三个需求就会失控。

提示:先看 .csproj 里的 TargetFramework。老包多半是 .NET Framework 4.x,代码里大量 System.Drawing 的同步调用;迁到 .NET 6/8 的 Windows 目标框架下基本能直接编,但 System.Drawing.Common 在非 Windows 平台已经不被支持,这点在选型时就要认下来。

2.2 接入方式:项目引用还是源码拷贝,先定死

两种接入方式各有代价,我的经验是按「这个图会不会长期演进」来选。

  • 项目引用:新建一个类库工程装绘图核心,主程序引用它。核心有 bug 时集中改一处,多个内部工具共享同一份,代价是多一个 DLL 要发布。
  • 源码拷贝:把绘图核心目录整个拖进主工程的一个子目录。发布只有一个 exe,代价是改核心时容易和原始版本分叉,后面想合回来很麻烦。

给一段最小接入流程,用 .NET SDK 风格的工程演示,老式 csproj 需要手动加<Compile Include>,SDK 风格默认会把子目录下所有 .cs 编译进去:

# 1. 建一个 WinForms 工程,目标框架按需改 net48 / net6.0-windows / net8.0-windows dotnet new winforms -n ChartDemo -f net8.0-windows # 2. 进入工程目录,把 EeceChart 的绘图核心目录整个拷到 Chart/ 下 cd ChartDemo mkdir Chart # 拷贝操作按你的解压路径来,这里只表达「整目录搬过去」这个动作 # 3. 编译,先确认没有任何第三方 DLL 依赖 dotnet build

编译阶段最常见的两类报错,一类是未能找到类型或命名空间名,说明命名空间和你 using 的对不上,去文件头看一眼namespace补上即可;另一类是System.Drawing.Common 在此平台上不受支持,那是目标框架写成了跨平台的net8.0而不是net8.0-windows,改框架而不是改代码。

注意:如果你顺手把 bin/obj 一起拷进了仓库,第一次编译可能出现「重复定义」之类的怪错。清掉这两个目录再编一次,比重装 SDK 快得多。

2.3 第一个图:三步把数据灌进去看到线

下面的示例是我接入这类图表包时的固定模板。里面出现的EeceChartControl、ChartSeries、ChartPoint是我在接入层统一过的包装名,你解压后看到的具体类名可能叫别的,对照着换掉即可,方法体和参数含义是通用的。

using System; using System.Drawing; using System.Windows.Forms; public class MainForm : Form { // 图表控件铺满窗体,先跑通再谈布局 private readonly EeceChartControl _chart = new EeceChartControl(); public MainForm() { Text = "EeceChart 最小示例"; ClientSize = new Size(900, 520); _chart.Dock = DockStyle.Fill; _chart.BackColor = Color.White; Controls.Add(_chart); Load += (s, e) => BindDemoData(); } private void BindDemoData() { // 序列 = 名字 + 颜色 + 一串点 var series = new ChartSeries("温度", Color.FromArgb(0x2E, 0x86, 0xDE)); for (int i = 0; i < 60; i++) { // X 用索引,Y 造一个正弦,方便肉眼判断画得对不对 series.Points.Add(new ChartPoint(i, 20 + 5 * Math.Sin(i / 6.0))); } _chart.Series.Add(series); // 轴标题只是标签,真正决定范围的是数据本身,这点后面第 3 章展开 _chart.AxisX.Title = "采样点"; _chart.AxisY.Title = "温度 / 摄氏度"; // 手动触发一次重绘,避免依赖控件自己的刷新时机 _chart.Invalidate(); } }

这段代码有三个点值得说清楚。第一,Load事件里绑数据而不是构造函数里,是为了让控件先完成句柄创建,避免早期版本里在OnPaint之前访问Graphics抛异常。第二,Invalidate()走的是「异步标记 + 消息循环重绘」,比直接调Refresh()温和,连续改数据时不会把 UI 线程拖死。第三,X 轴用索引而不是时间戳,是因为第一次跑通时你最想确认的是「线画得直不直、抗锯齿开没开」,不是「时间格式对不对」。

跑通之后先做一次自检:把窗体拉大拉小,看线和文字有没有跟着重排;把数据改成 0 到 1 之间的小数,看 Y 轴刻度是不是自动变成 0.1/0.2;把某条序列的点清空,看控件是消失还是抛异常。这三个动作能暴露绝大部分「示例能跑、接进去就翻车」的问题。

3. 坐标映射与绘制流程:一条折线是怎么落到像素上的

跑通之后就该看内部了。图表包最容易出问题的地方不是「画不出来」,而是「画出来了但对不上」——刻度和数据错位、Y 轴方向反了、0 值不在轴上。这一章把数据域到像素域的换算、刻度步长的选取、双缓冲与重绘顺序拆开讲,这些都是你后面调参数时绕不开的。

3.1 数据域到绘制域:四个必须自己算清楚的量

任何一张图,无论多简单,绘制前都要先把这四个量确定下来:数据最小值dataMin、数据最大值dataMax、绘图区左上角像素坐标plotLeft/plotTop、绘图区宽高plotWidth/plotHeight。绘图区不等于控件区域,还要扣掉左侧 Y 轴标签宽度、底部 X 轴标签高度、右上角留白和图例占位。

换算本身是一行线性映射,坑都藏在边界条件里。下面这段是我在接入层统一改写过的版本,把几个必踩的坑提前堵住:

/// <summary> /// 把数据值换算成像素坐标 /// </summary> /// <param name="value">原始数据值</param> /// <param name="pixelStart">绘制区起点(X 轴用 left,Y 轴用 top)</param> /// <param name="pixelLength">绘制区长度(宽或高)</param> /// <param name="inverted">Y 轴为 true,因为屏幕坐标向下为正</param> public float DataToPixel(double value, float pixelStart, float pixelLength, bool inverted) { // 坑 1:整条序列最大值等于最小值时,span 为 0,除法后整条线变成 NaN 直接不画 double span = _dataMax - _dataMin; if (Math.Abs(span) < 1e-12) span = 1.0; double ratio = (value - _dataMin) / span; // 归一化到 0..1 float px = pixelStart + (float)(ratio * pixelLength); // 坑 2:屏幕 Y 轴向下,数据 Y 向上,不翻转的话图是上下颠倒的 if (inverted) px = pixelStart + pixelLength - (px - pixelStart); // 坑 3:浮点换算出的边缘值可能刚好落在绘制区外一像素,被裁剪掉一截 return Math.Max(pixelStart, Math.Min(pixelStart + pixelLength, px)); }

三个参数里最容易含糊的是inverted。很多实现图省事,直接在设置dataMin/dataMax时把大小反过来传,结果 X 轴和 Y 轴共用一套逻辑,图例、命中测试、导出全跟着错。我一般坚持「数据域永远小到大,翻转只在像素映射这一层做」,改起来只有一行。

另一件必须在接入时就定死的事是dataMin/dataMax的来源:自动还是手动。自动计算的常见做法是遍历所有可见序列取极值,再向上下各留 5% 到 10% 的余量;手动则通过轴的属性直接赋值。两者混用时一定要有优先级规则,否则会出现「设置了手动范围、加一条序列又被自动改回去」的玄学现象。

3.2 刻度步长:为什么你的 Y 轴刻度是 0.3333、0.6666

自动范围算出来了,刻度怎么切?最粗暴的做法是把跨度除以固定份数,比如 10 份,得到的刻度标签往往是 0.3333、3.6667 这种没人想看的东西。合格的实现要引入「好看的数字」:步长只能是 1、2、5 乘以 10 的整数次幂。

/// <summary> /// 把粗糙步长规整成 1/2/5 × 10^n 的「好看步长」 /// </summary> private static double NiceStep(double roughStep) { if (roughStep <= 0) return 1; double exp = Math.Floor(Math.Log10(roughStep)); double baseUnit = Math.Pow(10, exp); double f = roughStep / baseUnit; // 落在 1..10 之间 double niceF = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10; return niceF * baseUnit; }

这个函数本身只有十行,关键是怎么算入参roughStep。我给的经验是:先用(dataMax - dataMin) / targetTickCount得到粗糙步长,其中targetTickCount不写死,而是按绘制区高度除以「相邻刻度最小像素间距」估算,折线图纵向给 50 到 70 像素一个刻度,横向给 80 到 100 像素,这样在 900×520 的窗口里大约 6 到 8 个刻度,放大窗口刻度自动变密,视觉效果比较稳。

有了步长还要处理起点,常见做法是dataMin向下取整到步长的整数倍,dataMax向上取整,然后把轴范围按整刻度重新赋值。这一步不做的话,轴的两端会出现半截刻度,视觉上很别扭。负数和跨零数据在这里要多看一眼:0 刻度必须落在真实位置,不能因为取整把 0 挪走了,很多「零值不在轴上」的投诉都出在这里。

3.3 绘制顺序与双缓冲:图会闪、拖窗口一片白,问题都在这

GDI+ 绘制图表有严格的层次顺序,顺序错了会出现网格盖住折线、图例被裁剪、轴标签被背景刷掉之类的怪象。我固定按这个次序写OnPaint:背景填充 → 网格线 → 坐标轴与刻度标签 → 数据序列 → 数据标签 → 图例 → 交互高亮(十字线、选中点)。数据序列必须画在网格之后、图例之前,理由简单——网格是参考线,不能压住数据;图例是覆盖层,必须最后画。

闪烁的根源几乎总是同一件事:控件先被系统用背景色擦一遍,再画你的内容,两个动作之间被用户看见了。解法是把三件事一起做:

public class EeceChartControl : Control { public EeceChartControl() { // 四件套:自己擦背景 + 双缓冲 + 全部绘制走 OnPaint + 尺寸变化就重绘 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); UpdateStyles(); } protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 折线抗锯齿 g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; // 文字清晰 g.PixelOffsetMode = PixelOffsetMode.HighQuality; // 细线不糊 DrawBackground(g); DrawGrid(g); DrawAxes(g); DrawSeries(g); // 数据量大时这一行最慢,后面第 5 章专门说 DrawLegend(g); } }

ControlStyles.UserPaint不允许系统帮你擦背景,OptimizedDoubleBuffer让绘制先落到内存位图再一次性贴到屏幕。这两个合起来,拖窗口和实时刷新都不会再闪。代价是内存多一张与控件等大的位图,900×520 大约 1.8MB,可以接受,但如果你在界面上摆十个图表控件,就得考虑按需创建。

还有一个容易被忽略的点:抗锯齿是全局开关,开了之后细线和高密度数据都会变慢。我的习惯是交互过程中(拖动、缩放、实时刷新)关掉抗锯齿,交互停止后 150 毫秒再开回来重绘一次。用户在动的时候看不出锯齿,停下来看到的又是平滑的版本,卡顿感能明显下降。

4. 交互补全:命中测试、缩放平移与图片导出

图能画对之后,用户的下一个要求通常是「鼠标指上去要显示数值」。这一章讲三件事:怎么判断鼠标点中了哪个数据点、怎么在不改数据的前提下做缩放平移、怎么把当前视图导出成一份能贴进文档的图片。

4.1 命中测试:不要用矩形去套点,用距离

新手写命中测试最常见的写法是判断鼠标是否落在数据点周围 5×5 的矩形里。这个做法在折线上会漏掉「两个点之间那一段线」,用户点了线上却选不中,体验很差。我一般做成两级:先找最近的顶点,再判断是否落在线上。

/// <summary> /// 命中测试:返回鼠标附近的数据点索引,找不到返回 -1 /// </summary> /// <param name="mouseX">鼠标 X(控件坐标)</param> /// <param name="mouseY">鼠标 Y(控件坐标)</param> /// <param name="hitRadius">命中半径,按 DPI 缩放后传入,通常 6~8 像素</param> public int HitTest(float mouseX, float mouseY, float hitRadius) { int bestIndex = -1; double bestDistSq = hitRadius * hitRadius; for (int i = 0; i < _visibleCount; i++) { float px = DataToPixel(_xs[i], _plotLeft, _plotWidth, false); float py = DataToPixel(_ys[i], _plotTop, _plotHeight, true); double dx = px - mouseX; double dy = py - mouseY; double distSq = dx * dx + dy * dy; if (distSq <= bestDistSq) { bestDistSq = distSq; bestIndex = i; } } return bestIndex; }

这段的复杂度是 O(n),点数在一两千以内完全够用,鼠标移动触发的频率也就几十赫兹。点数上万时就得换方法:按 X 像素排序后二分查找近邻,再只对邻近的若干个点做距离判断,把 O(n) 降到 O(log n)。hitRadius不建议写死成 5,要按当前 DPI 缩放,否则 150% 缩放的屏幕上会出现「看着点中了但选不中」的怪事。

命中之后的高亮绘制也很讲究。常见做法是在命中点画一个半径 3 到 4 像素的实心圆加一圈白边,再在附近弹一个轻量提示框。提示框不要用 ToolTip 控件,那个有几毫秒延迟,跟随鼠标会很迟钝;自己在OnPaint的最后一步画一个圆角矩形更跟手,代价是要自己算边界,防止提示框跑出控件外。

4.2 缩放与平移:改视窗,不动数据

很多人实现缩放的第一反应是「缩放时把数据乘以系数」,这是错的。正确做法是维护一个「可视窗口」——也就是当前 X 轴和 Y 轴的显示范围viewMinX/viewMaxX/viewMinY/viewMaxY,所有绘制、命中测试、导出都用这四个值,原始数据一个字节都不动。这样做的好处是缩放随时可以无损还原,也不会因为反复乘除引入精度误差。

滚轮缩放的难点是「以鼠标位置为锚点」,不处理的话会变成以控件中心缩放,用户想放大的那块总是跑出视野。

/// <summary> /// 以鼠标位置为锚点做 X 轴缩放 /// </summary> /// <param name="mouseX">鼠标在控件内的 X 坐标</param> /// <param name="factor">缩放因子,向上滚 0.8(缩小范围=放大),向下滚 1.25</param> private void ZoomAtX(float mouseX, double factor) { // 1. 鼠标位置换算成当前视窗里的数据值,这个值在缩放前后要保持不变 double anchor = PixelToData(mouseX, _plotLeft, _plotWidth, _viewMinX, _viewMaxX); // 2. 视窗范围按因子收缩或扩张 double leftSpan = anchor - _viewMinX; double rightSpan = _viewMaxX - anchor; _viewMinX = anchor - leftSpan * factor; _viewMaxX = anchor + rightSpan * factor; // 3. 限制最小跨度,防止滚到无穷小之后全是 NaN if (_viewMaxX - _viewMinX < 1e-9) { /* 还原到上一步 */ return; } ClampToDataRange(); // 可选:不允许滚出数据边界 Invalidate(); }

factor取 0.8/1.25 是为了让「放大三步、缩小三步」大致回到原位,因为它俩互为倒数。如果取 0.9/1.1,用户会感觉缩小比放大慢,越缩越远。平移同理,用鼠标按下时的坐标差换算成数据差,直接加到viewMin/viewMax上,注意要在按下时把Capture = true,不然鼠标移出控件就丢了事件。

4.3 导出图片:不要用 DrawToBitmap 截屏

导出是这类图表包最容易被低估的功能。用户要的是「把这张图贴进报告」,不是「截一张 900×520 的屏幕图」。有些实现直接调Control.DrawToBitmap,简单是简单,但有两个硬伤:一是走窗口 DC,受 DPI 虚拟化影响,150% 缩放下导出的图会被系统拉伸导致模糊;二是控件不可见或部分被遮挡时导出内容不完整。

我一般改用「离屏位图 + 同一套绘制方法」:

/// <summary> /// 把图表渲染到指定像素尺寸的位图,供导出或打印使用 /// </summary> /// <param name="widthPx">目标宽度(像素)</param> /// <param name="heightPx">目标高度(像素)</param> /// <param name="dpi">目标 DPI,屏幕用 96,打印用 300</param> public Bitmap RenderToBitmap(int widthPx, int heightPx, float dpi = 96f) { var bmp = new Bitmap(widthPx, heightPx); bmp.SetResolution(dpi, dpi); using (var g = Graphics.FromImage(bmp)) { g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; // 关键:让绘制逻辑里的像素常量跟着 DPI 走,而不是被硬编码成 96 float scale = dpi / 96f; g.ScaleTransform(scale, scale); // 绘制区按缩放后的逻辑尺寸传入,保证布局比例一致 DrawChart(g, (int)(widthPx / scale), (int)(heightPx / scale)); } return bmp; }

这样做的意义在于,屏幕显示和导出用的是同一个DrawChart,只有尺寸和 DPI 两个入参不同,不会出现「屏幕上一个样、导出后另一个样」。dpi参数必须真的传进SetResolution,否则 PNG 里记录的还是 96,插进 Word 之后尺寸会变成原图的 3 倍多,排版直接乱掉。

提示:导出大尺寸时(比如 3000×2000)记得临时关掉抗锯齿再打开,或者分块绘制。GDI+ 对超大位图上的抗锯齿路径有性能悬崖,一张图卡上两三秒很常见。

5. 踩坑记录:五个把图画出错地方的常见问题

前面讲的都是「应该怎么做」,这一章全是「做错了会怎样」。下面五条都是我在接入这类 C# 图表包时真实撞见过的,按现象、原因、解决三段写,能对上号就直接抄。

5.1 图是空的,只有坐标轴和网格

现象:控件显示出来了,轴、网格、图例都在,就是没有折线,也不报错。

原因有两类。一类是数据进去了但轴的自动范围没重算,dataMin和dataMax还停在 0 和 1,所有数据点都落在绘制区之外被裁剪掉了。另一类是序列被加到了Series集合里,但控件的_visibleCount缓存没更新,绘制循环遍历的是 0 个点。

解决:把「范围重算」挂到序列集合的变更事件里,而不是依赖调用方记得手动调;同时在OnPaint开头做一次防御性检查,若_visibleCount与实际集合数量不符就重建缓存。更省事的办法是在Invalidate之前强制调用一次RecalculateRange(),代价是每帧多一次遍历,数据量大时要注意。

5.2 高 DPI 屏幕上文字发虚、线粗细不对

现象:笔记本外接 4K 显示器,程序界面被系统放大后,图表里的轴标签糊成一团,1 像素的网格线变成 3 像素粗。

原因:进程没有声明 DPI 感知,Windows 把整个窗口当成位图整体拉伸,相当于把 96 DPI 的画面放大 2 倍,任何文字都必然模糊。

解决:在应用清单里声明 PerMonitorV2 支持,让程序自己处理缩放。

<?xml version="1.0" encoding="utf-8"?> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> </windowsSettings> </application> </assembly>

声明之后还要在代码里响应DpiChanged事件,把字体大小、刻度间距、命中半径这些「按像素写死」的常量按DeviceDpi / 96f重算一遍,否则图不会糊了,但标签会挤在一起。

5.3 数据上万点后,鼠标一动就卡

现象:静态显示还行,鼠标划过或者拖动缩放立刻掉到十几帧,任务管理器里 CPU 单核跑满。

原因:每次OnPaint都对全量点做抗锯齿的DrawLines,还要为每个点计算并绘制标记,GDI+ 在这些路径上没有做任何优化。再加上鼠标移动触发的高亮又调用了一次全量重绘,等于一帧干了两遍活。

解决:三件事一起做——按像素抽稀,把每个像素列内的多条数据聚合成最小值和最大值两个点,一万点画在 900 像素宽的图上,抽稀后只有 1800 个点;交互过程中关闭抗锯齿和数据点标记,只画线;高亮改成局部失效,只重绘变化的那一小块矩形区域。

5.4 负数和跨零数据的刻度全挤在 0 附近

现象:数据在 -3 到 +3 之间,轴范围自动算成 -3 到 3,但刻度标签显示成 -2、-1、0、1、2,0 的两边挤着两行字。

原因:NiceStep只按总跨度算步长,没考虑「跨零时 0 必须是一个真实刻度」这个约束。跨度 6 除以目标刻度数 8 得到 0.75,规整成 1,从 -3 开始切分得到 -3、-2、-1、0、1、2、3,正好七个刻度,本身没错;出问题的是范围起点用了dataMin而不是规整后的整刻度,0 落在两个刻度的中间位置。

解决:范围起点和终点都按步长向下、向上取整,保证 0 一定落在刻度点上;同时按标签最长字符串的实际像素宽度反算最小刻度间距,宽度不够就减少刻度数量,宁可少画两个刻度也不要叠字。

5.5 导出的 PNG 和屏幕上看到的不是一张图

现象:屏幕上字体大小正常,导出后字体变得很大,图例位置也偏了。

原因:导出时复用了控件当前的绘制参数,包括按屏幕 DPI 算好的字体大小和绘图区尺寸,但位图的物理尺寸和 DPI 是另一套。两套坐标系混在一起,所有「先算好再画」的常量都错位了。

解决:把绘制逻辑抽成一个只接受Graphics、逻辑宽高、DPI 三个入参的函数,屏幕绘制和导出绘制都调它,禁止在绘制函数里读控件属性。改完之后屏幕和导出就是同一个函数的两组参数,不会再出现两套行为。

6. 从能看 to 能交付:把输出对齐全场景

图在屏幕上对了,不代表交付就完成了。实际项目里这张图大概率要进打印、进 PDF、进 Word,甚至被贴到别的系统里,这时候高 DPI 和字号就成了新的战场。

我的习惯是先统一单位。绘制函数内部一律用「逻辑像素」,也就是 96 DPI 下的像素,所有硬编码的常量(线宽 1、刻度长 5、标签间距 6)都按逻辑像素写;真正落到Graphics之前,用一个scale = dpi / 96f的ScaleTransform一次性放大。这样同一份绘制代码在 96 DPI 的屏幕、192 DPI 的外接屏、300 DPI 的打印输出上,比例关系完全一致,不会出现「屏幕上线很细、打印出来看不见」的情况。

字体是第二个要单独立规矩的东西。不要用new Font("宋体", 9)这种写死字号的方式,改成按逻辑像素反算:fontSize = 12 / 96f * dpi,或者干脆声明时用点(pt)为单位,让 GDI+ 自己做换算。中英文混排时还要注意,默认的 ClearType 在打印输出上会失效,打印前把TextRenderingHint切成AntiAliasGridFit,字形边缘会干净很多。

验证方法我给一个笨但有效的:准备一份已知答案的数据,比如 X 从 0 到 10 步长 1、Y 等于 X 的平方,然后写一个断言,检查绘制前算出的关键点像素坐标是否落在预期位置,容差给 1 像素。

// 用已知数据验证坐标映射,比肉眼盯着看靠谱 var chart = new EeceChartControl(); chart.SetRange(0, 10, 0, 100); // X: 0~10, Y: 0~100 chart.SetPlotArea(50, 20, 800, 400); // left, top, width, height // X=10 应该落在绘制区最右边,Y=100 应该落在最上边 AssertNear(810f, chart.DataToPixelX(10), 1f); AssertNear(20f, chart.DataToPixelY(100), 1f); // Y=0 应该落在最下边,这条最容易因为翻转写错而出问题 AssertNear(420f, chart.DataToPixelY(0), 1f);

这四个断言覆盖了 X 正向、Y 翻转、边界裁剪三种情况,每次改完坐标映射相关代码跑一遍,比截图对比快得多。导出和打印也走同一套断言,只是入参换成对应 DPI,能一次把「屏幕对、导出错」的问题挡在提交之前。

血泪经验是,我曾经因为导出时忘了给位图设SetResolution,一份 300 DPI 的图在文档里被当成 96 DPI 显示,尺寸放大了三倍多,排版全部重做。从那以后我每次动导出相关的代码,都强制走一遍「96 和 300 两个 DPI 各导一张、量一下物理尺寸」的检查,哪怕只改了一个颜色常量。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进