做数据可视化的项目,选图表库这件事往往比写业务逻辑还让人头疼。用 GDI+ 手撸散点图?坐标轴、缩放、Tooltip 全得自己实现,一个功能完整的散点图没个两三天下不来。换 WPF?项目历史包袱太重,迁移成本根本不现实。
LiveCharts 2 是目前 .NET 生态里体验相当不错的图表库,支持 WPF、WinForms、MAUI、Blazor,底层渲染引擎统一,API 设计现代化。但官方文档对 WinForms 散点图的说明相当简略,很多细节——比如自定义点样式、动态数据更新、多系列差异化渲染——都得自己摸索。
读完这篇文章,你将掌握:
散点图表面上看简单——不就是一堆点吗?但真正落地到工业数据监控、传感器数据分析、质量管控等场景时,麻烦就来了。
第一个坑:坐标轴精度控制。 传统控件的坐标轴往往是整数刻度,遇到浮点精度数据(比如 0.0023、0.9987 这类),刻度显示要么挤成一团,要么跨度太大丢失细节。
第二个坑:大数据量渲染卡顿。 在测试环境下,一次性渲染 5000 个点,使用 GDI+ 手绘方案平均帧率只有 4~6 FPS,界面几乎不可交互。LiveCharts 2 底层使用 SkiaSharp 渲染,同等数量级下帧率可维持在 30 FPS 以上(测试环境:i7-12700H,16GB RAM,.NET 6,Windows 11)。
第三个坑:多系列数据区分困难。 当同一张图上需要展示多组数据(比如不同批次、不同设备)时,颜色、形状、大小的差异化配置如果没有统一管理,代码很快变成"颜色硬编码大杂烩"。
这三个问题,下面三个方案会逐一解决。
开发环境: .NET 6 / .NET 8,Visual Studio 2022,WinForms 项目
通过 NuGet 安装以下包:
LiveChartsCore.SkiaSharpView.WinForms
或在包管理器控制台执行:
powershellInstall-Package LiveChartsCore.SkiaSharpView.WinForms
注意:LiveCharts 2 与 LiveCharts(v1)是完全不同的库,API 不兼容,不要装错。
这是最简单的起点,适合快速验证效果、做 Demo 原型。
LiveCharts 2 的 WinForms 控件不会自动出现在工具箱,需要手动在代码中实例化并添加到窗体。
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.WinForms;
namespace AppLiveChart06
{
public partial class Form1 : Form
{
private CartesianChart _chart;
public Form1()
{
InitializeComponent();
InitChart();
}
private void InitChart()
{
_chart = new CartesianChart
{
Dock = DockStyle.Fill
};
this.Controls.Add(_chart);
// 准备散点数据
var values = new List<LiveChartsCore.Defaults.ObservablePoint>
{
new(1.2, 3.4),
new(2.5, 1.8),
new(3.1, 4.7),
new(4.0, 2.2),
new(5.3, 5.0),
new(6.1, 3.9),
};
// 配置散点系列
_chart.Series = new ISeries[]
{
new ScatterSeries<LiveChartsCore.Defaults.ObservablePoint>
{
Values = values,
Name = "数据集 A",
}
};
}
}
}

运行后就能看到一张基础散点图。ObservablePoint 是 LiveCharts 2 内置的二维坐标点类型,直接传入 X、Y 值即可,不需要额外的数据转换。
基础方案里所有点长得一模一样,在多系列场景下根本分不清。这个方案解决样式定制问题。
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using LiveChartsCore.SkiaSharpView.WinForms;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
namespace AppLiveChart06
{
public partial class Form2 : Form
{
private CartesianChart _chart;
public Form2()
{
InitializeComponent();
InitChartWithStyle();
}
private void InitChartWithStyle()
{
_chart = new CartesianChart { Dock = DockStyle.Fill };
this.Controls.Add(_chart);
var seriesA = GenerateRandomPoints(50, seed: 1);
var seriesB = GenerateRandomPoints(50, seed: 42);
_chart.Series = new ISeries[]
{
new ScatterSeries<LiveChartsCore.Defaults.ObservablePoint>
{
Values = seriesA,
Name = "批次 A",
// 点的填充色
Fill = new SolidColorPaint(SKColors.DodgerBlue),
// 点的描边
Stroke = new SolidColorPaint(SKColors.DarkBlue) { StrokeThickness = 1 },
// 点的几何大小(像素)
GeometrySize = 12,
},
new ScatterSeries<LiveChartsCore.Defaults.ObservablePoint>
{
Values = seriesB,
Name = "批次 B",
Fill = new SolidColorPaint(SKColors.OrangeRed),
Stroke = new SolidColorPaint(SKColors.DarkRed) { StrokeThickness = 1 },
GeometrySize = 10,
}
};
// 配置坐标轴标签格式
_chart.XAxes = new[]
{
new Axis
{
Name = "X 轴(测量值)",
NamePaint = new SolidColorPaint(SKColors.Gray),
LabelsPaint = new SolidColorPaint(SKColors.DarkGray),
// 保留两位小数
Labeler = value => value.ToString("F2"),
}
};
_chart.YAxes = new[]
{
new Axis
{
Name = "Y 轴(响应值)",
NamePaint = new SolidColorPaint(SKColors.Gray),
LabelsPaint = new SolidColorPaint(SKColors.DarkGray),
Labeler = value => value.ToString("F2"),
}
};
}
// 生成随机测试数据
private List<LiveChartsCore.Defaults.ObservablePoint> GenerateRandomPoints(int count, int seed)
{
var rnd = new Random(seed);
return Enumerable.Range(0, count)
.Select(_ => new LiveChartsCore.Defaults.ObservablePoint(
rnd.NextDouble() * 10,
rnd.NextDouble() * 10))
.ToList();
}
}
}

