在工业监控、金融交易、IoT数据采集等场景中,我们经常需要处理大量实时数据并进行可视化展示。传统的图表控件在面对数万个数据点时往往卡顿严重,甚至崩溃。今天就来分享一个基于ScottPlot的高性能实时数据图表解决方案,支持10万级数据点流畅显示,让你的C#应用告别卡顿!
这个方案不仅解决了大数据量的渲染性能问题,还实现了智能数据抽取、循环缓冲区优化、双定时器架构等多项技术创新。无论你是做工业软件、数据分析工具,还是监控系统,这套方案都能给你带来启发。
传统的List在频繁添加数据时会触发多次内存重分配,当数据量达到数万级别时,GC压力巨大。
大部分图表控件每次更新都会重绘所有数据点,10万个点的重绘操作足以让UI线程阻塞数秒。
使用普通集合存储历史数据,随着时间推移内存占用持续增长,最终导致程序崩溃。
其实用户真有必要一个界面看10w个点吗?
我这个写法是在超10w点后,需要抽取时体验不好
按我理解可以优化成默认1w个点,向后每拖拽一次加载历史多少个点,这样更有意义(不过没实现,也次吧)
C#public class CircularBuffer<T>
{
private T[] buffer;
private int head;
private int tail;
private int count;
private readonly int capacity;
public CircularBuffer(int capacity)
{
this.capacity = capacity;
buffer = new T[capacity];
head = 0;
tail = 0;
count = 0;
}
public void Add(T item)
{
buffer[tail] = item;
tail = (tail + 1) % capacity;
if (count < capacity)
{
count++;
}
else
{
head = (head + 1) % capacity;
}
}
public T[] ToArray()
{
T[] result = new T[count];
for (int i = 0; i < count; i++)
{
result[i] = buffer[(head + i) % capacity];
}
return result;
}
}
核心优势:
C#private void InitializeTimers()
{
// 数据更新定时器 - 高频率更新数据
tmrDataUpdate = new Timer();
tmrDataUpdate.Interval = 50; // 50ms更新一次数据
tmrDataUpdate.Tick += TmrDataUpdate_Tick;
tmrDataUpdate.Start();
// 渲染更新定时器 - 低频率更新显示
tmrRenderUpdate = new Timer();
tmrRenderUpdate.Interval = 200; // 200ms渲染一次
tmrRenderUpdate.Tick += TmrRenderUpdate_Tick;
tmrRenderUpdate.Start();
}
private void TmrDataUpdate_Tick(object sender, EventArgs e)
{
// 批量添加数据点提高效率
for (int i = 0; i < batchSize; i++)
{
// 添加新数据点
timePoints.Add(DateTime.Now.AddMilliseconds(i * 10));
temperatureData.Add(GenerateNewTemperature());
// ... 其他数据
}
needsRender = true; // 标记需要渲染,但不立即渲染
}
private void TmrRenderUpdate_Tick(object sender, EventArgs e)
{
// 只有在数据变化时才重新渲染
if (!needsRender) return;
UpdateChartOptimized();
needsRender = false;
}
技术亮点:
面对10万个数据点,直接渲染会严重影响性能。我们采用三段式智能抽取策略:
C#private (double[], double[], double[], double[]) ApplyIntelligentDecimation(
double[] tempData, double[] pressData, double[] flowData)
{
int dataLength = tempData.Length;
// 三段式抽取策略
int recentPoints = Math.Min(500, dataLength / 5); // 最新数据详细显示
int midPoints = Math.Min(1000, dataLength / 3); // 中期数据中等抽取
int historicalPoints = maxDisplayPoints - recentPoints - midPoints;
var xList = new List<double>();
var tempList = new List<double>();
// 1. 历史数据区间(大幅抽取)
int historicalEnd = dataLength - recentPoints - midPoints;
if (historicalEnd > 0 && historicalPoints > 0)
{
int step = Math.Max(1, historicalEnd / historicalPoints);
for (int i = 0; i < historicalEnd; i += step)
{
AddDataPoint(i, tempData, pressData, flowData, xList, tempList, pressList, flowList);
}
}
// 2. 中期数据区间(中等抽取)
// 3. 最新数据区间(详细显示)
// ... 具体实现
return (xList.ToArray(), tempList.ToArray(), pressList.ToArray(), flowList.ToArray());
}
抽取策略说明:
C#public partial class FrmMain : Form
{
// 使用循环缓冲区优化内存
private CircularBuffer<DateTime> timePoints;
private CircularBuffer<double> temperatureData;
private CircularBuffer<double> pressureData;
private CircularBuffer<double> flowRateData;
// 性能优化参数
private int maxDataPoints = 100000; // 支持10万个点
private int maxDisplayPoints = 3000; // 最多显示的点数
private bool needsRender = false; // 渲染标记
private int batchSize = 5; // 批量添加数据大小
private void InitializeDataBuffers()
{
timePoints = new CircularBuffer<DateTime>(maxDataPoints);
temperatureData = new CircularBuffer<double>(maxDataPoints);
pressureData = new CircularBuffer<double>(maxDataPoints);
flowRateData = new CircularBuffer<double>(maxDataPoints);
}
}
C#private void SetupChineseFonts()
{
try
{
// 关键:ScottPlot中文字体配置
plotMain.Plot.Axes.Title.Label.FontName = "Microsoft YaHei";
plotMain.Plot.Axes.Bottom.Label.FontName = "Microsoft YaHei";
plotMain.Plot.Axes.Left.Label.FontName = "Microsoft YaHei";
plotMain.Plot.Axes.Bottom.TickLabelStyle.FontName = "Microsoft YaHei";
plotMain.Plot.Axes.Left.TickLabelStyle.FontName = "Microsoft YaHei";
plotMain.Plot.Legend.FontName = "Microsoft YaHei";
}
catch
{
// 忽略字体设置错误
}
}
⚠️ 避坑指南: ScottPlot.Fonts.Default 和 plotMain.Font 设置对ScottPlot 5.x版本无效,必须分别设置各个组件的FontName属性。
C#private void AdjustPerformanceSettings()
{
int dataCount = temperatureData.Count;
// 根据数据量自动调整性能参数
if (dataCount > 80000)
{
renderInterval = 500;
maxDisplayPoints = 2000;
batchSize = 10;
}
else if (dataCount > 50000)
{
renderInterval = 300;
maxDisplayPoints = 2500;
batchSize = 8;
}
else if (dataCount > 20000)
{
renderInterval = 200;
maxDisplayPoints = 3000;
batchSize = 5;
}
// 动态更新定时器间隔
if (tmrRenderUpdate != null)
{
tmrRenderUpdate.Interval = renderInterval;
}
}
C#using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using ScottPlot;
using ScottPlot.WinForms;
using Timer = System.Windows.Forms.Timer;
using SystemColor = System.Drawing.Color;
namespace AppScottPlotDataChart
{
public partial class FrmMain : Form
{
private Timer tmrDataUpdate;
private Timer tmrRenderUpdate; // 独立的渲染定时器
private Random random = new Random();
// 使用循环缓冲区优化内存
private CircularBuffer<DateTime> timePoints;
private CircularBuffer<double> temperatureData;
private CircularBuffer<double> pressureData;
private CircularBuffer<double> flowRateData;
// ScottPlot 散点图对象
private ScottPlot.Plottables.Scatter temperatureScatter;
private ScottPlot.Plottables.Scatter pressureScatter;
private ScottPlot.Plottables.Scatter flowRateScatter;
// 数据参数
private double baseTemperature = 75.0;
private double basePressure = 101.3;
private double baseFlowRate = 50.0;
private int maxDataPoints = 100000; // 支持10万个点
private bool isRealTimeMode = true;
private int updateInterval = 50; // 数据更新间隔
private int renderInterval = 200; // 渲染间隔(降低渲染频率提高性能)
// 性能优化参数
private int maxDisplayPoints = 3000; // 最多显示的点数
private bool needsRender = false; // 渲染标记
private int batchSize = 5; // 批量添加数据大小
public FrmMain()
{
InitializeComponent();
InitializeDataBuffers();
InitializeChart();
InitializeTimers();
GenerateInitialData();
}
private void InitializeDataBuffers()
{
// 使用循环缓冲区,避免频繁的内存操作
timePoints = new CircularBuffer<DateTime>(maxDataPoints);
temperatureData = new CircularBuffer<double>(maxDataPoints);
pressureData = new CircularBuffer<double>(maxDataPoints);
flowRateData = new CircularBuffer<double>(maxDataPoints);
}
private void InitializeChart()
{
// 设置中文字体
SetupChineseFonts();
// 配置主图表
plotMain.Plot.Clear();
plotMain.Plot.Title("工业过程实时监控 (支持10万点)");
plotMain.Plot.XLabel("数据点");
plotMain.Plot.YLabel("数值");
// 初始化空的散点图
InitializeEmptyPlots();
plotMain.Plot.ShowLegend();
plotMain.Refresh();
}
private void SetupChineseFonts()
{
try
{
plotMain.Font = new Font("Microsoft YaHei",12f);
ScottPlot.Fonts.Default = "Microsoft YaHei";
// 上面这两没效,我一直以为上面可以指定
plotMain.Plot.Axes.Title.Label.FontName = "Microsoft YaHei";
plotMain.Plot.Axes.Bottom.Label.FontName = "Microsoft YaHei";
plotMain.Plot.Axes.Left.Label.FontName = "Microsoft YaHei";
plotMain.Plot.Axes.Bottom.TickLabelStyle.FontName = "Microsoft YaHei";
plotMain.Plot.Axes.Left.TickLabelStyle.FontName = "Microsoft YaHei";
plotMain.Plot.Legend.FontName = "Microsoft YaHei";
}
catch
{
}
}
private void InitializeEmptyPlots()
{
double[] emptyX = new double[] { 0 };
double[] emptyY = new double[] { 0 };
temperatureScatter = plotMain.Plot.Add.Scatter(emptyX, emptyY);
temperatureScatter.Color = ScottPlot.Colors.Red;
temperatureScatter.LegendText = "温度 (°C)";
temperatureScatter.LineWidth = 2;
temperatureScatter.MarkerSize = 0;
pressureScatter = plotMain.Plot.Add.Scatter(emptyX, emptyY);
pressureScatter.Color = ScottPlot.Colors.Blue;
pressureScatter.LegendText = "压力 (kPa)";
pressureScatter.LineWidth = 2;
pressureScatter.MarkerSize = 0;
flowRateScatter = plotMain.Plot.Add.Scatter(emptyX, emptyY);
flowRateScatter.Color = ScottPlot.Colors.Green;
flowRateScatter.LegendText = "流量 (L/min)";
flowRateScatter.LineWidth = 2;
flowRateScatter.MarkerSize = 0;
}
private void InitializeTimers()
{
// 数据更新定时器,高频率更新数据
tmrDataUpdate = new Timer();
tmrDataUpdate.Interval = updateInterval;
tmrDataUpdate.Tick += TmrDataUpdate_Tick;
tmrDataUpdate.Start();
// 渲染更新定时器,低频率更新显示
tmrRenderUpdate = new Timer();
tmrRenderUpdate.Interval = renderInterval;
tmrRenderUpdate.Tick += TmrRenderUpdate_Tick;
tmrRenderUpdate.Start();
}
private void GenerateInitialData()
{
DateTime startTime = DateTime.Now.AddHours(-1);
for (int i = 0; i < 80000; i++)
{
timePoints.Add(startTime.AddMilliseconds(i * 50));
double tempVariation = (random.NextDouble() - 0.5) * 10;
double pressVariation = (random.NextDouble() - 0.5) * 5;
double flowVariation = (random.NextDouble() - 0.5) * 8;
temperatureData.Add(baseTemperature + tempVariation + Math.Sin(i * 0.01) * 5);
pressureData.Add(basePressure + pressVariation + Math.Cos(i * 0.008) * 3);
flowRateData.Add(baseFlowRate + flowVariation + Math.Sin(i * 0.005) * 4);
}
needsRender = true;
}
private void TmrDataUpdate_Tick(object sender, EventArgs e)
{
if (!isRealTimeMode) return;
// 批量添加数据点以提高效率
for (int i = 0; i < batchSize; i++)
{
DateTime newTime = DateTime.Now.AddMilliseconds(i * 10);
double tempNoise = (random.NextDouble() - 0.5) * 8;
double pressNoise = (random.NextDouble() - 0.5) * 4;
double flowNoise = (random.NextDouble() - 0.5) * 6;
double newTemp = baseTemperature + tempNoise + Math.Sin(timePoints.Count * 0.01) * 5;
double newPress = basePressure + pressNoise + Math.Cos(timePoints.Count * 0.008) * 3;
double newFlow = baseFlowRate + flowNoise + Math.Sin(timePoints.Count * 0.005) * 4;
timePoints.Add(newTime);
temperatureData.Add(newTemp);
pressureData.Add(newPress);
flowRateData.Add(newFlow);
}
// 只标记需要渲染,不立即渲染
needsRender = true;
// 更新状态标签
UpdateStatusLabels();
}
private void TmrRenderUpdate_Tick(object sender, EventArgs e)
{
// 只有在数据变化时才重新渲染
if (!needsRender || temperatureData.Count == 0) return;
UpdateChartOptimized();
needsRender = false;
}
private void UpdateChartOptimized()
{
try
{
// 获取优化后的显示数据
var (xData, tempData, pressData, flowData) = GetOptimizedDisplayData();
if (xData.Length == 0) return;
// 移除旧图表对象
plotMain.Plot.Remove(temperatureScatter);
plotMain.Plot.Remove(pressureScatter);
plotMain.Plot.Remove(flowRateScatter);
// 创建新的图表对象
temperatureScatter = plotMain.Plot.Add.Scatter(xData, tempData);
temperatureScatter.Color = ScottPlot.Colors.Red;
temperatureScatter.LegendText = "温度 (°C)";
temperatureScatter.LineWidth = 2;
temperatureScatter.MarkerSize = 0;
pressureScatter = plotMain.Plot.Add.Scatter(xData, pressData);
pressureScatter.Color = ScottPlot.Colors.Blue;
pressureScatter.LegendText = "压力 (kPa)";
pressureScatter.LineWidth = 2;
pressureScatter.MarkerSize = 0;
flowRateScatter = plotMain.Plot.Add.Scatter(xData, flowData);
flowRateScatter.Color = ScottPlot.Colors.Green;
flowRateScatter.LegendText = "流量 (L/min)";
flowRateScatter.LineWidth = 2;
flowRateScatter.MarkerSize = 0;
// 自动缩放
if (chkAutoScale.Checked)
{
plotMain.Plot.Axes.AutoScale();
}
plotMain.Refresh();
}
catch (Exception ex)
{
Console.WriteLine($"图表更新错误: {ex.Message}");
}
}
private (double[], double[], double[], double[]) GetOptimizedDisplayData()
{
if (temperatureData.Count == 0)
return (new double[0], new double[0], new double[0], new double[0]);
double[] tempArray = temperatureData.ToArray();
double[] pressArray = pressureData.ToArray();
double[] flowArray = flowRateData.ToArray();
// 智能数据抽取策略
if (tempArray.Length > maxDisplayPoints)
{
return ApplyIntelligentDecimation(tempArray, pressArray, flowArray);
}
else
{
double[] xData = Enumerable.Range(0, tempArray.Length).Select(i => (double)i).ToArray();
return (xData, tempArray, pressArray, flowArray);
}
}
private (double[], double[], double[], double[]) ApplyIntelligentDecimation(
double[] tempData, double[] pressData, double[] flowData)
{
int dataLength = tempData.Length;
// 三段式抽取策略:
// 1. 历史数据:大幅抽取
// 2. 中期数据:中等抽取
// 3. 最新数据:详细显示,这个不太好,在默认显示下会挤的太厉害
int recentPoints = Math.Min(500, dataLength / 5); // 最新数据详细显示
int midPoints = Math.Min(1000, dataLength / 3); // 中期数据中等抽取
int historicalPoints = maxDisplayPoints - recentPoints - midPoints; // 剩余给历史数据
var xList = new List<double>();
var tempList = new List<double>();
var pressList = new List<double>();
var flowList = new List<double>();
// 1. 历史数据区间(大幅抽取)
int historicalEnd = dataLength - recentPoints - midPoints;
if (historicalEnd > 0 && historicalPoints > 0)
{
int step = Math.Max(1, historicalEnd / historicalPoints);
for (int i = 0; i < historicalEnd; i += step)
{
AddDataPoint(i, tempData, pressData, flowData, xList, tempList, pressList, flowList);
}
}
// 2. 中期数据区间(中等抽取)
int midStart = Math.Max(0, dataLength - recentPoints - midPoints);
int midEnd = dataLength - recentPoints;
if (midEnd > midStart && midPoints > 0)
{
int step = Math.Max(1, (midEnd - midStart) / midPoints);
for (int i = midStart; i < midEnd; i += step)
{
AddDataPoint(i, tempData, pressData, flowData, xList, tempList, pressList, flowList);
}
}
// 3. 最新数据区间(详细显示)
int recentStart = Math.Max(0, dataLength - recentPoints);
for (int i = recentStart; i < dataLength; i++)
{
AddDataPoint(i, tempData, pressData, flowData, xList, tempList, pressList, flowList);
}
return (xList.ToArray(), tempList.ToArray(), pressList.ToArray(), flowList.ToArray());
}
private void AddDataPoint(int index, double[] tempData, double[] pressData, double[] flowData,
List<double> xList, List<double> tempList, List<double> pressList, List<double> flowList)
{
xList.Add(index);
tempList.Add(tempData[index]);
pressList.Add(pressData[index]);
flowList.Add(flowData[index]);
}
private void UpdateStatusLabels()
{
if (temperatureData.Count > 0)
{
lblTemperatureValue.Text = $"{temperatureData.Last():F1} °C";
lblPressureValue.Text = $"{pressureData.Last():F1} kPa";
lblFlowRateValue.Text = $"{flowRateData.Last():F1} L/min";
lblDataPointsValue.Text = $"{temperatureData.Count:N0}";
}
}
// 性能控制方法,这也是将就给的比例
private void AdjustPerformanceSettings()
{
int dataCount = temperatureData.Count;
// 根据数据量自动调整性能参数
if (dataCount > 80000)
{
renderInterval = 500;
maxDisplayPoints = 2000;
batchSize = 10;
}
else if (dataCount > 50000)
{
renderInterval = 300;
maxDisplayPoints = 2500;
batchSize = 8;
}
else if (dataCount > 20000)
{
renderInterval = 200;
maxDisplayPoints = 3000;
batchSize = 5;
}
else
{
renderInterval = 100;
maxDisplayPoints = 4000;
batchSize = 3;
}
// 更新渲染定时器间隔
if (tmrRenderUpdate != null)
{
tmrRenderUpdate.Interval = renderInterval;
}
}
private void btnStartStop_Click(object sender, EventArgs e)
{
isRealTimeMode = !isRealTimeMode;
if (isRealTimeMode)
{
tmrDataUpdate.Start();
tmrRenderUpdate.Start();
btnStartStop.Text = "停止";
btnStartStop.BackColor = SystemColor.FromArgb(220, 53, 69);
}
else
{
tmrDataUpdate.Stop();
tmrRenderUpdate.Stop();
btnStartStop.Text = "开始";
btnStartStop.BackColor = SystemColor.FromArgb(40, 167, 69);
}
}
private void btnClearData_Click(object sender, EventArgs e)
{
timePoints.Clear();
temperatureData.Clear();
pressureData.Clear();
flowRateData.Clear();
plotMain.Plot.Clear();
plotMain.Plot.Title("工业过程实时监控 (支持10万点)");
plotMain.Plot.XLabel("数据点");
plotMain.Plot.YLabel("数值");
SetupChineseFonts();
InitializeEmptyPlots();
plotMain.Plot.ShowLegend();
plotMain.Refresh();
UpdateStatusLabels();
// 重置性能参数
renderInterval = 200;
maxDisplayPoints = 3000;
batchSize = 5;
if (tmrRenderUpdate != null)
tmrRenderUpdate.Interval = renderInterval;
}
private void trkUpdateRate_ValueChanged(object sender, EventArgs e)
{
updateInterval = trkUpdateRate.Value;
if (tmrDataUpdate != null)
{
tmrDataUpdate.Interval = updateInterval;
lblUpdateRateValue.Text = $"{updateInterval} ms";
}
}
private void chkAutoScale_CheckedChanged(object sender, EventArgs e)
{
if (chkAutoScale.Checked)
{
plotMain.Plot.Axes.AutoScale();
plotMain.Refresh();
}
}
private void FrmMain_FormClosing(object sender, FormClosingEventArgs e)
{
tmrDataUpdate?.Stop();
tmrDataUpdate?.Dispose();
tmrRenderUpdate?.Stop();
tmrRenderUpdate?.Dispose();
}
private void btnExportData_Click(object sender, EventArgs e)
{
if (temperatureData.Count == 0)
{
MessageBox.Show("没有可导出的数据!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
using (SaveFileDialog saveDialog = new SaveFileDialog())
{
saveDialog.Filter = "CSV文件|*.csv|所有文件|*.*";
saveDialog.Title = "导出数据";
saveDialog.FileName = $"工业数据_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
if (saveDialog.ShowDialog() == DialogResult.OK)
{
Task.Run(() => ExportToCsvAsync(saveDialog.FileName));
}
}
}
private async Task ExportToCsvAsync(string fileName)
{
try
{
var timeArray = timePoints.ToArray();
var tempArray = temperatureData.ToArray();
var pressArray = pressureData.ToArray();
var flowArray = flowRateData.ToArray();
await Task.Run(() =>
{
using (var writer = new System.IO.StreamWriter(fileName, false, System.Text.Encoding.UTF8))
{
writer.WriteLine("时间,温度(°C),压力(kPa),流量(L/min)");
for (int i = 0; i < timeArray.Length; i++)
{
writer.WriteLine($"{timeArray[i]:yyyy-MM-dd HH:mm:ss.fff},{tempArray[i]:F2},{pressArray[i]:F2},{flowArray[i]:F2}");
}
}
});
this.Invoke(new Action(() =>
{
MessageBox.Show($"数据已成功导出到:\n{fileName}", "导出完成", MessageBoxButtons.OK, MessageBoxIcon.Information);
}));
}
catch (Exception ex)
{
this.Invoke(new Action(() =>
{
MessageBox.Show($"导出失败:\n{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}));
}
}
// 定期调整性能设置
private void Timer_PerformanceAdjust_Tick(object sender, EventArgs e)
{
AdjustPerformanceSettings();
}
}
// 高效的循环缓冲区实现
public class CircularBuffer<T>
{
private T[] buffer;
private int head;
private int tail;
private int count;
private readonly int capacity;
public CircularBuffer(int capacity)
{
this.capacity = capacity;
buffer = new T[capacity];
head = 0;
tail = 0;
count = 0;
}
public void Add(T item)
{
buffer[tail] = item;
tail = (tail + 1) % capacity;
if (count < capacity)
{
count++;
}
else
{
head = (head + 1) % capacity;
}
}
public T[] ToArray()
{
T[] result = new T[count];
for (int i = 0; i < count; i++)
{
result[i] = buffer[(head + i) % capacity];
}
return result;
}
public T Last()
{
if (count == 0)
throw new InvalidOperationException("Buffer is empty");
int lastIndex = (tail - 1 + capacity) % capacity;
return buffer[lastIndex];
}
public void Clear()
{
head = 0;
tail = 0;
count = 0;
}
public int Count => count;
public int Capacity => capacity;
}
}


经过实际测试,该方案在处理10万个数据点时:
对比传统方案提升:
C#// 适配K线数据结构
private CircularBuffer<CandleStick> priceData;
private CircularBuffer<double> volumeData;
// 实时行情更新
private void UpdateMarketData(MarketTick tick)
{
priceData.Add(new CandleStick(tick.Open, tick.High, tick.Low, tick.Close));
volumeData.Add(tick.Volume);
needsRender = true;
}
C#// 多传感器数据管理
private Dictionary<string, CircularBuffer<SensorData>> sensorBuffers;
private void InitializeSensors(string[] sensorIds)
{
sensorBuffers = new Dictionary<string, CircularBuffer<SensorData>>();
foreach (var id in sensorIds)
{
sensorBuffers[id] = new CircularBuffer<SensorData>(maxDataPoints);
}
}
你在项目中遇到过类似的大数据量可视化需求吗?使用了哪些优化策略?欢迎在评论区分享你的经验,或者说说在数据可视化方面遇到的技术难题,让我们一起探讨最佳解决方案!
如果这篇文章对你有帮助,别忘了转发给更多需要的同行! 关注我,持续分享C#高性能编程技巧和实战经验。
相关信息
通过网盘分享的文件:AppScottPlotDataChart.zip 链接: https://pan.baidu.com/s/1CkrwHxU7bjNMZJv_70D_gg?pwd=bgyr 提取码: bgyr --来自百度网盘超级会员v9的分享
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!