2026-05-09
C#
0

目录

🏭 从一张报表说起
🔍 问题深度剖析:普通柱状图的局限性
信息密度不足
纵向对比困难
动态数据更新的性能陷阱
📦 环境搭建与依赖配置
NuGet 包安装
控件注册
🏗️ 数据模型设计
🎯 方案一:基础堆叠柱状图实现
Winform LiveCharts2 堆叠柱状图(StackedBar)在生产统计中的应用实战
🏭 从一张报表说起
🔍 问题深度剖析:普通柱状图的局限性
信息密度不足
纵向对比困难
动态数据更新的性能陷阱
📦 环境搭建与依赖配置
NuGet 包安装
控件注册
🏗️ 数据模型设计
🎯 方案一:基础堆叠柱状图实现
🔄 方案二:ObservableCollection 驱动的动态刷新
🎨 方案三:百分比堆叠模式 + 自定义 Tooltip
⚡ 常见陷阱与最佳实践
💬 技术讨论
📌 三句话技术洞察
🎯 总结与学习路径
WinForms LiveCharts2 数据可视化 生产统计 性能优化
🔄 方案二:ObservableCollection 驱动的动态刷新
🎨 方案三:百分比堆叠模式 + 自定义 Tooltip
⚡ 常见陷阱与最佳实践
💬 技术讨论
📌 三句话技术洞察
🎯 总结与学习路径
WinForms LiveCharts2 数据可视化 生产统计 性能优化

🏭 从一张报表说起

在不少制造业项目里,生产统计报表是车间管理的核心需求之一。产线上每天产出多少合格品、有多少返工件、报废了几个——这些数据如果只是躺在 Excel 表格里,管理层很难一眼看出问题所在。

曾经接手过一个 WinForms 工控项目,客户要求在本地软件界面上实时展示每条产线的"合格/返工/报废"三类产量占比。最初用的是普通柱状图,三列并排,视觉上很割裂,占比关系也不直观。后来换成 LiveCharts2 的堆叠柱状图(StackedBar),整个图表的信息密度和可读性都提升了一个档次——同样的数据,管理层扫一眼就能判断哪条线的报废率偏高。

本文会从零开始,带你完整走一遍:环境搭建 → 数据模型设计 → 基础堆叠图实现 → 动态刷新与性能优化,代码均在 .NET 6 + WinForms + LiveCharts2 v2.x 环境下验证通过,可以直接复用到实际项目中。


🔍 问题深度剖析:普通柱状图的局限性

信息密度不足

传统的分组柱状图(GroupedBar)在展示多类别数据时,会把每个类别单独画一根柱子。三条产线、三种类型,就是 9 根柱子并排——视觉噪音极大,占比关系更是无从直观判断。

纵向对比困难

生产统计的核心诉求之一是:在同一时间段内,各类别的构成比例。堆叠柱状图天然满足这个需求——每根柱子的总高度代表总产量,各色段的高度代表各类别占比,一目了然。

动态数据更新的性能陷阱

很多开发者在实现实时刷新时,习惯每次都重建 Series 集合,这会导致图表频繁重绘,在数据量稍大时出现明显卡顿。LiveCharts2 的 ObservableCollection 机制可以做到局部更新,避免全量重绘,这是性能优化的关键所在。


📦 环境搭建与依赖配置

NuGet 包安装

在 Visual Studio 的包管理器控制台执行:

powershell
Install-Package LiveChartsCore.SkiaSharpView.WinForms

说明:LiveCharts2 在 WinForms 下依赖 SkiaSharp 渲染引擎,安装上述包会自动引入所有必要依赖。测试环境:.NET 6.0、LiveChartsCore.SkiaSharpView.WinForms 2.0.0-rc2、Windows 10 x64。

控件注册

Form 设计器中,从工具箱拖入 CartesianChart 控件,或者在代码中动态添加:

csharp
using LiveChartsCore.SkiaSharpView.WinForms; // 在 Form 构造函数或 InitializeComponent 后添加 var chart = new CartesianChart { Dock = DockStyle.Fill }; this.Controls.Add(chart);

🏗️ 数据模型设计

良好的数据模型是图表稳定运行的基础。生产统计场景下,咱们定义三个核心结构:

csharp
/// <summary> /// 单条产线的生产数据快照 /// </summary> public class ProductionRecord { public string LineName { get; set; } // 产线名称,如 "A线" public int Qualified { get; set; } // 合格品数量 public int Rework { get; set; } // 返工品数量 public int Scrap { get; set; } // 报废品数量 public DateTime RecordTime { get; set; } } /// <summary> /// 图表数据源,按类别聚合 /// </summary> public class ProductionChartData { public string[] LineNames { get; set; } // X轴标签 public int[] QualifiedValues { get; set; } public int[] ReworkValues { get; set; } public int[] ScrapValues { get; set; } }

