說(shuō)實(shí)話,咱們做WPF開(kāi)發(fā)的,十有八九都遇到過(guò)這樣的需求:老板突然讓你在界面上展示個(gè)實(shí)時(shí)數(shù)據(jù)曲線,或者搞個(gè)設(shè)備監(jiān)控圖表啥的。這時(shí)候你可能會(huì)想到用微軟自家的Chart控件,結(jié)果發(fā)現(xiàn)性能差、樣式丑、自定義起來(lái)賊麻煩。我之前做過(guò)一個(gè)工業(yè)監(jiān)控項(xiàng)目,用Chart控件渲染10萬(wàn)個(gè)數(shù)據(jù)點(diǎn),直接卡成PPT,幀率從60fps掉到個(gè)位數(shù)。
后來(lái)我發(fā)現(xiàn)了ScottPlot這個(gè)開(kāi)源圖表庫(kù),真是相見(jiàn)恨晚。它專門針對(duì)大數(shù)據(jù)量?jī)?yōu)化,同樣10萬(wàn)個(gè)點(diǎn),渲染只需要幾十毫秒,而且API設(shè)計(jì)得特別人性化,三五行代碼就能搞定一個(gè)漂亮的圖表。
ScottPlot這個(gè)組件最讓我受不了的就是版本變化改的太多了。這塊得注意。
讀完這篇文章,你能收獲這些實(shí)實(shí)在在的技能:
? 15分鐘完成ScottPlot環(huán)境搭建,避開(kāi)常見(jiàn)的版本兼容性陷阱
? 掌握3種典型場(chǎng)景的圖表實(shí)現(xiàn),直接復(fù)制粘貼就能用
? 學(xué)會(huì)性能優(yōu)化的核心技巧,輕松應(yīng)對(duì)百萬(wàn)級(jí)數(shù)據(jù)展示
?? 為啥非要用ScottPlot?Chart控件它不香嗎?
痛點(diǎn)一:Chart控件真的扛不住大數(shù)據(jù)量
我先說(shuō)個(gè)真實(shí)數(shù)據(jù)對(duì)比。去年給一家制造業(yè)客戶做數(shù)據(jù)采集系統(tǒng),傳感器每秒采集100個(gè)點(diǎn),一分鐘就是6000個(gè)點(diǎn)。用微軟Chart控件實(shí)時(shí)刷新圖表,CPU占用直接飆到40%,界面操作明顯卡頓。換成ScottPlot之后,CPU占用降到5%以內(nèi),而且鼠標(biāo)縮放、拖動(dòng)都絲般順滑。
這背后的原因其實(shí)很簡(jiǎn)單:Chart控件是基于WinForms時(shí)代的設(shè)計(jì)思路,每次更新都要重新計(jì)算布局和渲染整個(gè)控件樹。而ScottPlot底層用的是高性能的Bitmap渲染,配合智能的緩存機(jī)制,只重繪變化的部分。
痛點(diǎn)二:樣式自定義簡(jiǎn)直是噩夢(mèng)
Chart控件的樣式系統(tǒng)復(fù)雜得離譜,想改個(gè)坐標(biāo)軸顏色都得翻半天文檔。我記得有次想把網(wǎng)格線改成虛線,找了一個(gè)小時(shí)資料,最后發(fā)現(xiàn)還得自己寫Custom繪制邏輯。
ScottPlot就友好多了,基本上所有樣式都能通過(guò)屬性直接設(shè)置:
// Chart控件:一堆嵌套屬性,頭都大了
chart1.ChartAreas[0].AxisX.MajorGrid.LineColor = Color.Gray;
chart1.ChartAreas[0]. AxisX.MajorGrid.LineDashStyle = ChartDashStyle.Dash;
// ScottPlot:簡(jiǎn)潔明了,一看就懂
wpfPlot1.Plot.Grid(color: System.Drawing.Color.Gray, lineStyle: LineStyle.Dash);
痛點(diǎn)三:跨平臺(tái)支持差
Chart控件是Windows專屬的,如果你們公司后面要做跨平臺(tái)方案,這部分代碼基本得重寫。ScottPlot支持WPF、WinForms、Avalonia甚至控制臺(tái)應(yīng)用,代碼基本不用改。
?? 環(huán)境搭建:十分鐘配置完戰(zhàn)斗環(huán)境
第一步:確認(rèn)你的開(kāi)發(fā)環(huán)境
這是我踩過(guò)坑之后總結(jié)的配置清單,照著來(lái)基本不會(huì)出問(wèn)題:
組件 推薦版本 最低要求
Visual Studio 2022(17.4+) 2019(16.8+)
.NET版本 . NET 6.0 / .NET 7.0 . NET Framework 4.6.2
ScottPlot. WPF 5.0+ 5.0以一版本api區(qū)別有點(diǎn)大
注意事項(xiàng):如果你用的是. NET Framework項(xiàng)目,強(qiáng)烈建議升級(jí)到4.7.2以上,不然某些依賴包會(huì)出現(xiàn)莫名其妙的加載失敗。
第二步:安裝NuGet包
打開(kāi)Visual Studio的包管理器控制臺(tái)(工具 NuGet包管理器 → 程序包管理器控制臺(tái)),輸入以下命令:
Install-Package ScottPlot.WPF
或者你習(xí)慣用圖形界面,右鍵項(xiàng)目 → 管理NuGet程序包 → 瀏覽,搜索"ScottPlot. WPF",點(diǎn)安裝就行。
踩坑預(yù)警:有些同學(xué)習(xí)慣直接裝ScottPlot包,這個(gè)是核心庫(kù),WPF項(xiàng)目必須裝ScottPlot.WPF才能用控件。我之前就因?yàn)檫@個(gè)浪費(fèi)了半小時(shí),一直報(bào)"找不到命名空間"的錯(cuò)誤。
第三步:驗(yàn)證安裝是否成功
安裝完成后,打開(kāi)MainWindow.xaml,在頂部添加命名空間引用:
<Window x:Class="AppScottPlotWfp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:AppScottPlotWfp"
mc:Ignorable="d"
xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"
Title="MainWindow" Height="450" Width="800">
<Grid>
<ScottPlot:WpfPlot Name="wpfPlot1" />
</Grid>
</Window>
按F5運(yùn)行,如果看到一個(gè)灰色的空白圖表區(qū)域,恭喜你,環(huán)境搭建成功!
?? 第一個(gè)圖表:十行代碼搞定折線圖
基礎(chǔ)版:最簡(jiǎn)單的數(shù)據(jù)可視化
咱們先來(lái)個(gè)最簡(jiǎn)單的例子,畫一條正弦曲線。打開(kāi)MainWindow.xaml. cs,在構(gòu)造函數(shù)里加上這段代碼:
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace AppScottPlotWfp
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
var fontName = "Microsoft YaHei";
var plot = wpfPlot1.Plot;
plot.Font.Set(fontName); //這個(gè)控制了Title的字體,標(biāo)簽和刻度標(biāo)簽需要單獨(dú)設(shè)置字體
plot.Title("我的第一個(gè)ScottPlot圖表"); //這個(gè)還不如Title加一個(gè)參數(shù)來(lái)設(shè)置字體呢
plot.Axes.Bottom.Label.Text = "時(shí)間 (秒)";
plot.Axes.Bottom.Label.FontName = fontName;
plot.Axes.Bottom.TickLabelStyle.FontName = fontName;
plot.Axes.Left.Label.Text = "幅值";
plot.Axes.Left.Label.FontName = fontName;
plot.Axes.Left.TickLabelStyle.FontName = fontName;
double[] xData = new double[50];
double[] yData = new double[50];
for (int i = 0; i < 50; i++)
{
xData[i] = i * 0.1;
yData[i] = Math.Sin(xData[i]);
}
plot.Add.Scatter(xData, yData);
wpfPlot1.Refresh();
}
}
}
![[Pasted image 20260113141640.png]]
運(yùn)行一下,你會(huì)看到一條漂亮的藍(lán)色正弦曲線。這段代碼雖然簡(jiǎn)單,但包含了ScottPlot的核心使用邏輯:
- 準(zhǔn)備數(shù)據(jù)數(shù)組:X軸和Y軸分別用double數(shù)組存儲(chǔ)
- 調(diào)用AddScatter:這是最常用的方法,用于繪制散點(diǎn)圖或折線圖
- 設(shè)置樣式:通過(guò)Plot對(duì)象的屬性方法配置標(biāo)簽和標(biāo)題
- 刷新渲染:Refresh()觸發(fā)界面更新
進(jìn)階版:多條曲線對(duì)比
實(shí)際項(xiàng)目中,我們經(jīng)常需要在同一個(gè)圖表里對(duì)比多組數(shù)據(jù)。比如監(jiān)控三個(gè)傳感器的溫度變化,代碼也就多幾行:
using ScottPlot;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
namespace AppScottPlotWfp
{
/// <summary>
/// Interaction logic for Window1.xaml
/// </summary>
public partial class Window1 : Window
{
public Window1()
{
InitializeComponent();
// 生成時(shí)間軸(共享X軸)
double[] timePoints = Enumerable.Range(0, 100)
.Select(i => i * 0.1)
.ToArray();
// 模擬三個(gè)傳感器的數(shù)據(jù)
double[] sensor1 = timePoints.Select(t => 20 + 5 * Math.Sin(t)).ToArray();
double[] sensor2 = timePoints.Select(t => 22 + 3 * Math.Cos(t * 1.2)).ToArray();
double[] sensor3 = timePoints.Select(t => 21 + 4 * Math.Sin(t * 0.8 + 1)).ToArray();
// 添加三條曲線,設(shè)置不同顏色和標(biāo)簽
var plot1 = wpfPlot1.Plot.Add.Scatter(timePoints,sensor1);
plot1.LineWidth = 2;
plot1.LegendText = "傳感器1";
var plot2 = wpfPlot1.Plot.Add.Scatter(timePoints, sensor2);
plot2.LineWidth = 2;
plot2.LegendText = "傳感器2";
var plot3 = wpfPlot1.Plot.Add.Scatter(timePoints, sensor3);
plot3.LineWidth = 2;
plot3.LegendText = "傳感器3";
wpfPlot1.Plot.Legend.FontName= "Microsoft YaHei"; //這些寫法吧,一言難盡
wpfPlot1.Plot.ShowLegend(Alignment.LowerLeft);
wpfPlot1.Plot.Axes.Bottom.Label.FontName = "Microsoft YaHei";
wpfPlot1.Plot.XLabel("時(shí)間 (秒)");
wpfPlot1.Plot.Axes.Left.Label.FontName = "Microsoft YaHei";
wpfPlot1.Plot.YLabel("溫度 (℃)");
wpfPlot1.Plot.Font.Set("Microsoft YaHei");
wpfPlot1.Plot.Title("多傳感器溫度監(jiān)控");
wpfPlot1.Refresh();
}
}
}