踩坑预警: GeometrySize 设置过大(超过 30)在点密集区域会严重重叠,视觉上反而丢失信息。实际项目中建议根据数据密度动态调整,一般 8~14 像素是比较合适的范围。
这是最接近生产场景的方案。传感器数据、实时监控数据需要持续写入图表,同时保证 UI 不卡顿。
LiveCharts 2 的数据绑定基于 ObservableCollection<T>,当集合变化时图表会自动刷新。但频繁的 UI 线程操作是性能杀手,需要做批量更新。
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.Timers;
using System.Windows.Forms;
namespace AppLiveChart06
{
public partial class Form3 : Form
{
private CartesianChart _chart;
private ObservableCollection<LiveChartsCore.Defaults.ObservablePoint> _liveData;
private System.Timers.Timer _dataTimer;
private readonly Random _rnd = new();
// 最大保留点数,防止内存无限增长
private const int MaxPoints = 200;
public Form3()
{
InitializeComponent();
InitRealtimeChart();
StartDataFeed();
}
private void InitRealtimeChart()
{
_liveData = new ObservableCollection<LiveChartsCore.Defaults.ObservablePoint>();
_chart = new CartesianChart { Dock = DockStyle.Fill };
this.Controls.Add(_chart);
_chart.Series = new ISeries[]
{
new ScatterSeries<LiveChartsCore.Defaults.ObservablePoint>
{
Values = _liveData,
Name = "实时采集",
Fill = new SolidColorPaint(SKColors.MediumSeaGreen.WithAlpha(180)),
Stroke = null, // 去掉描边,提升渲染性能
GeometrySize = 8,
}
};
// 关闭动画,实时场景下动画反而是负担
_chart.AnimationsSpeed = TimeSpan.Zero;
_chart.EasingFunction = null;
}
private void StartDataFeed()
{
// 使用 System.Timers.Timer 避免阻塞 UI 线程
_dataTimer = new System.Timers.Timer(100); // 100ms 间隔,约 10Hz 刷新率
_dataTimer.Elapsed += OnDataArrived;
_dataTimer.Start();
}
private void OnDataArrived(object sender, ElapsedEventArgs e)
{
// 模拟传感器数据
double x = _rnd.NextDouble() * 100;
double y = Math.Sin(x / 10) * 50 + _rnd.NextDouble() * 10;
// 必须回到 UI 线程操作 ObservableCollection
this.Invoke(() =>
{
_liveData.Add(new LiveChartsCore.Defaults.ObservablePoint(x, y));
// 超出上限时移除最旧的点,保持滑动窗口效果
if (_liveData.Count > MaxPoints)
_liveData.RemoveAt(0);
});
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_dataTimer?.Stop();
_dataTimer?.Dispose();
base.OnFormClosed(e);
}
}
}

| 方案 | 数据量 | 平均 CPU 占用 | 帧率(FPS) |
|---|---|---|---|
| GDI+ 手绘(无优化) | 500 点 | 38% | 5~8 |
| LiveCharts 2(含动画) | 500 点 | 22% | 20~28 |
| LiveCharts 2(关闭动画) | 500 点 | 9% | 55+ |
| LiveCharts 2(关闭动画) | 2000 点 | 17% | 30~40 |
测试环境:i7-12700H,16GB DDR5,.NET 8,Windows 11 22H2,分辨率 1920×1080
关闭动画是实时场景下最有效的单项优化,CPU 占用可以直接砍掉一半以上。
Q:图表控件添加后显示空白,什么都看不到?
检查两点:一是 Series 属性是否正确赋值;二是 Values 集合是否为空。LiveCharts 2 在数据为空时不报错,只是静默显示空白。
Q:跨线程操作 ObservableCollection 报异常?
ObservableCollection 不是线程安全的,所有修改必须在 UI 线程执行。使用 this.Invoke() 或 BeginInvoke() 切换回主线程。
Q:坐标轴范围自动跳变,视觉上很跳?
手动固定坐标轴范围:
csharp_chart.XAxes = new[]
{
new Axis { MinLimit = 0, MaxLimit = 100 }
};
固定范围后图表不会自动缩放,适合监控场景下保持稳定的视觉参考。
Q:Tooltip 显示的数据格式不对?
通过 TooltipLabelFormatter 自定义:
csharpnew ScatterSeries<ObservablePoint>
{
TooltipLabelFormatter = point =>
$"X: {point.Model.X:F3}, Y: {point.Model.Y:F3}"
}
ObservableCollection 的滑动窗口模式(超限删旧加新)是实时监控场景的标准解法,内存可控,视觉连续。如果你想在这个方向继续深入,建议按以下顺序推进:
话题一: 在你的项目里,实时数据可视化的刷新频率是多少?10Hz 够用还是需要更高?不同频率下你们是怎么控制 UI 压力的?
话题二: 除了散点图,LiveCharts 2 还支持折线图、热力图、极坐标图等。你在数据可视化场景里用得最多的是哪种图表类型,遇到过哪些坑?
欢迎在评论区分享你的实践经验,或者把你踩过的坑写出来,说不定能帮到正在搜索同一个问题的人。
标签: C# WinForms LiveCharts2 数据可视化 性能优化 C#开发 编程技巧
相关信息
我用夸克网盘给你分享了「AppLiveChart06.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。
/740d3YMpHs:/
链接:https://pan.quark.cn/s/13427fa89cd7
提取码:73vh
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!