做桌面端开发的同学,应该都遇到过这个场景:产品经理拍桌子说"把这组数据做成图表展示",然后你打开 WinForms 项目,盯着空白的 Panel 发呆——用 GDI+ 手撸折线图?光是计算坐标映射就能耗掉半天,更别提响应式缩放、动画过渡这些需求了。
根据开发者社区的调研数据,超过60%的 WinForms 开发者在首次实现图表功能时,平均花费超过4小时,其中大量时间消耗在环境配置、API 摸索和踩坑上。这个成本其实完全可以压缩到30分钟以内。
本文以 LiveCharts 2 为核心,带你从零搭建一个可运行的 WinForms 折线图应用。读完这篇文章,你将掌握:
市面上 C# 图表库不少,OxyPlot、ScottPlot、微软自带的 Chart 控件都有人用。咱们先把几个常见选项摆出来对比一下:
| 库名 | WinForms 支持 | 动画支持 | 实时数据 | 上手难度 | 许可证 |
|---|---|---|---|---|---|
| WinForms 内置 Chart | 原生支持 | 无 | 较弱 | 低 | 免费 |
| OxyPlot | 支持 | 无 | 一般 | 中 | MIT |
| ScottPlot | 支持 | 无 | 较好 | 低 | MIT |
| LiveCharts 2 | 支持 | 内置 | 优秀 | 中低 | MIT/商业双轨 |
LiveCharts 2 最大的优势在于跨平台架构设计——同一套数据模型,可以在 WinForms、WPF、MAUI、Blazor 之间复用,这对于有多端需求的项目来说省事不少。动画效果也是开箱即用,不需要自己写 Timer 去模拟。
当然,它也有代价:商业项目需要付费授权,个人学习和开源项目免费。这点在用之前需要确认清楚。
测试环境说明:
打开 VS2022,新建项目,选择 Windows 窗体应用(.NET),目标框架选 .NET 6 或 .NET 8,项目名随意,比如 LiveChartsDemo。
打开 程序包管理器控制台,执行以下命令:
bashInstall-Package LiveChartsCore.SkiaSharpView.WinForms
这一个包会自动把依赖的 LiveChartsCore 和 SkiaSharp 相关包都拉进来,不需要手动逐个安装。安装完成后,解决方案资源管理器里能看到 SkiaSharp、LiveChartsCore.SkiaSharpView 等引用,说明安装成功。
⚠️ 踩坑预警:如果项目目标框架是 .NET Framework 4.x,需要安装的包名略有不同,且部分功能存在限制。建议优先使用 .NET 6+,兼容性和性能都更好。
这是最基础的用法,适合展示固定数据集,比如月度报表、历史趋势等场景。
打开 Form1.cs 的设计器,工具箱里此时应该已经有 CartesianChart 控件(如果没有,重新生成一次解决方案)。把它拖到窗体上,调整大小,Dock 属性设为 Fill,让它填满整个窗体。
控件的 Name 属性保持默认 cartesianChart1 即可。
打开 Form1.cs,完整代码如下:
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using SkiaSharp;
namespace LiveChartsDemo
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
LoadStaticChart();
}
private void LoadStaticChart()
{
// 定义折线数据
var values = new double[] { 2, 5, 4, 6, 3, 8, 7, 9, 5, 10 };
// 构建 Series 集合
cartesianChart1.Series = new ISeries[]
{
new LineSeries<double>
{
Values = values,
Name = "月度销售额(万元)",
// 折线颜色
Stroke = new SolidColorPaint(SKColors.DodgerBlue) { StrokeThickness = 2 },
// 数据点填充颜色
Fill = new SolidColorPaint(SKColors.DodgerBlue.WithAlpha(40)),
// 数据点标记大小
GeometrySize = 8,
GeometryStroke = new SolidColorPaint(SKColors.DodgerBlue) { StrokeThickness = 2 },
GeometryFill = new SolidColorPaint(SKColors.White),
}
};
// 配置 X 轴标签
cartesianChart1.XAxes = new Axis[]
{
new Axis
{
Name = "月份",
Labels = new[] { "1月","2月","3月","4月","5月","6月","7月","8月","9月","10月" },
NamePaint = new SolidColorPaint(SKColors.Gray),
LabelsPaint = new SolidColorPaint(SKColors.Gray),
}
};
// 配置 Y 轴
cartesianChart1.YAxes = new Axis[]
{
new Axis
{
Name = "金额(万元)",
NamePaint = new SolidColorPaint(SKColors.Gray),
LabelsPaint = new SolidColorPaint(SKColors.Gray),
}
};
}
}
}
直接 F5 运行,一个带渐变填充、带数据点标记的折线图就出来了。整个过程不超过5分钟。
代码要点说明:
ISeries[] 是系列集合,可以往里面塞多条折线,实现多系列对比图Stroke 控制线条颜色和粗细,Fill 控制线下方的填充区域GeometrySize 控制每个数据点圆圈的大小,设为 0 可以隐藏数据点静态图满足不了监控类场景。比如设备温度监控、网络流量实时展示,这时候需要数据能动态更新,图表跟着滚动。
LiveCharts 2 支持 ObservableCollection<T>,只要集合数据变化,图表会自动重绘,不需要手动触发刷新。
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using SkiaSharp;
using System.Collections.ObjectModel;
namespace AppLiveChart02
{
public partial class Form1 : Form
{
// 使用 ObservableCollection,数据变化时图表自动更新
private readonly ObservableCollection<double> _realtimeValues = new();
private readonly System.Windows.Forms.Timer _timer = new();
private readonly Random _random = new();
private int _tick = 0;
public Form1()
{
InitializeComponent();
LoadRealtimeChart();
StartTimer();
}
private void LoadRealtimeChart()
{
cartesianChart1.Series = new ISeries[]
{
new LineSeries<double>
{
Values = _realtimeValues,
Name = "实时温度(°C)",
Stroke = new SolidColorPaint(SKColors.OrangeRed) { StrokeThickness = 2 },
Fill = null, // 实时图一般不需要填充,视觉更清晰
GeometrySize = 4,
GeometryStroke = new SolidColorPaint(SKColors.OrangeRed) { StrokeThickness = 1 },
GeometryFill = new SolidColorPaint(SKColors.White),
// 开启动画(默认已开启,此处显式说明)
AnimationsSpeed = TimeSpan.FromMilliseconds(300),
}
};
// X 轴设置为自动滚动,最多显示最近 20 个数据点
cartesianChart1.XAxes = new Axis[]
{
new Axis
{
Name = "时间(秒)",
NamePaint = new SolidColorPaint(SKColors.Gray),
LabelsPaint = new SolidColorPaint(SKColors.Gray),
MinLimit = null, // 不锁定最小值,让轴自动跟随
}
};
cartesianChart1.YAxes = new Axis[]
{
new Axis
{
Name = "温度(°C)",
MinLimit = 0,
MaxLimit = 100,
NamePaint = new SolidColorPaint(SKColors.Gray),
LabelsPaint = new SolidColorPaint(SKColors.Gray),
}
};
}
private void StartTimer()
{
_timer.Interval = 500; // 每500ms更新一次
_timer.Tick += (s, e) =>
{
_tick++;
// 模拟温度波动:基准值60°C,上下随机浮动15°C
double newTemp = 60 + (_random.NextDouble() - 0.5) * 30;
_realtimeValues.Add(Math.Round(newTemp, 1));
// 保留最近 30 个数据点,避免内存无限增长
if (_realtimeValues.Count > 30)
_realtimeValues.RemoveAt(0);
};
_timer.Start();
}
// 窗体关闭时停止 Timer,防止资源泄漏
protected override void OnFormClosed(FormClosedEventArgs e)
{
_timer.Stop();
_timer.Dispose();
base.OnFormClosed(e);
}
}
}