数据聚合逻辑单独抽取为服务类,与 UI 层解耦:

csharp
using AppLiveChart14; public class ProductionDataService { private readonly Random _rng = new Random(); public ProductionChartData GetTodayData() { // 模拟实时波动:在基准值上叠加随机偏移 var records = new List<ProductionRecord> { new() { LineName = "A线", Qualified = 1200 + _rng.Next(-50, 50), Rework = 85 + _rng.Next(-10, 10), Scrap = 15 + _rng.Next(-3, 3) }, new() { LineName = "B线", Qualified = 980 + _rng.Next(-50, 50), Rework = 120 + _rng.Next(-10, 10), Scrap = 40 + _rng.Next(-5, 5) }, new() { LineName = "C线", Qualified = 1450 + _rng.Next(-50, 50), Rework = 60 + _rng.Next(-10, 10), Scrap = 8 + _rng.Next(-2, 2) }, new() { LineName = "D线", Qualified = 760 + _rng.Next(-50, 50), Rework = 200 + _rng.Next(-10, 10), Scrap = 55 + _rng.Next(-5, 5) }, }; return new ProductionChartData { LineNames = records.Select(r => r.LineName).ToArray(), QualifiedValues = records.Select(r => Math.Max(0, r.Qualified)).ToArray(), ReworkValues = records.Select(r => Math.Max(0, r.Rework)).ToArray(), ScrapValues = records.Select(r => Math.Max(0, r.Scrap)).ToArray(), }; } }

🎯 方案一:基础堆叠柱状图实现

这是最核心的部分,把数据绑定到 StackedColumnSeries 上。

csharp
using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; namespace AppLiveChart14 { public partial class Form1 : Form { private readonly CartesianChart _chart; private readonly ProductionDataService _dataService = new(); public Form1() { InitializeComponent(); _chart = new CartesianChart { Dock = DockStyle.Fill }; this.Controls.Add(_chart); LoadChart(); } private void LoadChart() { var data = _dataService.GetTodayData(); // 合格品系列 —— 绿色 var qualifiedSeries = new StackedColumnSeries<int> { Name = "合格品", Values = data.QualifiedValues, Fill = new SolidColorPaint(SKColors.MediumSeaGreen), Stroke = null, // 柱子圆角,视觉更现代 Rx = 4, Ry = 4 }; // 返工品系列 —— 橙色 var reworkSeries = new StackedColumnSeries<int> { Name = "返工品", Values = data.ReworkValues, Fill = new SolidColorPaint(SKColors.Orange), Stroke = null, }; // 报废品系列 —— 红色 var scrapSeries = new StackedColumnSeries<int> { Name = "报废品", Values = data.ScrapValues, Fill = new SolidColorPaint(SKColors.Tomato), Stroke = null, }; _chart.Series = new ISeries[] { qualifiedSeries, reworkSeries, scrapSeries }; // X轴:产线名称 _chart.XAxes = new[] { new Axis { Labels = data.LineNames, TextSize = 13, LabelsPaint = new SolidColorPaint(SKColors.DimGray), } }; // Y轴:产量数值 _chart.YAxes = new[] { new Axis { Name = "产量(件)", TextSize = 12, NamePaint = new SolidColorPaint(SKColors.Gray), LabelsPaint = new SolidColorPaint(SKColors.DimGray), } }; // 图例显示在右侧 _chart.LegendPosition = LiveChartsCore.Measure.LegendPosition.Right; _chart.LegendTextPaint = new SolidColorPaint(SKColors.DarkSlateGray); } } }

Winform LiveCharts2 堆叠柱状图(StackedBar)在生产统计中的应用实战


🏭 从一张报表说起

在不少制造业项目里,生产统计报表是车间管理的核心需求之一。产线上每天产出多少合格品、有多少返工件、报废了几个——这些数据如果只是躺在 Excel 表格里,管理层很难一眼看出问题所在。

曾经接手过一个 WinForms 工控项目,客户要求在本地软件界面上实时展示每条产线的"合格/返工/报废"三类产量占比。最初用的是普通柱状图,三列并排,视觉上很割裂,占比关系也不直观。后来换成 LiveCharts2 的堆叠柱状图(StackedBar),整个图表的信息密度和可读性都提升了一个档次——同样的数据,管理层扫一眼就能判断哪条线的报废率偏高。

本文会从零开始,带你完整走一遍:环境搭建 → 数据模型设计 → 基础堆叠图实现 → 动态刷新与性能优化,代码均在 .NET 6 + WinForms + LiveCharts2 v2.x 环境下验证通过,可以直接复用到实际项目中。


🔍 问题深度剖析:普通柱状图的局限性

信息密度不足

传统的分组柱状图(GroupedBar)在展示多类别数据时,会把每个类别单独画一根柱子。三条产线、三种类型,就是 9 根柱子并排——视觉噪音极大,占比关系更是无从直观判断。

纵向对比困难

生产统计的核心诉求之一是:在同一时间段内,各类别的构成比例。堆叠柱状图天然满足这个需求——每根柱子的总高度代表总产量,各色段的高度代表各类别占比,一目了然。

动态数据更新的性能陷阱

很多开发者在实现实时刷新时,习惯每次都重建 Series 集合,这会导致图表频繁重绘,在数据量稍大时出现明显卡顿。LiveCharts2 的 ObservableCollection 机制可以做到局部更新,避免全量重绘,这是性能优化的关键所在。


📦 环境搭建与依赖配置

NuGet 包安装

在 Visual Studio 的包管理器控制台执行:

powershell
Install-Package LiveChartsCore.SkiaSharpView.WinForms

说明:LiveCharts2 在 WinForms 下依赖 SkiaSharp 渲染引擎,安装上述包会自动引入所有必要依赖。测试环境:.NET 6.0、LiveChartsCore.SkiaSharpView.WinForms 2.0.0-rc2、Windows 10 x64。

控件注册

Form 设计器中,从工具箱拖入 CartesianChart 控件,或者在代码中动态添加:

csharp
using LiveChartsCore.SkiaSharpView.WinForms; // 在 Form 构造函数或 InitializeComponent 后添加 var chart = new CartesianChart { Dock = DockStyle.Fill }; this.Controls.Add(chart);

🏗️ 数据模型设计

良好的数据模型是图表稳定运行的基础。生产统计场景下,咱们定义三个核心结构:

csharp
/// <summary> /// 单条产线的生产数据快照 /// </summary> public class ProductionRecord { public string LineName { get; set; } // 产线名称,如 "A线" public int Qualified { get; set; } // 合格品数量 public int Rework { get; set; } // 返工品数量 public int Scrap { get; set; } // 报废品数量 public DateTime RecordTime { get; set; } } /// <summary> /// 图表数据源,按类别聚合 /// </summary> public class ProductionChartData { public string[] LineNames { get; set; } // X轴标签 public int[] QualifiedValues { get; set; } public int[] ReworkValues { get; set; } public int[] ScrapValues { get; set; } }

数据聚合逻辑单独抽取为服务类,与 UI 层解耦:

csharp
using AppLiveChart14; public class ProductionDataService { private readonly Random _rng = new Random(); public ProductionChartData GetTodayData() { // 模拟实时波动:在基准值上叠加随机偏移 var records = new List<ProductionRecord> { new() { LineName = "A线", Qualified = 1200 + _rng.Next(-50, 50), Rework = 85 + _rng.Next(-10, 10), Scrap = 15 + _rng.Next(-3, 3) }, new() { LineName = "B线", Qualified = 980 + _rng.Next(-50, 50), Rework = 120 + _rng.Next(-10, 10), Scrap = 40 + _rng.Next(-5, 5) }, new() { LineName = "C线", Qualified = 1450 + _rng.Next(-50, 50), Rework = 60 + _rng.Next(-10, 10), Scrap = 8 + _rng.Next(-2, 2) }, new() { LineName = "D线", Qualified = 760 + _rng.Next(-50, 50), Rework = 200 + _rng.Next(-10, 10), Scrap = 55 + _rng.Next(-5, 5) }, }; return new ProductionChartData { LineNames = records.Select(r => r.LineName).ToArray(), QualifiedValues = records.Select(r => Math.Max(0, r.Qualified)).ToArray(), ReworkValues = records.Select(r => Math.Max(0, r.Rework)).ToArray(), ScrapValues = records.Select(r => Math.Max(0, r.Scrap)).ToArray(), }; } }

🎯 方案一:基础堆叠柱状图实现

这是最核心的部分,把数据绑定到 StackedColumnSeries 上。

csharp
using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; namespace AppLiveChart14 { public partial class Form1 : Form { private readonly CartesianChart _chart; private readonly ProductionDataService _dataService = new(); public Form1() { InitializeComponent(); _chart = new CartesianChart { Dock = DockStyle.Fill }; this.Controls.Add(_chart); LoadChart(); } private void LoadChart() { var data = _dataService.GetTodayData(); // 合格品系列 —— 绿色 var qualifiedSeries = new StackedColumnSeries<int> { Name = "合格品", Values = data.QualifiedValues, Fill = new SolidColorPaint(SKColors.MediumSeaGreen), Stroke = null, // 柱子圆角,视觉更现代 Rx = 4, Ry = 4 }; // 返工品系列 —— 橙色 var reworkSeries = new StackedColumnSeries<int> { Name = "返工品", Values = data.ReworkValues, Fill = new SolidColorPaint(SKColors.Orange), Stroke = null, }; // 报废品系列 —— 红色 var scrapSeries = new StackedColumnSeries<int> { Name = "报废品", Values = data.ScrapValues, Fill = new SolidColorPaint(SKColors.Tomato), Stroke = null, }; _chart.Series = new ISeries[] { qualifiedSeries, reworkSeries, scrapSeries }; // X轴:产线名称 _chart.XAxes = new[] { new Axis { Labels = data.LineNames, TextSize = 13, LabelsPaint = new SolidColorPaint(SKColors.DimGray), } }; // Y轴:产量数值 _chart.YAxes = new[] { new Axis { Name = "产量(件)", TextSize = 12, NamePaint = new SolidColorPaint(SKColors.Gray), LabelsPaint = new SolidColorPaint(SKColors.DimGray), } }; // 图例显示在右侧 _chart.LegendPosition = LiveChartsCore.Measure.LegendPosition.Right; _chart.LegendTextPaint = new SolidColorPaint(SKColors.DarkSlateGray); } } }

image

踩坑预警StackedColumnSeriesValues 类型必须与泛型参数一致。如果数据来源是 double,就用 StackedColumnSeries<double>,混用会导致运行时异常,且错误信息不够直观,排查起来比较费时。


🔄 方案二:ObservableCollection 驱动的动态刷新

生产现场的数据往往需要定时刷新,比如每 30 秒从数据库拉一次最新产量。这里的关键是不要每次重建 Series,而是更新 ObservableCollection 内部的值,让 LiveCharts2 自动触发局部重绘。

csharp
using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Data; using System.Drawing; using System.Text; using System.Windows.Forms; namespace AppLiveChart14 { public partial class Form2 : Form { private ObservableCollection<int> _qualifiedValues; private ObservableCollection<int> _reworkValues; private ObservableCollection<int> _scrapValues; private System.Windows.Forms.Timer _refreshTimer; private readonly ProductionDataService _dataService = new(); private readonly CartesianChart _chart; public Form2() { InitializeComponent(); _chart = new CartesianChart { Dock = DockStyle.Fill }; this.Controls.Add(_chart); InitChartWithObservable(); StartAutoRefresh(); } private void InitChartWithObservable() { var data = _dataService.GetTodayData(); // 使用 ObservableCollection,数据变更时图表自动更新 _qualifiedValues = new ObservableCollection<int>(data.QualifiedValues); _reworkValues = new ObservableCollection<int>(data.ReworkValues); _scrapValues = new ObservableCollection<int>(data.ScrapValues); _chart.Series = new ISeries[] { new StackedColumnSeries<int> { Name = "合格品", Values = _qualifiedValues, Fill = new SolidColorPaint(SKColors.MediumSeaGreen), Stroke = null, }, new StackedColumnSeries<int> { Name = "返工品", Values = _reworkValues, Fill = new SolidColorPaint(SKColors.Orange), Stroke = null, }, new StackedColumnSeries<int> { Name = "报废品", Values = _scrapValues, Fill = new SolidColorPaint(SKColors.Tomato), Stroke = null, }, }; // 设置 X 轴标签(产线名称一般不变,只初始化一次) _chart.XAxes = new[] { new Axis { Labels = data.LineNames, TextSize = 13 } }; } private void StartAutoRefresh() { _refreshTimer = new System.Windows.Forms.Timer { Interval = 2000 }; // 2秒 _refreshTimer.Tick += OnRefreshTimerTick; _refreshTimer.Start(); } private void OnRefreshTimerTick(object sender, EventArgs e) { var latest = _dataService.GetTodayData(); // 在 UI 线程上更新 ObservableCollection // WinForms Timer 的 Tick 本身在 UI 线程,无需 Invoke for (int i = 0; i < latest.QualifiedValues.Length; i++) { if (i < _qualifiedValues.Count) { _qualifiedValues[i] = latest.QualifiedValues[i]; _reworkValues[i] = latest.ReworkValues[i]; _scrapValues[i] = latest.ScrapValues[i]; } } // LiveCharts2 会自动检测 ObservableCollection 的变更并触发重绘 } } }

image


🎨 方案三:百分比堆叠模式 + 自定义 Tooltip

有时候管理层更关心的不是绝对产量,而是各类别的占比趋势。LiveCharts2 支持通过 StackedColumnSeriesStackGroup 配合 Y 轴格式化实现百分比显示,同时自定义 Tooltip 展示原始数值。

csharp
using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; using System; using System.Collections.Generic; using System.Linq; using System.Windows.Forms; namespace AppLiveChart14 { public partial class Form3 : Form { private readonly CartesianChart _chart; private readonly ProductionDataService _dataService = new(); private System.Windows.Forms.Timer _refreshTimer; public Form3() { InitializeComponent(); this.Text = "生产占比统计 - 百分比堆叠柱状图"; this.Size = new System.Drawing.Size(900, 550); _chart = new CartesianChart { Dock = DockStyle.Fill }; this.Controls.Add(_chart); LoadPercentageChart(); StartAutoRefresh(); } // 百分比转换 private double[] ToPercentage(int[] values, int[] total) { return values .Zip(total, (v, t) => t == 0 ? 0.0 : Math.Round((double)v / t * 100, 1)) .ToArray(); } // 核心:加载/刷新百分比堆叠图 private void LoadPercentageChart() { var data = _dataService.GetTodayData(); // 计算每条产线总产量 var totals = data.QualifiedValues .Zip(data.ReworkValues, (q, r) => q + r) .Zip(data.ScrapValues, (qr, s) => qr + s) .ToArray(); var qualifiedPct = ToPercentage(data.QualifiedValues, totals); var reworkPct = ToPercentage(data.ReworkValues, totals); var scrapPct = ToPercentage(data.ScrapValues, totals); _chart.Series = new ISeries[] { new StackedColumnSeries<double> { Name = "合格品 %", Values = qualifiedPct, Fill = new SolidColorPaint(SKColors.MediumSeaGreen), Stroke = null, DataLabelsPaint = new SolidColorPaint(SKColors.White), DataLabelsSize = 11, DataLabelsPosition = LiveChartsCore.Measure.DataLabelsPosition.Middle, // ✅ 占比太小时隐藏标签,避免文字溢出柱子 DataLabelsFormatter = point => point.Model < 5.0 ? string.Empty : $"{point.Model:F1}%", }, new StackedColumnSeries<double> { Name = "返工品 %", Values = reworkPct, Fill = new SolidColorPaint(SKColors.Orange), Stroke = null, DataLabelsPaint = new SolidColorPaint(SKColors.White), DataLabelsSize = 11, DataLabelsPosition = LiveChartsCore.Measure.DataLabelsPosition.Middle, DataLabelsFormatter = point => point.Model < 5.0 ? string.Empty : $"{point.Model:F1}%", }, new StackedColumnSeries<double> { Name = "报废品 %", Values = scrapPct, Fill = new SolidColorPaint(SKColors.Tomato), Stroke = null, DataLabelsPaint = new SolidColorPaint(SKColors.White), DataLabelsSize = 11, DataLabelsPosition = LiveChartsCore.Measure.DataLabelsPosition.Middle, DataLabelsFormatter = point => point.Model < 5.0 ? string.Empty : $"{point.Model:F1}%", }, }; // Y 轴:固定 0~100,格式化为百分比 _chart.YAxes = new[] { new Axis { Name = "占比", MinLimit = 0, MaxLimit = 100, Labeler = value => $"{value}%", NamePaint = new SolidColorPaint(SKColors.Gray), LabelsPaint = new SolidColorPaint(SKColors.DimGray), TextSize = 12, } }; // X 轴:产线名称 _chart.XAxes = new[] { new Axis { Labels = data.LineNames, TextSize = 13, LabelsPaint = new SolidColorPaint(SKColors.DimGray), } }; // 图例放右侧 _chart.LegendPosition = LiveChartsCore.Measure.LegendPosition.Right; _chart.LegendTextPaint = new SolidColorPaint(SKColors.DarkSlateGray); } // 定时刷新:每 3 秒重新拉数据并整体更新 private void StartAutoRefresh() { _refreshTimer = new System.Windows.Forms.Timer { Interval = 3000 }; _refreshTimer.Tick += (s, e) => LoadPercentageChart(); _refreshTimer.Start(); } // 窗体关闭时释放 Timer,防止内存泄漏 protected override void OnFormClosed(FormClosedEventArgs e) { _refreshTimer?.Stop(); _refreshTimer?.Dispose(); base.OnFormClosed(e); } } }

image

踩坑预警:DataLabels 在柱子段高度较小时会溢出显示区域,建议对占比低于 5% 的数据段隐藏标签,避免视觉混乱:

csharp
DataLabelsFormatter = point => point.Model < 5.0 ? string.Empty : $"{point.Model:F1}%",

⚡ 常见陷阱与最佳实践

跨线程更新问题:如果数据来自后台线程(如 Task、BackgroundWorker),更新 ObservableCollection 必须切回 UI 线程:

csharp
// 后台线程中的正确写法 this.Invoke(() => { _qualifiedValues[i] = newValue; });

内存泄漏预防Timer 在 Form 关闭时必须显式 Stop 并 Dispose,否则会持续触发 Tick 导致内存泄漏:

csharp
protected override void OnFormClosed(FormClosedEventArgs e) { _refreshTimer?.Stop(); _refreshTimer?.Dispose(); base.OnFormClosed(e); }

颜色一致性:生产统计场景建议固定颜色语义——绿色代表合格、橙色代表返工、红色代表报废,与行业通用认知保持一致,降低用户的认知负担。


💬 技术讨论

两个开放性问题,欢迎在评论区分享你的思路:

  1. 实时数据场景下,你倾向于用 WinForms Timer 还是后台 Task + Invoke 的方式驱动图表刷新?两种方案各有什么取舍?

  2. 堆叠图 vs 折线图:在展示产线产量趋势时,你会如何选择图表类型?是否有过把两者结合使用(双轴图)的实践经验?


📌 三句话技术洞察

  • ObservableCollection 是 LiveCharts2 动态刷新的正确姿势,避免重建 Series 带来的全量重绘开销。
  • 数据模型与 UI 解耦,把数据聚合逻辑放进 Service 层,图表代码只负责渲染,维护成本大幅降低。
  • 百分比堆叠比绝对值堆叠更适合占比分析,但两者结合(主图百分比 + Tooltip 显示原始值)才是生产统计的最优解。

🎯 总结与学习路径

本文覆盖了三个渐进式实现方案:基础堆叠图绑定、ObservableCollection 动态刷新、百分比模式与自定义 DataLabels。核心收获可以归纳为:数据模型先行、局部更新代替全量重建、颜色语义与业务认知对齐

如果你想进一步深入,推荐的学习路径是:

  • LiveCharts2 官方文档livecharts.dev):覆盖所有 Series 类型与 Axis 配置细节
  • SkiaSharp 绘图基础:理解 LiveCharts2 底层渲染机制,有助于处理复杂自定义样式需求
  • WinForms 数据绑定模式:结合 BindingSource 与 INotifyPropertyChanged,构建更健壮的 MVVM-like 架构

生产统计图表看似简单,但把它做到"管理层一眼能决策"的程度,需要在数据模型、刷新机制、视觉设计上都下功夫。希望本文的实战经验对你的项目有实际参考价值。


标签C# WinForms LiveCharts2 数据可视化 生产统计 性能优化

踩坑预警StackedColumnSeriesValues 类型必须与泛型参数一致。如果数据来源是 double,就用 StackedColumnSeries<double>,混用会导致运行时异常,且错误信息不够直观,排查起来比较费时。


🔄 方案二:ObservableCollection 驱动的动态刷新

生产现场的数据往往需要定时刷新,比如每 30 秒从数据库拉一次最新产量。这里的关键是不要每次重建 Series,而是更新 ObservableCollection 内部的值,让 LiveCharts2 自动触发局部重绘。

csharp
using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Data; using System.Drawing; using System.Text; using System.Windows.Forms; namespace AppLiveChart14 { public partial class Form2 : Form { private ObservableCollection<int> _qualifiedValues; private ObservableCollection<int> _reworkValues; private ObservableCollection<int> _scrapValues; private System.Windows.Forms.Timer _refreshTimer; private readonly ProductionDataService _dataService = new(); private readonly CartesianChart _chart; public Form2() { InitializeComponent(); _chart = new CartesianChart { Dock = DockStyle.Fill }; this.Controls.Add(_chart); InitChartWithObservable(); StartAutoRefresh(); } private void InitChartWithObservable() { var data = _dataService.GetTodayData(); // 使用 ObservableCollection,数据变更时图表自动更新 _qualifiedValues = new ObservableCollection<int>(data.QualifiedValues); _reworkValues = new ObservableCollection<int>(data.ReworkValues); _scrapValues = new ObservableCollection<int>(data.ScrapValues); _chart.Series = new ISeries[] { new StackedColumnSeries<int> { Name = "合格品", Values = _qualifiedValues, Fill = new SolidColorPaint(SKColors.MediumSeaGreen), Stroke = null, }, new StackedColumnSeries<int> { Name = "返工品", Values = _reworkValues, Fill = new SolidColorPaint(SKColors.Orange), Stroke = null, }, new StackedColumnSeries<int> { Name = "报废品", Values = _scrapValues, Fill = new SolidColorPaint(SKColors.Tomato), Stroke = null, }, }; // 设置 X 轴标签(产线名称一般不变,只初始化一次) _chart.XAxes = new[] { new Axis { Labels = data.LineNames, TextSize = 13 } }; } private void StartAutoRefresh() { _refreshTimer = new System.Windows.Forms.Timer { Interval = 2000 }; // 2秒 _refreshTimer.Tick += OnRefreshTimerTick; _refreshTimer.Start(); } private void OnRefreshTimerTick(object sender, EventArgs e) { var latest = _dataService.GetTodayData(); // 在 UI 线程上更新 ObservableCollection // WinForms Timer 的 Tick 本身在 UI 线程,无需 Invoke for (int i = 0; i < latest.QualifiedValues.Length; i++) { if (i < _qualifiedValues.Count) { _qualifiedValues[i] = latest.QualifiedValues[i]; _reworkValues[i] = latest.ReworkValues[i]; _scrapValues[i] = latest.ScrapValues[i]; } } // LiveCharts2 会自动检测 ObservableCollection 的变更并触发重绘 } } }

image


🎨 方案三:百分比堆叠模式 + 自定义 Tooltip

有时候管理层更关心的不是绝对产量,而是各类别的占比趋势。LiveCharts2 支持通过 StackedColumnSeriesStackGroup 配合 Y 轴格式化实现百分比显示,同时自定义 Tooltip 展示原始数值。

csharp
using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; using System; using System.Collections.Generic; using System.Linq; using System.Windows.Forms; namespace AppLiveChart14 { public partial class Form3 : Form { private readonly CartesianChart _chart; private readonly ProductionDataService _dataService = new(); private System.Windows.Forms.Timer _refreshTimer; public Form3() { InitializeComponent(); this.Text = "生产占比统计 - 百分比堆叠柱状图"; this.Size = new System.Drawing.Size(900, 550); _chart = new CartesianChart { Dock = DockStyle.Fill }; this.Controls.Add(_chart); LoadPercentageChart(); StartAutoRefresh(); } // 百分比转换 private double[] ToPercentage(int[] values, int[] total) { return values .Zip(total, (v, t) => t == 0 ? 0.0 : Math.Round((double)v / t * 100, 1)) .ToArray(); } // 核心:加载/刷新百分比堆叠图 private void LoadPercentageChart() { var data = _dataService.GetTodayData(); // 计算每条产线总产量 var totals = data.QualifiedValues .Zip(data.ReworkValues, (q, r) => q + r) .Zip(data.ScrapValues, (qr, s) => qr + s) .ToArray(); var qualifiedPct = ToPercentage(data.QualifiedValues, totals); var reworkPct = ToPercentage(data.ReworkValues, totals); var scrapPct = ToPercentage(data.ScrapValues, totals); _chart.Series = new ISeries[] { new StackedColumnSeries<double> { Name = "合格品 %", Values = qualifiedPct, Fill = new SolidColorPaint(SKColors.MediumSeaGreen), Stroke = null, DataLabelsPaint = new SolidColorPaint(SKColors.White), DataLabelsSize = 11, DataLabelsPosition = LiveChartsCore.Measure.DataLabelsPosition.Middle, // ✅ 占比太小时隐藏标签,避免文字溢出柱子 DataLabelsFormatter = point => point.Model < 5.0 ? string.Empty : $"{point.Model:F1}%", }, new StackedColumnSeries<double> { Name = "返工品 %", Values = reworkPct, Fill = new SolidColorPaint(SKColors.Orange), Stroke = null, DataLabelsPaint = new SolidColorPaint(SKColors.White), DataLabelsSize = 11, DataLabelsPosition = LiveChartsCore.Measure.DataLabelsPosition.Middle, DataLabelsFormatter = point => point.Model < 5.0 ? string.Empty : $"{point.Model:F1}%", }, new StackedColumnSeries<double> { Name = "报废品 %", Values = scrapPct, Fill = new SolidColorPaint(SKColors.Tomato), Stroke = null, DataLabelsPaint = new SolidColorPaint(SKColors.White), DataLabelsSize = 11, DataLabelsPosition = LiveChartsCore.Measure.DataLabelsPosition.Middle, DataLabelsFormatter = point => point.Model < 5.0 ? string.Empty : $"{point.Model:F1}%", }, }; // Y 轴:固定 0~100,格式化为百分比 _chart.YAxes = new[] { new Axis { Name = "占比", MinLimit = 0, MaxLimit = 100, Labeler = value => $"{value}%", NamePaint = new SolidColorPaint(SKColors.Gray), LabelsPaint = new SolidColorPaint(SKColors.DimGray), TextSize = 12, } }; // X 轴:产线名称 _chart.XAxes = new[] { new Axis { Labels = data.LineNames, TextSize = 13, LabelsPaint = new SolidColorPaint(SKColors.DimGray), } }; // 图例放右侧 _chart.LegendPosition = LiveChartsCore.Measure.LegendPosition.Right; _chart.LegendTextPaint = new SolidColorPaint(SKColors.DarkSlateGray); } // 定时刷新:每 3 秒重新拉数据并整体更新 private void StartAutoRefresh() { _refreshTimer = new System.Windows.Forms.Timer { Interval = 3000 }; _refreshTimer.Tick += (s, e) => LoadPercentageChart(); _refreshTimer.Start(); } // 窗体关闭时释放 Timer,防止内存泄漏 protected override void OnFormClosed(FormClosedEventArgs e) { _refreshTimer?.Stop(); _refreshTimer?.Dispose(); base.OnFormClosed(e); } } }

image

踩坑预警:DataLabels 在柱子段高度较小时会溢出显示区域,建议对占比低于 5% 的数据段隐藏标签,避免视觉混乱:

csharp
DataLabelsFormatter = point => point.Model < 5.0 ? string.Empty : $"{point.Model:F1}%",

⚡ 常见陷阱与最佳实践

跨线程更新问题:如果数据来自后台线程(如 Task、BackgroundWorker),更新 ObservableCollection 必须切回 UI 线程:

csharp
// 后台线程中的正确写法 this.Invoke(() => { _qualifiedValues[i] = newValue; });

内存泄漏预防Timer 在 Form 关闭时必须显式 Stop 并 Dispose,否则会持续触发 Tick 导致内存泄漏:

csharp
protected override void OnFormClosed(FormClosedEventArgs e) { _refreshTimer?.Stop(); _refreshTimer?.Dispose(); base.OnFormClosed(e); }

颜色一致性:生产统计场景建议固定颜色语义——绿色代表合格、橙色代表返工、红色代表报废,与行业通用认知保持一致,降低用户的认知负担。


💬 技术讨论

两个开放性问题,欢迎在评论区分享你的思路:

  1. 实时数据场景下,你倾向于用 WinForms Timer 还是后台 Task + Invoke 的方式驱动图表刷新?两种方案各有什么取舍?

  2. 堆叠图 vs 折线图:在展示产线产量趋势时,你会如何选择图表类型?是否有过把两者结合使用(双轴图)的实践经验?


📌 三句话技术洞察

  • ObservableCollection 是 LiveCharts2 动态刷新的正确姿势,避免重建 Series 带来的全量重绘开销。
  • 数据模型与 UI 解耦,把数据聚合逻辑放进 Service 层,图表代码只负责渲染,维护成本大幅降低。
  • 百分比堆叠比绝对值堆叠更适合占比分析,但两者结合(主图百分比 + Tooltip 显示原始值)才是生产统计的最优解。

🎯 总结与学习路径

本文覆盖了三个渐进式实现方案:基础堆叠图绑定、ObservableCollection 动态刷新、百分比模式与自定义 DataLabels。核心收获可以归纳为:数据模型先行、局部更新代替全量重建、颜色语义与业务认知对齐

如果你想进一步深入,推荐的学习路径是:

  • LiveCharts2 官方文档livecharts.dev):覆盖所有 Series 类型与 Axis 配置细节
  • SkiaSharp 绘图基础:理解 LiveCharts2 底层渲染机制,有助于处理复杂自定义样式需求
  • WinForms 数据绑定模式:结合 BindingSource 与 INotifyPropertyChanged,构建更健壮的 MVVM-like 架构

生产统计图表看似简单,但把它做到"管理层一眼能决策"的程度,需要在数据模型、刷新机制、视觉设计上都下功夫。希望本文的实战经验对你的项目有实际参考价值。


标签C# WinForms LiveCharts2 数据可视化 生产统计 性能优化

相关信息

我用夸克网盘给你分享了「AppLiveChart14.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。 /ef143YUiNP:/ 链接:https://pan.quark.cn/s/b1237a6d1dcd 提取码:hxah

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!