先说结论:需要,而且非常值得。
市面上的录屏软件要么臃肿、要么收费、要么在某些企业内网环境下根本装不上。作为 Python 开发者,我们手里有 Tkinter、有 OpenCV、有 threading——完全可以在一个下午的时间里,从零撸出一个轻量、可控、可二次开发的屏幕录制工具。
我在给内部团队做技术分享录制时,就踩过这个坑:OBS 太重,ShareX 在某台老机器上崩溃,最后索性自己写。写完之后发现,不过 300 行代码,性能却出乎意料地稳。帧率稳在 25fps,CPU 占用不超过 15%。这篇文章,就把这套思路完整拆给你看。
核心依赖只有三个:
ImageGrab.grab() 在 Windows 下性能相当可观VideoWriter 支持多种编解码器有人会问,为什么不用 pyautogui 截图?原因很简单——pyautogui.screenshot() 底层也是调 PIL,但多了一层封装,速度反而更慢。直接用 ImageGrab 是最短路径。
另外,帧率控制这块,咱们用 threading.Event 配合时间戳对齐,而不是简单粗暴地 time.sleep()。这个细节差别很大,后面会详细讲。
bashpip install pillow opencv-python numpy
Tkinter 是 Python 标准库的一部分,Windows 下安装 Python 时默认勾选,一般不需要额外安装。如果你用的是精简版 Python 环境,执行 import tkinter 报错的话,重装一遍 Python 并勾选 tcl/tk 组件即可。
在动手写代码之前,先把架构想清楚。这个录制器分三层:
┌─────────────────────────────────┐ │ Tkinter GUI 层 │ ← 用户交互、状态展示 ├─────────────────────────────────┤ │ 录制控制层 │ ← 线程调度、帧率控制 ├─────────────────────────────────┤ │ 底层采集 & 编码层 │ ← 截图、帧写入 └─────────────────────────────────┘
GUI 层和录制逻辑必须跑在不同线程上。这不是可选项,是必须的——录制是 CPU 密集型操作,如果塞在主线程里,界面会直接卡死,按钮点不动,体验极差。
数据可视化这件事,说难不难,说简单也真不简单。
做过报表系统的开发者大概都有类似的经历:产品经理扔过来一张 Excel,说"能不能做成好看的饼图,让老板一眼看明白"。于是翻遍了 WinForms 自带的 Chart 控件,捣鼓半天,出来的东西……怎么说,就是那种 2003 年 PPT 风格,颜色发灰,没有动画,悬停没有提示,客户一脸嫌弃。
LiveCharts 2 的出现解决了这个痛点。它基于 SkiaSharp 渲染,支持 60FPS 动画,内置 Tooltip、Legend,而且 API 设计非常现代化,MVVM 友好。更关键的是,它在 WinForms 里同样能跑得很顺。
读完这篇文章,你将掌握:
这三步下来,一个能直接交付给客户的数据大屏组件基本就有了。
开发环境:.NET 6 / .NET Framework 4.7.2+,Visual Studio 2022,Windows 10/11。
通过 NuGet 包管理器安装核心依赖:
bashInstall-Package LiveChartsCore.SkiaSharpView.WinForms
如果搜索不到,记得在 NuGet 管理器里勾选"包含预发行版",LiveCharts 2 的部分版本仍处于 RC 阶段。
安装完成后,项目会自动引入 LiveChartsCore 和 SkiaSharp 相关依赖。在需要使用图表的窗体代码文件顶部,加入以下命名空间:
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using LiveChartsCore.SkiaSharpView.WinForms;
using SkiaSharp;
先从最简单的场景说起。假设需要展示一个季度内各产品线的销售占比,数据是静态的,先把图画出来再说。
在 Visual Studio 的工具箱里,找到 PieChart 控件(安装 NuGet 包后会自动出现),直接拖到 Form 上,调整好大小。如果工具箱没有显示,右键工具箱 → "选择项" → 手动浏览 DLL 添加即可。
在 Form1_Load 事件中写入以下代码:
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using SkiaSharp;
namespace AppLiveChart05
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
this.Load += Form1_Load;
}
private void Form1_Load(object sender, EventArgs e)
{
// 定义各产品线的销售占比数据
pieChart1.Series = new ISeries[]
{
new PieSeries<double>
{
Name = "产品A",
Values = new double[] { 42 },
// 设置数据标签显示
DataLabelsPaint = new SolidColorPaint(SKColors.White),
DataLabelsSize = 14,
DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Middle,
DataLabelsFormatter = point =>
$"{point.Model:N0} ({point.StackedValue!.Share:P1})"
},
new PieSeries<double>
{
Name = "产品B",
Values = new double[] { 28 },
DataLabelsPaint = new SolidColorPaint(SKColors.White),
DataLabelsSize = 14,
DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Middle,
DataLabelsFormatter = point =>
$"{point.Model:N0} ({point.StackedValue!.Share:P1})"
},
new PieSeries<double>
{
Name = "产品C",
Values = new double[] { 18 },
DataLabelsPaint = new SolidColorPaint(SKColors.White),
DataLabelsSize = 14,
DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Middle,
DataLabelsFormatter = point =>
$"{point.Model:N0} ({point.StackedValue!.Share:P1})"
},
new PieSeries<double>
{
Name = "其他",
Values = new double[] { 12 },
DataLabelsPaint = new SolidColorPaint(SKColors.White),
DataLabelsSize = 14,
DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Middle,
DataLabelsFormatter = point =>
$"{point.Model:N0} ({point.StackedValue!.Share:P1})"
}
};
// 显示图例(放在右侧)
pieChart1.LegendPosition = LiveChartsCore.Measure.LegendPosition.Right;
// 设置动画速度(毫秒)
pieChart1.AnimationsSpeed = TimeSpan.FromMilliseconds(800);
}
}
}