运行后,折线图每隔500ms自动追加新数据点,旧数据从左侧滑出,整体呈现出滚动窗口效果。
⚠️ 踩坑预警:
_realtimeValues.RemoveAt(0)这一行非常关键。我在项目中见过不少同学忘记清理旧数据,跑了几分钟后集合里积累了几千条记录,内存蹭蹭往上涨,图表渲染也开始卡顿。实时图一定要设定数据窗口上限。
实际项目里,单条折线往往不够用。比如同时展示"计划值 vs 实际值"、"多设备温度对比",这时候需要多系列。
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace AppLiveChart02
{
public partial class Form2 : Form
{
public Form2()
{
InitializeComponent();
LoadMultiSeriesChart();
}
private void LoadMultiSeriesChart()
{
var planValues = new double[] { 5, 6, 7, 8, 8, 9, 10, 10, 11, 12 };
var actualValues = new double[] { 4, 5, 6, 9, 7, 8, 11, 9, 10, 13 };
cartesianChart1.Series = new ISeries[]
{
new LineSeries<double>
{
Values = planValues,
Name = "计划值",
Stroke = new SolidColorPaint(SKColors.SteelBlue) { StrokeThickness = 2 },
Fill = null,
GeometrySize = 6,
GeometryStroke = new SolidColorPaint(SKColors.SteelBlue) { StrokeThickness = 2 },
GeometryFill = new SolidColorPaint(SKColors.White),
// 虚线样式,用于区分计划线与实际线
LineSmoothness = 0,
},
new LineSeries<double>
{
Values = actualValues,
Name = "实际值",
Stroke = new SolidColorPaint(SKColors.Tomato) { StrokeThickness = 2 },
Fill = null,
GeometrySize = 6,
GeometryStroke = new SolidColorPaint(SKColors.Tomato) { StrokeThickness = 2 },
GeometryFill = new SolidColorPaint(SKColors.White),
LineSmoothness = 0.5, // 平滑曲线
}
};
cartesianChart1.XAxes = new Axis[]
{
new Axis
{
Labels = new[] { "1月","2月","3月","4月","5月","6月","7月","8月","9月","10月" },
LabelsPaint = new SolidColorPaint(SKColors.Gray),
}
};
// 开启图例
cartesianChart1.LegendPosition = LiveChartsCore.Measure.LegendPosition.Bottom;
}
}
}

