news 2026/9/7 12:02:38

基于C#和.NET MAUI的移动跨平台工业监控应用实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于C#和.NET MAUI的移动跨平台工业监控应用实践

车间的设备数据,不能只停留在工控机屏幕上。生产主管想在外面看产量,设备工程师想第一时间收到报警,操作工换班时想用手机快速确认设备状态——这些需求早就存在,以前只能靠微信截图或者二次开发 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# 上位机开发或者移动端改造,可以参考这套结构,先搭一个最小闭环再往里面填业务,会比直接套大型框架更稳妥。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 11:59:44

香橙派OrangePi Zero3系统烧录实战:从硬件准备到第一次点亮

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 11:59:27

通达信副图指标源码拆解:识别未来函数与改写选股公式实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 11:57:40

自媒体多平台分发插件实战:一键同步公众号/知乎/CSDN

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 11:57:32

从电压跌落到PDN目标阻抗:电源完整性设计实战

1. 电压跌落的根源——为什么要关心 PDN1.1 一个发生在量产阶段的真实故障前两年做一块多路服务器的管理板卡&#xff0c;板上有一颗大功耗的FPGA&#xff0c;12V转0.85V的DC-DC就贴在芯片背面。第一次投板回来&#xff0c;单板调试一切正常&#xff0c;结果到了小批量阶段&…

作者头像 李华
网站建设 2026/9/7 11:53:58

计算机单片机毕设实战-基于 STM32/51 单片机的便携式人体多参数健康监测终端设计 基于 STM32/51 单片机的生命体征超限声光及短信告警装置开发(024106)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/9/7 11:53:32

树莓派Pico用PIO实现非阻塞式步进电机控制,告别GPIO延时

树莓派 Pico 玩步进电机控制&#xff0c;网上 90% 的教程都还在教 GPIO 翻转加延时挤牙膏&#xff1a;要转几圈就先算好多少个脉冲&#xff0c;然后把引脚拉高、睡几微秒、拉低、再睡几微秒&#xff0c;CPU 全程陪跑。这种做法本身没问题&#xff0c;但一旦你要同时控制三四个轴…

作者头像 李华