编辑
2026-06-02
C#
0

目录

🏗️ 从零到可演示,一周够不够?
🔍 问题根源:Demo 做不出来,卡在哪里?
1️⃣ 架构想太多,动手太少
2️⃣ 三个模块互相等待,无法并行
3️⃣ 数据库选型拖慢节奏
💡 核心设计:MVP 的模块边界与数据流
整体架构思路
统一数据模型
🛠️ 方案一:采集模块(定时轮询 + 线程安全队列)
采集服务核心实现
为什么用 ConcurrentQueue 而不是普通 Queue
🚨 方案二:告警模块(规则引擎 + 状态机)
告警规则定义
告警处理服务
📦 方案三:追溯模块(SQLite + 轻量查询)
SQLite 数据库初始化
🖥️ WPF 界面层:ViewModel 把三个模块串起来
🖼️UI样式
⚠️ 踩坑预警:一周冲刺里最容易翻的几个坑
📊 一周开发节奏建议
🎯 三句话核心洞察
📚 结尾:Demo 是起点,不是终点

🏗️ 从零到可演示,一周够不够?

接手一个新的工控项目,甲方第一句话往往是:"能不能先给我看个 Demo?"

这句话背后的潜台词是:我不想在一个看不见摸不着的方案上押注,我要看到真实的东西跑起来。

这个需求合理,但对开发者来说压力不小。采集、告警、追溯——这三个模块单独拿出来都不简单,凑在一起还要在一周内跑通,很多人第一反应是"时间不够"。

但其实,MVP(最小可用版本)的核心不是功能完整,而是流程跑通。采集能读到数据,告警能触发提示,追溯能查到历史——这三件事做到,Demo 就成立了。

读完本文,你将掌握:

  • 一套适合一周落地的 WPF 上位机 MVP 架构设计
  • 采集模块的定时轮询实现与线程安全处理
  • 告警模块的规则引擎雏形与状态机管理
  • 基于 SQLite 的轻量追溯方案,零部署成本

全文约 3800 字,代码可直接运行,建议收藏对照项目使用。


🔍 问题根源:Demo 做不出来,卡在哪里?

1️⃣ 架构想太多,动手太少

做 Demo 最常见的死法,是在动手之前把架构设计得过于完美。微服务、消息队列、分布式存储……这些东西在生产环境有价值,但在 Demo 阶段是纯粹的负担。

我在项目中见过一个团队,花了两周讨论技术选型,结果 Demo 演示日到了,界面还没有。过度设计是 Demo 的头号杀手。

2️⃣ 三个模块互相等待,无法并行

采集、告警、追溯三个模块存在依赖关系——告警依赖采集的数据,追溯依赖告警和采集的记录。如果按顺序开发,后两个模块永远在等前一个模块"完善"。

正确的做法是:先定义好模块间的数据契约(接口和数据结构),然后用 Mock 数据让各模块独立开发、独立调试,最后再接真实数据。

3️⃣ 数据库选型拖慢节奏

很多人一上来就想用 SQL Server 或 MySQL,结果光环境配置就花掉半天。Demo 阶段用 SQLite 完全够用——文件型数据库,零安装,NuGet 一个包搞定,部署时直接把 .db 文件带走。


💡 核心设计:MVP 的模块边界与数据流

整体架构思路

咱们这个 Demo 系统的数据流是这样的:

定时采集 → 数据缓冲队列 → 告警规则引擎 → 告警状态机 ↓ ↓ SQLite 采集记录 SQLite 告警记录 ↓ ↓ 追溯查询界面

采集层负责定时读取设备数据(Demo 阶段用随机数模拟),推入一个线程安全的队列。告警层消费队列数据,对照规则表判断是否触发告警,并维护每条告警的状态(触发 → 确认 → 消除)。追溯层是纯查询,从 SQLite 里按时间段、按设备、按告警类型检索历史记录。

三层之间只通过数据模型和接口交互,互不依赖实现细节,这样才能并行开发。

统一数据模型

先把贯穿全系统的核心数据结构定义清楚:

csharp
/// <summary> /// 采集数据点:一次采集的最小单元 /// </summary> public class DataPoint { public int Id { get; set; } public string DeviceId { get; set; } // 设备编号 public string TagName { get; set; } // 测点名称,如 "Temperature" public double Value { get; set; } // 采集值 public string Unit { get; set; } // 单位,如 "℃" public DateTime Timestamp { get; set; } // 采集时间 public bool IsValid { get; set; } // 数据质量标志 } /// <summary> /// 告警记录:一条告警的完整生命周期 /// </summary> public class AlarmRecord { public int Id { get; set; } public string DeviceId { get; set; } public string TagName { get; set; } public string AlarmType { get; set; } // "HighHigh" / "High" / "Low" / "LowLow" public double TriggerValue { get; set; } // 触发时的实际值 public double ThresholdValue { get; set; } // 对应的阈值 public DateTime TriggeredAt { get; set; } public DateTime? AckedAt { get; set; } // 确认时间,null 表示未确认 public DateTime? ClearedAt { get; set; } // 消除时间,null 表示未消除 public AlarmState State { get; set; } } public enum AlarmState { Active, // 活跃:已触发,未确认 Acked, // 已确认:操作员知晓,但条件未消除 Cleared // 已消除:触发条件不再满足 }

🛠️ 方案一:采集模块(定时轮询 + 线程安全队列)

采集服务核心实现

