ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DevExpress v23.1固定式扫码系统C#源码解析:串口通信与多线程队列实战

DevExpress v23.1固定式扫码系统C#源码解析:串口通信与多线程队列实战 简介基于 DevExpress v23.1 构建的固定式扫码系统 C# 源码主要面向需要集成串口扫码枪、实现自动读码与异常报警的桌面应用开发者也适合在仓储、产线或门禁等固定工位场景快速落地同类需求。源码在基础扫码功能上完善了扫描报警和数据库查询包含 SerialPort 类的完整封装、串口数据接收事件驱动、报警判定与记录查询等核心实现可直接参考改造。资源压缩包约 120.67MB共 572 个文件。其中 170 个 dll 为 DevExpress 及相关运行库107 个 xml 对应组件配置与文档28 个 cs 为核心源码另有 SQL 脚本、配置文件和说明文档整体目录结构清晰便于定位和二次开发。目前已有 175 人学习下载。对于正在搭建固定式扫码识别模块的 C# 开发者这份源码能提供串口通信封装思路、报警联动逻辑以及数据库查询示例减少底层调试和重复开发成本可直接套用或按项目定制。1. DevExpress v23.1 固定式扫码系统 C# 源码这套工程先帮你把路铺平车间流水线上那台固定式扫码器如果只靠串口调试助手去收数据你会发现所有活儿都得从上位机重新做。DevExpress v23.1 固定式扫码系统 C# 源码解决的就是这一段——它把串口通信、数据帧解析、多线程队列、数据库落库、界面展示全部串成了一条完整链路。说白了你拿到手的是一个能跑起来的 WinForms 工程改改串口号和数据库连接串就能接上自己的扫码器。适合正在做 C# 上位机、被扫码数据粘包或 UI 卡死折磨过的开发也适合刚接手扫码项目、想少走弯路的新手。这篇笔记按我拆项目的习惯把源码里的关键模块、参数设置和踩坑点都过一遍。2. 源码结构与 DevExpress 选型从控件库到三层骨架2.1 DevExpress v23.1 为什么适合扫码上位机固定式扫码系统的界面通常不复杂但有一点很要命数据刷新频繁而且操作工要长时间盯着屏幕。用原生 WinForms 控件不是不行表格一按时间排序滚动时那个闪烁感看久了眼睛确实累。DevExpress 的 GridControl 在这个场景里有几个实打实的优势这也是源码作者选它的原因。首先是 GridControl 的刷新机制。它支持数据源的增量更新配合BeginUpdate和EndUpdate方法可以在不重建整张表的情况下插入新行。扫码场景一秒进来好几条数据这个能力直接决定了界面是流畅还是卡顿。其次是内置的视图模式比如 CardView 和 TileView适合做扫码结果的可视化看板比 GridView 更直观。最后是导出能力GridView自带ExportToExcel和ExportToPdf不用额外引第三方库。当然DevExpress 也有它的脾气最典型的就是授权问题。源码工程里一般会带上DX__license.licx之类的许可证文件编译时如果提示缺少许可证通常是 licx 文件路径不对或者版本不匹配这个在第 5 章展开说。v23.1 这个版本相对成熟对 .NET Framework 4.8 和 .NET 6/7 都支持扫码上位机大多跑在工控机上系统环境偏旧v23.1 的兼容性比 24.x 更稳妥一些。2.2 工程目录与模块划分这套源码的目录不算复杂但分层很清晰。常见做法是拆成四个工程或四个文件夹按功能边界切分。UI 层放 WinForms 窗体和 DevExpress 控件通信层封装 SerialPort 的打开、关闭、数据接收和断线重连业务层处理扫码数据的解析、校验和事件分发数据层管数据库连接和增删改查。ScanSystem/ ├── UI/ // WinForms 窗体 DevExpress 控件 │ ├── MainForm.cs │ ├── ScanGridView.cs │ └── ReportForm.cs ├── Communication/ // 串口通信模块 │ ├── SerialPortManager.cs │ ├── FrameParser.cs │ └── ReconnectService.cs ├── Business/ // 业务逻辑 │ ├── ScanDataModel.cs │ ├── ScanEventBus.cs │ └── DataValidator.cs ├── Data/ // 数据访问 │ ├── SqliteHelper.cs │ └── ScanRepository.cs └── Program.cs这个结构的核心思想是解耦。通信层的SerialPortManager只负责收发字节不关心数据长什么样FrameParser只做帧切割和解析不关心数据存到哪里UI 层不知道串口细节只订阅ScanEventBus的事件。这样做的直接好处是换一个扫码器品牌只需要改FrameParser里的协议解析部分从串口换成网口 TCP只需要替换SerialPortManager的实现。源码里如果没有现成的事件总线直接用 C# 的event关键字也能达到同样的效果。参数配置方面源码一般会在App.config或appsettings.json里放串口参数比如 COM 口号、波特率、数据位、停止位、校验位。这些参数必须外部可配原因很简单——现场工控机的 COM 口号和扫码器设置的波特率不可能每次都一样写死在代码里就失去通用性了。configuration appSettings add keySerialPortName valueCOM3 / add keyBaudRate value115200 / add keyDataBits value8 / add keyStopBits valueOne / add keyParity valueNone / add keyFrameEndMark value\r\n / add keyDbConnection valueData Sourcescan.db / /appSettings /configuration这段配置的逻辑在于把硬件相关参数和业务代码分离。SerialPortName对应设备管理器里的串口号BaudRate必须与扫码器 DIP 开关或配置软件设定一致常见固定式扫码器默认 115200但老机型可能是 9600FrameEndMark是帧结束标志这一项很关键有的扫码器发\r\n有的发\0还有的直接发纯 ASCII这直接决定了FrameParser怎么写。我一般会建议把所有跟硬件相关的参数都丢到配置文件里宁可多配几个不要写一个常量在代码里。3. 串口对接固定式扫码器SerialPort 参数、帧解析与断线重连3.1 SerialPort 的初始化与 DataReceived 事件的正确姿势C# 的System.IO.Ports.SerialPort类用起来不难但有几个细节决定成败。初始化时要设置串口号、波特率、数据位、停止位和校验位。这个顺序本身没什么玄学玄学在DataReceived事件。很多人直接在事件回调里读ReadExisting()然后更新 UI结果 UI 卡死、数据乱跳。问题出在哪里DataReceived是在后台线程触发的不是 UI 线程直接操作控件必然跨线程异常。public void Open() { _serialPort new SerialPort { PortName _config.SerialPortName, BaudRate _config.BaudRate, DataBits _config.DataBits, StopBits _config.StopBits, Parity _config.Parity, ReadTimeout 1000, WriteTimeout 1000 }; _serialPort.DataReceived OnDataReceived; _serialPort.Open(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { // 注意这里运行在后台线程不能直接碰 UI 控件 int bytesToRead _serialPort.BytesToRead; byte[] buffer new byte[bytesToRead]; _serialPort.Read(buffer, 0, bytesToRead); // 将原始数据送入帧解析器解析结果通过事件发布 _frameParser.Append(buffer); }这段代码的关键在于DataReceived里只做两件事读字节、把字节交给FrameParser。不做解析、不更新 UI、不写数据库。ReadTimeout和WriteTimeout设成 1000 毫秒避免串口异常时线程被无限阻塞。BytesToRead先取长度再分配缓冲区防止Read方法因为缓冲区太小而丢数据。这里遇到的一个常见问题是DataReceived事件触发的频率不定有时一次来十几个字节有时只来两三个字节直接ReadExisting()拿到的数据串可能是不完整的。所以必须引入累积缓冲机制不能每次事件都当成一帧完整数据。经验做法是FrameParser内部维护一个Listbyte或MemoryStream不断追加再按结束标志切帧。3.2 粘包与半包帧解析器的缓冲与切割逻辑固定式扫码器发数据不像我们人手工按键那么整齐硬件层面一次中断可能包含半帧、一帧或多帧数据。如果代码写得简单粗暴比如每次收到数据就当成一条完整条码那 90% 的场景会出问题——条码被截断或者两条数据黏在一起。解决方案是设计一个带缓冲区切割的帧解析器。核心逻辑就是收到新数据先追加到缓冲区然后循环查找帧结束标志找到一个完整帧就切出来缓冲区里剩下的部分继续等下一批数据。public class FrameParser { private readonly Listbyte _buffer new Listbyte(); private readonly byte[] _endMark; private readonly Actionstring _onFrameReceived; public FrameParser(byte[] endMark, Actionstring onFrameReceived) { _endMark endMark; _onFrameReceived onFrameReceived; } public void Append(byte[] data) { _buffer.AddRange(data); ProcessBuffer(); } private void ProcessBuffer() { int markIndex; while ((markIndex FindEndMark()) 0) { // 截取完整帧不含结束标志 byte[] frame _buffer.Take(markIndex).ToArray(); _buffer.RemoveRange(0, markIndex _endMark.Length); string content Encoding.UTF8.GetString(frame).Trim(); if (!string.IsNullOrEmpty(content)) { _onFrameReceived(content); } } } private int FindEndMark() { for (int i 0; i _buffer.Count - _endMark.Length; i) { bool matched true; for (int j 0; j _endMark.Length; j) { if (_buffer[i j] ! _endMark[j]) { matched false; break; } } if (matched) return i; } return -1; } }这段代码的逻辑要细说。_buffer是累积缓冲区Append方法把新数据追加进去后立即尝试切帧。FindEndMark用暴力匹配找结束标志找到就说明缓冲区里至少有一个完整帧。RemoveRange把已处理的数据从缓冲区移除保证下一轮不会重复解析。这段代码里有一个容易被忽略的细节为什么用while而不是if因为一次 Append 的字节里可能包含多个完整帧比如扫码器在高速模式下缓存了多条条码一次性发过来。如果只用if一次只切一帧剩下的帧会一直在缓冲区里等下一次 Append 才被处理造成延迟。用while循环可以把所有完整帧一次性处理完。参数说明_endMark来自配置文件的FrameEndMark转成字节数组传入。扫码器的结束标志通常是\r\n十六进制 0D 0A也有用\000或者纯\n0A的。实在不确定可以用串口调试工具抓一下原始十六进制数据看一眼结尾是什么。还有一种情况是扫码器配置成了“连续发送模式”而不是“触发模式”这样的话数据会源源不断进来需要根据实际业务决定是只取第一帧还是全部处理。3.3 断线检测与自动重连逻辑固定式扫码器在车间环境的故障率不算低——线缆松动、扫码器死机、USB 转串口芯片掉线都是常见问题。如果上位机没有断线重连机制操作工只能重启软件甚至重启工控机。这块源码里通常会有一个简单的轮询检测机制。public class ReconnectService { private readonly SerialPort _port; private readonly int _timeoutMs; private readonly Timer _timer; public ReconnectService(SerialPort port, int timeoutMs 3000) { _port port; _timeoutMs timeoutMs; _timer new Timer(CheckConnection, null, Timeout.Infinite, Timeout.Infinite); } public void Start() { _timer.Change(0, _timeoutMs); } private void CheckConnection(object state) { if (!_port.IsOpen) { try { _port.Open(); } catch (Exception) { // 重连失败等待下一轮定时器再试 } } } }重连逻辑本身不复杂核心是两点第一Timer定时检查串口状态间隔建议 3 到 5 秒太频繁会干扰正常通信太久又影响恢复速度第二重连时要做好异常捕获SerialPort.Open()在设备不存在或端口被占用时会抛异常这个异常必须在定时器回调里吞掉否则会一路抛到 UI 线程引发崩溃。另外提一个比较隐蔽的问题——SerialPort对象在Open()失败后处于不可复用状态直接再次Open()会抛InvalidOperationException。我一般会在重连前先Dispose()旧对象重新new SerialPort虽然听起来有点粗暴但实测最稳定。这个细节在源码里如果没处理运行一晚上之后大概率会翻车。4. 多线程扫码队列与数据落库别让 UI 卡死在扫码高峰4.1 为什么不能在 DataReceived 里直接更新 UI扫码数据进来之后最终要落到界面上、写进数据库里。但这三步必须拆开。如果直接在DataReceived回调里做gridView.AddRow()或者sqlHelper.Insert()扫码频率稍高一点界面就会开始掉帧、卡顿严重时直接假死。原因不难理解DataReceived线程和 UI 线程是两码事在后台线程里操作 UI 控件要么跨线程异常要么被迫走Invoke而Invoke的代价是阻塞后台线程直到 UI 处理完。更麻烦的是数据库写入。SQLite 写入有锁如果扫描频率高每次扫码都开一个连接写一条记录两三次操作撞在一起就报database is locked。正确处理方式是把“收数据”和“处理数据”彻底分离。收数据线程只负责把解析好的条码塞进一个线程安全的队列后台工作线程从队列里取数据再做界面刷新和数据入库。4.2 并发队列 消费线程的正确写法这里我推荐ConcurrentQueueT配合一个常驻后台线程。生产者的职责是Enqueue消费者的职责是TryDequeue两边各干各的解耦且不会互相阻塞。public class ScanDataProcessor { private readonly ConcurrentQueueScanDataModel _queue new ConcurrentQueueScanDataModel(); private readonly CancellationTokenSource _cts new CancellationTokenSource(); private readonly object _uiLock new object(); public void Start() { Task.Factory.StartNew(ProcessLoop, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } public void Enqueue(ScanDataModel data) { _queue.Enqueue(data); } private void ProcessLoop() { while (!_cts.IsCancellationRequested) { if (_queue.TryDequeue(out ScanDataModel data)) { // 1. 做业务校验条码长度、格式、重复检查 if (Validate(data)) { // 2. 批量入库使用连接池或单连接复用 InsertToDatabase(data); // 3. 通过事件通知 UI 更新UI 线程自行决定如何刷新 OnDataProcessed?.Invoke(data); } } else { // 队列为空sleep 一会儿避免空转占满 CPU Thread.Sleep(50); } } } }这个代码里有三个关键点。第一队列用ConcurrentQueue它是无锁并发集合多线程Enqueue和Dequeue都不会出问题。第二消费线程用LongRunning创建让线程池知道这是一个长期任务不要随意回收或迁移线程上下文。第三InsertToDatabase里不要每次开新连接要么用一个常驻连接要么用连接池SQLite 场景下一个连接做完所有写入是最稳的。关于队列为空的处理Thread.Sleep(50)是一个务实的选择。理论上可以用BlockingCollectionT的阻塞Take()但实际扫码场景里数据到达是突发的50 毫秒轮询的延迟几乎感知不到而且代码更可控。数据库写入的批量优化也有讲究。一次扫码一条记录的开销很小但如果是产线连续过料每秒 3 到 5 条一秒内频繁写磁盘SQLite 的 WAL 模式还会好一点默认模式很快就会遇到锁冲突。我一般会在消费线程里做一个小批量攒够 20 条或者间隔 500 毫秒再批量插入一次吞吐量会明显提升。4.3 界面刷新的线程安全做法界面刷新也要讲究章法。简单粗暴的做法是Invoke一条条加行频繁调用Invoke很耗性能。更好的做法是在消费线程里把待处理数据打包一次BeginInvoke把多条数据交给 UI 线程UI 线程里循环添加。private void OnDataProcessedBatch(ListScanDataModel items) { if (_gridControl.InvokeRequired) { _gridControl.BeginInvoke(new ActionListScanDataModel(AddRowsToGrid), items); return; } AddRowsToGrid(items); } private void AddRowsToGrid(ListScanDataModel items) { _gridView.BeginUpdate(); try { foreach (var item in items) { _gridView.AddRow(item); } } finally { _gridView.EndUpdate(); } }BeginUpdate和EndUpdate这对方法是 DevExpress 里优化批量操作的标配。在BeginUpdate之后 GridControl 暂停所有内部布局计算直到EndUpdate才一次性重绘这比逐条AddRow然后自动刷新要高效得多。扫码高峰期UI 线程只做一次批量刷新整体帧率能稳住。注意一个问题BeginInvoke是异步的它不等待 UI 线程处理完毕就返回。这在某些场景下会造成积压——后台线程疯狂BeginInvokeUI 线程处理不过来。解决办法是做一个简单的背压如果队列长度超过某个阈值比如 100就暂停Enqueue或者丢弃旧数据保证界面永远处理最新数据。生产环境里扫码数据不是关键业务数据丢几条旧的比卡死整个系统强。5. 避坑固定式扫码系统开发中的五个高频翻车点5.1 串口数据粘包和半包条码被截断或拼接现象扫描一批条码偶尔出现一条数据变成半截或者两条数据黏在一起变成一条。原因扫码器发送数据并不是一次中断发完一整帧DataReceived事件按字节流到达代码如果按“一次事件 一条数据”处理就必然出问题。解决用第 3.2 节的累积缓冲区方案先追加再按结束标志切帧。不要用ReadExisting()直接当完整数据用。另外建议在串口调试工具里先抓一次十六进制原始数据确认结束标志到底是什么不要猜。5.2 界面跨线程异常或Invoke卡死现象程序跑一会儿突然抛InvalidOperationException提示“线程间操作无效”或者界面间歇性无响应。原因在DataReceived回调里直接操作 UI 控件或者频繁调用Invoke导致后台线程被 UI 线程拖死。解决回顾第 4 章的架构——DataReceived里只入队消费线程完成解析和落库UI 更新用BeginInvoke批量通知。不要用Invoke同步版本它会在 UI 线程忙的时候阻塞调用线程。5.3 DevExpress 授权编译报错现象源码在自己机器上编译没问题换一台机器编译报许可证错误提示缺少 DevExpress 的 license 信息。原因DevExpress 的控件在运行时检查许可证licenses.licx文件记录的是设计时生成的许可证信息机器环境变了或者引用的 DLL 版本与本地安装版本不一致就会触发这个检查。解决确认所有 DevExpress DLL 的版本号与 licx 文件一致如果版本不一致删除当前 licx 文件打开主窗体设计器重新拖一遍控件让 VS 重新生成部署到目标机器时确保 DevExpress 的 DLL 都拷到了输出目录不要只拷部分引用。5.4 数据库连接频繁打开导致 SQLite 锁死现象扫码速度一快程序就报database is locked过一会儿自己恢复然后又报。原因每次写入都新建连接和关闭连接SQLite 对单文件数据库的写锁是全局的并发写必然冲突。解决整个程序生命周期内维护一个 SQLite 连接写入走同一个连接或者用 WAL 模式PRAGMA journal_modeWAL提高读写并发还可以在消费线程里做成批量提交减少事务次数。5.5 扫码器恢复连接后数据丢帧现象扫码器断电重启后上位机不自动恢复通信或者恢复之后丢了断电期间的数据。原因SerialPort对象在设备断开后会处于异常状态直接Open()可能不报错但实际收不到数据另外上位机没有做数据缓存断电期间的扫码数据没有本地保存机制。解决重连前强制Dispose()再重新创建SerialPort对象见 3.3 节对关键扫码数据进入一个本地待上传表UI 上标记“未上传”重连后补传。这个设计在产线场景里非常重要掉了数据有时候比系统崩溃还难解释。6. 进阶扫码联动与报表导出的一次铺好扫码系统做到能收、能存、能看只能算及格。真正让使用方觉得“好用”的是数据联动和追溯能力。我之前做的项目里有一条需求是扫码成功后在界面上弹出一个物料信息卡片同时把扫码记录导出成 Excel 日报。这两件事如果后期再加改动成本都不小所以源码里最好提前铺好这两条路。联动部分推荐做一个简单的ScanEventBus。用 C# 的event ActionScanDataModel扫码处理器每解析一条数据就触发一次事件界面的物料卡片、数据库的写入、外加一个状态栏计数器都订阅这个事件。好处是后续想增加联动设备比如扫码后自动触发相机拍照或者控制 PLC 分流只需要多写一个订阅方法不动已有的核心代码。public static class ScanEventBus { public static event ActionScanDataModel OnScanned; public static void Publish(ScanDataModel data) { OnScanned?.Invoke(data); } }订阅方在窗体Load事件里挂上关闭时记得-退订防止内存泄漏。这个写法很简单但能避免把一堆逻辑堆在扫码事件里越堆越乱。报表导出这块DevExpress 自带的能力够用。GridView直接调用ExportToExcel或者ExportToPdf几行代码就能把当前表格内容导出去。但如果要导出的不是当前看到的列表而是按班次、按批次汇总的数据就得在导出前先构造一个汇总用的DataTable再绑到一个临时GridView上导出。private void ExportDailyReport(DateTime date) { var summaryTable _repository.GetSummaryByDate(date); using (var saveDialog new SaveFileDialog { Filter Excel 文件|*.xlsx }) { if (saveDialog.ShowDialog() DialogResult.OK) { var gridView new GridView(); gridView.GridControl new GridControl { DataSource summaryTable }; gridView.ExportToExcel(saveDialog.FileName); } } }这个做法的核心思路是“视图与数据源分离”——GridView只是一个展示载体导出的内容由DataSource决定和界面上正显示着多少行数据没有关系。随便来一个DataTable都能导所以报表的格式灵活性反而更高。用这套逻辑重新梳理之后我的习惯是每个扫码项目开工前先把数据流图画一遍扫码器到串口、串口到解析器、解析器到队列、队列到数据库和 UI。画完这张图再写代码踩坑率会降一大截。从那以后我每次做类似项目都强制先走一遍这个流程尤其是DataReceived里只入队这条铁律救过我好几次。希望帮到你。本文还有配套的精品资源点击获取
返回列表