就这些,运行之后你会看到一个带入场动画、有图例、有数据标签的现代风格饼图。和 WinForms 原生 Chart 控件比起来,视觉效果差距相当明显。
DataLabelsFormatter 里用到了 point.StackedValue,这个属性在数据系列未完成布局计算前可能为 null,所以加了 ! 非空断言。如果项目开了严格的可空检查(<Nullable>enable</Nullable>),建议改成 point.StackedValue?.Share ?? 0 的写法更安全。
前不久在某风电场的状态监测系统项目中,遇到了个让人头疼的问题:3台风机,每台16个振动传感器,采样频率2kHz,也就是每秒钟有近10万个数据点涌入系统。原来用WPF Chart控件做的监控界面,跑了不到10分钟就开始卡顿,CPU直接飙到85%,客户现场工程师看着一帧一帧跳动的波形图,直接问:"这是实时监控还是慢动作回放?"
最终切换到ScottPlot 5.x后,同样的数据量下,界面刷新延迟从800ms降到35ms以内,CPU占用稳定在18%,48路振动信号同时流畅显示。这背后的性能提升不只是换个图表库这么简单,更多的是对多轴数据处理、内存管理和渲染优化的深入理解。
读完这篇文章,你将掌握:
振动分析不同于常规的温度、压力监控,它的数据密度要高出几个数量级。一台典型的旋转设备可能需要监测:
这意味着单台设备每秒产生5000+数据点,多台设备并发时数据流量呈指数增长。传统Chart控件的"来一个画一个"模式在这种场景下完全崩盘。
我在某石化装置的压缩机监测系统中实测过,6轴振动数据+转速信号同时显示时:
| 方案 | 刷新延迟 | CPU占用 | 内存增长速率 |
|---|---|---|---|
| WPF Chart | 1200ms | 72% | 120MB/小时 |
| LiveCharts | 680ms | 58% | 85MB/小时 |
| ScottPlot 5.x | 35ms | 18% | 稳定 |
振动分析中,不同轴向的数据必须严格时间对齐才有分析价值。比如轴承故障诊断时,需要对比X、Y轴的相位关系来判断不平衡类型。如果各轴数据的时间戳有哪怕几毫秒的偏差,分析结果都会失真。
更复杂的是坐标系设置:
csharp// ❌ 错误做法:各轴使用独立的坐标系
foreach(var axis in axes)
{
axis.Plot.Axes.AutoScale(); // 每个轴独立缩放,失去对比意义
}
// ✅ 正确做法:统一坐标系管理
var globalTimeRange = GetGlobalTimeRange();
var globalAmplitudeRange = GetGlobalAmplitudeRange();
foreach(var axis in axes)
{
axis.Plot.Axes.SetLimits(globalTimeRange.Min, globalTimeRange.Max,
globalAmplitudeRange.Min, globalAmplitudeRange.Max);
}
振动分析往往需要时域波形和频域频谱同时显示。当时域数据更新时,频谱也要实时计算并刷新。这涉及到FFT计算、数据缓冲、多图表联动等复杂逻辑。
在某齿轮箱监测项目中,我们需要同时显示:
如果处理不当,界面很容易因为计算复杂度过高而卡死。
ScottPlot 5.x支持在单个WpfPlot控件中管理多个子图表,这为多轴显示提供了天然优势:
csharp// 核心架构:一个容器控件管理多个子图表
WpfPlot mainPlot = new WpfPlot();
var subplot1 = mainPlot.Plot.Add.Subplot(0.0, 1.0, 0.7, 1.0); // 上半部分
var subplot2 = mainPlot.Plot.Add.Subplot(0.0, 1.0, 0.3, 0.7); // 中间部分
var subplot3 = mainPlot.Plot.Add.Subplot(0.0, 1.0, 0.0, 0.3); // 下半部分
这种设计的优势是所有子图共享时间轴,天然解决了时间同步问题。
针对振动数据的特点,ScottPlot提供了几种优化的数据容器:
对于振动监测,推荐使用DataStreamer:
csharp// DataStreamer自动管理数据窗口,无内存泄漏风险
var streamer = myPlot.Plot.Add.DataStreamer(capacity: 2000);
streamer.Color = Colors.Blue;
streamer.LineWidth = 1.5f;
多轴显示的性能瓶颈往往在内存管理,关键原则:
适用场景:单台设备、低频采样(<100Hz)、快速验证需求
csharpusing ScottPlot;
using ScottPlot.WPF;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
namespace AppScottPlot10
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
// 振动数据包:包含时间戳,确保多设备同步
public record VibrationPacket(DateTime Timestamp, string DeviceId, string Channel, double Value);
public partial class MainWindow : Window
{
private readonly Channel<VibrationPacket> _dataChannel;
private readonly Dictionary<string, Queue<(double time, double value)>> _deviceBuffers;
private readonly Dictionary<string, ScottPlot.Plottables.SignalXY> _signalPlots;
private readonly PeriodicTimer _refreshTimer;
private CancellationTokenSource _cts;
private DateTime _startTime;
private const double DISPLAY_TIME_WINDOW = 30.0; // 显示最近30秒
private const int MAX_POINTS_PER_DEVICE = 3000;
// 多设备多通道配置
private readonly (string DeviceId, string Channel, string Color)[] _channels =
{
("风机1号", "轴承X", "#E74C3C"),
("风机1号", "轴承Y", "#FF6B68"),
("风机1号", "轴承Z", "#C0392B"),
("风机2号", "轴承X", "#3498DB"),
("风机2号", "轴承Y", "#5DADE2"),
("风机2号", "轴承Z", "#2980B9"),
};
public MainWindow()
{
InitializeComponent();
// 首先初始化CancellationTokenSource
_cts = new CancellationTokenSource();
// 创建高性能数据通道
_dataChannel = Channel.CreateBounded<VibrationPacket>(new BoundedChannelOptions(10000)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleWriter = false,
SingleReader = true
});
_deviceBuffers = new Dictionary<string, Queue<(double, double)>>();
_signalPlots = new Dictionary<string, ScottPlot.Plottables.SignalXY>();
InitializeAdvancedCharts();
// 高频刷新定时器
_refreshTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(40)); // 25Hz刷新
// 启动异步处理
StartAsyncDataProcessing();
Task.Run(RefreshLoop);
}
private void InitializeAdvancedCharts()
{
var plt = VibrationPlot.Plot;
// 专业工业主题
plt.Font.Set("Microsoft YaHei");
plt.FigureBackground.Color = Color.FromHex("#1E1E1E");
plt.DataBackground.Color = Color.FromHex("#2D2D30");
// 高对比度网格
plt.Grid.MajorLineColor = Colors.Gray.WithAlpha(100);
plt.Grid.MajorLineWidth = 1;
plt.Grid.MinorLineColor = Colors.Gray.WithAlpha(40);
plt.Grid.MinorLineWidth = 0.5f;
// 为每个通道创建SignalXY(支持自定义时间轴)
foreach (var (deviceId, channel, color) in _channels)
{
string key = $"{deviceId}_{channel}";
// 初始化缓冲区
_deviceBuffers[key] = new Queue<(double, double)>();
// 创建SignalXY对象
var plot = plt.Add.SignalXY(
new double[] { 0 },
new double[] { 0 }
);
plot.Color = Color.FromHex(color);
plot.LineWidth = 1.2f;
plot.LegendText = $"{deviceId}-{channel}";
plot.MarkerSize = 0;
_signalPlots[key] = plot;
}
// 坐标轴配置
plt.Title("多设备振动联合监控系统", size: 18);
plt.XLabel("时间 (秒)");
plt.YLabel("振动幅值 (m/s²)");
plt.Legend.IsVisible = true;
plt.Legend.Alignment = Alignment.UpperRight;
// 添加报警线
var alarmLine = plt.Add.HorizontalLine(8.0);
alarmLine.Color = Colors.Red;
alarmLine.LinePattern = LinePattern.Dashed;
alarmLine.LineWidth = 2;
alarmLine.LegendText = "报警阈值";
VibrationPlot.Refresh();
}
private void StartAsyncDataProcessing()
{
// 模拟多设备异步数据采集
Task.Run(async () =>
{
var random = new Random();
_startTime = DateTime.Now;
while (!_cts.Token.IsCancellationRequested)
{
foreach (var (deviceId, channel, _) in _channels)
{
// 模拟不同设备的采样时间差异
var timestamp = DateTime.Now;
// 生成特征频率振动信号
double time = (timestamp - _startTime).TotalSeconds;
double baseFreq = deviceId.Contains("1号") ? 25 : 30; // 不同设备不同转频
double harmonics = 2 * Math.Sin(2 * Math.PI * baseFreq * 2 * time); // 2倍频
double noise = (random.NextDouble() - 0.5) * 1.5;
double value = 5 * Math.Sin(2 * Math.PI * baseFreq * time) + harmonics + noise;
// 发送到数据通道
var packet = new VibrationPacket(timestamp, deviceId, channel, value);
await _dataChannel.Writer.WriteAsync(packet, _cts.Token);
// 模拟采样间隔
await Task.Delay(random.Next(5, 15), _cts.Token);
}
}
}, _cts.Token);
}
private async Task RefreshLoop()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
// 批量处理数据包
var processedCount = 0;
var hasData = false;
while (_dataChannel.Reader.TryRead(out var packet) && processedCount < 100)
{
string key = $"{packet.DeviceId}_{packet.Channel}";
double relativeTime = (packet.Timestamp - _startTime).TotalSeconds;
// 更新缓冲区
var buffer = _deviceBuffers[key];
buffer.Enqueue((relativeTime, packet.Value));
// 清理过期数据
while (buffer.Count > 0 &&
relativeTime - buffer.Peek().time > DISPLAY_TIME_WINDOW)
{
buffer.Dequeue();
}
// 限制数据点数量
while (buffer.Count > MAX_POINTS_PER_DEVICE)
{
buffer.Dequeue();
}
processedCount++;
hasData = true;
}
// 如果处理了数据,更新图表
if (hasData)
{
await UpdateChartsAsync();
}
// 等待下一个刷新周期
await _refreshTimer.WaitForNextTickAsync(_cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
// 记录异常但继续运行
System.Diagnostics.Debug.WriteLine($"RefreshLoop error: {ex.Message}");
}
}
}
private async Task UpdateChartsAsync()
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
foreach (var (key, buffer) in _deviceBuffers)
{
if (buffer.Count == 0) continue;
var timeArray = buffer.Select(p => p.time).ToArray();
var valueArray = buffer.Select(p => p.value).ToArray();
var signal = _signalPlots[key];
VibrationPlot.Plot.Remove(signal);
var newSignal = VibrationPlot.Plot.Add.SignalXY(timeArray, valueArray);
newSignal.Color = signal.Color;
newSignal.LineWidth = signal.LineWidth;
newSignal.LegendText = signal.LegendText;
newSignal.MarkerSize = signal.MarkerSize;
_signalPlots[key] = newSignal;
}
// 自动调整时间轴范围
double currentTime = (DateTime.Now - _startTime).TotalSeconds;
VibrationPlot.Plot.Axes.SetLimitsX(currentTime - DISPLAY_TIME_WINDOW, currentTime);
VibrationPlot.Refresh();
});
}
protected override void OnClosed(EventArgs e)
{
_cts?.Cancel();
_refreshTimer?.Dispose();
base.OnClosed(e);
}
}
}

