在不少制造业项目里,生产统计报表是车间管理的核心需求之一。产线上每天产出多少合格品、有多少返工件、报废了几个——这些数据如果只是躺在 Excel 表格里,管理层很难一眼看出问题所在。
曾经接手过一个 WinForms 工控项目,客户要求在本地软件界面上实时展示每条产线的"合格/返工/报废"三类产量占比。最初用的是普通柱状图,三列并排,视觉上很割裂,占比关系也不直观。后来换成 LiveCharts2 的堆叠柱状图(StackedBar),整个图表的信息密度和可读性都提升了一个档次——同样的数据,管理层扫一眼就能判断哪条线的报废率偏高。
本文会从零开始,带你完整走一遍:环境搭建 → 数据模型设计 → 基础堆叠图实现 → 动态刷新与性能优化,代码均在 .NET 6 + WinForms + LiveCharts2 v2.x 环境下验证通过,可以直接复用到实际项目中。
传统的分组柱状图(GroupedBar)在展示多类别数据时,会把每个类别单独画一根柱子。三条产线、三种类型,就是 9 根柱子并排——视觉噪音极大,占比关系更是无从直观判断。
生产统计的核心诉求之一是:在同一时间段内,各类别的构成比例。堆叠柱状图天然满足这个需求——每根柱子的总高度代表总产量,各色段的高度代表各类别占比,一目了然。
很多开发者在实现实时刷新时,习惯每次都重建 Series 集合,这会导致图表频繁重绘,在数据量稍大时出现明显卡顿。LiveCharts2 的 ObservableCollection 机制可以做到局部更新,避免全量重绘,这是性能优化的关键所在。
在 Visual Studio 的包管理器控制台执行:
powershellInstall-Package LiveChartsCore.SkiaSharpView.WinForms
说明:LiveCharts2 在 WinForms 下依赖 SkiaSharp 渲染引擎,安装上述包会自动引入所有必要依赖。测试环境:.NET 6.0、LiveChartsCore.SkiaSharpView.WinForms 2.0.0-rc2、Windows 10 x64。
在 Form 设计器中,从工具箱拖入 CartesianChart 控件,或者在代码中动态添加:
csharpusing 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 层解耦:
csharpusing 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 上。
csharpusing 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);
}
}
}
在不少制造业项目里,生产统计报表是车间管理的核心需求之一。产线上每天产出多少合格品、有多少返工件、报废了几个——这些数据如果只是躺在 Excel 表格里,管理层很难一眼看出问题所在。
曾经接手过一个 WinForms 工控项目,客户要求在本地软件界面上实时展示每条产线的"合格/返工/报废"三类产量占比。最初用的是普通柱状图,三列并排,视觉上很割裂,占比关系也不直观。后来换成 LiveCharts2 的堆叠柱状图(StackedBar),整个图表的信息密度和可读性都提升了一个档次——同样的数据,管理层扫一眼就能判断哪条线的报废率偏高。
本文会从零开始,带你完整走一遍:环境搭建 → 数据模型设计 → 基础堆叠图实现 → 动态刷新与性能优化,代码均在 .NET 6 + WinForms + LiveCharts2 v2.x 环境下验证通过,可以直接复用到实际项目中。
传统的分组柱状图(GroupedBar)在展示多类别数据时,会把每个类别单独画一根柱子。三条产线、三种类型,就是 9 根柱子并排——视觉噪音极大,占比关系更是无从直观判断。
生产统计的核心诉求之一是:在同一时间段内,各类别的构成比例。堆叠柱状图天然满足这个需求——每根柱子的总高度代表总产量,各色段的高度代表各类别占比,一目了然。
很多开发者在实现实时刷新时,习惯每次都重建 Series 集合,这会导致图表频繁重绘,在数据量稍大时出现明显卡顿。LiveCharts2 的 ObservableCollection 机制可以做到局部更新,避免全量重绘,这是性能优化的关键所在。
在 Visual Studio 的包管理器控制台执行:
powershellInstall-Package LiveChartsCore.SkiaSharpView.WinForms
说明:LiveCharts2 在 WinForms 下依赖 SkiaSharp 渲染引擎,安装上述包会自动引入所有必要依赖。测试环境:.NET 6.0、LiveChartsCore.SkiaSharpView.WinForms 2.0.0-rc2、Windows 10 x64。
在 Form 设计器中,从工具箱拖入 CartesianChart 控件,或者在代码中动态添加:
csharpusing 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 层解耦:
csharpusing 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 上。
csharpusing 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);
}
}
}