這段代碼展示了幾個(gè)實(shí)用技巧:
? 復(fù)用X軸數(shù)據(jù):多條曲線共享同一個(gè)時(shí)間軸,節(jié)省內(nèi)存
? 返回值操作:AddScatter返回的對(duì)象可以進(jìn)一步設(shè)置樣式
? 圖例顯示:Legend()方法自動(dòng)根據(jù)label參數(shù)生成圖例
應(yīng)用場(chǎng)景:我在一個(gè)環(huán)境監(jiān)控系統(tǒng)里就是這么做的,實(shí)時(shí)顯示溫度、濕度、CO2濃度三條曲線,客戶看著特別直觀。
?? 三種典型場(chǎng)景的完整實(shí)現(xiàn)
場(chǎng)景一:實(shí)時(shí)數(shù)據(jù)流更新
這是最常見(jiàn)的需求,比如股票走勢(shì)、設(shè)備監(jiān)控、心電圖等。關(guān)鍵是要高效更新數(shù)據(jù),避免卡頓。
public partial class Window2 : Window
{
private readonly List<double> dataPoints = new();
private readonly Random random = new();
private DispatcherTimer? timer;
public Window2()
{
InitializeComponent();
InitializeRealtimeChart();
}
private void InitializeRealtimeChart()
{
wpfPlot1.Plot.Font.Set("Microsoft YaHei");
for (int i = 0; i < 50; i++)
{
dataPoints.Add(20 + random.NextDouble() * 5);
}
RenderScatter();
wpfPlot1.Refresh();
timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(100)
};
timer.Tick += Timer_Tick;
timer.Start();
}
private void RenderScatter()
{
double[] xData = Enumerable.Range(0, dataPoints.Count).Select(i => (double)i).ToArray();
double[] yData = dataPoints.ToArray();
wpfPlot1.Plot.Clear();
var scatter = wpfPlot1.Plot.Add.Scatter(xData, yData);
scatter.Color = new ScottPlot.Color(0, 120, 215);
scatter.LineWidth = 2;
scatter.MarkerSize = 0;
wpfPlot1.Plot.Axes.Bottom.Label.FontName = "Microsoft YaHei";
wpfPlot1.Plot.Axes.Left.Label.FontName = "Microsoft YaHei";
wpfPlot1.Plot.XLabel("時(shí)間點(diǎn)");
wpfPlot1.Plot.YLabel("數(shù)值");
wpfPlot1.Plot.Title("實(shí)時(shí)數(shù)據(jù)監(jiān)控");
wpfPlot1.Plot.Axes.SetLimits(left: 15, transform: translateY( 30, right: 50, bottom: 0);
}
private void Timer_Tick(object? sender, EventArgs e)
{
dataPoints.Add(20 + random.NextDouble() * 5);
if (dataPoints.Count > 50)
{
dataPoints.Rem)oveAt(0);
}
RenderScatter();
wpfPlot1.Refresh();
}
}

