ARTICLE DETAIL

资讯详情

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

C#上位机Chart控件实战:柱状图、折线图与样条曲线全解析

C#上位机Chart控件实战:柱状图、折线图与样条曲线全解析 做上位机的朋友应该都有过这种体会设备数据总算采上来了结果界面上一排排数字密密麻麻客户看了两眼就直摇头。数据不直观整个项目的说服力直接掉一半。所以UI界面上放几个能实时刷新、结构清晰、还能看得出趋势的图表基本是上位机开发的标配需求。这篇文章我就围绕C#上位机UI开发里最常用的Chart控件把柱状图、折线图、样条曲线这类多元化数据展示怎么做、怎么美化、怎么避免踩坑完整梳理一遍。内容会从图表控件的核心对象拆起到多系列柱状图的搭建、大数据量折线图的性能处理、样条曲线的平滑逻辑再到图表缩放、鼠标取值、跨线程刷新UI这些实战细节。适合正在做上位机软件开发、或者打算用WinForms做可视化监控界面的朋友参考项目里能直接套用。1. 整体设计思路为什么选Chart控件先理清这四件事1.1 选Chart控件而不是自绘或第三方控件上位机UI开发里做图表的方式其实有好几条路。一是用GDI自己画二是引入DevExpress、Telerik这类商业控件库三是直接用WinForms自带的Chart控件。我个人的体会是绝大多数场景下自带Chart控件反而是最优解。自己用GDI画图灵活度确实最高但代价也最大。要处理坐标轴刻度计算、单位换算、网格线绘制、文字防锯齿、缩放平移命中测试稍微复杂一点的需求就得写好几百行代码而且换一个项目又要重新调一遍。商业控件库倒是省事功能也花哨但引入第三方依赖意味着授权成本、学习成本和版本兼容问题很多工控环境里客户还不一定接受额外装运行库。Chart控件是.NET Framework自带的程序集System.Windows.Forms.DataVisualization.Charting这个命名空间下WinForms项目直接引用就能用。它内部把图表拆成了绘图区、系列、数据点、坐标轴、图例、标注这些对象以及缩放、光标、ToolTip、命中测试这些交互机制。学一遍之后反复复用性价比很高。1.2 先搞懂对象模型别急着写代码Chart控件上手前我建议先把几个核心对象之间的关系理清楚。很多人一上来就往Series里面塞数据最后发现坐标系、图例、颜色全乱成一锅粥就是因为对象层级没概念。拿一个简单的场景类比Chart控件是一块展示板ChartArea是板子上用来画图的区域你可以把一块板子划分成多个绘图区上下两个波形各画各的。每个ChartArea里有SeriesSeries就是一条条数据曲线柱状图里一个系列就是一组柱子一个Series包含多个DataPoint每个DataPoint就是具体的数据点。坐标轴Axis分X轴和Y轴负责把数据点的值映射到绘图区的像素位置上。Legend图例就是把不同系列的标识汇总在一起给人看。所以在动手之前先确定要几个ChartArea、几个Series、每个Series用什么图表类型再考虑坐标轴的显示范围和间隔最后才轮到颜色、线宽、渐变这些美化的部分。顺序反了代码会越写越乱。2. 柱状图的搭建与美化从默认样式到能交付的界面2.1 多系列柱状图的核心配置与数据绑定方式柱状图在监控界面里最常见的用途是产量统计、能耗对比、检测结果分布这类数据。要展示多组数据并排比较关键就是把多个Series放到同一个ChartArea里让Chart控件自动按分类索引排列柱子。先看一段最基础的创建方式// 创建图表区域 ChartArea chartArea1 new ChartArea(MainArea); chart1.ChartAreas.Add(chartArea1); // 创建两个柱状图系列 Series series1 new Series(1号机组); series1.ChartType SeriesChartType.Column; series1.ChartArea MainArea; Series series2 new Series(2号机组); series2.ChartType SeriesChartType.Column; series2.ChartArea MainArea; // 添加数据点 series1.Points.AddXY(周一, 86); series1.Points.AddXY(周二, 92); series1.Points.AddXY(周三, 78); series2.Points.AddXY(周一, 65); series2.Points.AddXY(周二, 81); series2.Points.AddXY(周三, 73); chart1.Series.Add(series1); chart1.Series.Add(series2);这里有一个非常容易踩的坑series1和series2的X值必须对应上不然柱子就不会并排显示而是各自占据独立位置。AddXY传入的第一个参数是X轴的值第二个是Y轴的值。如果两个系列的X值不一致比如一个从0开始、一个从1开始柱子就会错位看起来像两组毫不相干的数据。实际项目里数据往往来自DataTable或List直接绑定更省事DataTable dt new DataTable(); dt.Columns.Add(班组, typeof(string)); dt.Columns.Add(产量A, typeof(int)); dt.Columns.Add(产量B, typeof(int)); // 填充数据... series1.XValueMember 班组; series1.YValueMembers 产量A; series1.DataSource dt; series1.SortPointX true; // 关键按X值排序 // series2 同理会自动绑定绑定DataTable有个好处数据源更新后只要重新调用Chart控件的DataBind方法图表就会跟着刷新不用手动清理旧点。但要注意绑定时如果指定的列不存在或者数据里有DBNullChart控件会直接抛异常所以数据准备阶段最好先做过滤或默认值填充。2.2 柱顶部显示数值与渐变色把默认图表变成能交付的样子Chart控件的默认外观确实有点“朴素”灰色背景、平面柱体、没有数据标签看着就不够专业。项目中我一般会做这几个调整。第一柱子顶部显示数值。可以逐点设置DataPoint.Label或者在系列级别统一开启IsValueShownAsLabelseries1.IsValueShownAsLabel true; series1.LabelFormat {0}; series1.Font new Font(Microsoft YaHei, 9F);如果柱子较多标签文字之间可能会互相遮挡。这时候可以调整柱子的宽度也就是每个数据点的占位宽度series1[PointWidth] 0.6; // 默认是0.5最大可以到1PointWidth越大柱子越宽柱间距越小。要注意这个属性是通过系列的自定义属性字典访问的不是Series的普通成员所以用字符串键的方式设置。第二渐变色。柱状图用渐变色会显得立体很多。Series默认的颜色是单一色填充要渐变需要设置DataPoint的颜色或者自定义调色板。DataPoint dp series1.Points.AddXY(周一, 86); dp.Color Color.FromArgb(100, 120, 255); // 起始色 dp.BackGradientStyle LinearGradientStyle.Vertical; dp.BackSecondaryColor Color.FromArgb(20, 40, 140); // 结束色注意BackGradientStyle需要设置为LinearGradientStyle.Vertical垂直渐变渐变方向是从上到下一般高亮在顶部深色在底部这样柱子看起来像是被光从上方照过。如果想所有柱子的渐变方向一致、颜色统一也可以在循环里批量设置或者直接设置Series的Color和BackSecondaryColor再在数据集层级统一指定GradientStyle。第三柱子的标签内容不只是默认的Y值。比如想显示“86件”、带单位可以把LabelFormat改为{0}件。如果想让标签更有层次比如大数值用缩略单位则需要在代码里逐点设置Label字符串而不是依赖格式化字符串——因为格式化字符串没法做超过1000转成1k这类逻辑。2.3 堆叠柱状图与横向柱状图的切换场景除了并排对比柱状图还有两种常见变体堆叠柱状图和横向柱状图。堆叠柱状图适合展示“整体构成”的场景比如一条产线上多个工序的耗时占比、一台设备的多个部件能耗占比。实现方式非常简单把系列类型改成StackedColumn即可series1.ChartType SeriesChartType.StackedColumn; series2.ChartType SeriesChartType.StackedColumn;堆叠的时候系列添加的顺序决定堆叠的上下顺序先添加的在底部。这里有一个细节堆叠柱状图里每个柱子的标签默认显示的是“分段的值”而不是整个柱子的总值。如果你希望顶部显示总产量需要在最后一个系列也就是最上面的分段上做大文章——通过设置该系列所有点的标签为当前点值加上下面所有系列对应点的值。这块逻辑不复杂但很多人第一次都会漏掉。横向柱状图则适合分类名称比较长的场景比如设备名称、操作员姓名、故障代码这类文本类X轴标签。把SeriesChartType改成Bar就是横向柱状图了series1.ChartType SeriesChartType.Bar;Bar类型的X轴是垂直方向的Y轴是水平方向的逻辑上容易绕晕记住一点就行原来的Y值现在从左往右延伸原来的分类在纵轴方向从上往下排列。如果你的列表项多横向柱状图比纵向柱状图好用得多。3. 折线图与样条曲线让数据趋势说话的关键实现3.1 Line和FastLine大数据量折线图的性能取舍折线图在上位机里主要是实时趋势展示比如温度曲线、压力曲线、转速波形。数据量小的时候用Line系列没什么问题但一旦数据点超过几千个刷新就很容易卡顿这里的关键是选对系列类型。Line系列每个点都可以单独设置Marker标记、ToolTip、Color功能完整但性能开销大。FastLine系列牺牲了这些附属功能纯粹画线性能提升非常明显。实测数据几万个点的FastLine每秒刷新一次也能维持流畅Line只有几千点就可能掉帧。Series series new Series(实时扭矩); series.ChartType SeriesChartType.FastLine; series.BorderWidth 2;使用FastLine需要注意它不支持Marker如果想在最近一个点画个圆点指示当前值只能额外叠加一个Point类型的系列专门放最新点。另外FastLine在数据点绘制时弯曲和坐标计算都是快速路径实现的所以别指望能在上面做多复杂的样式自定义。还有一个提升性能的通用手段是尽量让图表在批量更新数据时暂停绘制而不是每加一个点就刷新一次chart1.SuspendLayout(); series.Points.Clear(); foreach (var item in dataList) { series.Points.AddXY(item.TimeStamp, item.Value); } chart1.ResumeLayout();这条规则适用于任何类型的图表。在采集频率很高的场景下数据的实时性其实不差这几毫秒的刷新时间但UI的流畅度提升是肉眼可见的。3.2 Spline样条曲线的平滑逻辑与数据预处理样条曲线Spline和折线图的区别在于折线图直接把数据点用直线段连起来样条曲线会在点与点之间做平滑插值出来是一条圆滑的曲线。从SeriesChartType换成Spline即可series.ChartType SeriesChartType.Spline;很多人在测量扭矩、压力这类数据时都喜欢用Spline因为曲线光溜看起来“高级”。但这里有个隐藏的坑Spline是让曲线穿过所有数据点的样条插值如果原始数据有噪声或者采样间隔不均匀曲线会在某些位置出现明显的过冲——就是莫名其妙的尖峰和波动。我之前处理一台拧紧设备的数据就遇到过扭矩明明在平稳上升样条曲线却在中间凹下去一块客户截图来问“这是不是设备有异常”。排查到最后才发现是Spline插值对那段数据里一个跳变的点做了过冲处理。从那以后我养成了一个习惯数据进Spline之前先做一次简单的滑动平均滤波或去毛刺处理。// 简单五点滑动平均示例 for (int i 2; i values.Length - 2; i) { smoothed[i] (values[i - 2] values[i - 1] values[i] values[i 1] values[i 2]) / 5.0; }另外如果数据点的间隔差很大Spline会出现局部弯折很严重的现象。这是因为它本质上是分段三次多项式拟合每一段的系数由左右相邻点的位置和切线决定。数据密的地方曲线容易绕来绕去数据疏的地方曲线又会拉出一条长弧。解决办法就是统一采样间隔或者在绑定数据前重采样。3.3 坐标轴、网格线与跨零点细节优化图表好不好看坐标轴占了很大比重。我见过很多项目默认的坐标轴刻度间隔是小数网格线密密麻麻标签字体还是默认的宋体整体观感一下就被拉低了。坐标轴的常规调整集中在ChartArea.AxisX和AxisY上chartArea1.AxisY.Maximum 100; // 固定最大刻度避免波动大时视觉跳动 chartArea1.AxisY.Minimum 0; chartArea1.AxisY.Interval 20; // 刻度间隔 chartArea1.AxisY.MajorGrid.LineColor Color.FromArgb(220, 220, 220); chartArea1.AxisY.MajorGrid.LineDashStyle ChartDashStyle.Dash; chartArea1.AxisX.MajorGrid.Enabled false; // 纵向网格通常不需要 chartArea1.AxisX.LabelStyle.Font new Font(Microsoft YaHei, 9F);把Maximum和Minimum固定住是有意为之的。如果让坐标轴根据数据自动调节范围看板上的柱子会长短不一看起来跳动幅度忽大忽小反而不利于判断趋势。固定范围后数据的相对比例关系是一目了然的。还有一个经常被忽略的点是跨零点显示。默认情况下图表的Value轴从0开始如果数据存在负数比如设备的正反转扭矩、压装力有正负变化直接画图会把柱子的0点放在底部负值柱子朝下延伸。如果希望零线在图表中间需要调整轴的位置属性chartArea1.AxisY.Crossing 0; // 使Y轴0线居中但要注意Crossing设置后Y轴的标签位置、网格线间距都要重新观察效果有时候负值区域和正值区域的高度不对称需要手动再调Maximum和Minimum来平衡视觉重心。ToolTip悬浮显示是折线图上很实用的小功能不用引入任何额外代码直接设DataPoint.ToolTip就能在鼠标悬停时看到数值series.ToolTip #VALX, #VALY; series.PointTemplate.ToolTip 时间: #VALX数值: #VALY;#VALX和#VALY是Chart控件内置的关键字会替换为当前数据点的横纵坐标值。这个功能简单但对客户体验的提升非常明显。4. 交互与刷新机制从静态图表到可操作的监控面板4.1 缩放、平移、滚轮操作让图表能“用”起来实时监控场景下数据是持续往后堆积的只看最新数据不行还要回去看历史片段。Chart控件内部就带了缩放和平移机制不用自己写。启用坐标系光标chartArea1.CursorX.IsUserEnabled true; chartArea1.CursorX.IsUserSelectionEnabled true; chartArea1.CursorY.IsUserEnabled true; chartArea1.CursorY.IsUserSelectionEnabled true;IsUserEnabled控制光标是否随鼠标移动IsUserSelectionEnabled控制是否允许通过鼠标拖拽框选范围进行缩放。启用后按住鼠标左键在图表区拖一个矩形就能放大那块区域。要恢复缩放到全量范围可以用chartArea1.AxisX.ScaleView.ZoomReset(); chartArea1.AxisY.ScaleView.ZoomReset();很多客户习惯用滚轮缩放而不是拖拽。自己写滚轮事件也不复杂private void chart1_MouseWheel(object sender, MouseEventArgs e) { AxisX axis chartArea1.AxisX; double viewMin axis.ScaleView.ViewMinimum; double viewMax axis.ScaleView.ViewMaximum; // 缩放倍数 double zoomStep 0.1; double delta (viewMax - viewMin) * zoomStep; if (e.Delta 0) { axis.ScaleView.Zoom(viewMin delta, viewMax); } else { axis.ScaleView.Zoom(Math.Max(axis.Minimum, viewMin - delta), Math.Min(axis.Maximum, viewMax delta)); } }这里注意一个问题Zoom方法的两个参数必须严格递增如果缩放边界超过坐标轴的范围或者两个参数相等会抛异常。所以写入前要做边界修正优先保证下限不小于轴的最小值上限不大于轴的最大值。4.2 鼠标取值与十字光标定位到具体数据点上位机监控里经常要“看一下这个时刻的具体值是多少”。实现方式有两种一种是前面说的ToolTip另一种是点击/移动时通过HitTest获取数据点信息。HitTest是Chart控件提供的一个强大方法传入鼠标坐标返回命中的图表元素类型private void chart1_MouseDown(object sender, MouseEventArgs e) { HitTestResult result chart1.HitTest(e.X, e.Y); if (result.ChartElementType ChartElementType.DataPoint) { DataPoint dp result.Series.Points[result.PointIndex]; label1.Text ${result.Series.Name}: {dp.YValues[0]:F2}; } }这个方法能准确区分用户点的是不是一个数据点、是哪个系列的哪个点比坐标反算靠谱得多。如果你希望鼠标在曲线附近移动时就高亮最近的数据点可以在MouseMove里做同样的HitTest再加一个Point类型系列的标记点来指示当前位置。十字光标的配置则更简单chartArea1.CursorX.LineColor Color.FromArgb(120, 120, 120); chartArea1.CursorX.LineDashStyle ChartDashStyle.Dash; chartArea1.CursorY.LineColor Color.FromArgb(120, 120, 120); chartArea1.CursorY.LineDashStyle ChartDashStyle.Dash;启用IsUserEnabled后光标默认就会跟随鼠标显示成十字线。有了十字线和状态栏数值显示整个图表的专业感立刻就不一样了。4.3 跨线程更新UITask中更新控件的正确姿势与卡顿根源做上位机一定会遇到这个问题数据是采集线程或串口事件里推过来的要在UI界面上更新Chart。如果直接在子线程里操作控件WinForms会抛异常“线程间操作无效”。原因是控件在创建线程UI线程上才有消息循环其他线程直接操作控件没有同步机制。最常见的做法是用Invoke把委托排队到UI线程上执行private void UpdateChartOnUI(PointValue pv) { if (chart1.InvokeRequired) { chart1.BeginInvoke(new Action(() { series.Points.AddXY(pv.Time, pv.Value); })); return; } series.Points.AddXY(pv.Time, pv.Value); }Invoke和BeginInvoke的区别在于Invoke是同步阻塞的子线程会等UI线程执行完才继续BeginInvoke是异步的子线程发完消息就继续跑。高频数据场景下用BeginInvoke明显更合适因为如果UI来不及处理Invoke会造成子线程越积越堵。但就算用了BeginInvoke高频刷新还是会卡。根源在于串口每10毫秒来一帧每帧你都在UI线程上操作Series.AddXY一秒钟100次UI控件操作消息队列会疯狂堆积。这时候我不是每帧都刷新而是让数据先进入一个缓存队列UI线程上放一个Timer每100毫秒批量取一次数据统一更新private ConcurrentQueuePointValue _dataQueue new ConcurrentQueuePointValue(); // 采集线程 private void OnDataReceived(PointValue pv) { _dataQueue.Enqueue(pv); } // UI定时器100ms一次 private void timer_Tick(object sender, EventArgs e) { chart1.SuspendLayout(); while (_dataQueue.TryDequeue(out PointValue pv)) { series.Points.AddXY(pv.Time, pv.Value); } chart1.ResumeLayout(); }这样既不会丢失数据只是延迟最多100毫秒又保证UI不卡。对监控场景来说100毫秒的延迟人眼几乎感知不到但对界面流畅度的影响是质的改变。卡顿优化还有一点如果你的Series里累积的点已经上万而界面上只需要显示最近五分钟那就定期清理旧点别让Series无限增长。5. 真实项目里的高频坑字体、DPI、内存与命中测试5.1 字体与中文显示默认宋体毁颜值Chart控件默认的字体是宋体在1080P屏幕上看起来还行一到150%缩放的显示器上就发虚标签还会被截断。建议全局设置chart1.Font new Font(Microsoft YaHei, 9F, FontStyle.Regular);但只设Chart.Font还不够坐标轴标签和Legend的字体是独立属性。如果你懒得逐项设置可以统一遍历foreach (ChartArea area in chart1.ChartAreas) { area.AxisX.LabelStyle.Font new Font(Microsoft YaHei, 9F); area.AxisY.LabelStyle.Font new Font(Microsoft YaHei, 9F); } chart1.Legends[0].Font new Font(Microsoft YaHei, 9F);英文字体用Segoe UI比Microsoft YaHei更紧凑。另外中文字体的清晰度和防锯齿效果受客户端系统渲染影响建议上位机程序在启动时调用SetProcessDPIAware()避免在高DPI显示器上字体模糊。这个API在WinForms里可以通过添加清单文件设置dpiAware为true实现也可以简单地在Main入口里调用[DllImport(user32.dll)] private static extern bool SetProcessDPIAware(); static void Main() { if (Environment.OSVersion.Version.Major 6) SetProcessDPIAware(); Application.EnableVisualStyles(); Application.Run(new MainForm()); }不处理DPI的话在缩放150%的系统上图表区域的宽高会被系统拉伸文字变糊、控件布局错位客户演示时一眼就被看穿。5.2 图表导出一键搞定保存图片与打印上位机项目往往有报表需求图表导出成图片是保留数据证据的重要手段。Chart控件自带了保存图片的方法chart1.SaveImage(chart.png, ChartImageFormat.Png);SaveImage有几个细节值得注意。默认保存的图片清晰度不够可以在保存前调整一下chart1.RightToLeft RightToLeft.No; chart1.AntiAliasing AntiAliasingStyles.All; // 抗锯齿 chart1.TextAntiAliasingQuality TextAntiAliasingQuality.High; chart1.SaveImage(D:\report\chart.png, ChartImageFormat.Png);保存的图片包含图表控件当前显示的全部内容标题、图例、图表区但不包含你手动叠加在控件上的其他控件比如自定义的Panel或Label。如果你想连外部的标题栏一起输出就需要把整个面板画到Bitmap里再保存。这个技巧在自动生成检测报告时很实用using (Bitmap bmp new Bitmap(panel1.Width, panel1.Height)) { panel1.DrawToBitmap(bmp, new Rectangle(0, 0, panel1.Width, panel1.Height)); bmp.Save(full_panel.png, ImageFormat.Png); }5.3 Annotations、事件订阅与内存泄漏隐患Chart控件的Annotations标注对象很有用比如在曲线上画一条警戒线、标出一个异常点。但有一个常见的坑Annotation对象如果只是往Chart.Annotations集合里Add了而不在Chart初始化时统一管理当Series数据动态移除时Annotation并不会自动跟随数据点销毁。举个例子你在某条曲线上给第100个点加了一个箭头标注如果你把这个数据点清除了标注对象还在并且它还引用着旧的数据点索引。如果这个索引已经不存在绘制时会异常或显示在错误位置。所以动态数据配合Annotations时最好在每次数据刷新前清空Annotations集合再按最新的数据点索引重新添加chart1.Annotations.Clear(); // 重新添加需要显示的标注事件订阅也该注意。如果Chart控件被动态创建并加入窗体比如TabControl中临时创建的图表页离开页面时一定要取消订阅并Dispose。我见过不少项目内存持续增长最后定位到是控件的MouseMove、MouseWheel这类事件在动态控件上重复订阅对象无法被回收。规律很简单谁订阅的事件谁负责在销毁时反订阅。5.4 命中测试的边界情况与坐标轴标签拥挤问题HitTest在绝大多数情况下好用但有几个边界场景需要处理。第一当图表被缩放到某个区域时界面上的坐标和数据点索引跟全量数据不是一一对应关系此时HitTest返回的PointIndex是当前可见数据点的索引而不是数据源里的原始索引。如果你要拿这个索引去数据库里查原始记录需要把可见范围偏移量考虑进去用AxisX.ScaleView.ViewMinimum作为偏移基准。第二X轴分类标签过多时比如柱状图按秒显示整整一天的分类标签会重叠压成一团黑。对策有两个一是通过LabelStyle.Interval设置隔几个分类显示一个标签chartArea1.AxisX.LabelStyle.Interval 5; // 每5个点显示1个标签二是在大量实时数据场景下干脆把AxisX的标签关掉只在ToolTip里看具体值chartArea1.AxisX.LabelStyle.Enabled false;第三如果使用自定义的X轴标签CustomLabels要注意它们不能比PlottingArea的宽度更密集不然它们同样会被挤在一起。更好的办法是控制标签数量优先展示关键时间点整点、交接班时刻。一些个人习惯我做了这么多年上位机最大的一个体会是界面好不好看很多时候不是在美工上花了多少功夫而是逻辑上有没有把数据组织清楚。Chart控件用得好一半靠参数熟练另一半靠对数据的理解。有没有提前处理噪声、有没有把坐标轴范围固定、有没有给用户留缩放和悬停的交互余地这些才是客户真正能感知到的“好用”。如果你手头正好在做一个数据展示型上位机项目我建议你先从一张最朴素的折线图开始跑通串口到界面的通路再逐步叠加柱状图、样条曲线这些展示形式。等基础功能稳定了再回头做美化会从容很多。Chart控件的很多细节也只有踩过实际项目的坑才能真正记住。
返回列表