踩坑预警:StackedColumnSeries 的 Values 类型必须与泛型参数一致。如果数据来源是 double,就用 StackedColumnSeries<double>,混用会导致运行时异常,且错误信息不够直观,排查起来比较费时。
生产现场的数据往往需要定时刷新,比如每 30 秒从数据库拉一次最新产量。这里的关键是不要每次重建 Series,而是更新 ObservableCollection 内部的值,让 LiveCharts2 自动触发局部重绘。
csharpusing 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 的变更并触发重绘
}
}
}

有时候管理层更关心的不是绝对产量,而是各类别的占比趋势。LiveCharts2 支持通过 StackedColumnSeries 的 StackGroup 配合 Y 轴格式化实现百分比显示,同时自定义 Tooltip 展示原始数值。
csharpusing 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);
}
}
}

踩坑预警:DataLabels 在柱子段高度较小时会溢出显示区域,建议对占比低于 5% 的数据段隐藏标签,避免视觉混乱:
csharpDataLabelsFormatter = 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 导致内存泄漏:
csharpprotected override void OnFormClosed(FormClosedEventArgs e)
{
_refreshTimer?.Stop();
_refreshTimer?.Dispose();
base.OnFormClosed(e);
}
颜色一致性:生产统计场景建议固定颜色语义——绿色代表合格、橙色代表返工、红色代表报废,与行业通用认知保持一致,降低用户的认知负担。
两个开放性问题,欢迎在评论区分享你的思路:
实时数据场景下,你倾向于用 WinForms Timer 还是后台 Task + Invoke 的方式驱动图表刷新?两种方案各有什么取舍?
堆叠图 vs 折线图:在展示产线产量趋势时,你会如何选择图表类型?是否有过把两者结合使用(双轴图)的实践经验?
本文覆盖了三个渐进式实现方案:基础堆叠图绑定、ObservableCollection 动态刷新、百分比模式与自定义 DataLabels。核心收获可以归纳为:数据模型先行、局部更新代替全量重建、颜色语义与业务认知对齐。
如果你想进一步深入,推荐的学习路径是:
生产统计图表看似简单,但把它做到"管理层一眼能决策"的程度,需要在数据模型、刷新机制、视觉设计上都下功夫。希望本文的实战经验对你的项目有实际参考价值。
标签:C# WinForms LiveCharts2 数据可视化 生产统计 性能优化
踩坑预警:StackedColumnSeries 的 Values 类型必须与泛型参数一致。如果数据来源是 double,就用 StackedColumnSeries<double>,混用会导致运行时异常,且错误信息不够直观,排查起来比较费时。
生产现场的数据往往需要定时刷新,比如每 30 秒从数据库拉一次最新产量。这里的关键是不要每次重建 Series,而是更新 ObservableCollection 内部的值,让 LiveCharts2 自动触发局部重绘。
csharpusing 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 的变更并触发重绘
}
}
}

有时候管理层更关心的不是绝对产量,而是各类别的占比趋势。LiveCharts2 支持通过 StackedColumnSeries 的 StackGroup 配合 Y 轴格式化实现百分比显示,同时自定义 Tooltip 展示原始数值。
csharpusing 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);
}
}
}

踩坑预警:DataLabels 在柱子段高度较小时会溢出显示区域,建议对占比低于 5% 的数据段隐藏标签,避免视觉混乱:
csharpDataLabelsFormatter = 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 导致内存泄漏:
csharpprotected override void OnFormClosed(FormClosedEventArgs e)
{
_refreshTimer?.Stop();
_refreshTimer?.Dispose();
base.OnFormClosed(e);
}
颜色一致性:生产统计场景建议固定颜色语义——绿色代表合格、橙色代表返工、红色代表报废,与行业通用认知保持一致,降低用户的认知负担。
两个开放性问题,欢迎在评论区分享你的思路:
实时数据场景下,你倾向于用 WinForms Timer 还是后台 Task + Invoke 的方式驱动图表刷新?两种方案各有什么取舍?
堆叠图 vs 折线图:在展示产线产量趋势时,你会如何选择图表类型?是否有过把两者结合使用(双轴图)的实践经验?
本文覆盖了三个渐进式实现方案:基础堆叠图绑定、ObservableCollection 动态刷新、百分比模式与自定义 DataLabels。核心收获可以归纳为:数据模型先行、局部更新代替全量重建、颜色语义与业务认知对齐。
如果你想进一步深入,推荐的学习路径是:
生产统计图表看似简单,但把它做到"管理层一眼能决策"的程度,需要在数据模型、刷新机制、视觉设计上都下功夫。希望本文的实战经验对你的项目有实际参考价值。
标签:C# WinForms LiveCharts2 数据可视化 生产统计 性能优化
相关信息
我用夸克网盘给你分享了「AppLiveChart14.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。
/ef143YUiNP:/
链接:https://pan.quark.cn/s/b1237a6d1dcd
提取码:hxah
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!