踩坑預(yù)警:
- 注意List的內(nèi)存管理,別讓數(shù)據(jù)無(wú)限增長(zhǎng)導(dǎo)致內(nèi)存泄漏
- 固定坐標(biāo)軸范圍能避免圖表上下跳動(dòng),用戶體驗(yàn)更好
場(chǎng)景二:柱狀圖對(duì)比分析
假設(shè)你要做個(gè)銷售數(shù)據(jù)對(duì)比,展示本月各產(chǎn)品線的銷售額:
private void CreateBarChart()
{
// 產(chǎn)品名稱和銷售額
string[] products = { "產(chǎn)品A", "產(chǎn)品B", "產(chǎn)品C", "產(chǎn)品D", "產(chǎn)品E" };
double[] sales = { 125. 5, 89.3, 156.8, 98.2, 134.7 }; // 單位:萬(wàn)元
// 創(chuàng)建柱狀圖
var barPlot = wpfPlot1.Plot.AddBar(sales);
// 設(shè)置柱子顏色(漸變效果)
barPlot. FillColor = System.Drawing.Color.FromArgb(200, 255, 165, 0);
barPlot.BorderColor = System.Drawing.Color.FromArgb(255, 255, 140, 0);
// 設(shè)置X軸標(biāo)簽
wpfPlot1.Plot.XTicks(Enumerable.Range(0, products.Length).Select(i => (double)i).ToArray(), products);
// 旋轉(zhuǎn)標(biāo)簽避免重疊
wpfPlot1.Plot.XAxis. TickLabelStyle(rotation: 45);
// 添加數(shù)值標(biāo)簽
for (int i = 0; i < sales.Length; i++)
{
wpfPlot1.Plot.AddText($"{sales[i]: F1}萬(wàn)", i, sales[i] + 5,
size: 12, color: System. Drawing.Color.Black);
}
wpfPlot1.Plot.YLabel("銷售額 (萬(wàn)元)");
wpfPlot1.Plot.Title("2024年1月產(chǎn)品銷售對(duì)比");
// 設(shè)置Y軸從0開(kāi)始
wpfPlot1.Plot.SetAxisLimits(yMin: 0);
wpfPlot1.Refresh();
}

