ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

WinForm调用大华摄像头NetSDK测试代码:预览抓图录像与断线重连

WinForm调用大华摄像头NetSDK测试代码:预览抓图录像与断线重连

简介:面向 Windows Forms 开发者与安防监控集成人员的大华摄像头调用测试工程,围绕视频流播放、图像抓拍、录像等核心功能给出可直接参考的实现,适用于需要快速接入大华 SDK、完成桌面端监控工具或物联网视频模块的初中级开发者。压缩包共 401 个文件,约 69.53MB,包含 67 个 cs 源码、144 个 dll 依赖、20 个 exe 示例程序,以及 csproj/sln 工程配置、resx/resources 界面资源、bmp/jpg 抓拍样张和 config/log 运行文件,较完整地保留了项目结构与调试痕迹。已有 1396 人学习下载。读者可从中了解 SDK 初始化、视频帧刷新、抓拍保存、录像编码、多线程与事件响应等关键环节,并借助现成工程对照排查设备连接、权限、兼容性等问题,减少从零搭建和联调的时间,适合作为二次开发与功能验证的参考底稿。

1. 从一次现场调试说起:为什么 WinForm 调摄像头总是先崩在回调上

去年冬天在某公司做园区改造,对方把一个 WinForm 客户端丢过来,说视频预览半小时后必掉线,抓拍偶尔生成 0 字节文件,关闭窗口时有概率直接闪退。我把代码翻了一遍,真正的问题只有三处:托管回调委托没有静态字段持有被 GC 回收了、结构体 dwSize 没赋值导致登录参数被拒、解决方案是 AnyCPU 而 SDK 库是 64 位。这类“玄学崩溃”在大华摄像头 NetSDK 的接入里极其典型——SDK 本身很稳,翻车几乎都发生在 C# 这层胶水代码上。这份 WinForm 调用测试代码的价值就在于把这些胶水写全了:登录、实时预览、抓图、本地录像、云台控制、断线重连都在里面,而且把位数匹配、结构体声明、回调持有这些最容易漏的地方做成了可运行的样板。

2. 环境与依赖对齐:位数、结构体、回调三件事先钉死

在动手写窗体之前,有三件事必须先钉死,否则后面所有调试都是玄学。第一是进程位数与 SDK 库位数必须一致,32 位进程加载 64 位 dll 会直接抛 BadImageFormatException,反过来则是登录返回 0 且没有任何错误码。第二是结构体声明必须与头文件逐字段对齐,NetSDK 里几乎所有入参结构体都要求先填 dwSize,字段顺序错一个、类型宽窄差一点,SDK 侧读到的就是垃圾数据。第三是回调委托必须有托管侧引用持有,这是 .NET 互操作里最经典的坑:委托对象被 GC 回收后,非托管代码还在往那个地址回调,进程直接 0xc0000005。这一章先把这三件事做成可复现的检查动作。

2.1 先确认 dll 位数,再谈其他

拿到 SDK 包之后不要急着往项目里拖。我一般先写一段 PowerShell 读 PE 头的 Machine 字段,把包里的主库位数一次性看清楚,确认它和你要构建的平台目标是同一套。这个动作花不了一分钟,但能省掉后面两小时的“登录返回 0”排查。

# 读取 PE 文件头中的 Machine 字段,判断 dll 是 32 位还是 64 位 function Get-PEMachine([string]$Path) { $fs = [System.IO.File]::OpenRead($Path) try { $br = New-Object System.IO.BinaryReader($fs) $fs.Position = 0x3C # PE 头偏移量所在位置 $peOff = $br.ReadInt32() # 取出 PE 头偏移 $fs.Position = $peOff + 4 # 跳过 "PE\0\0" 签名 return $br.ReadUInt16() # Machine 字段 } finally { $fs.Dispose() } } $code = Get-PEMachine "libs\x64\dhnetsdk.dll" switch ($code) { 0x8664 { "x64" } 0x014C { "x86" } default { "未知架构: 0x{0:X}" -f $code } }