LiveChartsCore.SkiaSharpView.WinForms、拖一个 CartesianChart 控件、给 Series 赋一个 ColumnSeries<T> 就能跑起来,全程不用写一行 GDI+ 绘图代码。咱们做 C# 桌面开发的兄弟,多多少少都有过这么一段回忆:领导拍着桌子说"这个月报表得加个图表",然后你打开工具箱找 Chart 控件——嗯,.NET Framework 时代还有个 System.Windows.Forms.DataVisualization.Charting,到了 .NET 6/7/8 直接就没了。去网上一搜,要么是十几年前的老控件配色土到掉渣,要么是商业库动辄几千刀一个授权,要么就是官方文档写得像天书,照着抄都跑不起来。
我自己在去年做一个工业数据采集系统的时候也踩过这坑,老项目用的是 MSChart,迁移到 .NET 8 之后直接报错,最后选型选了 LiveCharts 2(也就是社区里常说的 LVC)。这东西基于 SkiaSharp 渲染,速度快、样式漂亮、API 也算干净,关键是免费开源、跨框架(WinForms / WPF / MAUI / Avalonia 都能用)。
这篇文章咱们不整那些花里胡哨的理论,就聚焦一件事:从零开始,在 WinForms 里画出你的第一张柱状图。读完之后你能拿到:一份可以直接复制运行的完整代码、三种由浅入深的实现方式、以及几个我踩过的坑的避雷指南。
在正式动手之前,我想花一点篇幅说清楚选型这件事。因为我见过太多同学上来就 Ctrl+C、Ctrl+V,跑起来一出问题就懵了,根源就是没搞懂自己用的是什么。
WinForms 图表库现在市面上主流的有这么几个:
| 图表库 | 渲染方式 | .NET 8 支持 | 授权 | 上手难度 |
|---|---|---|---|---|
| MSChart(老牌) | GDI+ | 需手动引用包 | 免费 | 低 |
| LiveCharts 2 | SkiaSharp | 原生支持 | MIT | 中 |
| ScottPlot | GDI+/Skia | 原生支持 | MIT | 低 |
| 商业控件(如 DevExpress) | GDI+/DirectX | 支持 | 付费 | 中高 |
LiveCharts 2 最大的优势是跨框架一致性——你在 WinForms 里写的配置代码,挪到 WPF 项目里几乎不用改。这对做多端桌面应用的团队来说太香了。另外它的动画效果是原生内置的,柱子从零开始"长"出来的那种丝滑感,用 MSChart 想做得手撸计时器,LVC 里就是一个属性的事儿。
当然它也不是完美的。我在实际使用中发现它的内存占用比 MSChart 稍高(大概高 20~30%),如果你的场景是嵌入式工控机内存只有 2G,可能还得权衡一下。
先把前置条件列清楚,省得大家半路卡壳。
测试环境说明:
注意:LiveCharts 2 目前仍处于 rc 阶段,NuGet 上搜索时必须勾选"包括预发行版本",否则你会搜不到包。这是 99% 新手第一次踩的坑。
新建一个 WinForms 项目,目标框架选 .NET 8.0,然后打开 NuGet 包管理器,安装:
LiveChartsCore.SkiaSharpView.WinForms
这一个包会自动把 LiveChartsCore、SkiaSharp、以及 WinForms 适配层全部带进来,不用你一个个装。
咱们先追求"能跑起来",再谈"跑得好看"。新建一个 Form,拖一个 CartesianChart 控件到窗体上(工具箱里找不到的话,先编译一次项目,控件就会自动出现)。
然后在 Form1.cs 里写下这段代码:
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
namespace AppLiveChart04
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
InitChart();
}
private void InitChart()
{
// 1. 准备数据:各城市 Q1 销售额(单位:万元)
var values = new double[] { 128, 256, 189, 342, 215 };
// 2. 构造柱状图系列
cartesianChart1.Series = new ISeries[]
{
new ColumnSeries<double>
{
Values = values,
Name = "销售额"
}
};
// 3. 配置 X 轴标签
cartesianChart1.XAxes = new[]
{
new Axis
{
Labels = new[] { "北京", "上海", "广州", "深圳", "杭州" },
LabelsRotation = 0
}
};
// 4. 配置 Y 轴
cartesianChart1.YAxes = new[]
{
new Axis
{
Name = "金额(万元)",
MinLimit = 0
}
};
}
}
}

按 F5 运行,你会看到一张带柔和动画的柱状图,五根柱子依次"弹"出来。到这一步,你就已经完成了第一张 LiveCharts 2 柱状图。
这段代码有几个关键点值得拎出来说:
ColumnSeries<T> 里的 T 是数据类型,double、int、甚至自定义对象都行Series 属性接受的是 ISeries[] 数组,意味着你可以同时画多组柱子做对比XAxes 和 YAxes 也是数组,理论上你能做双 Y 轴图表