编辑
2026-05-29
C#
0

最近在帮一个做点胶机的老铁解决Modbus通讯问题。他苦着脸跟我说:"兄弟,我们这套系统连了12个从站设备,天天出问题!有时候读不到数据,有时候直接卡死,现场工程师都快疯了..."

这话听着熟悉吗?统计显示,工业项目中有47%的时间都在处理通讯异常。而Modbus RTU作为工业界的"老兵",虽然简单可靠,但在多从站环境下却容易"翻车"。

今天咱们就来聊聊如何设计一套真正稳定的Modbus多从站轮询系统。别急着关掉页面——这套方案已经在3个工厂稳定运行半年多,零故障!

🎯 多从站通讯的三大"坑"

第一坑:串行通讯的"交通堵塞"

Modbus RTU是典型的主从模式,同一时刻只能有一个设备发言。想象一下12个人排队打电话,前面那位如果"占线",后面全得干等着。

第二坑:超时处理的"连锁反应"

传统做法是设置一个全局超时时间,比如500ms。问题来了——如果第3号从站网络不好,卡了2秒,那后面9个从站都要跟着等。一个设备出问题,全线遭殃

第三坑:错误恢复的"死循环"

更要命的是,很多系统对故障设备没有"隔离机制"。坏了的设备会一直尝试连接,把整个轮询拖垮。就像队伍里有个"话痨",永远轮不到后面的人。

💡 核心设计思路:独立超时 + 错误隔离

咱们的解决方案核心就8个字:独立超时,错误隔离

简单说就是:

  • 每个从站有自己的"容错额度"
  • 出错太多次的设备会被"暂时冷处理"
  • 保证好设备不受坏设备影响

🔧 架构设计深度解析

📊 从站配置类:给每个设备建档案