csharp
using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Text; namespace AppWpf202611 { /// <summary> /// 采集服务:定时轮询设备数据,推入处理队列 /// Demo 阶段使用随机数模拟,生产环境替换 ReadFromDevice 方法即可 /// </summary> public class CollectionService : IDisposable { private readonly ConcurrentQueue<DataPoint> _dataQueue; private readonly IDataRepository _repository; private readonly List<DeviceConfig> _devices; private Timer? _timer; private readonly Random _rng = new Random(); public event Action<DataPoint>? DataCollected; // 采集间隔:Demo 阶段设 1 秒,生产环境按需调整 private const int INTERVAL_MS = 1000; public CollectionService( ConcurrentQueue<DataPoint> dataQueue, IDataRepository repository, List<DeviceConfig> devices) { _dataQueue = dataQueue; _repository = repository; _devices = devices; } public void Start() { _timer = new Timer(OnTick, null, 0, INTERVAL_MS); } private void OnTick(object state) { foreach (var device in _devices) { var point = ReadFromDevice(device); if (point == null) continue; // 推入队列供告警模块消费 _dataQueue.Enqueue(point); DataCollected?.Invoke(point); // 异步持久化,不阻塞采集线程 Task.Run(() => _repository.SaveDataPointAsync(point)); } } /// <summary> /// 模拟采集:生产环境替换为 Modbus/OPC-UA/串口读取 /// </summary> private DataPoint ReadFromDevice(DeviceConfig device) { try { // 模拟温度传感器:基准值 ± 随机波动 double value = device.BaseValue + (_rng.NextDouble() - 0.5) * device.Fluctuation; // 随机制造异常波动,提高告警触发概率 if (_rng.NextDouble() < 0.20) { if (device.TagName == "Temperature") { value += 12 + _rng.NextDouble() * 8; } else if (device.TagName == "Pressure") { value -= 20 + _rng.NextDouble() * 20; } } return new DataPoint { DeviceId = device.DeviceId, TagName = device.TagName, Value = Math.Round(value, 2), Unit = device.Unit, Timestamp = DateTime.Now, IsValid = true }; } catch (Exception) { // 采集失败:记录无效数据点,保持时序完整性 return new DataPoint { DeviceId = device.DeviceId, TagName = device.TagName, Value = 0, Timestamp = DateTime.Now, IsValid = false }; } } public void Stop() => _timer?.Change(Timeout.Infinite, Timeout.Infinite); public void Dispose() => _timer?.Dispose(); } /// <summary> /// 设备配置:Demo 阶段硬编码,生产环境从配置文件或数据库读取 /// </summary> public class DeviceConfig { public string DeviceId { get; set; } public string TagName { get; set; } public string Unit { get; set; } public double BaseValue { get; set; } // 模拟基准值 public double Fluctuation { get; set; } // 模拟波动范围 } }

为什么用 ConcurrentQueue 而不是普通 Queue

采集线程和告警处理线程是两个独立的执行上下文,普通 Queue 在多线程读写时需要手动加锁,稍不注意就死锁或数据错乱。ConcurrentQueue<T> 是 .NET 内置的无锁并发队列,生产者(采集)和消费者(告警)可以同时操作,不需要任何额外的同步代码。


🚨 方案二:告警模块(规则引擎 + 状态机)

告警模块是整个系统里逻辑最密集的部分。它需要做两件事:判断是否触发(规则引擎),以及管理告警的生命周期(状态机)。

告警规则定义

csharp
/// <summary> /// 告警规则:每条规则对应一个测点的一个告警条件 /// </summary> public class AlarmRule { public string DeviceId { get; set; } public string TagName { get; set; } public string AlarmType { get; set; } public double Threshold { get; set; } // 触发阈值 public double Deadband { get; set; } // 回差,防止值在阈值附近反复抖动触发 public bool IsHighAlarm { get; set; } // true=超上限告警,false=低于下限告警 /// <summary> /// 判断当前值是否触发告警 /// </summary> public bool IsTriggered(double value) => IsHighAlarm ? value >= Threshold : value <= Threshold; /// <summary> /// 判断告警是否已消除(含回差) /// </summary> public bool IsCleared(double value) => IsHighAlarm ? value < Threshold - Deadband : value > Threshold + Deadband; }

告警处理服务

csharp
/// <summary> /// 告警处理服务:消费采集队列,对照规则表管理告警状态 /// </summary> public class AlarmService { private readonly ConcurrentQueue<DataPoint> _dataQueue; private readonly IAlarmRepository _repository; private readonly List<AlarmRule> _rules; // 活跃告警字典:Key = "DeviceId_TagName_AlarmType" private readonly ConcurrentDictionary<string, AlarmRecord> _activeAlarms = new ConcurrentDictionary<string, AlarmRecord>(); // 告警变化事件,供 ViewModel 订阅刷新界面 public event Action<AlarmRecord> AlarmTriggered; public event Action<AlarmRecord> AlarmCleared; private CancellationTokenSource _cts; public AlarmService( ConcurrentQueue<DataPoint> dataQueue, IAlarmRepository repository, List<AlarmRule> rules) { _dataQueue = dataQueue; _repository = repository; _rules = rules; } public void Start() { _cts = new CancellationTokenSource(); Task.Run(() => ProcessLoop(_cts.Token)); } private async Task ProcessLoop(CancellationToken ct) { while (!ct.IsCancellationRequested) { // 批量消费队列,减少循环空转 while (_dataQueue.TryDequeue(out var point)) { await ProcessDataPoint(point); } await Task.Delay(200, ct); // 200ms 轮询一次队列 } } private async Task ProcessDataPoint(DataPoint point) { if (!point.IsValid) return; // 找出所有匹配该测点的规则 var matchedRules = _rules.Where(r => r.DeviceId == point.DeviceId && r.TagName == point.TagName); foreach (var rule in matchedRules) { var key = $"{rule.DeviceId}_{rule.TagName}_{rule.AlarmType}"; if (rule.IsTriggered(point.Value)) { // 告警触发:若该告警尚未活跃,则新建记录 if (!_activeAlarms.ContainsKey(key)) { var alarm = new AlarmRecord { DeviceId = rule.DeviceId, TagName = rule.TagName, AlarmType = rule.AlarmType, TriggerValue = point.Value, ThresholdValue = rule.Threshold, TriggeredAt = DateTime.Now, State = AlarmState.Active }; _activeAlarms[key] = alarm; await _repository.SaveAlarmAsync(alarm); // 通知 UI 层 AlarmTriggered?.Invoke(alarm); } } else if (rule.IsCleared(point.Value)) { // 告警消除:更新状态并从活跃字典移除 if (_activeAlarms.TryRemove(key, out var alarm)) { alarm.ClearedAt = DateTime.Now; alarm.State = AlarmState.Cleared; await _repository.UpdateAlarmAsync(alarm); AlarmCleared?.Invoke(alarm); } } } } /// <summary> /// 操作员确认告警 /// </summary> public async Task AcknowledgeAlarmAsync(int alarmId) { var key = _activeAlarms.Keys.FirstOrDefault(k => _activeAlarms[k].Id == alarmId); if (key != null && _activeAlarms.TryGetValue(key, out var alarm)) { alarm.AckedAt = DateTime.Now; alarm.State = AlarmState.Acked; await _repository.UpdateAlarmAsync(alarm); } } public void Stop() => _cts?.Cancel(); }

回差(Deadband)是告警系统里非常容易被忽视的细节。 如果没有回差,当测点值在阈值附近轻微抖动时,告警会在一秒内反复触发和消除,产生大量无效记录,操作员根本没法看。加了回差之后,触发阈值是 80℃,消除阈值就变成 78℃(回差 2℃),抖动问题自然消失。


📦 方案三:追溯模块(SQLite + 轻量查询)

SQLite 数据库初始化

csharp
using System; using System.Collections.Generic; using System.Text; using Dapper; using Microsoft.Data.Sqlite; namespace AppWpf202611 { /// <summary> /// SQLite 数据仓储:采集记录与告警记录的统一存储 /// 依赖:Microsoft.Data.Sqlite(NuGet 安装) /// </summary> public class SqliteRepository : IDataRepository, IAlarmRepository { private readonly string _connectionString; public SqliteRepository(string dbPath) { _connectionString = $"Data Source={dbPath}"; InitializeDatabase(); } private void InitializeDatabase() { using var conn = new SqliteConnection(_connectionString); conn.Open(); // 创建采集记录表 conn.Execute(@" CREATE TABLE IF NOT EXISTS DataPoints ( Id INTEGER PRIMARY KEY AUTOINCREMENT, DeviceId TEXT NOT NULL, TagName TEXT NOT NULL, Value REAL NOT NULL, Unit TEXT, Timestamp TEXT NOT NULL, IsValid INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX IF NOT EXISTS idx_dp_device_time ON DataPoints(DeviceId, Timestamp); "); // 创建告警记录表 conn.Execute(@" CREATE TABLE IF NOT EXISTS AlarmRecords ( Id INTEGER PRIMARY KEY AUTOINCREMENT, DeviceId TEXT NOT NULL, TagName TEXT NOT NULL, AlarmType TEXT NOT NULL, TriggerValue REAL NOT NULL, ThresholdValue REAL NOT NULL, TriggeredAt TEXT NOT NULL, AckedAt TEXT, ClearedAt TEXT, State INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_ar_device_time ON AlarmRecords(DeviceId, TriggeredAt); "); } /// <summary> /// 保存采集数据点 /// </summary> public async Task SaveDataPointAsync(DataPoint point) { using var conn = new SqliteConnection(_connectionString); await conn.ExecuteAsync(@" INSERT INTO DataPoints (DeviceId, TagName, Value, Unit, Timestamp, IsValid) VALUES (@DeviceId, @TagName, @Value, @Unit, @Timestamp, @IsValid)", point); } /// <summary> /// 追溯查询:按设备、测点、时间范围检索采集历史 /// </summary> public async Task<IEnumerable<DataPoint>> QueryDataPointsAsync( string deviceId, string tagName, DateTime from, DateTime to, int maxRows = 1000) { using var conn = new SqliteConnection(_connectionString); return await conn.QueryAsync<DataPoint>(@" SELECT * FROM DataPoints WHERE DeviceId = @DeviceId AND TagName = @TagName AND Timestamp BETWEEN @From AND @To AND IsValid = 1 ORDER BY Timestamp DESC LIMIT @MaxRows", new { DeviceId = deviceId, TagName = tagName, From = from, To = to, MaxRows = maxRows }); } /// <summary> /// 追溯查询:按设备和时间范围检索告警历史 /// </summary> public async Task<IEnumerable<AlarmRecord>> QueryAlarmsAsync( string deviceId, DateTime from, DateTime to) { using var conn = new SqliteConnection(_connectionString); return await conn.QueryAsync<AlarmRecord>(@" SELECT * FROM AlarmRecords WHERE DeviceId = @DeviceId AND TriggeredAt BETWEEN @From AND @To ORDER BY TriggeredAt DESC", new { DeviceId = deviceId, From = from, To = to }); } public async Task SaveAlarmAsync(AlarmRecord alarm) { using var conn = new SqliteConnection(_connectionString); alarm.Id = await conn.ExecuteScalarAsync<int>(@" INSERT INTO AlarmRecords (DeviceId, TagName, AlarmType, TriggerValue, ThresholdValue, TriggeredAt, State) VALUES (@DeviceId, @TagName, @AlarmType, @TriggerValue, @ThresholdValue, @TriggeredAt, @State); SELECT last_insert_rowid();", alarm); } public async Task UpdateAlarmAsync(AlarmRecord alarm) { using var conn = new SqliteConnection(_connectionString); await conn.ExecuteAsync(@" UPDATE AlarmRecords SET AckedAt = @AckedAt, ClearedAt = @ClearedAt, State = @State WHERE Id = @Id", alarm); } } }

依赖说明: 上述代码使用了 Dapper(轻量 ORM)和 Microsoft.Data.Sqlite,NuGet 安装命令:

<PackageReference Include="Dapper" Version="2.1.66" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.4" />

🖥️ WPF 界面层:ViewModel 把三个模块串起来

csharp
using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.ComponentModel; using System.Windows.Input; namespace AppWpf202611; public class MainViewModel : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; private void Notify(string n) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); private readonly CollectionService _collector; private readonly AlarmService _alarmService; private readonly SqliteRepository _repository; private readonly Dictionary<string, DataPoint> _liveDataIndex = new(); private readonly RelayCommand _startCommand; private readonly AsyncRelayCommand _queryTraceCommand; private readonly AsyncRelayCommand _ackAlarmCommand; private bool _isRunning; private int _totalCollected; private string _statusText = "系统待启动"; private string _selectedTraceDevice = "DEV-01"; private string _selectedTraceTag = "Temperature"; private DateTime _traceFrom = DateTime.Now.AddHours(-1); private DateTime _traceTo = DateTime.Now; private AlarmRecord? _selectedAlarm; public ObservableCollection<DataPoint> LiveData { get; } = new(); public ObservableCollection<AlarmRecord> ActiveAlarms { get; } = new(); public ObservableCollection<DataPoint> TraceResult { get; } = new(); public ObservableCollection<string> DeviceIds { get; } = new(); public ObservableCollection<string> TagNames { get; } = new(); public ICommand StartCommand => _startCommand; public ICommand QueryTraceCommand => _queryTraceCommand; public ICommand AckAlarmCommand => _ackAlarmCommand; public int TotalCollected { get => _totalCollected; set { if (_totalCollected == value) return; _totalCollected = value; Notify(nameof(TotalCollected)); } } public int ActiveAlarmCount => ActiveAlarms.Count; public string StatusText { get => _statusText; set { if (_statusText == value) return; _statusText = value; Notify(nameof(StatusText)); } } public string SelectedTraceDevice { get => _selectedTraceDevice; set { if (_selectedTraceDevice == value) return; _selectedTraceDevice = value; Notify(nameof(SelectedTraceDevice)); _queryTraceCommand.RaiseCanExecuteChanged(); } } public string SelectedTraceTag { get => _selectedTraceTag; set { if (_selectedTraceTag == value) return; _selectedTraceTag = value; Notify(nameof(SelectedTraceTag)); _queryTraceCommand.RaiseCanExecuteChanged(); } } public DateTime TraceFrom { get => _traceFrom; set { if (_traceFrom == value) return; _traceFrom = value; Notify(nameof(TraceFrom)); } } public DateTime TraceTo { get => _traceTo; set { if (_traceTo == value) return; _traceTo = value; Notify(nameof(TraceTo)); } } public AlarmRecord? SelectedAlarm { get => _selectedAlarm; set { _selectedAlarm = value; Notify(nameof(SelectedAlarm)); _ackAlarmCommand.RaiseCanExecuteChanged(); } } public MainViewModel() { var queue = new ConcurrentQueue<DataPoint>(); _repository = new SqliteRepository("demo.db"); var devices = new List<DeviceConfig> { new() { DeviceId = "DEV-01", TagName = "Temperature", Unit = "℃", BaseValue = 75, Fluctuation = 10 }, new() { DeviceId = "DEV-01", TagName = "Pressure", Unit = "kPa", BaseValue = 200, Fluctuation = 30 }, new() { DeviceId = "DEV-02", TagName = "Temperature", Unit = "℃", BaseValue = 60, Fluctuation = 8 } }; var rules = new List<AlarmRule> { new() { DeviceId = "DEV-01", TagName = "Temperature", AlarmType = "High", Threshold = 80, Deadband = 2, IsHighAlarm = true }, new() { DeviceId = "DEV-01", TagName = "Temperature", AlarmType = "HighHigh", Threshold = 90, Deadband = 2, IsHighAlarm = true }, new() { DeviceId = "DEV-01", TagName = "Pressure", AlarmType = "Low", Threshold = 170, Deadband = 5, IsHighAlarm = false } }; foreach (var deviceId in devices.Select(d => d.DeviceId).Distinct()) DeviceIds.Add(deviceId); foreach (var tagName in devices.Select(d => d.TagName).Distinct()) TagNames.Add(tagName); _collector = new CollectionService(queue, _repository, devices); _alarmService = new AlarmService(queue, _repository, rules); _collector.DataCollected += point => App.Current.Dispatcher.Invoke(() => UpdateLiveData(point)); _alarmService.AlarmTriggered += alarm => App.Current.Dispatcher.Invoke(() => { ActiveAlarms.Insert(0, alarm); Notify(nameof(ActiveAlarmCount)); }); _alarmService.AlarmCleared += alarm => App.Current.Dispatcher.Invoke(() => { var existing = ActiveAlarms.FirstOrDefault(a => a.Id == alarm.Id); if (existing != null) { ActiveAlarms.Remove(existing); Notify(nameof(ActiveAlarmCount)); } }); _startCommand = new RelayCommand(_ => StartSystem(), _ => !_isRunning); _queryTraceCommand = new AsyncRelayCommand(_ => QueryTraceAsync(), _ => !string.IsNullOrWhiteSpace(SelectedTraceDevice) && !string.IsNullOrWhiteSpace(SelectedTraceTag)); _ackAlarmCommand = new AsyncRelayCommand(_ => AckSelectedAlarmAsync(), _ => SelectedAlarm is { State: AlarmState.Active }); } private void UpdateLiveData(DataPoint point) { var key = $"{point.DeviceId}:{point.TagName}"; if (_liveDataIndex.TryGetValue(key, out var existing)) { existing.Value = point.Value; existing.Timestamp = point.Timestamp; existing.IsValid = point.IsValid; } else { _liveDataIndex[key] = point; LiveData.Add(point); } TotalCollected++; StatusText = $"采集中... 最新刷新 {DateTime.Now:HH:mm:ss}"; } public void StartSystem() { if (_isRunning) { return; } _collector.Start(); _alarmService.Start(); _isRunning = true; _startCommand.RaiseCanExecuteChanged(); StatusText = "系统已启动"; } private async Task QueryTraceAsync() { var from = TraceFrom; var to = TraceTo; if (from > to) { (from, to) = (to, from); } var results = await _repository.QueryDataPointsAsync(SelectedTraceDevice, SelectedTraceTag, from, to); TraceResult.Clear(); foreach (var p in results) { TraceResult.Add(p); } StatusText = $"追溯查询完成,共 {TraceResult.Count} 条"; } private async Task AckSelectedAlarmAsync() { if (SelectedAlarm is not { State: AlarmState.Active } alarm) { return; } await _alarmService.AcknowledgeAlarmAsync(alarm.Id); alarm.AckedAt = DateTime.Now; alarm.State = AlarmState.Acked; _ackAlarmCommand.RaiseCanExecuteChanged(); StatusText = $"告警已确认:{alarm.DeviceId}-{alarm.TagName}-{alarm.AlarmType}"; } }

