车间的设备数据,不能只停留在工控机屏幕上。生产主管想在外面看产量,设备工程师想第一时间收到报警,操作工换班时想用手机快速确认设备状态——这些需求早就存在,以前只能靠微信截图或者二次开发 Web 页面,维护成本都不低。实际上,用 C# 一套代码同时覆盖 Android、iOS 和 Windows 客户端的移动跨平台方案,现在完全能承担工业监控这种偏工具型的业务场景。
这篇文章以“基于 C# 的移动跨平台工业监控应用”为设计主线,完整走一遍技术选型、通信层设计、MVVM 架构、告警推送、历史查询和批量采集的实现思路。方案以 .NET MAUI 为 UI 框架,Modbus TCP 和 MQTT 为主要工业通信协议,SQLite 负责本地历史存储,适合作为车间设备状态监控、产线数据看板和远程告警提醒的整体参考。整体工程量不算大,但难点相对集中:通信层要稳定、UI 刷新要及时、离线数据要兜底,这三个问题解决好了,整套系统就能扛住现场长期运行。
1. 核心能力速览
先看这个移动跨平台工业监控方案的整体能力。
| 能力项 | 说明 |
|---|---|
| 项目类型 | C# 移动跨平台工业监控应用 |
| UI 框架 | .NET MAUI(Android / iOS / Windows) |
| 运行时 | .NET 8 |
| 开发工具 | Visual Studio 2022 17.8+ |
| 工业通信协议 | Modbus TCP、MQTT、Socket TCP |
| 数据存储 | SQLite(本地历史)、JSON 导出 |
| 核心功能 | 设备实时采集、数值显示、状态告警、历史趋势、批量采集 |
| 扩展能力 | HTTP API、扫码枪联动、机器视觉结果对接 |
| 部署形态 | Android APK、Windows 安装包、iOS TestFlight |
| 建议测试环境 | Modbus 从站模拟器 + 真实 PLC 二次验证 |
从表格能看出,这套方案本质上是一个移动端的上位机工具,替代的是传统 WinForm/WPF 监控软件的移动端延伸。它不追求替代 PLC、不追求毫秒级运动控制,而是把设备数据可靠地送到管理者和管理系统的手上。如果项目的核心诉求是现场可视化、远程告警和简单交互,这条技术路线完全够用;如果涉及运动控制、安全联锁或者大点数数据采集,还是应该以专用上位机和 SCADA 为主,移动端只做展示和辅助决策。另外,移动端工业监控关注的是内存占用、CPU 占用、网络延迟和数据吞吐,跟 GPU 任务关注显存占用是两条不同的评估路径。
2. 适用场景与使用边界
2.1 适合的使用场景
这种移动跨平台工业监控应用,最典型的落地位置是下面这几类:
- 车间巡检:维护人员拿着 Android 或 iPhone,扫码或点选设备,直接看温度、压力、转速、运行状态等实时数据。
- 远程看板:生产管理者不在车间,也能通过移动端查看当班产量、设备开机率、告警数量。
- 告警推送:PLC 或仪表触发阈值后,应用实时弹出本机通知,并记录告警历史。
- 数据采集上报:把设备寄存器数据采集后,通过 HTTP API 或 MQTT 上报到 MES/SCADA 系统。
- 设备调试辅助:现场调试人员用手机临时连 PLC 读取寄存器,省去带笔记本电脑。
2.2 使用边界与合规提醒
不是说一个 App 就能替代工业 SCADA 或者 DCS。高实时闭环控制、安全联锁、运动控制这类对确定性要求极高的场景,不适合放在移动端实现,因为无线网络抖动和进程调度都会带来延迟。移动端网络进入生产网时,必须做访问控制,建议通过专线、防火墙 ACL、证书认证等安全方式接入,不要把 PLC 的设备端口直接映射到公网。设备数据涉及产量、能耗、良率等生产工艺参数时,要遵循企业内部数据安全规范。如果后续要对接摄像头抓拍画面、机器视觉结果,需要确认被拍摄区域和人员具有合法授权,不能绕过企业安防审批。这些不是空话,而是这类项目上线前必然要过的一关,设计阶段就把权限模型、日志审计和敏感数据脱敏写进需求清单,后期会省事很多。
3. 关键技术选型
3.1 为什么选 C# 和 .NET MAUI
传统 C# 上位机开发通常基于 WinForm 或 WPF,程序只能跑在 Windows 工控机上。如果工厂要求同时出 Android、iOS 和 Windows 三个端,过去常见做法是每个端单独开发,或者用 HTML5 套壳方案。前者成本高,后者在相机预览、串口、本地通知、离线缓存等能力上偏弱。.NET MAUI 的优势在于:C# 和 XAML 一套代码编译到 Android、iOS、Windows 和 macOS;可以直接复用已有 C# 上位机项目里的协议解析、业务逻辑和工具类;通过 MVVM 模式,界面和逻辑解耦明显;MAUI 项目本质上是原生应用,可以调用各平台 API,比如本地通知、相机、扫码、蓝牙和串口。如果项目更偏向工业桌面端且需要更开放的自绘界面,可以考虑 Avalonia UI;但以移动端支持、厂商支持和资料丰富程度来说,.NET MAUI 是更稳妥的起点。
3.2 通信协议怎么选
工业现场常见的通信协议很多,从 Modbus RTU、Modbus TCP、三菱 FINS、西门子 S7、欧姆龙 HostLink,到现代工厂越来越常用的 MQTT、OPC UA。移动端 App 不能把所有协议都内置,实际选型要看上位机已有的数据出口:
- 设备已经接入上位机或网关,且上位机能提供 HTTP/JSON 接口:优先走 HTTP API,简单可靠。
- 现场有多台 PLC,想在 App 里直接配置 IP 和寄存器地址:用 Modbus TCP 最通用,PLC 和仪表支持率最高。
- 需要长连接、主动下发控制指令:用 Socket TCP 或 MQTT,MQTT 要注意 Broker 的 QoS 等级。
- 要兼容西门子、三菱等私有协议:建议在本地网关或上位机里做协议转换,App 统一走标准协议,避免在移动端维护多个私有协议栈。
这套设计里以 Modbus TCP 为主打协议,同时预留 MQTT 选项用于对接物联网平台。C# 侧实现 Modbus TCP 并不复杂,关键是报文解析和超时控制要做扎实。
4. 环境准备与前置条件
4.1 开发环境
- 操作系统:Windows 10/11(建议 64 位)。
- Visual Studio 2022:安装时勾选“.NET 多平台应用 UI 开发”工作负载。
- .NET SDK:建议 .NET 8,MAUI 在这个版本上比较稳定。
- Android 工作负载:需要 Android SDK、JDK 17 和 Android 模拟器。
- iOS 构建:需要 macOS 上的 Xcode;在 Windows 上可以先完成 Android 和 Windows 端,iOS 端在发布阶段再用 Mac 打包。
4.2 建议的测试环境
没有真实 PLC 也能完整验证功能。用 Modbus Slave 模拟器或 Python 的 pymodbus 搭建一个 Modbus TCP 从站,就能先跑通整条数据链路。下面给出一组用 pymodbus 快速搭建从站的命令,方便在没有硬件的情况下先做开发调试。
pip install pymodbus# 简易 Modbus TCP 从站模拟,用于本地验证移动端读取数据 from pymodbus.server import StartTcpServer from pymodbus.datastore import ModbusSequentialDataBlock from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext datablock = ModbusSequentialDataBlock(0, [100, 200, 300, 400, 500, 600]) slave_context = ModbusSlaveContext( di=datablock, co=datablock, hr=datablock, ir=datablock ) server_context = ModbusServerContext(slaves=slave_context, single=True) if __name__ == "__main__": StartTcpServer(context=server_context, address=("0.0.0.0", 502))注意,如果手机和模拟器不在同一网段,手机连接时要把 IP 改成电脑的局域网 IP,同时在防火墙里放行 502 端口。这是很多人第一次测试时最容易卡住的地方。如果电脑有多个网卡,比如同时插着有线网和无线网,需要确认手机连的那个网段的 IP 是哪一个。Modbus TCP 默认端口是 502,部分系统需要管理员权限才能监听,这一步也容易踩坑。
5. 项目架构与通信层实现
5.1 整体分层
移动跨平台工业监控应用的项目结构,建议按下面的分层来组织:
/src ├── App.UI // MAUI 界面层:Pages、Views、Controls ├── App.ViewModels // MVVM ViewModel 层:绑定和命令 ├── App.Services // 服务层:设备监控、告警、历史、日志 ├── App.Communication // 通信层:Modbus Tcp、Mqtt、Socket ├── App.Models // 数据模型:Device、DeviceData、AlarmRecord ├── App.Data // 数据层:SQLite 仓储、JSON 导入导出 └── App.Utils // 工具类:字节解析、值转换、时间处理这套分层的好处非常直接:通信层不引用任何 UI 相关的类,ViewModel 只依赖服务接口。后续要把 Modbus 换成 OPC UA,或者把 SQLite 换成 MySQL,都只影响对应的一层。对于现场维护来说,改动最小化是最重要的工程经验,分层设计能有效降低这类回归风险。
5.2 Modbus TCP 通信层实现
Modbus TCP 报文的要点是 MBAP 头加 PDU。请求帧和响应帧结构固定,移动端实现时要注意字节序和超时处理。下面是一个最小可用的客户端实现,覆盖连接、读保持寄存器和超时逻辑。
public class ModbusTcpClient : IDisposable { private TcpClient? _tcpClient; private readonly object _lock = new object(); public async Task<bool> ConnectAsync(string ip, int port, int timeoutMs = 3000) { try { _tcpClient = new TcpClient(); Task connectTask = _tcpClient.ConnectAsync(ip, port); Task completedTask = await Task.WhenAny(connectTask, Task.Delay(timeoutMs)); if (completedTask != connectTask) { throw new TimeoutException($"连接 {ip}:{port} 超时"); } if (!_tcpClient.Connected) { throw new IOException($"连接 {ip}:{port} 失败"); } _tcpClient.NoDelay = true; return true; } catch (Exception ex) { Console.WriteLine($"[Modbus] 连接失败: {ex.Message}"); return false; } } public async Task<ushort[]> ReadHoldingRegistersAsync( byte unitId, ushort startAddress, ushort quantity) { if (_tcpClient == null || !_tcpClient.Connected) { throw new InvalidOperationException("Modbus 客户端未连接"); } byte transactionLo = (byte)(DateTime.Now.Millisecond & 0xFF); byte[] request = { 0x00, transactionLo, // 事务标识符 0x00, 0x00, // 协议标识符 0x00, 0x06, // 后续长度 unitId, // 单元标识符 0x03, // 功能码:读保持寄存器 (byte)(startAddress >> 8), (byte)(startAddress & 0xFF), (byte)(quantity >> 8), (byte)(quantity & 0xFF) }; byte[] response; lock (_lock) { NetworkStream stream = _tcpClient.GetStream(); stream.Write(request, 0, request.Length); // MBAP 头 7 字节 + 功能码 1 字节 + 字节数 1 字节 byte[] header = new byte[9]; int readLen = ReadFull(stream, header, 9, 3000); if (readLen < 9) { throw new IOException("Modbus 响应头不完整"); } // header[8] 是寄存器数据的字节数 int dataLen = header[8]; byte[] data = new byte[dataLen]; ReadFull(stream, data, dataLen, 3000); response = header.Concat(data).ToArray(); } if ((response[7] & 0x80) != 0) { throw new Exception($"Modbus 错误码: 0x{response[8]:X2}"); } ushort[] result = new ushort[quantity]; int dataOffset = 9; for (int i = 0; i < quantity; i++) { result[i] = (ushort)((response[dataOffset + i * 2] << 8) | response[dataOffset + i * 2 + 1]); } return result; } private int ReadFull(NetworkStream stream, byte[] buffer, int count, int timeoutMs) { int offset = 0; while (offset < count) { stream.ReadTimeout = timeoutMs; int n = stream.Read(buffer, offset, count - offset); if (n <= 0) { break; } offset += n; } return offset; } public void Dispose() { _tcpClient?.Close(); _tcpClient?.Dispose(); } }这段代码可以继续完善的地方有:事务标识符改成自增计数器,避免多客户端并发时冲突;正式项目里寄存器数据要支持 Int16、Int32、Float 和 Boolean 拆包;超时时间要可配置,不同现场设备响应速度差别很大;断线重连逻辑要放在上层服务里,不放在单个客户端里。
5.3 设备监控服务:单例、委托与事件
移动端页面上显示的数据不能是静态的,必须由后台采集任务持续推送。这里用 C# 单例模式管理设备监控服务,用事件委托把设备数据分发给各个页面。
public sealed class DeviceMonitorService { private static readonly Lazy<DeviceMonitorService> _instance = new(() => new DeviceMonitorService()); public static DeviceMonitorService Instance => _instance.Value; private readonly Dictionary<string, DeviceChannel> _channels; private readonly Dictionary<string, DeviceData> _latestData = new(); public event Action<DeviceData>? DataReceived; private DeviceMonitorService() { _channels = new Dictionary<string, DeviceChannel>(); } public void StartChannel(string deviceName, string ip, int port, ushort startAddress, ushort quantity, int intervalMs) { if (_channels.ContainsKey(deviceName)) { return; } var tokenSource = new CancellationTokenSource(); var channel = new DeviceChannel { Name = deviceName, Ip = ip, Port = port, StartAddress = startAddress, Quantity = quantity, IntervalMs = intervalMs, TokenSource = tokenSource }; _channels[deviceName] = channel; _ = Task.Run(async () => { using var client = new ModbusTcpClient(); await client.ConnectAsync(ip, port); while (!tokenSource.IsCancellationRequested) { try { ushort[] values = await client.ReadHoldingRegistersAsync( 1, startAddress, quantity); var data = new DeviceData { DeviceName = deviceName, Timestamp = DateTime.Now, Values = values, Online = true }; DataReceived?.Invoke(data); lock (_latestData) { _latestData[deviceName] = data; } } catch (Exception ex) { var data = new DeviceData { DeviceName = deviceName, Timestamp = DateTime.Now, Values = Array.Empty<ushort>(), Online = false, ErrorMessage = ex.Message }; DataReceived?.Invoke(data); lock (_latestData) { _latestData[deviceName] = data; } } await Task.Delay(intervalMs, tokenSource.Token); } }, tokenSource.Token); } public void StopChannel(string deviceName) { if (_channels.Remove(deviceName, out var channel)) { channel.TokenSource.Cancel(); } } public DeviceData? GetLatestData(string deviceName) { lock (_latestData) { return _latestData.TryGetValue(deviceName, out var data) ? data : null; } } public sealed class DeviceChannel { public required string Name { get; set; } public required string Ip { get; set; } public required int Port { get; set; } public required ushort StartAddress { get; set; } public required ushort Quantity { get; set; } public required int IntervalMs { get; set; } public required CancellationTokenSource TokenSource { get; set; } } }这个服务被多个页面共享。首页看板订阅实时值,设备详情页订阅详细寄存器,告警页订阅在线状态,互不干扰。使用 C# 委托和事件来解耦,是这里比较核心的设计思路。订阅方在页面销毁时一定要取消订阅,否则会出现内存泄漏。
6. 移动端 UI 与数据展示
6.1 用 CollectionView 展示设备列表
MAUI 的 XAML 设计和 WPF 很接近。设备列表页直接用 CollectionView 绑定设备集合,每个设备的名称、数值和状态用数据模板渲染。
<ContentPage x:Class="IndustrialMonitor.Pages.DeviceListPage" x:DataType="viewModels:DeviceListViewModel" Title="设备监控"> <Grid RowDefinitions="Auto,*"> <Label Grid.Row="0" Text="{Binding SummaryText}" FontSize="18" FontAttributes="Bold" Padding="16,12" /> <RefreshView Grid.Row="1" IsRefreshing="{Binding IsRefreshing}" Command="{Binding RefreshCommand}"> <CollectionView ItemsSource="{Binding Devices}" SelectionMode="Single" SelectionChanged="OnDeviceSelected"> <CollectionView.ItemTemplate> <DataTemplate x:DataType="models:DeviceItem"> <Grid Padding="16,12" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="12"> <VerticalStackLayout> <Label Text="{Binding Name}" FontSize="16" FontAttributes="Bold" /> <Label Text="{Binding Description}" FontSize="12" TextColor="Gray" /> </VerticalStackLayout> <Label Grid.Column="1" Text="{Binding ValueText}" FontSize="20" FontAttributes="Bold" /> <Label Grid.Column="2" Text="{Binding StatusText}" FontSize="13" TextColor="{Binding StatusColor}" /> </Grid> </DataTemplate> </CollectionView.ItemTemplate> </CollectionView> </RefreshView> </Grid> </ContentPage>页面绑定的 ViewModel 必须实现 INotifyPropertyChanged,否则设备数据变化后 UI 不会自动刷新。这是从 C# 上位机开发转向移动端时最常忽视的点。建议直接用 CommunityToolkit.Mvvm 的 ObservableObject 基类和 [ObservableProperty] 源生成器,可以减少大量手写通知代码。
6.2 实时数值更新与趋势图
对工业监控来说,光有数值不够,最好能看一段时间的趋势变化。MAUI 项目里可以集成 LiveCharts2 或 Microcharts.Maui,下面是一个 ViewModel 中更新趋势数据的片段。
public partial class DeviceDetailViewModel : ObservableObject { private readonly string _deviceName; [ObservableProperty] private List<double> _trendValues; [ObservableProperty] private string _currentValueText; public DeviceDetailViewModel(string deviceName) { _deviceName = deviceName; TrendValues = new List<double>(60); DeviceMonitorService.Instance.DataReceived += OnDataReceived; } private void OnDataReceived(DeviceData data) { if (data.DeviceName != _deviceName) { return; } double currentValue = 0; if (data.Values.Length > 0) { currentValue = data.Values[0]; } MainThread.BeginInvokeOnMainThread(() => { CurrentValueText = $"{currentValue:F2}"; TrendValues.Add(currentValue); if (TrendValues.Count > 120) { TrendValues.RemoveAt(0); } }); } }注意,订阅事件后页面释放时一定要取消订阅,否则会出现页面已经关闭但后台还在推送数据、内存不断增长的问题。特别是使用单例服务时,这种泄漏非常隐蔽。更稳妥的做法是在页面 OnDisappearing 中取消订阅,在 OnAppearing 中重新订阅。
7. 告警、历史数据与本地存储
7.1 告警判断与本地通知
工业监控的核心不只是看数据,更重要的是异常时及时提醒。告警规则可以配置为数值大于上限、小于下限、跳变超限、设备离线。下面是一个轻量告警服务的实现片段。
public class AlarmService { private readonly Dictionary<string, AlarmRule> _rules; public event Action<AlarmRecord>? AlarmRaised; public void Evaluate(DeviceData data) { if (!_rules.TryGetValue(data.DeviceName, out var rule)) { return; } double value = data.Values.Length > 0 ? data.Values[0] : 0; bool triggered = data.Online == false || value > rule.HighLimit || value < rule.LowLimit; if (triggered) { var record = new AlarmRecord { DeviceName = data.DeviceName, Value = value, Timestamp = data.Timestamp, Level = rule.Level, Message = $"{data.DeviceName} 触发告警,当前值 {value:F2}" }; AlarmRaised?.Invoke(record); } } }告警事件触发后,可以在 ViewModel 里用 MAUI 的本地通知插件弹出系统通知,也可以把告警记录写入 SQLite。移动端本地通知依赖平台能力,Android 需要申请通知权限,iOS 由系统统一管理。告警逻辑要增加状态机去重,避免同一问题在短时间内反复弹窗。常见的做法是维护一个告警活动表,只有从正常状态跳变到告警状态时才通知,设备恢复后再跳变回来才产生下一次告警。
7.2 SQLite 历史数据仓储
历史数据采用 SQLite 最合适,移动端不用额外部署数据库服务。注意不要在构造函数里直接阻塞等待建表,建议用异步初始化方式。
public class HistoryRepository { private readonly SQLiteAsyncConnection _db; private readonly Task _initTask; public HistoryRepository(string dbPath) { _db = new SQLiteAsyncConnection(dbPath); _initTask = _db.CreateTableAsync<HistoryRecord>(); } private Task InitTask => _initTask; public async Task InsertAsync(HistoryRecord record) { await InitTask; await _db.InsertAsync(record); } public async Task<List<HistoryRecord>> QueryAsync( string deviceName, DateTime start, DateTime end) { await InitTask; return await _db.Table<HistoryRecord>() .Where(x => x.DeviceName == deviceName && x.Timestamp >= start && x.Timestamp <= end) .OrderBy(x => x.Timestamp) .ToListAsync(); } public async Task<int> DeleteBeforeAsync(DateTime cutoff) { await InitTask; var oldRecords = await _db.Table<HistoryRecord>() .Where(x => x.Timestamp < cutoff) .ToListAsync(); await _db.DeleteAllAsync(oldRecords); return oldRecords.Count; } }历史数据不能无限积累。采集间隔 1 秒,一天就是 86400 条记录,SQLite 的压力不大,但连续跑两三个月后查询性能会明显下降。建议按天或按周做一次清理,只保留最近 30 天数据,或者把历史数据导出到 CSV/JSON 后归档到上位机。
8. 功能测试与效果验证
8.1 用模拟器验证通信链路
在没有实际 PLC 的情况下,先用 Modbus 从站模拟器验证整个链路。
- 第一步:在电脑上启动 Modbus Slave 模拟器,监听 502 端口,保持寄存器地址 0 到 9 的值按固定步长变化。
- 第二步:启动 MAUI 应用,在设备配置页填写电脑的局域网 IP 和端口 502。
- 第三步:观察设备列表页是否能显示实时数值,数值是否和模拟器一致。
- 第四步:把模拟器的网络断开,观察设备状态是否在 3 秒内标记为离线。
- 第五步:重新恢复模拟器,观察应用是否自动重连并恢复显示。
如果第一步都通不过,优先检查 Windows 防火墙是否放行 502 端口,Android 模拟器访问宿主机时要使用 10.0.2.2 而不是 127.0.0.1。
8.2 页面刷新与内存检查
移动端监控页面需要长时间驻留,测试时重点看:页面切换后后台采集任务是否还在运行;设备列表滚动时刷新是否卡顿;连续运行 8 小时后内存占用是否持续增长;页面关闭后事件订阅是否释放。Android 端建议用 Android Studio 的 Profiler 检查内存和 CPU 占用,Windows 端可以用 Visual Studio 的诊断工具直接分析 MAUI 进程。正常情况下,一个几十台设备的监控页面,内存占用曲线应该是平稳波动的,如果持续上升,基本可以判断存在事件订阅或其他资源泄漏。
8.3 告警功能验证
验证告警时,把模拟寄存器的值改到超过高限阈值,确认应用能触发告警并写入 SQLite;再把值恢复正常,确认告警自动恢复。重点验证:设备离线告警、数值高限告警、数值低限告警、告警去重、重启 App 后告警记录仍然存在。告警去重的验证方式是连续保持超限状态 5 分钟,确认只弹一次通知,而不是每分钟都弹。
9. 接口与批量任务扩展
9.1 对外 HTTP API
移动端应用不能只自己看数据,还可以把采集结果以 HTTP API 形式暴露给 MES 或第三方系统。这里可以采用 ASP.NET Core Minimal API,把同一个解决方案里的服务和数据层直接复用。
var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(DeviceMonitorService.Instance); builder.Services.AddSingleton<HistoryRepository>(); var app = builder.Build(); app.MapGet("/api/devices/{deviceName}/latest", ( string deviceName, DeviceMonitorService monitor) => { return Results.Ok(monitor.GetLatestData(deviceName)); }); app.MapGet("/api/devices/{deviceName}/history", async ( string deviceName, DateTime start, DateTime end, HistoryRepository repo) => { var data = await repo.QueryAsync(deviceName, start, end); return Results.Ok(data); }); app.Run();对外接口必须加认证,最简单的做法是 API Key 或 Token 校验,不能裸奔在网络上。如果部署在 Windows 工控机上,通过 IIS 或 Nginx 反向代理并配置 TLS,基本能满足常规现场要求。下面是一个 Python 调用示例,方便第三方系统接入。
import requests api_url = "http://192.168.1.100:5100/api/devices/Line1_Press/latest" headers = {"Authorization": "Bearer YOUR_TOKEN"} resp = requests.get(api_url, headers=headers, timeout=5) if resp.status_code == 200: data = resp.json() print("设备在线:", data.get("online")) print("最新数值:", data.get("values")) else: print("请求失败:", resp.status_code)9.2 批量采集任务
工厂里经常有这种需求:临时要对 30 台设备做一次全量数据快照,或者每天定时导出前一日的产量数据。这种任务适合做成批量采集队列。
public class BatchCollectService { private readonly HistoryRepository _repo; public async Task<int> RunAsync( List<DeviceChannelConfig> devices, IProgress<string> progress) { int successCount = 0; using var semaphore = new SemaphoreSlim(5); var tasks = devices.Select(async device => { await semaphore.WaitAsync(); try { using var client = new ModbusTcpClient(); bool connected = await client.ConnectAsync(device.Ip, device.Port, 3000); if (!connected) { return false; } var values = await client.ReadHoldingRegistersAsync( 1, device.StartAddress, device.Quantity); await _repo.InsertAsync(new HistoryRecord { DeviceName = device.Name, Timestamp = DateTime.Now, Value = values.Length > 0 ? values[0] : 0 }); progress.Report($"{device.Name} 采集成功"); return true; } catch (Exception ex) { progress.Report($"{device.Name} 采集失败: {ex.Message}"); return false; } finally { semaphore.Release(); } }); var results = await Task.WhenAll(tasks); successCount = results.Count(r => r); return successCount; } }批量采集要注意三点:一是并发不能开太大,现场设备和网络承受能力有限,一般 3 到 5 个并发比较稳妥;二是每台设备的超时时间独立设置,一台设备超时不能卡住整个队列;三是成功和失败必须有日志记录,方便后续排查。
9.3 扫码枪联动
扫码枪在工厂里使用很频繁。通过 USB 或蓝牙连接的扫码枪,本质上是一个键盘输入设备,焦点在输入框时就会触发输入事件。MAUI 里调用摄像头扫码可以用 Camera.Maui,也可以直接接扫码枪,在页面里放一个隐藏的 Entry 接收扫码结果,然后按设备编码查询并跳转。这个功能落地很快,但对巡检场景的体验提升非常明显。
10. 资源占用与性能观察
移动端工业监控的性能观察重点不在显存,而在内存、CPU、网络和功耗。
- 内存占用:MAUI 应用基础内存占用比同类原生应用高一些,但可接受。长时间运行必须检查是否有事件订阅泄漏。
- CPU 占用:Modbus 轮询和 SQLite 写入占用不大,主要开销在趋势图刷新和页面动画。每 500 毫秒刷新一次趋势图,CPU 占用会明显升高,建议把图表刷新频率降到 1 秒以上。
- 网络占用:轮询 10 台设备、每台读取 10 个寄存器,每秒网络流量非常小。但如果采集间隔调到 500 毫秒,手机功耗和网络开销会明显上升,要按实际需要设置。
- 功耗:长期巡检场景建议关闭屏幕常亮,只保留后台采集。Android 端省电策略可能暂停后台任务,需要配置前台服务或限制电池优化。
- 掉线重连:移动端网络切换频繁,Wi-Fi 和蜂窝切换时会断线。通信层必须支持自动重连,重连时做指数退避,避免同时发大量重连请求打垮设备。
从资源占用角度看,这种移动监控方案不适合做 50 台以上设备的高频集中采集。现场设备数量大时,建议仍然由上位机统一采集,移动端只连上位机的接口,这样网络开销和稳定性都更可控。
11. 常见问题与排查方法
根据 C# 上位机开发里常见的问题,整理了一份排查清单。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| Android 模拟器连不上 Modbus 模拟器 | 目标地址用了 127.0.0.1 | 确认模拟器访问宿主机方式 | 改用 10.0.2.2 |
| 真机连不上电脑上的模拟器 | 防火墙拦截 502 端口 | 检查防火墙规则 | 放行 TCP 502,或临时关闭防火墙验证 |
| Modbus 读取数据为 0 | 寄存器地址偏移或端序不对 | 用 Modbus Poll 对照读取 | 核对起始地址和字节顺序 |
| 页面一直不刷新 | ViewModel 没实现 INotifyPropertyChanged | 检查属性通知代码 | 使用 ObservableObject 或手动调用 OnPropertyChanged |
| 长时间运行内存上涨 | 事件订阅未释放 | 用 Profiler 查看泄漏 | 页面销毁时取消订阅事件 |
| 设备离线状态不恢复 | 重连逻辑没有实现 | 观察日志 | 在断线后执行指数退避重连 |
| iOS 打包报签名错误 | 证书和描述文件配置错误 | 查看构建日志 | 在 Mac 上重新配置签名 |
| SQLite 查询越来越慢 | 历史数据没有清理 | 查看表记录数 | 增加定期清理策略 |
| 现场 WiFi 网络断断续续 | AP 漫游或信道拥堵 | 抓包分析网络质量 | 调整采集间隔,增加断线重连 |
| 告警重复弹出 | 告警去重逻辑缺失 | 查看告警记录 | 增加状态机,状态变化时才告警 |
遇到不确定的问题时,第一件事是看日志。正式项目里要加一个简单的日志工具,把通信、断线、告警、数据库写入的关键信息输出到本地文件,并支持按天滚动。没有日志的情况下排查这类系统,定位问题会非常慢。
12. 最佳实践与合规提醒
综合前面所有内容,把这类项目落地时值得遵守的实践列一下:
- 第一版先用模拟器跑通 Modbus TCP 链路,再接真实 PLC,不要在开发前期被硬件问题卡住。
- 通信层和 UI 层严格分离,方便后续替换协议或增加新协议。
- 采集配置做成 JSON 文件或数据库表,不要硬编码 IP 和寄存器地址。
- 设备配置和寄存器映射要做版本管理,现场修改要有记录。
- 告警、操作日志、数据导出都要留存,方便故障追溯。
- 部署到工控机或现场服务器时,通过防火墙和访问控制限制接口调用范围,不要把 Modbus 502 端口直接暴露到公网。
- 需要远程查看时,先接入企业内部已有的安全通道再进行访问,不要自己随意开公网端口。
- 涉及人员信息、摄像头抓拍、声音采集等功能时,必须先确认授权范围,遵守企业数据安全合规要求。
- 发布商用版本前,至少进行连续 72 小时稳定性测试,验证内存、网络重连和数据不丢失。
- 控制指令类操作,比如启动设备、复位设备,必须在界面上做二次确认,并记录操作人、时间和结果。
这些实践并不是额外负担,而是把移动端监控应用真正落地到车间时的基本盘。尤其是操作权限和数据安全,一旦出问题,影响的不只是功能,而是整个生产线的正常秩序。
13. 总结与下一步
这个基于 C# 的移动跨平台工业监控应用,本质上是用 .NET MAUI 把传统 C# 上位机的数据采集能力延伸到 Android、iOS 和 Windows 移动端。最值得先跑通的是 Modbus TCP 通信链路和设备数据实时推送,这两个模块直接决定整个系统好不好用。最容易踩的坑有两个:一个是 Android 模拟器访问宿主机的地址问题,另一个是事件订阅泄漏导致内存不断上涨。先解决这两个问题,后面的功能开发会顺利很多。
接下来可以继续扩展的方向包括:对接 OPC UA 网关、增加视频巡检画面集成、接入机器视觉检测结果、把告警推送到企业微信或钉钉,以及用多端数据看板替代传统单机 SCADA 界面。每一步都是在现有架构上增加一个模块,不需要推翻重来。建议先把最小可运行版本跑起来,再逐步加功能。如果你正好在做 C# 上位机开发或者移动端改造,可以参考这套结构,先搭一个最小闭环再往里面填业务,会比直接套大型框架更稳妥。