这段脚本的核心是 0x3C 这个固定偏移,PE 文件在该位置存放指向 PE 头的 32 位偏移量,再往后 4 个字节跳过签名就是 Machine 字段。0x8664 对应 AMD64,0x014C 对应 i386。判定结果要和项目属性里的平台目标严格对齐,不要相信“AnyCPU 在 64 位系统上会自动跑 64 位”这种说法——AnyCPU 加 Prefer32Bit 的组合在部分模板里会生成 32 位进程。

接下来在 csproj 里把平台和 dll 复制固定下来。手写这段比在 VS 里点鼠标可靠,因为换机器、换 CI 的时候属性面板上的设置经常丢。

<PropertyGroup> <!-- 强制 x64,与 SDK 主库位数保持一致 --> <Platforms>x64</Platforms> <PlatformTarget>x64</PlatformTarget> <Prefer32Bit>false</Prefer32Bit> </PropertyGroup> <ItemGroup> <!-- 把 libs\x64 下的所有 dll 平铺复制到输出目录 --> <None Include="libs\x64\**\*.dll"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <TargetPath>%(Filename)%(Extension)</TargetPath> </None> </ItemGroup>

TargetPath写成平铺很关键。SDK 包通常按目录分层,但主库在加载同目录依赖时不会递归找子目录,一旦被放进runtimes之类的子路径就会报“找不到指定的模块”,而这个错误在异常信息里往往只显示主库名字,看起来像是主库没复制过去,实际是它的依赖没跟上。

PreserveNewest保证增量构建时不会反复覆盖已经调试过的库;如果你的调试习惯是直接替换输出目录里的 dll,把它改成IfNewer更省事。另外提醒一句,SDK 包的完整依赖清单以包内说明为准,不要只复制dhnetsdk.dll一个文件,我见过太多只拷主库然后卡在初始化阶段的案例。

2.2 P/Invoke 声明:结构体字段顺序与 dwSize

C# 侧最容易被低估的工作量就是结构体声明。NetSDK 的头文件里,入参结构体第一个字段几乎都是dwSize,SDK 靠它做版本兼容和越界检查,不填或者填错就返回参数错误。下面这段是登录用的高安全级别入参,字段顺序和类型要严格照着dhnetsdk.h抄,不要凭记忆补字段。