🖼️UI样式

image.png

image.png


⚠️ 踩坑预警:一周冲刺里最容易翻的几个坑

坑一:UI 线程更新集合忘了 Dispatcher

AlarmService 的事件是在后台线程触发的,直接操作 ObservableCollection 会抛出跨线程异常。所有 UI 集合的修改必须通过 App.Current.Dispatcher.Invoke() 回到主线程,这是 WPF 开发的基本规则,但在赶进度时特别容易忘。

坑二:SQLite 写入频率过高导致锁竞争

采集间隔 1 秒、3 个测点,每秒写入 3 条记录,SQLite 单连接完全扛得住。但如果采集间隔缩短到 100ms 或测点数量超过 50 个,建议改用批量写入策略:先把数据攒在内存里,每 5 秒批量 INSERT 一次,写入吞吐量可以提升 10 倍以上。

坑三:告警规则表写死在代码里

Demo 阶段图省事把规则硬编码没问题,但演示时甲方一定会说"我想改一下这个阈值"。建议从第一天就把规则存到 SQLite 的 AlarmRules 表里,启动时读取,支持运行时热更新,这个改动只需要半小时,却能让 Demo 显得专业很多。

坑四:时间戳存储格式不统一

SQLite 没有原生的 DateTime 类型,存成字符串时格式必须统一为 yyyy-MM-dd HH:mm:ss.fff,否则按时间范围查询时排序结果会乱掉。用 Dapper 时建议注册全局的 DateTime 类型处理器,一次配置全局生效。