這個(gè)實(shí)現(xiàn)有幾個(gè)小細(xì)節(jié)值得注意:
? Add.Text可以在柱子上方顯示具體數(shù)值,特別實(shí)用
? 旋轉(zhuǎn)標(biāo)簽解決了中文標(biāo)簽重疊的問(wèn)題,這是我調(diào)試了好幾次才發(fā)現(xiàn)的技巧
? Y軸從0開(kāi)始是數(shù)據(jù)可視化的最佳實(shí)踐,避免誤導(dǎo)讀者
場(chǎng)景三:信號(hào)分析(帶閾值線)
工業(yè)控制里經(jīng)常要監(jiān)控某個(gè)參數(shù)是否超限,這時(shí)候需要在圖表上畫幾條閾值線:
private void CreateSignalChart()
{
// 清除之前的圖表
wpfPlot1.Plot.Clear();
wpfPlot1.Plot.Font.Set("Microsoft YaHei");
wpfPlot1.Plot.Axes.Bottom.Label.FontName = "Microsoft YaHei";
wpfPlot1.Plot.Axes.Left.Label.FontName = "Microsoft YaHei";
// 模擬采集的電壓信號(hào)
int pointCount = 200;
double[] time = Enumerable.Range(0, pointCount).Select(i => i * 0.01).ToArray();
double[] voltage = new double[pointCount];
Random rand = new Random();
for (int i = 0; i < pointCount; i++)
{
voltage[i] = 14 + Math.Sin(time[i] * 10) * 2 + (rand.NextDouble() - 0.5) * 0.5;
}
// 繪制信號(hào)曲線
var signalPlot = wpfPlot1.Plot.Add.Scatter(time, voltage);
signalPlot.Color = ScottPlot.Color.FromHex("#660000");
signalPlot.LineWidth = 1.5f;
signalPlot.LegendText = "電壓信號(hào)";
// 添加上限閾值線
var upperLimit = wpfPlot1.Plot.Add.HorizontalLine(14.5);
upperLimit.LineWidth = 2;
upperLimit.LineColor = ScottPlot.Color.FromHex("#FF0000");
upperLimit.LinePattern = LinePattern.Solid;
upperLimit.LegendText = "上限 (14.5V)";
// 添加下限閾值線
var lowerLimit = wpfPlot1.Plot.Add.HorizontalLine(9.5);
lowerLimit.LineWidth = 2;
lowerLimit.LineColor = ScottPlot.Color.FromHex("#0000FF");
lowerLimit.LinePattern = LinePattern.Dashed;
lowerLimit.LegendText = "下限 (9.5V)";
// 標(biāo)注超限點(diǎn) - 創(chuàng)建超限點(diǎn)的數(shù)組
List<double> outlierTimes = new List<double>();
List<double> outlierVoltages = new List<double>();
for (int i = 0; i < pointCount; i++)
{
if (voltage[i] > 14.5 || voltage[i] < 9.5)
{
outlierTimes.Add(time[i]);
outlierVoltages.Add(voltage[i]);
}
}
// 如果有超限點(diǎn),添加到圖表
if (outlierTimes.Count > 0)
{
var outlierPlot = wpfPlot1.Plot.Add.Scatter(outlierTimes.ToArray(), outlierVoltages.ToArray());
outlierPlot.Color = ScottPlot.Color.FromHex("#FF0000");
outlierPlot.MarkerSize = 4;
outlierPlot.LineWidth = 0; // 只顯示點(diǎn),不顯示線
outlierPlot.LegendText = "超限點(diǎn)";
}
// 設(shè)置圖例
wpfPlot1.Plot.Legend.IsVisible = true;
wpfPlot1.Plot.Legend.Alignment = Alignment.LowerRight;
// 設(shè)置軸標(biāo)簽和標(biāo)題
wpfPlot1.Plot.Axes.Left.Label.Text = "電壓 (V)";
wpfPlot1.Plot.Axes.Bottom.Label.Text = "時(shí)間 (秒)";
wpfPlot1.Plot.Title("電壓監(jiān)控 - 超限檢測(cè)");
// 刷新圖表
wpfPlot1.Refresh();
}