[StructLayout(LayoutKind.Sequential)] public struct NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY { public uint dwSize; // 必须 = Marshal.SizeOf(本结构体) [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string szIP; // 设备 IP 或域名 public int nPort; // 私有协议端口,默认 37777 [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string szUserName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string szPassword; public int emSpecCap; // 登录能力集,普通登录填 0 public IntPtr pCapParam; // 与 emSpecCap 配套,普通登录填 IntPtr.Zero } [StructLayout(LayoutKind.Sequential)] public struct NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY { public uint dwSize; public int nError; // 登录失败时的错误码 public IntPtr pDevInfo; // 指向设备信息结构,需要时再 Marshal }

ByValTStr加SizeConst = 64表示定长字符数组内联在结构体里,这正是 C 头文件里char szIP[64]的等价写法。如果偷懒用string直接映射,默认会按指针封送,结构体总长度就变了,SDK 读到szUserName的位置会拿到 IP 的后半截。dwSize要在每次调用前重新算,因为结构体在调试过程中可能被改动,写死 128 之类的常量迟早出事。

回调委托的声明要注意调用约定。NetSDK 在 Windows 下是 StdCall,漏掉UnmanagedFunctionPointer默认走 Winapi,在 x64 上恰好兼容,到 x86 上就栈失衡,表现为回调触发一次之后进程直接退出。

[UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate void fDisConnect(IntPtr lLoginID, IntPtr pchDVRIP, int nDVRPort, IntPtr dwUser); [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate int fHaveReConnect(IntPtr lLoginID, IntPtr pchDVRIP, int nDVRPort, IntPtr dwUser); public static class Native { [DllImport("dhnetsdk.dll")] public static extern bool CLIENT_Init(fDisConnect cbDisConnect, IntPtr dwUser); [DllImport("dhnetsdk.dll")] public static extern void CLIENT_Cleanup(); [DllImport("dhnetsdk.dll")] public static extern bool CLIENT_SetAutoReconnect(fHaveReConnect cbAutoConnect, IntPtr dwUser); [DllImport("dhnetsdk.dll")] public static extern IntPtr CLIENT_LoginWithHighLevelSecurity( ref NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY pstInParam, ref NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY pstOutParam); [DllImport("dhnetsdk.dll")] public static extern bool CLIENT_Logout(IntPtr lLoginID); }

CLIENT_Init返回 bool,很多样例代码不看返回值直接往下走,结果后面所有调用都返回 0。初始化失败最常见的原因是依赖库没铺齐或者位数不对,此时配合 2.1 的位数检查基本能定位。CLIENT_SetAutoReconnect的回调返回 int,不同版本头文件里可能是 int 也可能是 bool,照着dhnetsdk.h抄,别照抄博客。

提示:结构体字段的定义以 SDK 包内dhnetsdk.h为准,不同发布版本的字段可能有增补,本文给的是常见字段组合,接入新版本时一定要逐字段比对。

2.3 初始化与清理的时序,写在 Form 生命周期里

时序错是另一类高频翻车点。正确顺序是:CLIENT_Init→CLIENT_SetAutoReconnect→ 登录 → 预览/抓图/录像 → 停预览 → 登出 →CLIENT_Cleanup。清理必须严格逆序,而且要在所有业务句柄释放之后。我见过把CLIENT_Cleanup写在FormClosing最前面、后面还在调CLIENT_StopRealPlayEx的代码,那基本等于主动制造访问违例。

private static fDisConnect _disConnectCb; // 静态字段持有,防止被 GC 回收 private static fHaveReConnect _reConnectCb; private void FrmMain_Load(object sender, EventArgs e) { // 委托实例必须挂在静态字段上,局部变量会在方法返回后失去强引用 _disConnectCb = OnDeviceDisconnect; _reConnectCb = OnDeviceReconnect; if (!Native.CLIENT_Init(_disConnectCb, IntPtr.Zero)) { MessageBox.Show("SDK 初始化失败,检查 dll 位数与依赖"); return; } Native.CLIENT_SetAutoReconnect(_reConnectCb, IntPtr.Zero); } private void FrmMain_FormClosing(object sender, FormClosingEventArgs e) { StopLocalRecord(); // 先停录像,保证文件头写完 StopPreview(); // 再停预览句柄 if (m_loginId != IntPtr.Zero) { Native.CLIENT_Logout(m_loginId); m_loginId = IntPtr.Zero; } Native.CLIENT_Cleanup(); // 最后清 SDK }

OnDeviceDisconnect和OnDeviceReconnect两个回调里只做标记和投递消息,不要在里面直接操作控件。非托管线程回调进入托管世界时并不持有 UI 线程上下文,直接改Label.Text会抛跨线程异常,而这个异常在回调栈里往往被吞掉,表现为“界面就是不刷新”。常规做法是BeginInvoke回主线程,或者往一个ConcurrentQueue里塞事件、由Timer统一消费。

3. 登录与预览:把视频流塞进 PictureBox 的完整链路

预览这条链路看着简单,实际是整份代码里分支最多的地方:登录方式有新老两套、句柄要绑到具体控件、码流要区分主辅、断线要能自己爬起来。这一章按“登录 → 绑定窗口 → 重连”三段拆,每一段都给出可直接跑的代码和失败时的观察点。

3.1 高安全级别登录与设备信息

新版本 SDK 推荐用CLIENT_LoginWithHighLevelSecurity,它对密码传输做了保护,老接口CLIENT_LoginEx在部分新固件上会被拒绝。登录前先把结构体填全,dwSize用Marshal.SizeOf现算,IP、端口、账号、密码从配置读,不要硬编码在方法里。

private IntPtr m_loginId = IntPtr.Zero; private bool DoLogin(string ip, int port, string user, string pwd) { var inParam = new NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY { dwSize = (uint)Marshal.SizeOf(typeof(NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY)), szIP = ip, nPort = port, szUserName = user, szPassword = pwd, emSpecCap = 0, pCapParam = IntPtr.Zero }; var outParam = new NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY { dwSize = (uint)Marshal.SizeOf(typeof(NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY)) }; m_loginId = Native.CLIENT_LoginWithHighLevelSecurity(ref inParam, ref outParam); if (m_loginId == IntPtr.Zero) { // nError 是排查登录失败的第一手信息:密码错、账号锁、协议不匹配各有不同错误码 Log($"登录失败,错误码 {outParam.nError}"); return false; } return true; }

emSpecCap填 0 表示普通设备登录,如果要做主动注册或者特殊能力协商才需要改,配套的pCapParam也得跟着填,普通场景固定IntPtr.Zero。登录失败时不要只看返回值,outParam.nError才是关键:密码错、账户被锁、端口写错、设备不支持高安全级别登录,错误码各不相同,把错误码打日志里,现场排查能省一半时间。

还有一个细节:szPassword传明文即可,加解密由 SDK 内部完成,自己再套一层 MD5 反而登不上。至于字符编码,如果设备名、通道名出现乱码,检查 SDK 是否有编码设置接口,新版通常默认按 UTF-8,老版本可能是 GBK,这个开关按包内说明设一次就行。

3.2 实时预览:句柄绑定、码流选择、窗口比例

预览接口的关键参数只有两个:通道号和窗口句柄。句柄直接传PictureBox.Handle,SDK 会在这个窗口上自己建渲染表面,不需要你在Paint事件里画任何东西。这也是为什么预览窗口要用PictureBox而不是Panel——前者有稳定的窗口句柄和双缓冲,后者在部分场景下会被父容器重绘覆盖。

private IntPtr m_realHandle = IntPtr.Zero; private void StartPreview(int channel) { if (m_loginId == IntPtr.Zero) return; // 传入控件句柄,SDK 直接在目标窗口上渲染;nType 按头文件里的 EM_REALPLAY_TYPE 取值 m_realHandle = Native.CLIENT_RealPlayEx(m_loginId, channel, picPreview.Handle, 0); if (m_realHandle == IntPtr.Zero) { Log("预览失败,检查通道号是否存在、码流是否被占满"); return; } // 预览成功后再切码流:0 主码流,1 辅码流,按现场带宽选 Native.CLIENT_SetRealPlayStreamType(m_realHandle, 1); }

通道号从 0 开始,0 通常是第一路,负值在部分版本里表示本地通道(比如设备自带的 HDMI 输出画面),这个约定必须查头文件,不要照搬别人的示例。码流切换接口在不同版本里名字可能不同,有的用CLIENT_SetRealPlayStreamType,有的在CLIENT_RealPlayEx的nType参数里直接表达,接入前先确认。辅码流分辨率低、码率小,多窗口轮巡时优先用它,单窗口大屏展示再切主码流,这个策略在现场几乎不会错。

窗口尺寸变化时不要自己算缩放,SDK 跟随窗口大小自动适配。但如果你的PictureBox设了SizeMode = StretchImage,那个属性只影响Image属性,和 SDK 渲染无关,留着不影响。真正要注意的是窗体最小化:部分版本在窗口句柄失效后会停止解码,恢复时需要重新调一次预览,稳妥做法是在Resize事件里判断WindowState并从Minimized恢复时重启预览。

3.3 断线重连:回调里只做标记,重连放到主线程

CLIENT_SetAutoReconnect注册的回调在设备掉线时触发,但它触发不代表 SDK 已经替你重连成功,它只是通知你“这个登录句柄断了”。很多样例代码在这个回调里直接调CLIENT_LoginWithHighLevelSecurity,然后在非托管线程里把整个句柄体系重建一遍,运气好能跑,运气不好就是句柄竞争导致的崩溃。

我的做法是回调里只置一个标志位、把登录 ID 塞进队列,重连动作交给 UI 线程上的定时器。

private volatile bool m_needReconnect; private int OnDeviceReconnect(IntPtr loginId, IntPtr ip, int port, IntPtr user) { m_needReconnect = true; // 只打标记,不做任何 SDK 调用 return 0; // 返回 0 让 SDK 保留内部重连行为 } private void tmrReconnect_Tick(object sender, EventArgs e) { if (!m_needReconnect) return; m_needReconnect = false; StopPreview(); // 先收掉旧句柄,避免野指针 if (ReLogin()) // 用原参数重新登录 StartPreview(m_lastChannel); }

重连定时器间隔建议 3 到 5 秒,太短会在设备还没恢复时反复打登录请求,触发设备侧的锁定策略。重连成功后要重新拿预览句柄,旧句柄在掉线时已经失效,继续拿它抓图或录像都会失败。这套逻辑写下来不到三十行,但它是长时间运行的客户端和“跑半小时就卡死”的 Demo 之间的分水岭。

4. 抓拍与本地录像:两个最容易被忽视的句柄依赖

抓图和录像都挂在预览句柄上,而不是登录句柄上。这个依赖关系是所有问题的源头:预览没起来,抓图必然失败;预览被停了,录像文件多半是坏的。这一章把两个功能各自的参数、文件和失败判定拆开讲,顺带补一段云台控制,凑齐一个能直接演示的客户端。

4.1 抓图:路径、格式与成功判定

抓图接口的第二个参数是完整的文件路径,必须带扩展名,且目录必须已经存在。SDK 不会替你建目录,目录不存在时接口可能返回成功但文件根本没生成,这是现场最容易误判的一种“成功”。

private void Capture(string saveDir, int channel) { if (m_realHandle == IntPtr.Zero) { Log("预览未启动,抓图不可用"); return; } if (!Directory.Exists(saveDir)) Directory.CreateDirectory(saveDir); // SDK 不建目录,必须自己来 // 文件名用通道 + 时间戳,避免多路并发时互相覆盖 string file = Path.Combine(saveDir, $"ch{channel}_{DateTime.Now:yyyyMMddHHmmssfff}.jpg"); // 第三个参数是图片格式,按 EM_PIC_TYPE 取值:JPEG 通常为 0,BMP 为 1 bool ok = Native.CLIENT_CapturePictureEx(m_realHandle, file, 0); // 返回 true 不代表文件已落盘,要再校验一次大小 var fi = new FileInfo(file); if (!ok || !fi.Exists || fi.Length == 0) Log($"抓图失败: ok={ok}, exists={fi.Exists}, size={(fi.Exists ? fi.Length : 0)}"); }

这段代码里最有价值的是最后三步校验。CLIENT_CapturePictureEx是同步写盘,但它写的是解码后的当前帧,如果这一帧还没到,可能生成 0 字节文件。加上FileInfo校验之后,失败原因立刻能分成三类:句柄问题(ok为 false)、路径问题(文件不存在)、解码问题(文件大小为 0)。路径里带中文时要确认编码设置,部分版本需要先调编码接口,否则生成的文件名会变成乱码。

注意:抓图频率不要高于设备帧率的合理范围,连续高频抓图会占用解码资源,表现为预览卡顿,不是 SDK 的 bug。

4.2 本地录像:开始、停止与文件完整性

本地录像把实时流直接写成文件,不经过转码,所以 CPU 占用很低,缺点是文件格式是 SDK 私有封装,需要用配套播放器或转换工具处理。如果业务方要求 MP4,只能在预览的同时自己用编码器重编码,那是另一套成本,别指望这个接口直接吐 MP4。

private string m_recordFile; private bool StartRecord(string saveDir, int channel) { if (m_realHandle == IntPtr.Zero) return false; if (!Directory.Exists(saveDir)) Directory.CreateDirectory(saveDir); m_recordFile = Path.Combine(saveDir, $"rec_ch{channel}_{DateTime.Now:yyyyMMddHHmmss}.dav"); bool ok = Native.CLIENT_SaveRealData(m_realHandle, m_recordFile); if (!ok) Log("录像启动失败,检查磁盘空间与写权限"); return ok; } private void StopRecord() { if (m_realHandle == IntPtr.Zero || m_recordFile == null) return; // 停止录像会把文件头和索引写完,漏掉这一步文件基本打不开 Native.CLIENT_StopSaveRealData(m_realHandle); m_recordFile = null; }

CLIENT_StopSaveRealData必须在停止预览之前调用,顺序反了会出现“文件在、大小正常、打不开”的情况,因为索引区没写完。程序异常退出时这个问题更明显,所以正规做法是在FormClosing里先停录像、再停预览、最后登出,前面 2.3 的时序代码就是按这个顺序写的。磁盘空间也要提前判断,录像写满磁盘后接口不一定报错,但后面的帧会丢,表现为文件时长比实际录制时长短。

文件命名带上时间戳和通道号,能避免多路并发写同一个文件导致的句柄冲突。如果业务需要按天归档,就在StartRecord外面套一层定时器,每小时切一次文件,切文件时记得先Stop再Start,不要试图直接改路径。

4.3 云台控制:一次动作对应一次停止

球机类设备的云台控制是“开始 + 停止”成对出现的,只发开始不发停止,镜头会一直转到底。这个接口的参数里有个bStop标志,很多人只调用了开始那一半。

// 云台方向指令的取值按头文件里的 PTZ 控制命令枚举来,这里用变量代替具体数值 private void PtzMove(int channel, int command, bool stop) { if (m_loginId == IntPtr.Zero) return; // 参数 1 是速度,1~8,参数 2/3/4 按命令类型解释,方向控制一般只用第一个 Native.CLIENT_DHPTZControlEx2(m_loginId, channel, command, 4, 0, 0, stop); } private void btnUp_MouseDown(object sender, MouseEventArgs e) => PtzMove(0, CmdUp, false); private void btnUp_MouseUp(object sender, MouseEventArgs e) => PtzMove(0, CmdUp, true);

按钮交互用MouseDown/MouseUp配对,不要用Click,否则按一下镜头转到头才停。command的具体数值必须查头文件里的枚举,不同版本可能增补,写死数字在升级 SDK 时会静默失效。速度参数建议做成滑块暴露给用户,现场调试时低速更容易对准预置点。

5. 避坑排查:五条现场血泪记录

这一章列的都是在真实项目里反复出现的故障,每条按“现象 → 原因 → 解决”给。看完再去跑测试代码,能省掉大量试错时间。

5.1 登录返回 0,错误码也是 0

现象:CLIENT_LoginWithHighLevelSecurity返回IntPtr.Zero,outParam.nError打出来是 0,日志里什么线索都没有。

原因:这种情况基本不是账号密码问题,而是结构体没被正确封送。最常见的是dwSize没赋值、结构体里混用了string直接映射导致长度变化、或者进程位数与库位数不匹配导致调用栈根本没进入 SDK。

解决:先跑 2.1 的位数检查,确认 x86/x64 一致;再逐字段核对dhnetsdk.h,把每个字符串字段都改成ByValTStr定长形式;最后在调用前打印Marshal.SizeOf的结果,和头文件里的sizeof对不上就说明还有字段错。错误码为 0 而返回空句柄,九成是封送问题。

5.2 预览正常,跑几十分钟后进程直接消失

现象:没有任何异常弹窗,程序从任务管理器里消失,Windows 事件日志里是 0xc0000005 访问冲突。

原因:回调委托被 GC 回收。CLIENT_Init和CLIENT_SetAutoReconnect注册的函数指针指向托管委托,如果委托只被局部变量引用,方法返回后就成了垃圾,非托管侧继续往那块内存回调就是访问违例。

解决:把委托实例挂到静态字段或者窗体的实例字段上,保证生命周期覆盖整个 SDK 使用期。检查方法很简单,把注册回调的那几行代码里的委托变量声明成static字段,问题立刻消失。

5.3 抓图返回 true,文件是 0 字节

现象:接口返回成功,File.Exists也是 true,但文件大小是 0,图片打不开。

原因:三种可能——目录是 SDK 帮你“创建”的(实际没有写权限)、路径里有中文且编码设置不对、当前帧还没解码出来就抓了。前两种是路径问题,第三种是时序问题。

解决:调用前用Directory.CreateDirectory自己建目录并检查写权限;路径统一改用 ASCII 文件名,中文部分放到展示层;抓图前判断预览是否已经渲染出画面,可以在StartPreview之后延迟几百毫秒再允许抓图,或者先取一次码流状态。

5.4 关闭窗体卡死,或者退出时崩溃

现象:点关闭按钮界面不响应,任务管理器显示进程还在;或者直接崩在退出阶段。

原因:清理顺序错了。CLIENT_Cleanup在预览、录像句柄还活着的时候被调用,SDK 内部线程还在往已释放的窗口句柄上写数据;另一种是在FormClosing里同步调用网络接口,设备已经掉线,调用一直阻塞。

解决:严格按“停录像 → 停预览 → 登出 → Cleanup”的顺序释放,每一步都判断句柄非空再调用。窗体关闭时如果设备不可达,把登出放到后台线程并设超时,不要在主线程上等网络返回。

5.5 多路预览时只有第一路有画面

现象:四路PictureBox依次调预览,只有第一路出画面,其余黑屏,接口返回值都不是 0。

原因:多个控件的句柄被复用,或者所有预览都传了同一个窗口句柄。还有一种情况是设备侧并发码流数到了上限,超出部分被静默拒绝。

解决:确认每路传入的是各自控件的Handle,并且控件已经完成句柄创建(在Load之后调用,不要在构造函数里调)。并发数受限时先查设备规格里的最大码流路数,超了就把部分通道降级到辅码流,或者做轮巡而不是全开。

6. 从能跑到能用:轮巡、资源回收与一次压测验证

测试代码跑通只是起点,真正交付前我会做一轮“能不能连续跑八小时”的验证。轮巡是最容易暴露资源泄漏的场景:四路通道、每路停留十秒、循环切换,如果每次切换只StartPreview不StopPreview,句柄会一点点泄漏,跑到两三个小时画面就开始卡。正确写法是每次切换前先停旧句柄,停的时候判断非空,停完把变量置零。

private void SwitchChannel(int channel) { if (m_realHandle != IntPtr.Zero) { Native.CLIENT_StopRealPlayEx(m_realHandle); // 先收旧句柄 m_realHandle = IntPtr.Zero; // 立刻置零,防止重复释放 } StartPreview(channel); m_lastChannel = channel; }

置零这一步看着多余,实际很关键。异常路径下StopRealPlayEx可能返回失败,但句柄本身已经失效,不置零的话下一轮会拿它去调抓图,然后得到一堆莫名其妙的失败。

验证方法我一般用两个指标:进程句柄数和私有内存。跑之前记一次基线,每小时采一次,八小时波动在合理范围内就算过关。用 PowerShell 采一次很简单:

Get-Process -Name "YourApp" | Select-Object Handles, @{n='PrivateMB';e={[math]::Round($_.PrivateMemorySize64/1MB,1)}}

句柄数持续单调上涨基本就是预览句柄没释放,或者回调里BeginInvoke投递的消息没人消费。私有内存缓慢上涨则要看是不是每个通道都保留了抓图缓存。这两个数字比任何日志都直观。

还有一个容易被忽略的点:断线重连之后要重新绑定窗口句柄。有些版本在重连后旧的渲染表面会失效,画面区域变成黑块但接口不报错,重新调一次StartPreview就恢复。我在重连逻辑里固定加了这一步,代价是一次多余的调用,换来的是现场不用再解释“为什么掉线后画面黑了但程序没崩”。

从那以后我每次接入新的 SDK 版本,都强制先跑一遍位数检查、结构体SizeOf对比、回调静态持有这三项自检,再谈功能。这套代码把这三件事都写进了样板里,照着改参数就能用,省下的时间足够把业务逻辑打磨好。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表