LineSmoothness 属性值在 0(折线)到 1(平滑曲线)之间,根据数据特性选择合适的值。监控类数据通常用 0,趋势类数据用 0.5 左右视觉更舒适。
以下数据在**测试环境(Windows 11, i7-12700H, .NET 8, Release 模式)**下测得,仅供参考:
| 场景 | 数据点数量 | 渲染帧率 | 内存占用 |
|---|---|---|---|
| 静态折线图 | 100点 | 流畅(60fps) | ~45 MB |
| 实时滚动(窗口30点) | 持续运行10分钟 | 流畅(60fps) | 稳定~50 MB |
| 多系列(3条线,各100点) | 300点总计 | 流畅(55fps) | ~60 MB |
| 大数据量(单系列5000点) | 5000点 | 轻微卡顿(约30fps) | ~120 MB |
结论:日常业务场景(单系列500点以内)完全流畅,无需额外优化。超过2000点时,建议开启数据抽样或使用 LineSeries 的 MaxItems 属性限制渲染点数。
Q1:控件拖到设计器后显示为空白或报错
多半是 SkiaSharp 的 Native 运行时没有正确复制到输出目录。检查 NuGet 包是否完整安装,重新生成(Rebuild)一次解决方案通常能解决。
Q2:在非 UI 线程更新 ObservableCollection 导致跨线程异常
LiveCharts 2 的图表控件本质上还是 WinForms 控件,数据更新必须在 UI 线程上进行。如果数据来自后台线程,需要用 Invoke 或 BeginInvoke 切回 UI 线程:
csharp// 后台线程中更新数据的正确写法
this.Invoke(() =>
{
_realtimeValues.Add(newValue);
if (_realtimeValues.Count > 30)
_realtimeValues.RemoveAt(0);
});
Q3:图表在高 DPI 屏幕上模糊
在 Program.cs 的 Main 方法中,确保启用了 DPI 感知:
csharpApplication.SetHighDpiMode(HighDpiMode.SystemAware);
或者在 app.manifest 中配置 DPI 感知级别,这是 WinForms 应用的通用处理方式,与 LiveCharts 无关。
本文介绍的三个方案覆盖了大多数日常使用场景,但 LiveCharts 2 的能力远不止于此。有几个方向值得进一步探索:
INotifyPropertyChanged 无缝配合,数据层与视图层解耦更彻底CartesianChart 中同时使用 LineSeries 和 ColumnSeries,实现折线+柱状的组合图欢迎在评论区聊聊:你在项目中遇到过哪些图表需求是现有库难以满足的?或者你有没有在 WinForms 中做实时数据可视化的实战经验,踩过哪些坑?
ObservableCollection + 数据窗口限制,忘记清理旧数据是最常见的内存泄漏来源。ISeries[] 扩展,LineSmoothness 属性决定折线还是平滑曲线,根据数据语义选择合适值。如果你想进一步深入 C# 数据可视化方向,可以按以下路径推进:
INotifyPropertyChanged,让数据层与视图层解耦#C#开发 #WinForms #数据可视化 #LiveCharts #编程技巧
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!