實(shí)戰(zhàn)經(jīng)驗(yàn):在做電池管理系統(tǒng)時(shí)就用了這套方案,把充電電壓、電流的安全范圍標(biāo)出來(lái),一旦數(shù)據(jù)點(diǎn)超出閾值就用紅點(diǎn)高亮顯示。
?? 常見(jiàn)問(wèn)題與解決方案
問(wèn)題1:中文字體顯示為方框
這是. NET繪圖組件的老問(wèn)題了,解決方法是手動(dòng)指定中文字體:
// 設(shè)置字體,這個(gè)是4.x版本變化比較大
wpfPlot1.Plot.Font.Set("Microsoft YaHei");
wpfPlot1.Plot.Axes.Bottom.Label.FontName = "Microsoft YaHei";
wpfPlot1.Plot.Axes.Left.Label.FontName = "Microsoft YaHei";
問(wèn)題2:圖表在高DPI屏幕上模糊
WPF在高DPI下有個(gè)坑,需要在App.xaml.cs里加這段:
public partial class App : Application
{
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool SetProcessDPIAware();
protected override void OnStartup(StartupEventArgs e)
{
// 啟用DPI感知
if (Environment.OSVersion.Version.Major >= 6)
{
SetProcessDPIAware();
}
base.OnStartup(e);
}
}
問(wèn)題3:導(dǎo)出圖片分辨率太低
默認(rèn)導(dǎo)出是按屏幕分辨率來(lái)的,想要高清圖片得這么寫:
// 導(dǎo)出4K分辨率的PNG圖片
wpfPlot1.Plot.SavePng("output.png", width: 3840, height: 2160);
我在給客戶做報(bào)告生成功能時(shí),就是用這個(gè)方法導(dǎo)出高清圖表,打印出來(lái)效果特別好。
?? 寫在最后
好了,到這里你應(yīng)該已經(jīng)掌握了ScottPlot在WPF項(xiàng)目中的核心用法。簡(jiǎn)單總結(jié)三個(gè)要點(diǎn):
- 環(huán)境搭建別大意:一定要裝對(duì)NuGet包(ScottPlot.WPF),. NET Framework項(xiàng)目注意版本兼容性
- 性能優(yōu)化記三招:大數(shù)據(jù)用Signal、調(diào)低渲染質(zhì)量換性能
最后甩三個(gè)金句給你收藏:
? ? "數(shù)據(jù)可視化不是炫技,關(guān)鍵是讓讀者一秒看懂核心信息"
? ? "性能優(yōu)化的本質(zhì)是減少不必要的計(jì)算,而不是追求最酷的算法"
? ? "好的圖表庫(kù)應(yīng)該讓你專注業(yè)務(wù)邏輯,而不是糾結(jié)繪圖細(xì)節(jié)"
?? 來(lái)聊聊你的實(shí)戰(zhàn)場(chǎng)景
你在項(xiàng)目中遇到過(guò)哪些圖表展示的難題?或者你有什么ScottPlot的使用技巧想分享?歡迎在評(píng)論區(qū)留言交流!
如果這篇文章幫到了你,不妨點(diǎn)個(gè)在看或轉(zhuǎn)發(fā)給同樣在做WPF開(kāi)發(fā)的朋友,咱們一起進(jìn)步 ??
相關(guān)技術(shù)標(biāo)簽:#CSharp開(kāi)發(fā) #WPF #數(shù)據(jù)可視化 #性能優(yōu)化 #ScottPlot