csharp
using System; namespace AppLoopMasterRtu { /// <summary> /// 单个从站的轮询配置与运行时状态 /// </summary> public class SlaveConfig { public byte SlaveId { get; set; } public ushort StartAddr { get; set; } public ushort Count { get; set; } public int ErrorCount { get; set; } public string Status { get; set; } = "待机"; public string LastData { get; set; } = "--"; public DateTime LastPoll { get; set; } = DateTime.MinValue; public Action<ushort[]>? OnData { get; set; } } }
编辑
2026-05-29
C#
0

🔧 开篇

产线上新增了5台焊接机器人,领导要求把每台设备的实时温度都显示在监控界面上。

你打开 VS,写了一个 float[] temps = new float[5],心想完事了。

结果第二周又加了3台,你只能回去改代码,把 5 改成 8……

第三周又加了2台。

「这数组到底要写多大才够用?」

如果你有这个困惑,今天这篇文章正好帮你解决它。


📌 上节回顾

「上一节我们学了数组,掌握了一维、多维与交错数组的声明和使用方法。今天在这个基础上,我们进一步学习 List<T> 泛型集合——一种比数组更灵活、更适合工业动态数据的容器。」


💡 核心知识讲解

数组的问题出在哪?

数组(Array)有一个硬伤:长度一旦定好,就不能改变

你在程序启动时声明了 int[] alarmCodes = new int[10],最多只能存10条报警记录。第11条进来,直接越界崩溃。

在工厂环境里,设备数量、报警条数、生产记录——这些数据天生就是动态的,数组应付起来很吃力。

List<T>(泛型列表) 就是为了解决这个问题而生的。


List<T> 是什么?

List<T> 是 C# 提供的一种 动态数组(可以自动扩容的集合容器)。

这里的 T 是一个占位符,代表你要存什么类型的数据。

  • 存设备温度(小数):List<float>
  • 存报警代码(整数):List<int>
  • 存设备名称(文字):List<string>

「把 T 理解成一个模具,你告诉它用什么材料,它就帮你做出对应的容器。」


和数组的核心区别

对比项数组 Array列表 List<T>
长度是否固定✅ 固定,声明时确定❌ 动态,随时增删
添加元素❌ 不支持直接添加.Add() 一行搞定
适合场景数量确定的静态数据数量变化的动态数据
性能略高(内存连续)略低(但工业场景够用)

大多数工厂应用场景,List<T> 是首选,数组反而用得少。


List<T> 的核心操作

掌握以下6个操作,日常开发够用了:

① 创建列表

csharp
var alarmList = new List<int>(); // 空列表 var deviceNames = new List<string> { "焊接机1号", "焊接机2号" }; // 带初始值

② 添加元素 .Add()

csharp
alarmList.Add(1001); // 添加一条报警码 alarmList.Add(1002);

③ 删除元素 .Remove() / .RemoveAt()

csharp
alarmList.Remove(1001); // 按值删除 alarmList.RemoveAt(0); // 按位置删除(第1个)

④ 查找元素 .Contains() / .Find()

csharp
bool exists = alarmList.Contains(1002); // 是否存在某个值

⑤ 获取数量 .Count

csharp
int total = alarmList.Count; // 当前有多少条记录

⑥ 遍历列表

csharp
foreach (var code in alarmList) { Console.WriteLine($"报警码:{code}"); }

「记住这6个操作,List<T> 的80%使用场景都覆盖了。」


泛型(Generic)是什么意思?

第一次看到 <T> 可能会懵。

泛型 的意思是:这个容器的"规格"由你来定,而不是写死的。

类比工厂里的周转箱:同一款箱子,装螺丝就是"螺丝箱",装轴承就是"轴承箱",箱子本身的结构没变,只是装的东西不同。

List<T> 就是这个"通用箱子",T 就是你要装的"货物类型"。


💻 VS2026 操作步骤

Step 1:新建控制台项目

打开 VS2026,选择 文件 > 新建 > 项目,搜索「控制台应用」,选择 .NET 10 框架,项目名填 ListDemo,点击创建。

Step 2:编写 List<T> 代码

打开 Program.cs,清空默认内容,按照下方代码示例输入。

输入 var alarmList = new List 时,VS2026 的 IntelliSense(智能代码补全)会自动弹出 List<T> 的类型提示,按 Tab 键快速补全。

Step 3:运行并查看输出

F5 启动调试,或点击顶部工具栏的 ▶ 按钮。

输出结果会显示在底部的「终端」窗口中。

VS2026 Copilot 辅助:如果运行报错,Copilot 会在错误行旁边显示 💡 图标,点击可获得"一键修复"建议,非常适合初学阶段快速排错。


Vibe Coding Prompt 写法

如果你想用 Vibe Coding 方式让 Copilot 帮你生成代码,可以在 VS2026 的 Copilot 对话框中输入以下 Prompt:

用 C# 14 和 .NET 10 写一个控制台程序, 使用 List<string> 存储5台注塑机的设备名称, 演示添加、删除、遍历操作, 变量名使用工业语义命名,每行关键代码加中文注释。

Copilot 会直接生成可运行的完整代码,你只需要检查逻辑是否符合需求即可。


📋 完整代码示例

这段代码演示了用 List<string> 管理注塑车间的设备列表,包含添加、删除、查找和遍历的完整操作流程。

csharp
// ============================================= // 示例:注塑车间设备列表管理 // 平台:VS2026 + .NET 10 + C# 14 // ============================================= using System; using System.Collections.Generic; // 【1】创建设备名称列表(初始包含3台设备) var deviceNameList = new List<string> { "注塑机1号", "注塑机2号", "注塑机3号" }; Console.WriteLine("===== 当前设备列表 ====="); // 【2】遍历并输出所有设备名称 foreach (var deviceName in deviceNameList) { Console.WriteLine($" 设备:{deviceName}"); } Console.WriteLine($"\n当前设备总数:{deviceNameList.Count} 台"); // 【3】新增设备(模拟产线扩产) deviceNameList.Add("注塑机4号"); deviceNameList.Add("注塑机5号"); Console.WriteLine("\n===== 新增2台设备后 ====="); Console.WriteLine($"当前设备总数:{deviceNameList.Count} 台"); // 【4】检查某台设备是否在列表中 string targetDevice = "注塑机3号"; bool isOnline = deviceNameList.Contains(targetDevice); Console.WriteLine($"\n{targetDevice} 是否在线:{isOnline}"); // 【5】移除一台设备(模拟设备下线) deviceNameList.Remove("注塑机2号"); Console.WriteLine("\n注塑机2号已下线,从列表移除。"); // 【6】按索引访问(获取第1台设备,索引从0开始) string firstDevice = deviceNameList[0]; Console.WriteLine($"当前列表第一台设备:{firstDevice}"); // 【7】输出最终设备列表 Console.WriteLine("\n===== 最终设备列表 ====="); for (int i = 0; i < deviceNameList.Count; i++) { Console.WriteLine($" [{i + 1}] {deviceNameList[i]}"); }

image.png

运行后,你会在终端窗口看到设备列表的动态变化过程:从3台到5台,再到移除1台后的4台,每次操作结果都清晰打印出来。整个过程不需要改一行长度定义,List 自动帮你管好了。

编辑
2026-05-29
Python
0

🎯 你是不是也踩过这个坑?

做桌面工具的时候,参数配置页几乎是绕不开的模块。IP地址、端口号、超时时长、文件路径……一堆输入框往那儿一摆,用户随手填个"abc"进去,程序直接崩掉。更尴尬的是,错误提示要等到点"保存"之后才弹出来——用户已经填了七八个字段,结果被告知第二个框填错了,心态直接崩。

这不是小问题。在工控、自动化、数据处理这类场景里,配置错误轻则程序异常,重则设备误动作。我在做一个设备管理工具的时候,就因为没做好即时校验,被测试同事连续提了三天Bug——全是"用户输入非法值但没有提示"类的问题。

所以今天咱们就来把这件事彻底说清楚:用 CustomTkinter 构建一个带必填校验、格式校验、即时响应的参数配置页,从组件选型到校验策略,给你一套可以直接落地的方案。


🔍 先搞清楚:校验到底分几层?

很多人把"校验"当成一件事来做,其实它有三个层次,混在一起写会很乱。

第一层:必填校验。 字段不能为空,这是最基础的门槛。

第二层:格式校验。 输入的内容必须符合特定规则——IP地址、端口范围、邮箱格式、纯数字等。

第三层:业务逻辑校验。 比如"最大连接数不能小于最小连接数",这涉及字段之间的关联关系。

三层校验的触发时机也不一样。必填和格式校验适合即时触发(用户输入过程中或离开输入框时),业务逻辑校验通常在提交时统一触发——因为它依赖多个字段的组合状态,提前触发反而会产生误导。

把这个分层想清楚,代码结构自然就清晰了。


🏗️ 组件选型:CTkEntry 够用吗?

CustomTkinter 的 CTkEntry 是核心输入组件,但原生状态下它就是个"素颜"输入框,没有任何校验能力。咱们需要在它外面包一层——不是继承,而是组合

思路是这样的:把一个输入框、一个错误提示标签、一个可选的标题标签打包成一个 ValidatedField 组件。这个组件内部管理自己的状态(正常/错误/必填未填),对外暴露 get_value()is_valid() 两个接口。

python
import customtkinter as ctk import re from typing import Callable, Optional class ValidatedField(ctk.CTkFrame): """ 封装了校验逻辑的输入字段组件。 支持必填校验、正则格式校验、自定义校验函数。 """ # 颜色常量,方便后期统一调整主题 COLOR_NORMAL = ("gray70", "gray30") COLOR_ERROR = ("#FF4444", "#CC2222") COLOR_OK = ("#2ECC71", "#27AE60") def __init__( self, master, label: str = "", required: bool = False, placeholder: str = "", validator: Optional[Callable[[str], tuple[bool, str]]] = None, validate_on: str = "focusout", # "focusout" | "keystroke" | "none" width: int = 280, **kwargs ): super().__init__(master, fg_color="transparent", **kwargs) self.required = required self.validator = validator self.validate_on = validate_on self._is_valid = True # 初始状态视为合法(未触碰) self._touched = False # 用户是否操作过这个字段 # --- 标题行 --- if label: label_frame = ctk.CTkFrame(self, fg_color="transparent") label_frame.pack(fill="x", pady=(0, 2)) ctk.CTkLabel( label_frame, text=label, font=ctk.CTkFont(size=13, weight="bold"), anchor="w" ).pack(side="left") # 必填标记:小红星,醒目但不突兀 if required: ctk.CTkLabel( label_frame, text=" *", text_color="#FF4444", font=ctk.CTkFont(size=13), ).pack(side="left") # --- 输入框 --- self.entry = ctk.CTkEntry( self, width=width, placeholder_text=placeholder, border_color=self.COLOR_NORMAL, height=36, ) self.entry.pack(fill="x") # --- 错误提示标签(默认隐藏,占位高度固定避免布局跳动)--- self.error_label = ctk.CTkLabel( self, text="", text_color="#FF4444", font=ctk.CTkFont(size=11), anchor="w", height=18, ) self.error_label.pack(fill="x", pady=(2, 0)) # --- 绑定事件 --- self.entry.bind("<FocusIn>", self._on_focus_in) self.entry.bind("<FocusOut>", self._on_focus_out) if validate_on == "keystroke": self.entry.bind("<KeyRelease>", self._on_key_release) def _on_focus_in(self, _event): # 获得焦点时清除错误样式,给用户"重新来过"的感觉 if self._touched: self._clear_error_style() def _on_focus_out(self, _event): self._touched = True if self.validate_on in ("focusout", "keystroke"): self.validate() def _on_key_release(self, _event): if self._touched: self.validate() def validate(self) -> bool: value = self.entry.get().strip() # 第一层:必填 if self.required and not value: self._show_error("此字段为必填项") return False # 第二层:格式(仅在有值时触发,空值且非必填则跳过) if value and self.validator: ok, msg = self.validator(value) if not ok: self._show_error(msg) return False # 全部通过 self._show_ok() return True # 状态渲染 def _show_error(self, message: str): self._is_valid = False self.error_label.configure(text=f"⚠ {message}") self.entry.configure(border_color=self.COLOR_ERROR) def _show_ok(self): self._is_valid = True self.error_label.configure(text="") self.entry.configure(border_color=self.COLOR_OK) def _clear_error_style(self): self.entry.configure(border_color=self.COLOR_NORMAL) self.error_label.configure(text="") # 对外接口 def get_value(self) -> str: return self.entry.get().strip() def is_valid(self) -> bool: return self._is_valid def force_validate(self) -> bool: """提交时强制校验(无论用户是否操作过)""" self._touched = True return self.validate() def set_value(self, value: str): self.entry.delete(0, "end") self.entry.insert(0, value)

这段代码有几个细节值得注意。_touched 标志位很关键——页面刚打开时不应该满屏飘红,只有用户实际操作过某个字段之后,离开时才触发校验。force_validate() 是留给"提交"按钮用的,它会无视 _touched 状态,强制校验所有字段。

编辑
2026-05-28
C#
0

做实时监控类应用的时候,有一个问题几乎每个人都会遇到:数据在滚动显示,但"看到的"和"存下来的"到底是不是同一套数据? 这听起来是个很基础的问题,但在高频采集场景下,它会演变成一系列连锁反应——显示窗口越来越卡,历史数据查询响应越来越慢,内存占用缓慢爬升,最终在某个不合时宜的时刻触发一次 GC 停顿,波形图冻住了整整几百毫秒。

这背后的核心矛盾是:实时显示需要的是"最近 N 秒"的滑动窗口,而历史回溯需要的是"完整时间序列",两者的数据结构需求截然不同,却常常被塞进同一个 List<double> 里凑合用。

本文会把这个问题拆清楚,给出三个渐进式的设计方案,覆盖从"能跑"到"生产稳定"的完整路径。读完之后,你手里会有:

  • 一套基于双缓冲队列的滑动窗口实现,可以直接作为项目模板;
  • 一个分层存储架构,让实时显示和历史管理互不干扰;
  • 一份时间戳索引 + 分段压缩的长时间历史数据管理策略。

测试环境:Windows 11,.NET 8,ScottPlot 5.0.36,WinForms,模拟采集频率 500 Hz。


🔍 问题深度剖析:滑动窗口的三个经典陷阱

陷阱一:用 RemoveAt(0) 实现滑动窗口

这大概是最常见的写法,每来一个新数据就在 List<double> 头部删掉最旧的一个:

csharp
// 看起来合理,实则是性能炸弹 dataList.Add(newValue); if (dataList.Count > windowSize) dataList.RemoveAt(0); // O(N) 操作,每次都要移动整个数组

List<T> 底层是数组,RemoveAt(0) 会触发整个数组向前移动一位,时间复杂度是 O(N)。窗口大小 5000 个点的时候,500 Hz 的采集意味着每秒执行 500 次 O(5000) 的内存移动操作。在我实测的项目里,仅这一个操作就能把 CPU 占用推高 8~15 个百分点,而且这个开销会随着窗口变大线性增长。

陷阱二:显示数据与历史数据共用同一个集合

很多实现里,ScottPlot 直接持有对 List<double> 的引用,历史存储也往同一个列表里追加。这带来两个问题:一是历史数据无限增长,内存没有边界;二是在渲染线程读取数据的同时,采集线程在写入,没有任何同步机制,数据竞争是迟早的事。

陷阱三:时间戳管理缺失

纯粹的 double[] 只有幅值,没有时间信息。当需要做历史回溯("给我看 14:32 到 14:35 这段数据")的时候,只能靠采样点索引反推时间,一旦中间有数据丢包或采集暂停,时间对应关系就全乱了。这个问题在开发阶段不容易发现,上线后遇到网络抖动或设备断线重连,就会暴露出来。


💡 核心要点提炼

在动手写代码之前,先把几个设计原则确定下来,后面的方案都围绕这几点展开:

  • 滑动窗口用 Queue<T>ArrayDeque:头部出队、尾部入队,O(1) 操作,这才是正确的数据结构选择。
  • 显示层与存储层分离:实时图表只消费"显示缓冲区",历史数据写入独立的存储层,两者通过事件或 Channel 解耦。
  • 时间戳随数据一起流动:每个采样点携带 long 类型的时间戳(Unix 毫秒),历史查询基于时间范围而非索引范围。
  • 历史数据分段管理:按时间分段(如每段 60 秒),超出保留期的段整体释放,避免逐条删除的碎片化内存问题。

🛠️ 方案一:基于 Queue<T> 的滑动窗口(基础版)

这是最直接的改进,把 List<double> 换成 Queue<double>,彻底解决 RemoveAt(0) 的性能问题。适合采集频率中等(<300 Hz)、不需要历史回溯的简单显示场景。

csharp
// 基于 Queue 的线程安全滑动窗口 public class SlidingWindowBuffer { private readonly Queue<double> _queue; private readonly int _windowSize; private readonly object _lock = new(); // _snapshotCache 用于减少渲染时的数组分配频率 private double[] _snapshotCache; public SlidingWindowBuffer(int windowSize) { _windowSize = windowSize; _queue = new Queue<double>(windowSize + 1); _snapshotCache = new double[windowSize]; } /// <summary>写入新数据点,自动淘汰超出窗口的旧数据</summary> public void Push(double value) { lock (_lock) { _queue.Enqueue(value); // 超出窗口大小时,从头部出队——O(1) 操作 if (_queue.Count > _windowSize) _queue.Dequeue(); } } /// <summary>批量写入,减少锁争用次数</summary> public void PushRange(IEnumerable<double> values) { lock (_lock) { foreach (var v in values) { _queue.Enqueue(v); if (_queue.Count > _windowSize) _queue.Dequeue(); } } } /// <summary> /// 获取当前窗口快照,复用内部缓存数组,减少 GC 压力。 /// 返回实际有效数据长度。 /// </summary> public int CopyTo(double[] destination) { lock (_lock) { int count = _queue.Count; _queue.CopyTo(destination, 0); return count; } } public int Count { get { lock (_lock) return _queue.Count; } } }
csharp
namespace AppScottPlot15 { public partial class Form1 : Form { private const int WindowSize = 1500; // 显示最近 3 秒(500Hz × 3s) private readonly SlidingWindowBuffer _window = new(WindowSize); private readonly double[] _renderBuffer = new double[WindowSize]; // 预分配,避免每帧分配 private ScottPlot.Plottables.Signal? _signal; private System.Windows.Forms.Timer _renderTimer = new(); private System.Windows.Forms.Timer _acquisitionTimer = new(); public Form1() { InitializeComponent(); InitPlot(); StartAcquisition(); StartRenderTimer(); } private void InitPlot() { var plot = formsPlot1.Plot; plot.Axes.Left.Label.FontName = "Microsoft YaHei"; plot.Axes.Right.Label.FontName = "Microsoft YaHei"; plot.Axes.Top.Label.FontName = "Microsoft YaHei"; plot.Axes.Bottom.Label.FontName = "Microsoft YaHei"; plot.Font.Set("Microsoft YaHei"); plot.Title("实时滑动窗口 · 最近 3 秒"); plot.XLabel("采样点"); plot.YLabel("幅值 (V)"); // 用预分配的空数组初始化 Signal,避免后续频繁重建 Plottable 对象 _signal = plot.Add.Signal(_renderBuffer); plot.Axes.SetLimitsY(-1.5, 1.5); } private void StartAcquisition() { // 模拟 500 Hz 采集:每 2ms 一个点 _acquisitionTimer.Interval = 2; _acquisitionTimer.Tick += (s, e) => { double t = Environment.TickCount64 / 1000.0; // 模拟带噪声的正弦信号 double value = Math.Sin(2 * Math.PI * 2 * t) + (new Random().NextDouble() - 0.5) * 0.15; _window.Push(value); }; _acquisitionTimer.Start(); } private void StartRenderTimer() { _renderTimer.Interval = 33; // 约 30 FPS _renderTimer.Tick += (s, e) => { // 直接写入预分配缓冲区,零堆分配 int count = _window.CopyTo(_renderBuffer); if (count == 0) return; // 更新 Signal 的有效点数范围 _signal!.MaxRenderIndex = count - 1; formsPlot1.Refresh(); }; _renderTimer.Start(); } protected override void OnFormClosed(FormClosedEventArgs e) { _renderTimer.Stop(); _acquisitionTimer.Stop(); base.OnFormClosed(e); } } }

image.png

这里有个细节值得注意:_signal.MaxRenderIndex 这个属性。ScottPlot 5 的 Signal 支持只渲染数组的前 N 个元素,这样就不需要每次渲染都重新创建数组或重新绑定数据源,预分配一次、反复复用,GC 压力大幅降低。

性能对比(测试环境:i5-12400,16GB RAM,500 Hz 采集,窗口 1500 点,运行 20 分钟):

指标List + RemoveAt(0)Queue + CopyTo 预分配
采集线程 CPU 占用12~18%2~4%
渲染线程 CPU 占用8~14%5~8%
GC Gen0 次数(20min)约 1200 次约 85 次
内存占用(稳定后)持续增长稳定在基线 ±3MB
编辑
2026-05-28
C#
0

🎯 数字够用,但仪表盘更直观

工控项目里有一个很典型的场景:操作员面对一屏幕的数字——转速 1450rpm、温度 78.3°C、压力 4.2MPa、电流 18.6A——每隔几秒刷新一次。数字本身没问题,但人眼处理纯数字的效率远不如处理图形。操作员需要在脑子里换算"1450 在正常范围内吗",这个认知转换在高压操作环境下是实实在在的负担。

仪表盘图(Gauge Chart) 的价值正在于此——把数值的"位置感"直接用视觉表达出来。指针指向绿色区域,一切正常;偏向红色,立刻警觉。不需要记忆阈值,不需要心算,视觉语言直接触发判断。

LiveCharts 2 的 PieChart 配合 GaugeBuilder 提供了完整的仪表盘实现方案,支持单值仪表、多段色阶、动态数值更新,几十行代码就能搭出专业级效果。

读完本文,你将掌握:

  • GaugeBuilder 的基础用法与参数配置
  • ✅ 三段色阶告警仪表盘(绿/黄/红分区)
  • ✅ 多仪表盘并排布局与动态实时刷新

🔍 问题深度剖析:为什么数字显示不够用?

认知负担是真实的工程问题

人类视觉系统对"位置"和"颜色"的处理速度,比对"数字"的处理速度快 3~5 倍。在需要同时监控 8~12 个参数的工控场景里,纯数字界面要求操作员对每个参数都有记忆阈值,这本质上是在用人脑做数据库查询。

仪表盘图把这个查询过程外包给视觉系统——绿色区域就是正常,红色区域就是异常,不需要任何记忆和计算。

LiveCharts 2 的仪表盘实现方式与直觉不同

很多开发者第一次接触 LiveCharts 2 的仪表盘时会困惑:它没有专门的 GaugeChart 控件,而是用 PieChart + GaugeBuilder 来实现。以前是用GaugeBuilder 是一个工厂类,它把普通的饼图系列组合成仪表盘的视觉形态,理解这个设计后,配置就会清晰很多。

核心数据类型是 GaugeItem,它代表仪表盘上的一个"填充段"。通过组合多个 GaugeItem,可以实现分段色阶、背景轨道、当前值指示等效果。

常见误区:把 PieChart 的属性搬到仪表盘上

PieChartInitialRotationMaxAngle 两个属性在仪表盘场景里至关重要——InitialRotation 控制仪表盘的起始角度(通常设为 -225° 让仪表从左下角开始),MaxAngle 控制仪表盘的总弧度(通常设为 270° 形成经典的 3/4 圆弧)。很多开发者忽略这两个属性,结果仪表盘变成了一个完整的圆饼,失去了仪表的形态感。


💡 核心要点提炼

GaugeBuilder 这个在以前版本可以用,现在正式版本API 改为 GaugeGenerator.BuildSolidGauge() + GaugeItem,并且入口命名空间变成了 LiveChartsCore.SkiaSharpView.Extensions

几个关键属性值得重点理解:

  • InnerRadius:仪表盘内圆半径,控制仪表的"厚度"感,值越大仪表弧越细
  • MaxRadialColumnWidth:弧最大宽度
  • OuterRadiusOffset:控制弧外边缘与控件最大半径之间的留白距离,多层嵌套仪表盘时用于分层错开。

GaugeItem 的第一个参数是数值,第二个参数是该段的画笔(颜色),通过组合多个 GaugeItem 实现分段色阶。


🛠️ 方案一:基础单值仪表盘

场景描述

展示单台设备的转速,量程 0~3000rpm,当前值动态刷新,经典的半圆仪表盘形态。

csharp
using LiveChartsCore; using LiveChartsCore.Measure; using LiveChartsCore.SkiaSharpView.Extensions; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; namespace AppLiveChart19 { public partial class Form1 : Form { private readonly System.Windows.Forms.Timer _timer; private readonly Random _rng = new Random(); // 用普通 double 字段存当前转速,手动刷新 Series private double _currentRpm = 1200; private PieChart _chart = null!; private Label _label = null!; public Form1() { InitializeComponent(); InitGaugeChart(); _timer = new System.Windows.Forms.Timer { Interval = 1000 }; _timer.Tick += OnTick; _timer.Start(); } private IEnumerable<ISeries> BuildSeries(double rpm) { return GaugeGenerator.BuildSolidGauge( // 前景弧:当前转速值 new GaugeItem(rpm, series => { series.Fill = new SolidColorPaint(new SKColor(33, 150, 243)); series.InnerRadius = 60; series.DataLabelsSize = 0; // 不在弧上显示数字,用 Label 代替 }), // 背景轨道 new GaugeItem(GaugeItem.Background, series => { series.Fill = new SolidColorPaint(new SKColor(230, 230, 230)); series.InnerRadius = 60; }) ); } private void InitGaugeChart() { Text = "设备转速监控 - 单值仪表盘"; Size = new System.Drawing.Size(400, 380); _chart = new PieChart { Dock = DockStyle.Fill, Series = BuildSeries(_currentRpm), InitialRotation = -225, MaxAngle = 270, MinValue = 0, MaxValue = 3000, LegendPosition = LegendPosition.Hidden }; Controls.Add(_chart); _label = new Label { AutoSize = false, TextAlign = System.Drawing.ContentAlignment.MiddleCenter, Font = new System.Drawing.Font("微软雅黑", 18f, System.Drawing.FontStyle.Bold), ForeColor = System.Drawing.Color.FromArgb(33, 150, 243), BackColor = System.Drawing.Color.Transparent, Text = "1200\nrpm", Size = new System.Drawing.Size(150, 80) }; void PositionLabel() { _label.Location = new System.Drawing.Point( (ClientSize.Width - _label.Width) / 2, (ClientSize.Height - _label.Height) / 2 + 10 ); } PositionLabel(); Resize += (s, e) => PositionLabel(); Controls.Add(_label); _label.BringToFront(); } private void OnTick(object? sender, EventArgs e) { double newRpm = 1500 + Math.Sin(DateTime.Now.Second * 0.5) * 600 + _rng.NextDouble() * 200 - 100; newRpm = Math.Clamp(newRpm, 0, 3000); _currentRpm = Math.Round(newRpm); // ✅ 重新构建 Series 让图表刷新 _chart.Series = BuildSeries(_currentRpm); _label.Text = $"{_currentRpm:F0}\nrpm"; _label.ForeColor = _currentRpm > 2500 ? System.Drawing.Color.FromArgb(244, 67, 54) : System.Drawing.Color.FromArgb(33, 150, 243); } protected override void OnFormClosed(FormClosedEventArgs e) { _timer?.Stop(); _timer?.Dispose(); base.OnFormClosed(e); } } }

image.png

踩坑预警

InitialRotation = -225MaxAngle = 270 这两个值是配套的,缺一不可。只设 MaxAngle 不设 InitialRotation,仪表盘会从右侧水平方向开始,形态怪异。只设 InitialRotation 不设 MaxAngle,仪表盘会是完整的圆。两个值要一起配置才能得到经典的 3/4 圆弧仪表形态。