编辑
2026-06-08
C#
0

你有没有想过,那些屏幕录制软件、自动化测试工具、游戏辅助程序是如何捕获鼠标操作的?答案就是Windows钩子技术。但在实际开发中,很多C#开发者会遇到这样的困境:网上的示例代码要么不完整,要么运行后鼠标卡顿,要么根本捕获不到双击事件。

本文将带你从零开始,构建一个真正可用的鼠标双击监控程序,并解决开发过程中的各种坑点。无论是做行为分析工具、自动化测试,还是用户体验优化,这套方案都能直接套用。


🎯 需求场景分析

在企业级应用中,鼠标双击监控有这些典型场景:

场景一:用户行为分析

产品经理想知道用户最常双击哪个按钮,以优化交互设计。

场景二:自动化测试

测试团队需要录制用户操作并回放,验证软件稳定性。

场景三:辅助功能开发

为行动不便的用户提供自定义双击热区,提升可访问性。

传统方案是在每个控件上绑定事件,但这有明显局限:

  • ❌ 只能监控本应用内的控件
  • ❌ 无法捕获系统桌面或其他应用的操作
  • ❌ 需要逐个控件注册,代码冗余

而Windows全局钩子可以完美解决这些问题!


🔧 技术方案选择

方案对比

方案优点缺点适用场景
控件事件简单易用仅限本应用普通UI交互
EasyHook库功能强大学习成本高,依赖第三方复杂注入场景
Windows API钩子全局监控,轻量级需要理解Win32 API✅ 本文方案

我们选择直接调用Windows API,原因是:

  • ✅ 零依赖,不需要安装NuGet包
  • ✅ 性能最优,没有中间层损耗
  • ✅ 完全可控,出问题容易排查

💻 核心实现原理

什么是低级鼠标钩子?

Windows提供了SetWindowsHookEx函数,可以在消息队列中插入一个"监听器"。每当鼠标事件发生时,系统会先通知你的钩子函数,然后你可以:

  1. 记录事件信息
  2. 修改或阻止事件
  3. 传递给下一个钩子

关键参数说明:

c#
SetWindowsHookEx( WH_MOUSE_LL, // 14 = 低级鼠标钩子 hookCallback, // 你的回调函数 hModule, // 模块句柄 0 // 0 = 全局钩子 )

⚠️ 常见陷阱与解决方案

陷阱1:鼠标移动卡顿

❌ 错误代码:

c#
private IntPtr MouseCallback(int nCode, IntPtr wParam, IntPtr lParam) { // 直接在钩子里处理耗时操作 MessageBox.Show("双击了!"); // 阻塞钩子链 return CallNextHookEx(... ); }

✅ 正确做法:

c#
private IntPtr MouseCallback(int nCode, IntPtr wParam, IntPtr lParam) { // 快速处理,立即传递 ThreadPool.QueueUserWorkItem(state => { MessageBox.Show("双击了!"); // 放到线程池执行 }); return CallNextHookEx(...); // 立即返回 }

陷阱2:捕获不到双击消息ws的WM_LBUTTONDBLCLK(0x0203)消息并非总是触发,因为:

  • 系统双击时间设置过短
  • 两次点击位置偏移超过容差
  • 某些应用拦截了双击消息

🚀 完整代码实现

第一步:项目配置

创建. NET控制台项目(或WinForms项目):

关键配置说明:

  • OutputType=WinExe:隐藏控制台窗口(可选)
  • UseWindowsForms=true:启用消息循环支持
  • PlatformTarget=x64:64位系统必须匹配

我这里用的是.NETFramework,Version=v4.7.2

编辑
2026-06-08
C#
0

🏭 开篇:排程可视化,真的有那么难吗?

在工厂数字化项目里,生产排程可视化是一个绕不开的需求。产线经理想知道每台设备今天排了几个工单、什么时候空出来;调度员需要直观地看到工序之间的时间冲突;质检人员要对照计划核查实际完工节点。

问题在于——大多数开发者面对"甘特图"这三个字,第一反应是去找第三方报表控件,结果发现收费、集成复杂、样式难以定制,或者直接用 Excel 糊弄了事。

实际上,用 WinForms + LiveCharts 2,完全可以在一个下午搭出一个可交互、可动态刷新、样式可控的生产排程甘特图。本文会带你从零走完整个过程:环境搭建 → 数据建模 → 基础甘特渲染 → 动态刷新与交互增强,每一步都有可直接运行的代码。

读完本文,你将掌握:

  • 用 RowSeries 模拟甘特条形的核心思路
  • 多工序、多设备的数据组织方式
  • 工单状态颜色映射与动态刷新的实现方法

🔍 问题深度剖析:为什么甘特图"不好做"?

甘特图的本质是什么?

甘特图本质上是一种时间轴 × 资源轴的二维矩形分布图。X 轴是时间,Y 轴是资源(设备/人员/产线),每一个工单对应一个矩形块,矩形的左边界是开始时间,右边界是结束时间,高度表示资源占用。

听起来简单,但标准折线图、柱状图的数据结构完全对不上这个需求。常见误区有三个:

误区一:直接用 BarSeries 硬堆。普通柱状图的 X 轴是类别,没有"起始偏移"的概念,画出来的条形永远从零开始,根本不是甘特图。

误区二:用 DataGridView 模拟。用格子背景色来"画"甘特图,像素级对齐是噩梦,时间粒度一变整个布局就崩。

误区三:引入重型报表控件。DevExpress、Telerik 的甘特组件功能强大,但授权费用高、学习曲线陡,对于内部工具类项目来说性价比极低。

LiveCharts 2 的切入点

LiveCharts 2 提供了 RowSeries<TModel>,配合自定义的 Mapping,可以把每个工单映射为一段有偏移的横向矩形——这正是甘特图的几何本质。关键在于理解 LiveCharts 2 的坐标系:

  • PrimaryValue(Y 轴):资源编号(设备索引)
  • SecondaryValue(X 轴):时间戳(转换为数值)
  • TertiaryValue:条形的宽度(持续时长)

这三个维度组合起来,就能精确描述一个甘特条。


💡 核心要点提炼

数据模型设计

在开始写图表代码之前,先把数据模型定义清楚,后续所有逻辑都围绕它展开。

csharp
/// <summary> /// 工单排程实体 /// </summary> public class ProductionOrder { public string OrderId { get; set; } // 工单编号 public string MachineName { get; set; } // 设备名称 public int MachineIndex { get; set; } // 设备在 Y 轴的索引 public DateTime StartTime { get; set; } // 计划开始时间 public DateTime EndTime { get; set; } // 计划结束时间 public OrderStatus Status { get; set; } // 工单状态 public string ProductName { get; set; } // 产品名称 } public enum OrderStatus { Pending, // 待生产 Running, // 生产中 Completed, // 已完成 Delayed // 已延期 }

时间到数值的映射

LiveCharts 2 的坐标轴本质上是 double,所以需要把 DateTime 转换为一个统一的数值基准。这里选用"距当天零点的分钟数"作为单位,既直观又方便后续格式化显示。

csharp
// 基准时间(当天零点) private static readonly DateTime BaseTime = DateTime.Today; // DateTime → double(分钟数) public static double ToMinutes(DateTime dt) => (dt - BaseTime).TotalMinutes; // double → DateTime(反向格式化用) public static DateTime FromMinutes(double minutes) => BaseTime.AddMinutes(minutes);

🚀 解决方案一:基础甘特图渲染

环境准备

新建一个 WinForms 项目(.NET 6 或 .NET 8),通过 NuGet 安装以下包:

LiveChartsCore.SkiaSharpView.WinForms

Form1.Designer.cs 中拖入一个 CartesianChart 控件,或直接在代码中动态添加:

csharp
using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.WinForms; using LiveChartsCore.SkiaSharpView.Painting; using SkiaSharp;

核心渲染代码

csharp
using LiveChartsCore; using LiveChartsCore.Measure; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; namespace AppLiveChart20 { public partial class Form1 : Form { private CartesianChart _chart; private List<string> _machineLabels = new(); public Form1() { InitializeComponent(); InitChart(); LoadScheduleData(); } private void InitChart() { _chart = new CartesianChart { Dock = DockStyle.Fill, // 关闭动画,生产场景下刷新更流畅 AnimationsSpeed = TimeSpan.FromMilliseconds(300), EasingFunction = EasingFunctions.BounceInOut }; this.Controls.Add(_chart); } private void LoadScheduleData() { // 模拟生产排程数据 var orders = new List<ProductionOrder> { new() { OrderId="WO-001", MachineName="CNC-01", MachineIndex=0, StartTime=DateTime.Today.AddHours(8), EndTime=DateTime.Today.AddHours(10.5), Status=OrderStatus.Completed, ProductName="轴套A" }, new() { OrderId="WO-002", MachineName="CNC-01", MachineIndex=0, StartTime=DateTime.Today.AddHours(11), EndTime=DateTime.Today.AddHours(14), Status=OrderStatus.Running, ProductName="齿轮B" }, new() { OrderId="WO-003", MachineName="CNC-02", MachineIndex=1, StartTime=DateTime.Today.AddHours(8), EndTime=DateTime.Today.AddHours(12), Status=OrderStatus.Running, ProductName="法兰C" }, new() { OrderId="WO-004", MachineName="装配线-1", MachineIndex=2, StartTime=DateTime.Today.AddHours(13), EndTime=DateTime.Today.AddHours(17), Status=OrderStatus.Pending, ProductName="总成D" }, new() { OrderId="WO-005", MachineName="CNC-02", MachineIndex=1, StartTime=DateTime.Today.AddHours(13), EndTime=DateTime.Today.AddHours(15.5), Status=OrderStatus.Delayed, ProductName="衬套E" }, }; _machineLabels = orders .OrderBy(o => o.MachineIndex) .Select(o => o.MachineName) .Distinct() .ToList(); RenderGantt(orders); } private void RenderGantt(List<ProductionOrder> orders) { // 按状态分组,每种状态一个 RowSeries(便于统一着色) var seriesMap = new Dictionary<OrderStatus, List<GanttPoint>>(); foreach (var order in orders) { if (!seriesMap.ContainsKey(order.Status)) seriesMap[order.Status] = new List<GanttPoint>(); seriesMap[order.Status].Add(new GanttPoint { MachineIndex = order.MachineIndex, StartMinutes = ToMinutes(order.StartTime), DurationMinutes = (order.EndTime - order.StartTime).TotalMinutes, Label = $"{order.OrderId}\n{order.ProductName}" }); } var seriesList = new List<ISeries>(); foreach (var (status, points) in seriesMap) { var color = GetStatusColor(status); var series = new RowSeries<GanttPoint> { Values = points, Name = status.ToString(), Mapping = (point, index) => new(point.MachineIndex, point.StartMinutes, point.DurationMinutes), Fill = new SolidColorPaint(color), Stroke = new SolidColorPaint(SKColors.White) { StrokeThickness = 1 }, MaxBarWidth = 28, DataLabelsPaint = new SolidColorPaint(SKColors.White), DataLabelsSize = 10, DataLabelsPosition = DataLabelsPosition.Middle, DataLabelsFormatter = (point) => { var gp = points[(int)point.Context.Entity.MetaData!.EntityIndex]; return gp.Label; } }; seriesList.Add(series); } // 配置 X 轴(时间轴) var xAxis = new Axis { Name = "时间", MinLimit = ToMinutes(DateTime.Today.AddHours(7)), MaxLimit = ToMinutes(DateTime.Today.AddHours(20)), Labeler = value => { var dt = FromMinutes(value); return dt.ToString("HH:mm"); }, UnitWidth = 30, // 每30分钟一个刻度单位 MinStep = 30 }; // 配置 Y 轴(设备轴) var yAxis = new Axis { Name = "设备", Labels = _machineLabels, MinLimit = -0.5, MaxLimit = _machineLabels.Count - 0.5 }; _chart.Series = seriesList.ToArray(); _chart.XAxes = new[] { xAxis }; _chart.YAxes = new[] { yAxis }; } // 工单状态 → 颜色映射 private static SKColor GetStatusColor(OrderStatus status) => status switch { OrderStatus.Completed => new SKColor(76, 175, 80), // 绿色 OrderStatus.Running => new SKColor(33, 150, 243), // 蓝色 OrderStatus.Pending => new SKColor(158, 158, 158), // 灰色 OrderStatus.Delayed => new SKColor(244, 67, 54), // 红色 _ => SKColors.LightBlue }; private static double ToMinutes(DateTime dt) => (dt - DateTime.Today).TotalMinutes; private static DateTime FromMinutes(double m) => DateTime.Today.AddMinutes(m); } }
c#
using System; using System.Collections.Generic; using System.Text; namespace AppLiveChart20 { // 甘特图数据点 public class GanttPoint { public int MachineIndex { get; set; } public double StartMinutes { get; set; } public double DurationMinutes { get; set; } public string Label { get; set; } = ""; } }

image.png

运行之后,你会看到一个按设备分行、按时间段展开的彩色横向条形图,不同状态的工单用不同颜色区分——这已经是一个完整可用的甘特图雏形了。

编辑
2026-06-08
Python
0

🏭 你真的选对框架了吗?

做工控软件这行,我见过太多人在框架选型上走弯路。有个做注塑机控制系统的朋友,项目做到一半才发现自己选的框架根本撑不住实时数据刷新——界面卡得像PPT,客户现场演示当场翻车。

工业上位机不是普通桌面软件。它要同时处理串口数据、Modbus通信、实时曲线绘制,还得保证界面响应不能掉链子。这种场景下,框架选错了,后期重构的代价比从头写还高。

咱们今天就把这个问题掰开了说清楚。


🎯 工业上位机的特殊需求

在聊具体框架之前,先得搞清楚工控界面到底有哪些"特殊癖好"——这跟做个普通管理系统完全不是一回事。

实时性要求苛刻。 传感器数据可能100ms刷新一次,界面必须跟得上,不能有明显的视觉延迟。

数据可视化密集。 温度曲线、压力波形、设备状态图——这些东西不是随便画个折线图就完事的,得支持大数据量高频更新。

稳定性是命根子。 工厂环境里,软件崩一次可能导致生产线停工,损失直接按小时算。

Windows部署为主。 大多数工控现场还是Windows 7/10环境,有些甚至是XP(别笑,真的存在)。

操作员不是程序员。 界面要足够直观,触摸屏友好,字体要大,按钮要明显。

带着这些需求,我们来看几个主流方案。


🔧 框架横评:四个选手上场

1️⃣ PyQt5/PyQt6 —— 工控界的"老大哥"

说实话,如果你问我工控领域用哪个框架最稳,我会毫不犹豫说PyQt。不是因为它完美,而是因为它踩过的坑都被前人填好了

Qt本身就是为工业和嵌入式场景设计的,背后是诺基亚和西门子这些老牌工业公司多年投入的成果。PyQt只是给它套了层Python外衣。

python
import sys from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QLabel) from PyQt5.QtCore import QTimer, Qt from PyQt5.QtGui import QFont import pyqtgraph as pg import random class IndustrialMonitor(QMainWindow): """工业监控主窗口 - 实时数据显示示例""" def __init__(self): super().__init__() self.setWindowTitle("设备监控系统 v2.1") self.setMinimumSize(1200, 800) # 数据缓冲区,保留最近500个点 self.data_buffer = [] self.max_points = 500 self._setup_ui() self._start_data_timer() def _setup_ui(self): central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) # 状态标签 self.status_label = QLabel("设备状态:运行中") font = QFont("Microsoft YaHei", 14, QFont.Bold) self.status_label.setFont(font) self.status_label.setStyleSheet("color: #00AA00; padding: 8px;") layout.addWidget(self.status_label) # 实时曲线图(pyqtgraph 比 matplotlib 快10倍以上) self.plot_widget = pg.PlotWidget(title="主轴温度实时曲线") self.plot_widget.setBackground('#1a1a2e') self.plot_widget.showGrid(x=True, y=True, alpha=0.3) self.plot_widget.setLabel('left', '温度', units='°C') self.plot_widget.setLabel('bottom', '时间', units='s') # 设置Y轴范围(工控场景通常有明确的量程) self.plot_widget.setYRange(0, 150) self.curve = self.plot_widget.plot( pen=pg.mkPen(color='#00d4ff', width=2) ) layout.addWidget(self.plot_widget) def _start_data_timer(self): """100ms刷新一次,模拟真实采集频率""" self.timer = QTimer() self.timer.timeout.connect(self._update_data) self.timer.start(100) def _update_data(self): # 实际项目中这里替换为串口/Modbus读取 new_value = 75 + random.gauss(0, 3) self.data_buffer.append(new_value) if len(self.data_buffer) > self.max_points: self.data_buffer.pop(0) self.curve.setData(self.data_buffer) # 超温报警 if new_value > 100: self.status_label.setText(f"⚠️ 温度告警:{new_value:.1f}°C") self.status_label.setStyleSheet("color: #FF4444; padding: 8px;") else: self.status_label.setText(f"设备状态:正常 | 当前温度:{new_value:.1f}°C") self.status_label.setStyleSheet("color: #00AA00; padding: 8px;") if __name__ == '__main__': app = QApplication(sys.argv) window = IndustrialMonitor() window.show() sys.exit(app.exec_())

image.png

这里有个细节值得说一下:pyqtgraph而不是matplotlib。后者在高频刷新场景下会卡成狗,前者基于OpenGL渲染,100ms刷新500个数据点轻轻松松。这是工控开发里最常见的一个坑,我见过不止三个项目因为这个问题重写了图表模块。

PyQt的适用场景: 中大型上位机系统、需要自定义控件(仪表盘、指示灯、流程图)、有长期维护需求的项目。

需要注意的地方: 商业项目需要购买授权(或者用LGPL的PySide6替代),学习曲线比tkinter陡一些。

编辑
2026-06-07
C#
0

🤔 开篇:同一个列表,为什么每条数据长得不一样?

做过复杂列表界面的同学,应该都碰到过这种需求:同一个 ListBoxItemsControl,里面的数据类型不同,展示方式也要完全不同。比如消息流里,文本消息、图片消息、系统通知三种卡片布局差异巨大;再比如工单管理系统里,普通工单、紧急工单、已完成工单需要截然不同的视觉呈现。

遇到这种需求,很多人的第一反应是:在 DataTemplate 里堆 Visibility 控制,把所有布局塞进一个模板,然后用绑定来显示或隐藏不同区域。这种做法短期能跑,但随着类型增多,模板会变成一个庞然大物,改起来牵一发动全身。

实际项目统计表明,这类"万能模板"方案在类型超过 4 种后,维护成本会上升约 60%,且极易引入因 Visibility 判断遗漏导致的显示 Bug。

读完这篇文章,你将掌握:

  • DataTemplateSelector 的底层机制与正确使用姿势
  • 基于数据类型、数据属性值的两种选择策略
  • 结合真实场景的完整可运行实现,以及性能优化要点

🔍 问题深度剖析:为什么 DataTrigger 不够用?

误区:用 DataTrigger 硬撑多类型布局

先看一个典型的"反面教材",工单列表的常见错误实现:

xml
<!-- ❌ 用 Visibility 堆砌的"万能模板"——维护噩梦 --> <DataTemplate> <Grid> <!-- 普通工单区域 --> <StackPanel> <StackPanel.Style> <Style TargetType="StackPanel"> <Setter Property="Visibility" Value="Collapsed"/> <Style.Triggers> <DataTrigger Binding="{Binding OrderType}" Value="Normal"> <Setter Property="Visibility" Value="Visible"/> </DataTrigger> </Style.Triggers> </Style> </StackPanel.Style> <TextBlock Text="{Binding Title}" FontSize="14"/> <TextBlock Text="{Binding Description}" FontSize="12"/> </StackPanel> <!-- 紧急工单区域 --> <Border Background="Red"> <Border.Style> <Style TargetType="Border"> <Setter Property="Visibility" Value="Collapsed"/> <Style.Triggers> <DataTrigger Binding="{Binding OrderType}" Value="Urgent"> <Setter Property="Visibility" Value="Visible"/> </DataTrigger> </Style.Triggers> </Style> </Border.Style> <!-- 紧急工单的复杂布局... --> </Border> <!-- 已完成工单区域... 继续叠加 --> </Grid> </DataTemplate>

这种写法有三个致命问题:

第一,所有类型的控件同时存在于视觉树中,只是通过 Visibility 隐藏。即便用户看不见,WPF 依然会为隐藏的控件分配内存、参与布局计算,白白消耗资源。

第二,XAML 文件体积膨胀,单个 DataTemplate 动辄几百行,可读性趋近于零。

第三,类型之间的布局逻辑相互干扰,修改一种类型的样式时,稍不注意就会影响其他类型的显示状态。


💡 核心要点提炼:DataTemplateSelector 的底层机制

DataTemplateSelector 是 WPF 提供的一个抽象基类,它的职责非常单一:在运行时,根据数据对象和容器信息,决定返回哪个 DataTemplate

其核心方法签名如下:

csharp
public abstract DataTemplate SelectTemplate(object item, DependencyObject container);
  • item:当前要渲染的数据对象
  • container:承载该数据项的容器控件(如 ListBoxItem
  • 返回值:一个 DataTemplate 实例,WPF 用它来渲染 item

整个调用链路是这样的:ItemsControl 在为每个数据项生成容器时,如果检测到绑定了 ItemTemplateSelector,就会调用 SelectTemplate,根据返回的模板生成对应的视觉树。每个数据项的视觉树是独立的,不存在隐藏控件占用资源的问题,这是它相比 Visibility 方案的根本优势。

DataTemplateSelector 有两种常见的选择策略:

  • 基于数据类型item is TypeA 返回模板 A,item is TypeB 返回模板 B,适合多态数据集合
  • 基于属性值:检查 item 的某个属性值,根据值范围或枚举返回不同模板,适合同一类型但状态差异大的场景

🛠️ 方案一:基于数据类型的模板选择

场景描述

消息流界面,包含三种消息类型:文本消息、图片消息、系统通知。三者共用一个 ItemsControl,但视觉布局完全不同。

数据模型定义

csharp
// 消息基类 public abstract class MessageBase : INotifyPropertyChanged { public DateTime Timestamp { get; set; } = DateTime.Now; public string SenderId { get; set; } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } // 文本消息 public class TextMessage : MessageBase { public string Content { get; set; } } // 图片消息 public class ImageMessage : MessageBase { private string _imageUrl; public string ImageUrl { get => _imageUrl; set { _imageUrl = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasImage)); } } public string Caption { get; set; } public double ImageWidth { get; set; } = 150; public double ImageHeight { get; set; } = 100; // 用于控制占位符显示 public bool HasImage => string.IsNullOrEmpty(ImageUrl); } // 系统通知 public class SystemNotification : MessageBase { public string NoticeText { get; set; } public NoticeLevel Level { get; set; } = NoticeLevel.Info; } public enum NoticeLevel { Info, Warning, Error }

模板选择器实现

csharp
public class MessageTemplateSelector : DataTemplateSelector { public DataTemplate TextMessageTemplate { get; set; } public DataTemplate ImageMessageTemplate { get; set; } public DataTemplate SystemNotificationTemplate { get; set; } public override DataTemplate SelectTemplate(object item, DependencyObject container) { return item switch { TextMessage => TextMessageTemplate, ImageMessage => ImageMessageTemplate, SystemNotification => SystemNotificationTemplate, _ => base.SelectTemplate(item, container) }; } }

XAML 模板定义与绑定

xml
<Window x:Class="AppTemplateSelecter.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:AppTemplateSelecter" mc:Ignorable="d" Title="Template Selector Demo" Height="600" Width="800" WindowStartupLocation="CenterScreen"> <Window.Resources> <local:NullToCollapsedConverter x:Key="NullToCollapsedConverter"/> <local:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter"/> <DataTemplate x:Key="TextMsgTemplate"> <Border Background="#F0F4FF" CornerRadius="8" Padding="12,8" Margin="4,2" MaxWidth="400" HorizontalAlignment="Left"> <StackPanel> <TextBlock Text="{Binding SenderId}" FontSize="11" Foreground="#757575" FontWeight="SemiBold"/> <TextBlock Text="{Binding Content}" FontSize="14" Foreground="#212121" TextWrapping="Wrap" Margin="0,4,0,0"/> <TextBlock Text="{Binding Timestamp, StringFormat='HH:mm'}" FontSize="10" Foreground="#BDBDBD" HorizontalAlignment="Right" Margin="0,4,0,0"/> </StackPanel> </Border> </DataTemplate> <DataTemplate x:Key="ImageMsgTemplate"> <Border Background="#FAFAFA" CornerRadius="8" Padding="8" Margin="4,2" MaxWidth="300" HorizontalAlignment="Left"> <StackPanel> <TextBlock Text="{Binding SenderId}" FontSize="11" Foreground="#757575" FontWeight="SemiBold"/> <Border CornerRadius="4" Margin="0,6,0,0" Background="#E0E0E0"> <Grid Width="{Binding ImageWidth}" Height="{Binding ImageHeight}"> <Image Source="{Binding ImageUrl}" Stretch="UniformToFill" Visibility="{Binding ImageUrl, Converter={StaticResource NullToCollapsedConverter}}"/> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="{Binding HasImage, Converter={StaticResource BoolToVisibilityConverter}}"> <TextBlock Text="🖼️" FontSize="24" HorizontalAlignment="Center"/> <TextBlock Text="Image Placeholder" FontSize="10" Foreground="#757575" HorizontalAlignment="Center"/> </StackPanel> </Grid> </Border> <TextBlock Text="{Binding Caption}" FontSize="12" Foreground="#616161" Margin="0,4,0,0" TextWrapping="Wrap" Visibility="{Binding Caption, Converter={StaticResource NullToCollapsedConverter}}"/> <TextBlock Text="{Binding Timestamp, StringFormat='HH:mm'}" FontSize="10" Foreground="#BDBDBD" HorizontalAlignment="Right" Margin="0,4,0,0"/> </StackPanel> </Border> </DataTemplate> <DataTemplate x:Key="SystemNoticeTemplate"> <Border CornerRadius="4" Padding="10,6" Margin="20,4"> <Border.Style> <Style TargetType="Border"> <Setter Property="Background" Value="#E3F2FD"/> <Style.Triggers> <DataTrigger Binding="{Binding Level}" Value="{x:Static local:NoticeLevel.Warning}"> <Setter Property="Background" Value="#FFF8E1"/> </DataTrigger> <DataTrigger Binding="{Binding Level}" Value="{x:Static local:NoticeLevel.Error}"> <Setter Property="Background" Value="#FFEBEE"/> </DataTrigger> </Style.Triggers> </Style> </Border.Style> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> <!-- 图标 --> <TextBlock Margin="0,0,8,0" VerticalAlignment="Center"> <TextBlock.Style> <Style TargetType="TextBlock"> <Setter Property="Text" Value="ℹ️"/> <Setter Property="Foreground" Value="#2196F3"/> <Style.Triggers> <DataTrigger Binding="{Binding Level}" Value="{x:Static local:NoticeLevel.Warning}"> <Setter Property="Text" Value="⚠️"/> <Setter Property="Foreground" Value="#FF9800"/> </DataTrigger> <DataTrigger Binding="{Binding Level}" Value="{x:Static local:NoticeLevel.Error}"> <Setter Property="Text" Value="❌"/> <Setter Property="Foreground" Value="#F44336"/> </DataTrigger> </Style.Triggers> </Style> </TextBlock.Style> </TextBlock> <TextBlock Text="{Binding NoticeText}" FontSize="12" VerticalAlignment="Center" Foreground="#546E7A"/> </StackPanel> </Border> </DataTemplate> <local:MessageTemplateSelector x:Key="MsgSelector" TextMessageTemplate="{StaticResource TextMsgTemplate}" ImageMessageTemplate="{StaticResource ImageMsgTemplate}" SystemNotificationTemplate="{StaticResource SystemNoticeTemplate}"/> </Window.Resources> <Grid> <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto" Padding="10"> <ItemsControl x:Name="MessageList" ItemsSource="{Binding Messages}" ItemTemplateSelector="{StaticResource MsgSelector}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <StackPanel/> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> </ItemsControl> </ScrollViewer> <Border Grid.Row="1" Background="#F5F5F5" Padding="10"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> <Button Name="BtnAddText" Content="Add Text Message" Margin="5" Padding="10,5" MinWidth="120" Background="#2196F3" Foreground="White" BorderThickness="0" /> <Button Name="BtnAddImage" Content="Add Image Message" Margin="5" Padding="10,5" MinWidth="120" Background="#4CAF50" Foreground="White" BorderThickness="0" /> <Button Name="BtnAddNotice" Content="Add System Notice" Margin="5" Padding="10,5" MinWidth="120" Background="#FF9800" Foreground="White" BorderThickness="0"/> <Button Name="BtnClear" Content="Clear All" Margin="5" Padding="10,5" MinWidth="120" Background="#F44336" Foreground="White" BorderThickness="0"/> </StackPanel> </Border> </Grid> </Window>

image.png

这里有个值得关注的细节:系统通知模板内部仍然使用了 DataTrigger 来处理不同级别的背景色变化。DataTemplateSelectorDataTrigger 不是互斥关系,而是互补的——前者处理"用哪个模板",后者处理"模板内部的状态变化",两者组合使用才是最优解。

编辑
2026-06-05
C#
0

注塑车间的质检程序里,有一段判断产品是否合格的逻辑。

你写了一遍,能用。

然后领导说:"冲压线也要加这个判断。"你又复制了一遍。

焊接线要加?再复制。

三条线改完,参数阈值调整,你要找三处去改……

改漏了一处,生产线报了假警,被领导点名。

这不是你粗心,是你还没用上"方法"这把利器。

学完今天这节,这种情况彻底消失。


📌 上节回顾

「上一节我们学了 Dictionary<K,V> 字典集合,掌握了用"键值对"快速存取设备数据的方法。

今天在这个基础上,我们进一步学习如何把重复逻辑封装成方法,让代码可以反复调用、灵活传参。」


💡 核心知识讲解

方法是什么?用工厂类比秒懂

你可以把**方法(Method)**想象成车间里的一台专用设备。

你把原材料(参数)送进去,设备加工完,把成品(返回值)送出来。

每次需要加工,你不用重新造一台设备,直接调用就行。

这就是方法的核心价值:定义一次,调用多次,逻辑集中,易于维护。


方法的基本结构,拆开看就这几块

一个完整的方法长这样:

访问修饰符 返回类型 方法名(参数列表) { // 方法体:具体逻辑 return 返回值; }
部分作用工厂类比
访问修饰符控制谁能调用车间门禁权限
返回类型告诉调用者结果是什么类型成品规格说明
参数列表调用时传入的数据投料口
方法体实际执行的逻辑加工工序
return把结果送出去出料口

「记住:如果方法不需要返回任何结果,返回类型写 void(空的意思),就像有些设备只做动作、不出料。」


参数:向方法"投料"的方式

参数分两种你最常用的:

① 普通参数(值传递)——把数值的"副本"传进去,原始数据不受影响。就像你给设备一份加工单的复印件,原件还在你手里。

② 默认参数——调用时可以不传,方法里有预设值。就像设备有默认转速,不特别指定就按默认跑。

csharp
// 有默认参数的方法示例 static bool CheckTemperature(double deviceTemp, double alarmThreshold = 85.0) { return deviceTemp >= alarmThreshold; }

调用时可以这样写:

  • CheckTemperature(92.5) — 用默认阈值 85°C 判断
  • CheckTemperature(92.5, 90.0) — 自定义阈值 90°C

「默认参数必须放在参数列表的最后面,不能放在普通参数前面,否则编译器会报错。」