📊 一周开发节奏建议

时间任务产出
Day 1搭项目结构、定数据模型、建 SQLite 表可运行的空壳项目
Day 2实现采集服务 + Mock 数据数据能写入 SQLite
Day 3实现告警规则引擎 + 状态机告警能触发和消除
Day 4搭主界面 ViewModel + 事件绑定数据能显示在界面
Day 5实现追溯查询界面 + 时间选择器历史数据可查询
Day 6联调三个模块,修复边界 Bug流程完整跑通
Day 7UI 美化、加载动画、异常提示可对外演示的 Demo

测试环境:Windows 10,.NET 6,WPF,Visual Studio 2022,3 个虚拟测点,采集间隔 1 秒,持续运行 1 小时,SQLite 文件大小约 2.1MB,查询响应时间 < 50ms。


🎯 三句话核心洞察

  • MVP 的边界是流程跑通,不是功能完整——采集有数据、告警能触发、追溯能查询,Demo 就成立了。
  • ConcurrentQueue + 事件驱动,是解耦采集、告警、UI 三层的最轻量方式,不需要引入任何消息框架。
  • 回差(Deadband)是告警系统的必备设计,没有它,阈值附近的正常抖动会淹没真正需要关注的告警信息。

📚 结尾:Demo 是起点,不是终点

一周搭出来的 Demo,离生产环境还有相当距离——真实的设备通信协议(Modbus、OPC-UA)、多用户权限管理、报表导出、远程监控……这些都是后续要补的功课。

但 Demo 的价值恰恰在于:它让你和甲方站在同一个真实的系统面前对话,而不是对着一份 PPT 各自想象。流程跑通之后,每一个后续的功能迭代都有了落脚点,开发节奏也会越来越稳。

本文介绍的采集 + 告警 + 追溯三模块架构,已在实际项目的 Demo 阶段验证可用,代码结构清晰,可直接作为脚手架使用。

**你在做工控 Demo 时,遇到过哪些让你印象深刻的技术坑?欢迎在评论区分享,看看大家踩过的

相关信息

我用夸克网盘给你分享了「AppWpf202611.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。 /53a53YrKNy:/ 链接:https://pan.quark.cn/s/e0996c091fad 提取码:UchB

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!