简介:一套面向物联网与安防场景的Java工程(hcws_project),完整实现了通过海康威视SDK调用摄像头预览功能,适合需要将视频监控接入业务系统的开发人员。压缩包共三百零二个文件,大小约七点七四兆,主要包含二百六十二个编译后的class文件、二十一个dll动态链接库、六个Java源码文件、六个jar依赖库以及工程配置文件,能够直观呈现从JNA导入原生SDK、设备连接、通道选择、预览句柄申请、编码参数设置到视频流渲染和资源释放的整个流程。已有九千四百九十七人学习下载,可作为海康威视二次开发的入门模板。借助源码可重点掌握Java与C++底层库的交互方式、多线程取流与画面显示、播放面板封装及异常处理等实现细节,并在此基础上扩展录像、抓图、云台控制等常用功能。
1. JAVA调用HCNetSDK实现摄像头预览:项目定位与一次真实对接场景
JAVA调用HCNetSDK实现摄像头预览(HCNetSDK就是某头部安防厂商那套设备网络SDK,老项目里习惯这么叫),是我拆完一个老项目后觉得最值得复述的一段技术路线。某公司内部管理系统要接新园区的监控画面,老代码还在用OCX插件,Chrome升级一次就白屏一次。最后这个模拟项目X(下文叫CamBridge)干脆让Java服务端扛下所有脏活:通过厂商私有协议完成设备登录、实时码流拉取和H264数据推送,浏览器只负责解码播放。这比RTSP公网穿透稳,比搭国标平台轻,尤其适合设备在内网、前端只留一个网页的场景。要复现这条路,得先搞懂三件事:SDK初始化、回调取流、WebSocket推流。下面按调通的顺序展开,每一步都有能直接抄的代码和踩坑记录。
2. 整体架构与接入设计:SDK选型、项目结构与数据流走向
2.1 为什么选SDK直连而不是RTSP或GB28181
接入摄像头预览无非三条路。RTSP最省事,ffmpeg拉流再推流,代码量确实少;但现实场景里设备不一定开放RTSP服务,部分项目设备分布在多个网段,554端口被防火墙拦死,公网还得额外搞中转。GB28181适合要接入国标平台的大系统,SIP注册、目录订阅、信令交互一堆东西,一个预览需求引入这么重的链路不划算。厂商私有协议走的是设备默认的8000端口(具体设备可能改),安全策略相对简单,而且顺带能拿到设备状态、云台控制、报警回调、通道参数修改这些API。CamBridge选的就是这种直连方式:Java通过JNA加载厂商提供的HCNetSDK动态库,直接像C++一样调用导出函数。
2.2 顶层模块划分
CamBridge的模块划分很常规,但每个模块边界清楚,加功能不容易乱。服务通过Spring Boot起一个WebSocket服务,负责接收浏览器的播放请求,再通过SDK模块去登录设备、启动预览,收到Native回调的码流数据后按通道整理好,推给前端播放器。下面表格列出了五层模块的职责。
| 模块 | 职责 | 关键类或文件 |
|---|---|---|
| cam-boot | 启动入口、配置读取、线程池管理 | BootApplication |
| cam-sdk-jna | JNA接口定义、结构体绑定、错误码转换 | HCNetSDK.java、LoginService.java |
| cam-stream | 多路预览会话、回调缓冲、H264切流 | RealPlayService.java、FrameBuffer.java |
| cam-ws | WebSocket会话管理与二进制推送 | StreamWebSocket.java |
| cam-web | 前端播放页面,接jessibuca播放器 | index.html、decoder.js |
这个分层最大的收益是:换设备或者换SDK版本,只需要动cam-sdk-jna这一层;要加多路、加录像、加对讲,都在cam-stream上加会话类型就行,不会把调用点散落到controller里。
2.3 工程目录与动态库部署
JNA的本质是运行时动态加载动态库,所以HCNetSDK相关dll放哪里,直接决定项目能不能跑起来。Windows下很多人喜欢把dll丢到系统目录,开发机能跑,一部署就崩,不推荐。CamBridge的做法是把动态库存到resources/bin目录,启动时用JNA提取到java.io.tmpdir下的临时目录,再通过jna.library.path指向它。这样打包后就是一个干净的jar,换环境不需要手工去系统里放dll。
cam-bridge/ ├── pom.xml # spring-boot + jna + websocket ├── src/main/java/com/bridge/ │ ├── BootApplication.java # Spring Boot 入口 │ ├── sdk/HCNetSDK.java # JNA 接口定义,对应 HCNetSDK.dll │ ├── sdk/LoginService.java # 登录、注销、连接超时配置 │ ├── stream/RealPlayService.java # 预览句柄管理与回调分发 │ ├── stream/FrameBuffer.java # 每路预览的环形缓冲队列 │ └── ws/StreamWebSocket.java # WebSocket 推流端点 └── src/main/resources/ ├── bin/ # 打包时释放 dll 到临时目录 │ ├── HCNetSDK.dll │ └── hcnetsdk.so # Linux 部署时用 └── static/decoder.js # 前端 jessibuca 解码器注意一点:32位JDK只能加载32位的dll,64位JDK只能加载64位的dll。HCNetSDK的dll和jna.jar的位数必须与JDK一致,这是后面第5章第一个坑的来源。如果要在Linux上部署,同样要确认SDK库文件是x64编译,同时用ldd检查依赖。
2.4 视频数据流的走向
整个预览链路的实时性取决于每一跳是否被不必要的处理拖慢。设备数据到达后,SDK的Native线程会调用Java回调,CamBridge在回调里只做一件事:把二进制数据copy到一个有界队列里立刻返回。消费线程从队列取出数据,做H264 NAL切分,然后按通道号把数据推到WebSocket连接。前端拿到裸H264数据交给jessibuca解码,整个过程没有经过FFmpeg重编码,延迟可以控制在1秒以内。
这段链路里最常见的问题就是想当然地在回调线程里做视频处理,结果Native回调被阻塞,SDK内部缓冲满,画面卡住甚至掉线。CamBridge把“取流”和“转码/推流”从线程上彻底分开,队列满了宁可丢帧,也不能拖住回调线程。
3. JNA桥接层搭建:HCNetSDK接口定义、初始化与设备登录
3.1 用JNA定义HCNetSDK接口
JNA不需要写一行C/C++代码,只需要定义一个继承Library的Java接口,把需要调用的函数按C签名声明出来。HCNetSDK作为一个庞大的动态库,CamBridge只声明了当前用到的几个函数,后面要加抓图或对讲,再往接口里补方法即可。
import com.sun.jna.Library; import com.sun.jna.Native; import com.sun.jna.Pointer; public interface HCNetSDK extends Library { HCNetSDK INSTANCE = Native.load("HCNetSDK", HCNetSDK.class); // 初始化和注销 boolean NET_DVR_Init(); boolean NET_DVR_Cleanup(); boolean NET_DVR_SetConnectTime(int dwWaitTime, int dwTryTimes); int NET_DVR_GetLastError(); // 登录相关:V40 是较新的登录结构体版本 boolean NET_DVR_Login_V40(NET_DVR_USER_LOGIN_INFO pLoginInfo, NET_DVR_DEVICEINFO_V40 lpDeviceInfo); boolean NET_DVR_Logout(long lUserID); // 实时预览:回调类型由内部接口 FRealDataCallBack_V30 定义 long NET_DVR_RealPlay_V40(long lUserID, NET_DVR_PREVIEWINFO lpPreviewInfo, FRealDataCallBack_V30 fRealDataCallBack, Pointer pUser); boolean NET_DVR_StopRealPlay(long lRealHandle); }Native.load的第一个参数是动态库逻辑名,Windows下会去寻找HCNetSDK.dll,Linux下会去寻找libHCNetSDK.so。这个接口定义里最容易出问题的是每个函数指针参数的映射:LONG对应Java long,BOOL对应boolean,回调函数对应com.sun.jna.Callback接口。如果返回类型写错,轻则报错,重则拿到错误的句柄值,后面登录、取流都会跟着出错。
3.2 结构体绑定:字段顺序不能乱
C动态库传参靠结构体内存布局,JNA的Structure必须按C头文件的字段顺序一字不差地声明。CamBridge里登录信息结构体声明如下。
import com.sun.jna.Structure; public class NET_DVR_USER_LOGIN_INFO extends Structure { public byte[] sDeviceAddress = new byte[129]; public byte[] sUserName = new byte[64]; public byte[] sPassword = new byte[64]; public short wPort; public byte bUseAsynLogin = 0; public byte[] byRes2 = new byte[135]; @Override protected java.util.List<String> getFieldOrder() { return java.util.Arrays.asList( "sDeviceAddress", "sUserName", "sPassword", "wPort", "bUseAsynLogin", "byRes2"); } }字段顺序一旦和头文件不一致,比如把wPort放到sPassword前面,JNA会把short拆成两个字节按错误位置解释,表现出来就是登录请求发出去但设备解析出错误账号或错误端口。常见做法是先按官方头文件把结构体原型注释写到代码里,再人工核对一遍字节长度。这里的sDeviceAddress长度129、sUserName 64、sPassword 64,都是SDK头文件规定的最大长度,不能为了省内存改成更小的数组。
3.3 初始化SDK与登录封装
SDK初始化是每次进程启动后必须做的一步,忘记调用后续函数会直接崩溃。CamBridge把初始化放进一个静态方法,应用启动时执行一次。
public static void initSdk() { boolean ok = HCNetSDK.INSTANCE.NET_DVR_Init(); if (!ok) { throw new IllegalStateException("NET_DVR_Init 失败: " + HCNetSDK.INSTANCE.NET_DVR_GetLastError()); } // 连接超时 3 秒,1 次重试;局域网设备建议比默认值短,避免页面长时间转圈 HCNetSDK.INSTANCE.NET_DVR_SetConnectTime(3000, 1); }NET_DVR_SetConnectTime的两个参数分别是等待时间和重试次数,单位毫秒和次。很多人的页面卡在“正在连接设备”几十秒,多半是这里用了默认超时。接着是登录逻辑,CamBridge封装的LoginService返回会话使用的登录句柄,这个句柄后续启动预览、抓图、云台控制都依赖它。
public Long login(String ip, int port, String username, String password) { NET_DVR_USER_LOGIN_INFO loginInfo = new NET_DVR_USER_LOGIN_INFO(); loginInfo.sDeviceAddress = ip.getBytes(); loginInfo.sUserName = username.getBytes(); loginInfo.sPassword = password.getBytes(); loginInfo.wPort = (short) port; loginInfo.bUseAsynLogin = 0; NET_DVR_DEVICEINFO_V40 deviceInfo = new NET_DVR_DEVICEINFO_V40(); boolean ok = HCNetSDK.INSTANCE.NET_DVR_Login_V40(loginInfo, deviceInfo); if (!ok) { int code = HCNetSDK.INSTANCE.NET_DVR_GetLastError(); throw new IllegalStateException("设备登录失败, ip=" + ip + ", error=" + code); } return loginInfo.lUserID; }这里有一个容易翻车的细节:lUserID是从loginInfo里取的,而不是从deviceInfo里取。V40的Login返回boolean,真正可用的登录句柄写回在LoginInfo内部字段。如果用旧版NET_DVR_Login_V30的思维去拿返回值,会拿不到合法的会话句柄。bUseAsynLogin建议固定为0,用同步登录,异步登录在部分设备上回调时机不好控制。
4. 实时预览链路实现:RealPlay回调、H264切流与WebSocket播放
4.1 启动一路实时预览
登录成功后,下一站是NET_DVR_RealPlay_V40。这一步的作用是让设备开始向SDK推送实时码流,推送的数据通过回调函数交给Java侧。CamBridge在RealPlayService里为每一路摄像头创建一个唯一的句柄。
public Long startPreview(Long userId, int channel) { NET_DVR_PREVIEWINFO previewInfo = new NET_DVR_PREVIEWINFO(); previewInfo.lChannel = channel; previewInfo.dwStreamType = 0; previewInfo.dwLinkMode = 0; previewInfo.bBlocked = 1; FRealDataCallBack_V30 callback = (realHandle, dataType, pBuffer, size, pUser) -> { if (dataType == 0) { // 0 表示视频流数据 byte[] data = pBuffer.getByteArray(0, size); frameBuffer.offer(channel, data); } }; long realHandle = HCNetSDK.INSTANCE.NET_DVR_RealPlay_V40( userId, previewInfo, callback, null); if (realHandle == -1) { throw new IllegalStateException("预览启动失败, error=" + HCNetSDK.INSTANCE.NET_DVR_GetLastError()); } return realHandle; }几个关键参数不是随手填的。lChannel从1开始计数,设备第1路通道是1,很多新人在0和1之间反复试。dwStreamType 0主码流分辨率高,1子码流更省带宽,做Web预览建议先从子码流调通,表现稳定后再换主码流。dwLinkMode 0走TCP,数据可靠但延迟略高;UDP延迟低但容易花屏。bBlocked固定为1,1表示不向调用线程推送原始YUV数据,而是让回调收到编码后的码流。
4.2 回调缓冲与H264 NAL提取
RealPlay回调返回的是PS封装的码流,不是一帧一个干净的H264 NAL,直接推到播放器大概率花屏。CamBridge的做法是先缓存,再用起始码切NAL。切分逻辑放在单独的取流消费线程里,回调线程依旧只入队。
while (session.isRunning()) { byte[] packet = frameBuffer.poll(channel, 100, TimeUnit.MILLISECONDS); if (packet == null) continue; List<byte[]> nals = new ArrayList<>(); int start = -1; for (int i = 0; i < packet.length - 4; i++) { if ((packet[i] & 0xFF) == 0 && (packet[i + 1] & 0xFF) == 0 && (packet[i + 2] & 0xFF) == 0 && (packet[i + 3] & 0xFF) == 1) { if (start >= 0) { nals.add(Arrays.copyOfRange(packet, start, i)); } start = i; i += 3; } } if (start >= 0) { nals.add(Arrays.copyOfRange(packet, start, packet.length)); } for (byte[] nal : nals) { webSocket.push(String.valueOf(channel), nal); } }简化后的切分逻辑能应付绝大多数预览场景,但严格说PS流里还包含音视频同步的系统头,真要抠细节可以用FFmpeg的demuxer先解析一遍。日常够用:把00 00 00 01作为NAL分界,遇到SPS/PPS和关键帧会原样保留,jessibuca拿到手就能组帧解码。队列容量不要无限增长,CamBridge给每路设置了512个包的容量上限,队列满就丢弃最旧的数据,优先保证画面的实时性。
4.3 WebSocket推流与前端播放
推送模块用Spring Boot自带的WebSocket,按通道号建立独立连接,避免一路卡顿影响其他路。服务端推送代码很简单,关键在二进制发送要使用同步发送,避免并发写同一Session。
@Component @ServerEndpoint("/ws/live/{channel}") public class StreamWebSocket { private static final ConcurrentHashMap<String, Session> SESSIONS = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("channel") String channel) { SESSIONS.put(channel, session); } @OnClose public void onClose(Session session, @PathParam("channel") String channel) { SESSIONS.remove(channel); } public void push(String channel, byte[] nal) { Session session = SESSIONS.get(channel); if (session != null && session.isOpen()) { session.getBasicRemote().sendBinary(ByteBuffer.wrap(nal)); } } }前端页面接jessibuca播放器,初始化时指定一个canvas容器和WebSocket地址,播放器内部完成H264解码渲染。
const player = new Jessibuca({ container: document.getElementById('live'), videoBuffer: 1024 * 1024 * 2, decoder: '/static/decoder.js', useMSE: true, }); player.play('ws://192.168.1.10:8080/ws/live/1');这段配置里的useMSE是jessibuca在浏览器支持Media Source Extensions时优先走的硬解通道,比wasm软解省CPU,但只支持H264裸流。如果设备输出的是H265,服务端要做转码,前端这一段就不适用,需要换成支持H265的解码方案。整体预览延迟实测基本在200到500毫秒,比RTSP插件方案稳一个量级。
5. 常见问题与排查:从DLL加载到黑屏的五个现场
5.1 DLL加载失败:UnsatisfiedLinkError
现象:Spring Boot启动或第一次调用Native.load时抛出UnsatisfiedLinkError,提示Unable to load library 'HCNetSDK'。
原因:HCNetSDK.dll不在java.library.path指向的目录里;或者JNA找到了库但位数不匹配,32位JDK去加载64位dll,同样报这个错。还有人是把dll丢到resources根目录,jar打包后dll在classpath内但JNA不会自动从jar里解压。
解决:Windows开发机把dll放到一个固定目录,启动参数加-Djna.library.path=D:/cam/lib;打包部署时在启动类里先提取dll到临时目录再加载。检查位数时打开命令行执行java -version,确认是64位JDK后再下载对应的64位SDK包,这事在项目上翻车两次。
5.2 登录失败 error=9 或 error=1003
现象:设备IP能ping通,但Login_V40返回false,GetLastError得到9或1003。
原因:错误码9在多数设备上表示账号或密码错误,但设备重启后账号被锁定也会返回9;1003表示SDK连不上设备的服务端口。大量案例最后定位到设备8000端口被防火墙拦截或改过端口,ping通IP不代表端口通。
解决:先telnet设备IP 8000确认端口通不通;不通就去设备Web后台查看SDK端口是否改过;通但返回9,就去后台重新创建操作员账号,勾选远程访问权限,不要复用已经被锁定或禁用的旧账号。
5.3 预览启动成功但黑屏
现象:RealPlay返回了有效句柄,回调里也能看到数据,浏览器连接正常,但页面黑屏。
原因:第一个想到的是通道号。lChannel传0在部分设备上表示设备本身,不是视频通道,第二路很可能返回很多无效数据。另一个高频原因是主码流是4K高分辨率或者H265编码,前端播放器解析不了。
解决:把lChannel从1开始逐个通道测试;调试期间dwStreamType设为1走子码流,画面流畅后再说主码流;H265设备要么在服务端转码,要么换支持H265的播放器内核。日志里如果看到大量NAL不是H264起始码,基本可以判定编码格式不对。
5.4 回调线程阻塞导致画面卡顿和掉流
现象:启动后几秒画面正常,接着马赛克、卡顿、最后WebSocket断开,重新开始又是同样循环。
原因:在FRealDataCallBack里做了耗时的网络发送或日志打印。SDK的Native回调线程被阻塞后,内部数据缓冲会被占满,SDK会直接丢弃后续回调,表现就是越来越严重的花屏和断流。最常见是直接回调里写WebSocket或者写文件。
解决:回调方法体只允许把数据copy进队列,越短越好;队列消费放到独立线程,消费速度跟不上的话宁可丢帧也要保证回调不被阻塞。用ArrayBlockingQueue给每路限制容量,数据积压自动舍弃最老的一批,画面反而一直稳定在低延迟状态。
5.5 Linux部署缺少so依赖
现象:Windows下跑通的项目部署到Linux容器,启动时报Error loading library libHCNetSDK.so: libcrypto.so.1.0.0: cannot open shared object file。
原因:厂商SDK的Linux版本动态库依赖openssl等系统库,基础镜像里没有对应版本的so文件。这不是Java代码问题,是动态库的系统依赖问题。
解决:部署前先执行ldd libHCNetSDK.so,看缺失列表,然后安装对应的compat包或把依赖so放到/usr/lib下。如果是Docker镜像,在Dockerfile里用apt安装openssl兼容版本。之后再启动项目,库加载问题就不会出现。
6. 二次开发改造:从单路预览到多路并发、抓图与自动重连
6.1 多路并发改造
把单路改成多路,核心是拆“登录”和“流会话”。CamBridge一台设备只login一次拿到userId,每个通道创建CameraSession对象,各自持有realHandle、队列和消费线程。线程池统一管理消费任务,避免一路一路地new Thread。
public class CameraSession { private final Long userId; private final Long realHandle; private final BlockingQueue<byte[]> frameQueue = new ArrayBlockingQueue<>(512); private volatile boolean running = true; // stop 时先停取流再置状态 }多路改造最容易出的问题是回调里并发访问session,上面这些都带上锁或用ConcurrentHashMap,别抱着ArrayList不撒手。
6.2 抓图与回放
抓图用NetSDK的NET_DVR_CaptureJPEGPicture,设备句柄、通道号、保存路径传进去就能拿到一张JPEG,不依赖预览是否开启,告警联动场景很好用。回放要单独按文件名查录像,再搞另一套播放会话,别和实时预览共用同一个队列。
6.3 自动重连
设备重启后,探活线程发现超过3秒没有新回调数据,就按顺序走stopPreview、logout、login、startPreview。这个顺序不能调换,先退出登录会让RealPlay句柄泄漏,重连两次后设备连接数就满了。同一设备的所有通道重连需要串行,用一把锁挡一下就行。
从那以后我每次接新设备,都会先把重连三步顺序写进代码注释里,上线后反而再也看不到黑屏玄学了。希望帮到你。
本文还有配套的精品资源,点击获取