Delphi7对接TCG-225抓拍机:实时预览与车牌识别回调实战
简介:这是一份面向 Delphi7 开发者与旧系统集成人员的海康 TCG-225 联调示例,聚焦摄像头预览、手动抓拍、开启监听、监听抓拍事件并提取车牌号码,适合快速验证设备能力或在既有 Delphi 项目中接入海康 SDK。压缩包共 54 个文件、约 14.25MB,以 dll、lib 等 SDK 运行与链接库为主,配合 pas、dcu、dfm、dpr 等 Delphi 源码、编译单元、窗体和工程文件,并含 cfg、properties、intlog 等配置日志及一个可执行测试程序,便于对照调用链。已有 1396 人学习下载。工程保留预览、抓拍、报警监听与车牌提取的完整线索,可参考监听地址和端口配置、视频检测触发方式与事件处理思路,减少重复摸索并复用到设备接入联调场景。
1. 一台 TCG-225 加一个 Delphi7 工程:预览与车牌识别为什么还落在老栈上
TCG-225 这类抓拍一体机在存量系统里到处都是,抓拍和车牌识别都在前端完成,真正的硬活在后端:画面要稳定显示出来,识别结果要一条不落地接进业务库。用 Delphi7 做这件事不但没过时,反而是不少现场唯一编得过、派得下去的方案——老机器、老工程、老运维,换不了环境。这套实现的核心就三块:调设备 DLL 完成登录,把实时画面挂到窗口上做预览,订阅报警回调拿车牌号与抓拍图。适合两类人:手里压着一批存量抓拍机、要做客户端改造的工程师;以及想搞清楚设备 SDK 在 Delphi7 里到底怎么调、哪些坑必须先趟的人。下面按“先调通、再出画、最后拿数据”的顺序拆。
2. 先把 DLL 调通:声明、结构体对齐与登录句柄
这一段是全部工作的地基。DLL 没调通,后面所有关于预览和车牌的问题都无从谈起,而且调不通的表现往往不是“报个错”,是进程直接消失,连堆栈都没有,查起来全靠猜。所以宁可在这里多花半天,把声明、对齐、字符集这三件事一次做对。
2.1 静态加载还是 GetProcAddress:32 位 DLL 的三条硬约束
第一条约束是位数。Delphi7 只能产出 32 位可执行文件,所以必须用 SDK 包里 x86 那一套 DLL。拿错成 x64 的,LoadLibrary直接返回 0,GetLastError是 193(不是有效的 Win32 应用程序),这个还算好查;麻烦的是目录里同时躺着好几个同名 DLL,程序启动时按搜索路径先命中了错的那个,表现就是运行到某个接口才崩。
第二条约束是调用约定。这类设备 SDK 的导出函数绝大多数是stdcall,回调也是stdcall;少数辅助接口是cdecl。声明错一个,栈在函数返回时就不平衡了,崩的位置离真正的错误十万八千里。拿不准的时候,看头文件里的WINAPI、CALLBACK宏,那就是stdcall。
第三条是加载方式。我一般不用external静态绑定,改用LoadLibrary+GetProcAddress。理由很实际:现场设备固件版本杂,配套 DLL 也杂,静态绑定等于把 DLL 名字焊死在 exe 里,换一台设备就得重编译。动态加载可以在启动时按配置路径找 DLL,找不到给一句人话提示,而不是弹一个系统级错误框。
unit uSdkGate; interface type TSdkInit = function: LongBool; stdcall; TSdkCleanup = procedure; stdcall; TSdkGetLastError= function: DWORD; stdcall; TSdkLoginV40 = function(pIP: PAnsiChar; wPort: Word; pUser, pPwd: PAnsiChar; pDevInfo: Pointer; var pdwErr: DWORD): LongInt; stdcall; TSdkLogout = function(lUserID: LongInt): LongBool; stdcall; var SdkInit: TSdkInit = nil; SdkCleanup: TSdkCleanup = nil; SdkGetLastError: TSdkGetLastError = nil; SdkLoginV40: TSdkLoginV40 = nil; SdkLogout: TSdkLogout = nil; function SdkLoad(const ADllPath: string): Boolean; procedure SdkUnload; function SdkErrText(AErr: DWORD): string; implementation uses Windows, SysUtils; var hSdk: THandle = 0; function Bind(const AName: AnsiString; AAddr: PPointer): Boolean; begin AAddr^ := GetProcAddress(hSdk, PAnsiChar(AName)); Result := Assigned(AAddr^); end; function SdkLoad(const ADllPath: string): Boolean; begin Result := False; if not FileExists(ADllPath) then Exit; hSdk := LoadLibrary(PChar(ADllPath)); if hSdk = 0 then Exit; // 193 基本就是位数不对 // 任何一个必需符号缺失都整体失败,别让程序带病跑 if not Bind('SDK_Init', @SdkInit) then Exit; if not Bind('SDK_Cleanup', @SdkCleanup) then Exit; if not Bind('SDK_GetLastError',@SdkGetLastError)then Exit; if not Bind('SDK_Login_V40', @SdkLoginV40) then Exit; if not Bind('SDK_Logout', @SdkLogout) then Exit; Result := SdkInit(); end; procedure SdkUnload; begin if Assigned(SdkCleanup) then SdkCleanup(); if hSdk <> 0 then FreeLibrary(hSdk); hSdk := 0; end;函数名这里做了去厂商化处理,实际写的时候按随包头文件逐个对着改,一个字母都不能差——GetProcAddress是大小写敏感的,而且没有“差不多”这回事。Bind用PPointer接收地址而不是var Pointer,是因为 Delphi 的var参数要求类型严格一致,@SdkInit传进去会被编译器拦下。SdkInit失败通常意味着依赖的运行时库没跟着放齐,把 SDK 包里的运行库一起拷到 exe 同目录。
2.2 packed 与字符集:结构体错一个字节,返回值就是垃圾
结构体是这套代码里最容易翻车的地方,而且翻车方式是静默的:调用返回成功,字段读出来是乱码或者天文数字。原因几乎都是对齐。Delphi 默认按 8 字节对齐,编译期会在字段之间塞填充字节,而 SDK 那头是按 1 字节紧凑排布的,两边对同一个偏移量的理解完全不一样。解决办法只有一个:结构体一律packed record,或者在单元头部压{$A-}。
type TDevInfoV40 = packed record sSerialNumber: array[0..47] of AnsiChar; // 48 字节序列号,定长、非 #0 结尾 byAlarmInPortNum: Byte; byAlarmOutPortNum: Byte; byDiskNum: Byte; byDevType: Byte; byChanNum: Byte; // 模拟通道数 byStartChan: Byte; byAudioChanNum: Byte; byRes: array[0..3] of Byte; // 保留字段必须占位,不能删 end; // 上线前必须过的一关:与头文件里的 sizeof 逐个对 procedure CheckStructSize; begin Assert(SizeOf(TDevInfoV40) = 48 + 12, 'TDevInfoV40 尺寸与 SDK 头文件不一致'); end;几个细节值得单独说。第一,保留字段byRes不能因为“用不上”就删掉,删了后面的偏移全错。第二,sSerialNumber是定长数组、不一定以#0结尾,直接用StrPas碰上没结尾的会一路读到越界,正确做法是SetLength(S, 48)后Move过去再Trim。第三是字符集:Delphi7 的string就是AnsiString,走系统代码页,简体中文环境下是 GBK,而设备返回的车牌、车道名也是 GBK,所以取回来直接显示没问题;一旦要写成 UTF-8 的 JSON 或传给只吃 Unicode 的接口,就得老实走一遍MultiByteToWideChar(936, ...)再UTF8Encode,中间少一步就是满屏问号。
2.3 登录、能力集与退出:把 userId 管起来
登录本身只有一行调用,麻烦的是登录之后。userId是后续所有接口的通行证,必须当成有生命周期的东西管起来:谁登录的、什么时候失效、程序退出时有没有Logout。见过太多现场是登录成功就撒手不管,进程一异常退出,设备侧会话没释放,反复重启几十次之后设备直接拒绝新连接,只能断电。
function LoginDevice(const AIP: AnsiString; APort: Word; const AUser, APwd: AnsiString; out AUserID: LongInt; out ADevInfo: TDevInfoV40; out AErr: DWORD): Boolean; begin FillChar(ADevInfo, SizeOf(ADevInfo), 0); AUserID := SdkLoginV40(PAnsiChar(AIP), APort, PAnsiChar(AUser), PAnsiChar(APwd), @ADevInfo, AErr); Result := AUserID >= 0; if not Result then AErr := SdkGetLastError; // 设备侧返回码之外,再取一次网络层错误 end;参数上有几点经验。APort一般是设备默认服务端口,填错的表现是超时而不是拒绝,跟网络不通很难区分,所以排查时先telnet一下端口。AErr这个出参分两层含义:调用返回 -1 时它可能是设备回的鉴权错误,也可能是网络层错误,两者要靠SdkGetLastError再取一次才能分清。AUserID >= 0才是成功,用> 0判断在某些实现上会误判。
登录成功不等于能预览。ADevInfo.byChanNum告诉你这台设备有几个通道,通道号从byStartChan开始连续编号;单通道的抓拍一体机通常是 1 个通道,起始通道号 1。有人图省事直接写死通道 1,碰上多通道设备或者做过通道映射的现场,就变成“登录成功但预览是黑的”。把这个值读出来,界面上让运维自己选,比写死强。
还有退出。Logout要在窗体OnClose里、SdkCleanup之前调,顺序反了会泄漏会话。
3. 预览怎么落到窗口上:句柄模式、回调模式与多路分屏
登录通了,下一步是出画。这块最容易出的错不是接口不会调,是窗口和 SDK 抢渲染权。
3.1 句柄模式:把 Panel.Handle 交给 SDK,别交给 Form
句柄模式的逻辑很简单:告诉 SDK 一个窗口句柄,它在里面建自己的子窗口,自己解码、自己画。你只要保证这个句柄活着、可见、不被 VCL 反复擦除。
type TPreviewParam = packed record dwSize: DWORD; hPlayWnd: HWND; // 渲染目标窗口 byProtoType: Byte; // 传输协议,0 一般走默认 byRes: array[0..2] of Byte; end; function StartPreview(AUserID, AChan: LongInt; AWnd: HWND; out APlayID: LongInt): Boolean; var P: TPreviewParam; begin FillChar(P, SizeOf(P), 0); P.dwSize := SizeOf(P); // 忘了赋值,很多 SDK 直接返回参数错误 P.hPlayWnd := AWnd; APlayID := SdkRealPlayV40(AUserID, AChan, @P, nil, nil); Result := APlayID >= 0; end;dwSize一定要赋,这是这类 SDK 做版本兼容的标准套路,不填就是“参数非法”,而不是崩溃,所以容易被当成接口不支持。APlayID是预览句柄,停预览的时候要用它,别丢。
窗口这边有个坑值得说透:句柄要用TPanel.Handle,别用Form.Handle。给 Form 的句柄,SDK 建出来的子窗口会盖住整个窗体,上面的按钮全被埋掉。给 Panel 的时候,Panel 的BevelOuter设成bvNone,Ctl3D关掉,也别在它上面叠 Label——VCL 的重绘和 SDK 的直接绘制一打架,表现就是画面一闪一闪、或者切到别的窗口再切回来只剩一块灰。
如果现场必须叠加 OSD(比如车牌号大字显示),两条路:一是用 SDK 自己的叠加接口,画在解码层里,稳;二是在 Panel 上面盖一个透明背景的TLabel,改ParentBackground让 VCL 不刷底,但这条路在某些显卡驱动上依然闪,我一般优先选前者。
3.2 回调模式:什么时候必须自己解帧
句柄模式够用,但它有个前提——画面只需要给人看。一旦要把帧送去做二次处理,句柄模式就没法参与了。
回调模式有两种粒度:一种拿原始码流(H.264/H.265),一种拿解码后的 YUV 帧。Delphi7 里自己解 H.264 的成本很高,没有成熟的解码库就得引第三方 DLL,得不偿失。所以常见做法是:句柄模式负责给人看,抓图接口负责给算法取帧,两套并存,互不干扰。
// 抓一帧 JPEG,交给后续的车牌二次识别或取证留档 function SnapshotJpeg(AUserID, AChan: LongInt; AStream: TMemoryStream; out AW, AH: Integer): Boolean; var PicParam: TSnapParam; pBuf: Pointer; dwLen: DWORD; begin Result := False; FillChar(PicParam, SizeOf(PicParam), 0); PicParam.dwSize := SizeOf(PicParam); PicParam.dwChannel := AChan; PicParam.dwPicSize := 2; // 一般 0=CIF 1=QCIF 2=4CIF,按需调 PicParam.dwPicQuality := 0; // 0 最好,文件也最大 pBuf := nil; dwLen := 0; if not SdkCapturePic(AUserID, @PicParam, nil, @pBuf, dwLen, AW, AH) then Exit; try AStream.Clear; AStream.WriteBuffer(pBuf^, dwLen); // 只在返回后立即拷,指针随时可能被回收 finally SdkFreePicBuf(pBuf); // 有释放接口就必须调,否则必然泄漏 end; Result := True; end;这段的关键在最后三行。抓图接口返回的缓冲区是 SDK 内部分配的,必须调对应的释放函数,不释放跑一天就把内存吃干净;同时拷贝要在释放之前完成。dwPicSize和dwPicQuality这两个参数直接决定带宽和存储——4CIF 高质量的单帧 JPEG 一二百 KB,每秒抓一张一天就是十几个 G,现场按实际需要往下降一档,肉眼几乎看不出差别。
3.3 分屏切换与窗口重建:先 Stop 再 Destroy
多路预览的界面必然要支持 1/4/9 分屏切换,切换就意味着销毁 Panel 再重建。顺序错了,SDK 还握着一个已经释放的窗口句柄在往里画,结果是进程随机崩,事件查看器里只留一句访问冲突。
procedure TfrmMain.ApplySplit(ACols, ARows: Integer); var i: Integer; begin // 1) 先全部停预览,把句柄还给 SDK for i := 0 to High(FChannels) do if FChannels[i].PlayID >= 0 then begin SdkStopPlay(FChannels[i].PlayID); SdkLogoutPlay... // 按需:有的实现要额外释放 FChannels[i].PlayID := -1; end; // 2) 再释放面板 for i := 0 to High(FChannels) do FreeAndNil(FChannels[i].Panel); // 3) 重建并重新开始预览 BuildPanels(ACols, ARows); StartAllPreviews; end;还有个隐蔽的问题:窗口最小化再还原。有些显卡驱动在窗口隐藏时会回收渲染表面,SDK 不知道,还原之后就是一片黑。稳妥做法是主窗体OnResize里判断WindowState,从最小化恢复时把预览全部 Stop 再 Start 一遍,代价是闪一下,但比黑屏让运维打电话强。
4. 车牌结果怎么拿到手:布防回调、结构体解析与抓图落盘
预览是给人看的,车牌数据才是业务要的。TCG-225 这类设备自己完成识别,后端要做的就是订阅。
4.1 布防与回调线程模型:SDK 线程不碰 VCL
拿车牌结果要先布防(订阅报警),布防成功后设备一旦识别到车牌,就在 SDK 自己的线程里回调你注册的函数。这里有一条铁律:回调里绝对不能碰任何 VCL 控件。Delphi 的 VCL 不是线程安全的,从非主线程去改Label.Caption,轻则界面卡住,重则直接 AV,而且这种崩溃是概率性的,测的时候好好的,上线跑两天炸一次。
type PPlatePacket = ^TPlatePacket; TPlatePacket = record Buf: Pointer; Len: DWORD; RecvTick: Cardinal; end; function AlarmCallBack(lCommand: LongInt; lHandle: LongInt; pAlarmInfo: Pointer; dwBufLen: DWORD; pUser: Pointer): Boolean; stdcall; var Pkt: PPlatePacket; begin Result := True; // 必须返回 True,否则可能被反复重投 if lCommand <> CMD_ITS_PLATE then Exit; if (pAlarmInfo = nil) or (dwBufLen = 0) then Exit; New(Pkt); GetMem(Pkt^.Buf, dwBufLen); CopyMemory(Pkt^.Buf, pAlarmInfo, dwBufLen); // 只拷,不解引用原指针 Pkt^.Len := dwBufLen; Pkt^.RecvTick := GetTickCount; PostMessage(g_hMsgWnd, WM_PLATE_ARRIVED, 0, LPARAM(Pkt)); end;三个要点。pAlarmInfo指向的内存只在回调执行期间有效,回调一返回就被 SDK 回收,所以必须CopyMemory出一份自己的副本,任何“先存指针、回头再读”的写法都是自找麻烦。回调用PostMessage而不是SendMessage,前者投递完立刻返回,不阻塞 SDK 线程;后者会在主线程忙的时候把 SDK 回调线程一起拖住,久了设备侧会判定客户端无响应。回调函数本身必须是stdcall,并且在程序整个生命周期内保持有效——如果写成某个窗体对象的方法,窗体一释放,SDK 再来回调就是野指针。
布防和撤防要成对:
FAlarmHandle := SdkSetupAlarmChan_V30(FUserID); if FAlarmHandle < 0 then ShowSdkError('布防失败'); // 退出时 if FAlarmHandle >= 0 then begin SdkCloseAlarmChan_V30(FAlarmHandle); FAlarmHandle := -1; end;4.2 车牌结构体怎么读:指针、长度和编码三件事
回调里拿到的那块内存,开头是一个结构体,里面既有定长的车牌字符串,也有指向 JPEG 的指针和长度。字段顺序必须和头文件逐字节对齐,这一点和 2.2 节是同一个道理。
type TItsPlateResult = packed record dwSize: DWORD; // 本结构体大小,用于版本兼容 byResultType: Byte; byPlateColor: Byte; // 0 蓝 1 黄 2 白 3 黑 4 绿 byPlateLen: Byte; // 车牌字符数 byRes1: Byte; sPlateNo: array[0..31] of AnsiChar; // GBK,可能不以 #0 结尾 dwConfidence: DWORD; // 置信度 0-100 dwPassTime: DWORD; // 过车时间,Unix 秒 wLane: Word; byRes2: array[0..1] of Byte; pJpegPic: Pointer; // 抓拍图,指向本块内存内部 dwJpegLen: DWORD; end; function ParsePlate(ABuf: Pointer; ALen: DWORD; out APlate: AnsiString; out AColor, AConf: Integer; out APassTime: TDateTime): Boolean; var R: TItsPlateResult; begin Result := False; if (ABuf = nil) or (ALen < SizeOf(DWORD)) then Exit; // 先只读 dwSize,判断设备固件给的块比我们认识的大还是小 Move(ABuf^, R.dwSize, SizeOf(DWORD)); if R.dwSize < SizeOf(TItsPlateResult) then Exit; // 结构体版本过旧,拒绝解析 FillChar(R, SizeOf(R), 0); Move(ABuf^, R, SizeOf(R)); // 只拷认识的前缀,多出来的忽略 APlate := Trim(string(R.sPlateNo)); // GBK 在此环境下可直接显示 AColor := R.byPlateColor; AConf := R.dwConfidence; APassTime := UnixToDateTime(R.dwPassTime); Result := APlate <> ''; end;dwSize是这套结构体设计里最有价值的东西:设备固件可能比 SDK 新,结构体尾部多出几个字段,只要它报的dwSize不小于我们认识的尺寸,就按前缀解析、多余部分忽略,不会因为版本不一致直接崩。反过来,dwSize比我们小的,说明对方是老固件,要么降级 SDK,要么按最小公共字段解析。
车牌字符串这里,Trim(string(...))能直接出中文,因为源和目标都是 GBK;但注意别写成逐字节for循环拼Char,汉字是双字节,逐字节拼必然出乱码,这类问题在测试阶段只要拿一块真车牌试一次就能发现,不试就等着上线后被业务方投诉。
4.3 落盘与入库:文件名、去重和一条 SQL
抓拍图存在结构体内部(pJpegPic指向同一块报警内存),所以拷贝的时机和 4.1 一样——在回调里整体拷走,主线程解析时再定位。
procedure SavePlateJpeg(const APlate: AnsiString; ATime: TDateTime; AData: Pointer; ALen: DWORD); var FS: TFileStream; Path: string; begin if (AData = nil) or (ALen = 0) then Exit; Path := Format('%s\%s_%s.jpg', [FPicDir, string(APlate), FormatDateTime('yyyymmddhhnnsszzz', ATime)]); FS := TFileStream.Create(Path, fmCreate); try FS.WriteBuffer(AData^, ALen); finally FS.Free; end; end;文件名用“车牌_毫秒时间戳”,天然可读,也方便按天归档。目录按yyyy-mm-dd分层,别一个目录塞几十万文件,Windows 上单目录文件数一大,列目录能卡好几秒。
去重是必须的。同一辆车在抓拍区里往往连续触发好几条,业务上只要一条。我一般按“车牌 + 通道 + 3 秒窗口”做内存去重,用一个TStringList存车牌|通道|秒的键,定时清理过期项。入库就是一条普通的参数化插入:
INSERT INTO t_plate_pass (plate_no, plate_color, chan_no, pass_time, confidence, img_path, recv_time) VALUES (:plate_no, :plate_color, :chan_no, :pass_time, :confidence, :img_path, :recv_time);plate_color存数字枚举而不是中文,枚举映射放在展示层做,将来设备换了颜色定义不用改库。recv_time单独存一列,用来和设备的pass_time对比——两者差得离谱就说明客户端处理堵了,是排查性能问题的第一手证据。
5. 避坑与排查:五类翻车现场和处理顺序
下面这些不是理论推演,是这类客户端里出现频率最高的几类问题。按发生概率排序,遇到问题时从上往下对。
5.1 预览黑屏、拉花、切窗口回来变灰
现象:接口全返回成功,APlayID >= 0,但 Panel 里什么都没有;或者切到别的程序再切回来,画面只剩一块灰。
原因基本在三处:句柄给错了(给了 Form 或者给了还没HandleAllocated的 Panel);Panel 的 VCL 重绘和 SDK 渲染打架(BevelOuter没关、上面叠了其他控件);窗口最小化后渲染表面被驱动回收。
解决:句柄一律用已经创建完成的TPanel.Handle,BevelOuter := bvNone、Ctl3D := False,预览区不叠普通控件;主窗体OnResize里监听从最小化恢复,先StopPlay再重新RealPlay。如果还黑,用抓图接口抓一帧看看有没有图——有图说明是渲染问题,没图说明是通道号或码流问题,这一步能把排查范围砍一半。
5.2 登录成功,一调预览(或布防)进程就消失
现象:没有任何异常提示,进程直接退出,事件查看器里只有一条应用程序错误。
原因:调用约定写错(stdcall写成cdecl)、结构体没packed导致dwSize算错、必填出参传了nil、回调函数被释放后 SDK 仍在调用。这四种在表现上完全一样,都是栈或内存被破坏。
解决:按顺序验证——用SizeOf断言的CheckStructSize把结构体尺寸对一遍;确认每个函数指针声明的尾部是stdcall;回调函数用全局单元级函数而不是窗体方法;出参一律传真实变量地址。这类问题别靠猜,写一个只做登录 + 布防 + 停预览的最小验证程序,把变量一个个减到不能再减,问题是哪一个立刻现形。
5.3 车牌来了界面卡住、越跑越卡
现象:刚开始流畅,跑一两个小时之后界面明显迟钝,最后假死。
原因:回调里直接操作了 VCL,或者布防回调里做了耗时活(写文件、连数据库)。SDK 回调线程被拖住,报警包在设备侧堆积,最终反噬到预览线程。
解决:回调里只做一件事——CopyMemory出一份副本,然后PostMessage回主线程,立刻返回。所有解析、存盘、入库都在主线程或者独立的消费者线程里做。如果入库慢,就在主线程再丢进一个队列,用一个专门的线程消费,界面上做个小角标显示队列积压数,超过阈值有告警。
5.4 抓图文件 0 字节,或者整张全黑
现象:文件生成了,点开是全黑,或者大小是 0。
原因:一是在回调返回之后才去读pJpegPic指针,内存已经被回收;二是拷贝长度用了结构体里的长度字段但那个字段是 0(老固件不填);三是抓图接口返回的缓冲区没释放,或者释放了还在用。
解决:所有 JPEG 数据一律在回调内CopyMemory出来,长度优先用报警包总长减去结构体头,比结构体里的dwJpegLen更可靠;抓图接口的缓冲区遵循“拷完立刻释放”的固定套路。验证方法很土但有效:存盘后立刻用TJPEGImage尝试加载一次,加载失败就打日志,别等业务方发现。
5.5 断网、断电之后预览不恢复
现象:现场断一次网,恢复之后预览黑屏、车牌不再上报,程序必须重启。
原因:登录会话在设备侧已经失效,但客户端还以为userId有效,所有调用都返回错误码,而错误码没人检查。
解决:所有 SDK 调用统一走一个包装函数,返回值小于 0 就记错误码并触发状态机;加一个 5~10 秒的心跳检查(定期查一次设备状态或时间),连续失败两次就标记为掉线,进入重连流程。重连要先把旧的userId用Logout尝试释放一次(失败也无所谓),再重新登录、重新布防、重新开始预览。这条在 5.1 到 5.4 都稳定之后必做,因为这是现场最常发生的场景。
6. 进阶:重连状态机、72 小时压测与上机自检清单
前面几章解决的是“能跑”。这一章解决的是“敢让它在现场连着跑一个月”。
先说状态机。我在这种客户端里固定塞一个四状态的枚举,配一个 5 秒定时器,比什么重试策略都可靠:
type TConnState = (csIdle, csConnecting, csOnline, csLost); procedure TfrmMain.tmrHealthTimer(Sender: TObject); begin case FState of csIdle, csConnecting: TryConnect; csOnline: if not SdkCheckAlive(FUserID) then // 心跳失败 begin Inc(FFailCount); if FFailCount >= 2 then // 连续两次失败才判掉线,避免抖动 begin FState := csLost; StopAllPreviews; DoLogout; end; end else FFailCount := 0; csLost: begin Inc(FRetryCount); if FRetryCount mod 12 = 0 then // 一分钟重试一次,别死循环打设备 TryConnect; end; end; end;FFailCount >= 2这个阈值是要的。网络抖一下就把预览全停掉,用户体验很糟;但也不能太宽容,现场拔网线的测试里,一次失败就停比连续失败五次才停体验好得多。
压测我一般这么干:写个脚本,每 10 分钟禁用一次网卡再启用,自动跑 72 小时;期间用任务管理器盯三个数——句柄数、GDI 对象数、内存占用。句柄数单调上涨是最典型的泄漏信号,八成是预览句柄没停、或者报警内存没释放。数据上也有个粗略基准可以对照:
| 观察项 | 正常区间 | 异常信号 |
|---|---|---|
| 进程句柄数 | 浪费 1 小时内基本稳定 | 每小时涨 50 以上,必查预览释放 |
| GDI 对象数 | 稳定或小幅波动 | 与句柄同步上涨,多为 Panel 反复重建 |
| 内存占用 | 启动后 1 小时趋于稳定 | 每小时涨几十 MB,先查报警拷贝 |
| 抓图存盘失败率 | 0 | 出现即为路径权限或磁盘满 |
上机前我自己还会走一遍这张清单:DLL 位数与 exe 一致;SizeOf断言全部通过;预览区无叠加控件;报警回调只做拷贝;抓图缓冲区有对应释放;心跳与重连能自动恢复;日志里能看到每次登录、布防、掉线的原因码。这几条走完,剩下的问题基本都是设备侧或者网络侧的了。
我吃过最大的亏是一次现场升级,新固件把报警结构体尾部加了两个字段,客户端没做dwSize判断,直接整块Move到自己定义的小结构体里,越界写了两个字节——平时看不出来,赶上内存紧张的机器就崩。从那以后我每次对接新固件,都强制先把dwSize打出来和本地SizeOf比一遍再继续。希望帮到你。
本文还有配套的精品资源,点击获取