简介:面向视频监控、物联网及安防行业开发者,提供一套Java调用海康威视SDK实现摄像头预览的工程包,解决在Java应用中直接对接HCNetSDK完成视频预览的集成问题。工程基于JNA技术桥接原生SDK,覆盖设备登录、通道选择、预览句柄申请、参数设置、视频流渲染到资源释放的完整链路,适合有Java基础并希望快速上手海康设备二次开发的工程师参考。压缩包共302个文件,以262个class和21个dll为主体,另含6个java源码、6个jar、3个lib以及工程配置文件,整体7.74MB。class为海康SDK的Java封装,dll为Windows原生依赖库,源码与jar便于项目移植和依赖管理。已有9497人学习下载,可见该主题在安防开发社区中的热度。具体源码中提供播放控制面板、网络配置等可复用模块,开发者可学习到JNA结构体映射、回调处理、资源释放等关键写法,并快速迁移到自有项目中。
1. JAVA调用海康威视SDK实现摄像头预览:能跑通比什么都重要
某次接一个桌面小工具的需求,要实时预览分布在各处的网络摄像机画面。设备自带的网页端和客户端都正常,换成Java去调海康威视SDK实现摄像头预览,却一路黑屏、卡死、JVM直接崩掉,折腾了两天才搞清楚大部分问题不是出在摄像机上,而是出在JNA映射、动态库位数和生命周期管理上。这个项目要解决的就是这件事:用纯Java接入海康威视官方HCNetSDK,完成设备登录、实时取流、抓图保存和码流二次处理。适合做设备接入、视频管理平台、安防系统集成的Java开发者,尤其是需要快速出Demo、又要能稳定落地的场景。
2. 技术选型与工程结构:JNA桥接C接口,三类职责彼此隔离
2.1 为什么选JNA而不是JNI
海康威视官方SDK是一套C语言动态库,Windows下是HCNetSDK.dll、PlayCtrl.dll这些,Linux下是libhcnetSDK.so。Java要调它,路线无非三条:JNI、JNA、或者绕开SDK直接走RTSP/ONVIF。实际项目里我选JNA,主要原因是JNI的成本太高——每声明一个接口都要写C包装层、生成头文件、单独编译维护一个dll/so,SDK升级一次就要跟着重编一轮。而JNA只需要在Java里写出对应的interface声明,运行时会自动完成C函数的寻址和参数转换,开发效率完全不在一个量级。
性能方面,JNA每次调用会比JNI多一些参数转换开销,但预览场景下的高频操作是回调收流,这部分SDK会把数据直接写进内存缓冲区,Java侧只是读指针,损耗可以接受。我见过一些项目用JNI封装后性能提升有限,维护成本却翻了几倍。对海康SDK这种以业务控制为主的接口来说,JNA的这点开销换来的是跨平台和可维护性,值。
另外还要排除一个常见误区:不要一上来就想用RTSP拉流绕过SDK。RTSP虽然能拿视频流,但设备登录的在线状态、PTZ云台控制、抓图、报警上报这些能力,本质上都在SDK里,绕开SDK等于自己重新做一套协议对接。所以这个项目还是以HCNetSDK为底座,预览环节也走SDK的取流回调,不依赖独立的流媒体服务器。
2.2 项目目录结构:SDK声明、设备封装、界面三层分离
资源里给的是一个标准的Maven工程结构,核心包分三层。第一层是sdk包,只放HCNetSDK的JNA映射接口,和项目业务代码零耦合;第二层是device包,负责把SDK调用封装成可操作的摄像机对象,对外提供登录、预览、抓图、退出这些方法;第三层是codec和ui包,一个处理码流解析与解码显示,一个放Swing界面。这样做的好处是,以后SDK升级或者换设备型号,只需要动sdk映射那一层。
camera-preview-demo/ ├── src/main/java/ │ ├── sdk/HCNetSDK.java # JNA接口声明,对应C头文件 │ ├── sdk/HCNetSDKException.java # SDK调用失败的统一异常 │ ├── device/HikCamera.java # 单台设备的完整生命周期 │ ├── device/CameraManager.java # 多路设备管理(连接池) │ ├── codec/H264Parser.java # 从PS流中解析H.264 NALU │ ├── codec/JavaCVDecoder.java # JavaCV解码成图片帧 │ └── ui/MainFrame.java # Swing预览界面 ├── lib/ │ ├── HCNetSDK.dll │ ├── HCCore.dll │ ├── PlayCtrl.dll │ ├── HCNetSDKCom/ # SDK组件目录,加载时要在同路径 │ └── javacv-*.jar / jna-*.jar └── src/main/resources/logback.xmllib目录放的是SDK运行必需的动态库。这里要特别说明,HCNetSDKCom这个子目录不能丢,里面装着SDK的多个功能模块,比如语音对讲、设备能力集解析的独立dll。程序启动时需要把这个目录保持在与HCNetSDK.dll相同的加载路径下,否则调用部分功能会报“无法找到指定模块”。
2.3 核心类型映射:从结构体到回调接口,JNA里最花时间的部分
HCNetSDK的C头文件里有大量结构体,JNA中对应继承Structure的Java类,并且字段的定义顺序、类型大小要和C完全一致。这是整个项目里最枯燥也最容易翻车的地方,字段偏移错一位,轻则参数解析成乱码,重则直接崩溃。先看接口定义的核心片段。
import com.sun.jna.Library; import com.sun.jna.Native; import com.sun.jna.Pointer; import com.sun.jna.Structure; import com.sun.jna.win32.StdCallLibrary; public interface HCNetSDK extends StdCallLibrary { HCNetSDK INSTANCE = Native.load("HCNetSDK", HCNetSDK.class); // C: typedef struct { ... } NET_DVR_DEVICEINFO_V40; public static class NET_DVR_DEVICEINFO_V40 extends Structure { public byte[] sSerialNumber = new byte[48]; // 序列号 public byte byAlarmInPortNum; // 报警输入个数 public byte byAlarmOutPortNum; // 报警输出个数 public byte byDiskNum; // 硬盘个数 public int dwOemCode; // OEM码 // 其余字段按头文件顺序补齐 } // C: typedef void (CALLBACK *fRealDataCallBack_V30)(...) 回调函数指针 public interface fRealDataCallBack extends StdCallCallback { void invoke(int lUserID, int dwDataType, Pointer pRecvData, int dwDataLen); } boolean NET_DVR_Init(); int NET_DVR_Login_V40(NET_DVR_USER_LOGIN_INFO pLoginInfo, NET_DVR_DEVICEINFO_V40 lpDeviceInfo); int NET_DVR_RealPlay_V40(int lUserID, Pointer hPlayWnd, NET_DVR_PREVIEWINFO lpPreviewInfo, fRealDataCallBack cb, Pointer pUser); int NET_DVR_GetLastError(); boolean NET_DVR_Cleanup(); }说明几点。第一,接口继承StdCallLibrary而不是Library,因为海康Windows版SDK使用stdcall调用约定;Linux版则换成Library。第二,回调接口fRealDataCallBack必须继承StdCallCallback,SDK收到码流时会在自己的线程里调用这个回调。第三,结构体里凡是C语言数组都映射成byte[],凡是char*指针在JNA映射中通常用byte[]或Pointer承载,等从回调里拿数据时再用Pointer.getByteArray读取。
关于NET_DVR_USER_LOGIN_INFO这个登录结构体,里面字符串字段是定长数组,所以要在Java里写一个赋值方法做字符串拷贝,直接等号赋值会覆盖整个数组。谷歌出来的很多帖子里翻车都翻在这里,赋值后登录永远报参数错误。字段顺序一定要和C头文件一致,比如byUseTransport、wPort这些字段顺序调换了,登录立刻失败。
3. 核心流程拆解:从设备登录到预览画面显示的完整链路
3.1 初始化与登录:NET_DVR_Init和NET_DVR_Login_V40的参数细节
整个SDK使用前必须做两步:调用NET_DVR_Init初始化SDK,再设置连接超时和接收超时。超时参数是救命的东西,默认值在部分网络环境下可能长达几十秒,设备不在线时界面会卡住。常见做法是连接超时设3秒、重试1次,接收超时设5秒。
HCNetSDK sdk = HCNetSDK.INSTANCE; if (!sdk.NET_DVR_Init()) { throw new HCNetSDKException("SDK初始化失败,错误码:" + sdk.NET_DVR_GetLastError()); } sdk.NET_DVR_SetConnectTime(3000, 1); // 连接超时3秒,重试1次 sdk.NET_DVR_SetRecvTimeOut(5000); // 接收数据超时5秒 HCNetSDK.NET_DVR_USER_LOGIN_INFO loginInfo = new HCNetSDK.NET_DVR_USER_LOGIN_INFO(); loginInfo.sDeviceAddress = ip; // IP地址 loginInfo.wPort = 8000; // 海康设备默认服务端口 loginInfo.sUserName = username; loginInfo.sPassword = password; loginInfo.write(); // 重要:将Java字段同步写入native内存 HCNetSDK.NET_DVR_DEVICEINFO_V40 deviceInfo = new HCNetSDK.NET_DVR_DEVICEINFO_V40(); int userId = sdk.NET_DVR_Login_V40(loginInfo, deviceInfo); if (userId == -1) { int errCode = sdk.NET_DVR_GetLastError(); throw new HCNetSDKException("登录失败,错误码:" + errCode); }loginInfo.write()这步非常关键。JNA的Structure类默认在Native.load之前会按需同步内存,但登录结构体里我们动态赋了字符串,必须显式调用write()把Java堆里的字段值刷到native内存。如果漏了,IP和密码可能还是全零数组,登录必然失败。deviceInfo是输出参数,登录成功后里面带设备序列号、通道数、硬盘信息,后续预览和抓图要依赖byChanNum判断通道范围。
登录返回的userId是一个大于0的int值,之后所有操作都以它作为设备标识,可以类比成JDBC的Connection对象。退出时先NET_DVR_Logout(userId),再NET_DVR_Cleanup(),顺序不能反,这点在第四章也会再次踩到。
3.2 实时预览与回调取流:hPlayWnd传null,自己动手拿码流
海康SDK预览接口NET_DVR_RealPlay_V40,在C++里可以直接传一个HWND窗口句柄让SDK自动画画面。但Java侧的AWT组件句柄和C窗口句柄不是一回事,硬传会黑屏。所以Java项目的主流做法是:hPlayWnd参数传null,开启码流回调模式,由SDK把每一帧数据送到回调函数里,再由Java侧自己解码和显示。
HCNetSDK.NET_DVR_PREVIEWINFO previewInfo = new HCNetSDK.NET_DVR_PREVIEWINFO(); previewInfo.lChannel = 1; // 通道号,从1开始,对应设备Info的byChanNum previewInfo.dwStreamType = 0; // 0-主码流,1-子码流 previewInfo.dwLinkMode = 0; // 0-TCP方式取流 previewInfo.bBlocked = 0; // 0-非阻塞取流 HCNetSDK.fRealDataCallBack callback = new HCNetSDK.fRealDataCallBack() { @Override public void invoke(int lUserID, int dwDataType, Pointer pRecvData, int dwDataLen) { // dwDataType 0-码流数据, 1-音频数据, 2-私有数据 byte[] data = pRecvData.getByteArray(0, dwDataLen); // 交给队列,由独立线程消费 dataQueue.offer(data); } }; int realHandle = sdk.NET_DVR_RealPlay_V40(userId, null, previewInfo, callback, null); if (realHandle == -1) { throw new HCNetSDKException("预览失败,错误码:" + sdk.NET_DVR_GetLastError()); }previewInfo里的参数需要按实际场景调整。主码流分辨率高、码率大,适合保存,但预览和回调处理压力也大;子码流适合多路网格预览。dwLinkMode设置为0走TCP,稳定性好;如果设备在跨网段环境,可以换1或2试试。回调里的pRecvData是native指针,必须用getByteArray及时拷出来,不能直接持有Pointer等待后续使用,因为SDK复用这块内存。
回调模式下数据是一帧一帧投递的,回调函数本身运行在SDK内部的取流线程。如果在这个线程里直接做耗时操作,会阻塞后续所有通道的数据接收。收到的data先入队,拿一个专门的消费者线程慢慢处理,这样也方便后期做追帧、丢帧策略。
3.3 抓图和保存录像:一张JPEG和一段录像的落盘路径
很多时候不需要实时画面,只要定时抓一张图。登录成功之后,SDK提供了NET_DVR_CaptureJPEG接口,它是直接截取设备端编码后的JPEG,不需要本地解码,性能开销很小,适合做事件联动。
HCNetSDK.NET_DVR_JPEGPARA jpegPara = new HCNetSDK.NET_DVR_JPEGPARA(); jpegPara.wPicSize = 0; // 0-使用设备默认分辨率 jpegPara.wPicQuality = 6; // 画质等级 1-最高,递减 boolean ok = sdk.NET_DVR_CaptureJPEG(userId, 1, jpegPara, "D:/capture/snapshot.jpg"); if (!ok) { int errCode = sdk.NET_DVR_GetLastError(); // 对照错误码手册定位原因 }注意这里的通道号也是从1开始。实际项目中抓图前最好先判断设备通道总数,超过byChanNum的通道号会直接返回失败。抓图输出是设备给的JPEG,所以图片质量由设备端编码参数决定,wPicQuality调低反而图片更清晰但文件更大,调高则压缩更狠、体积小、画质略有损失,需要根据归档需求取舍。
如果要做录像保存,在预览句柄上直接挂本地录像是最简单的:
sdk.NET_DVR_SaveRealData(realHandle, "D:/record/20250601.mp4"); // ... 预览结束 sdk.NET_DVR_StopSaveRealData(realHandle);SaveRealData写在realHandle上而不是userId上,这是新人容易搞混的地方。它保存的是当前预览通道的码流文件,实际是原始码流封装,未必能直接用播放器打开,需要后期转封装或用SDK自带播放库播放。
4. 常见问题排查:JAVA调用海康SDK的5个典型翻车现场
4.1 Load library failed:动态库位数不匹配
现象:项目启动时抛UnsatisfiedLinkError,提示找不到HCNetSDK.dll,或者“%1不是有效的Win32应用程序”。
原因:SDK提供32位和64位两套动态库,而JVM进程位数必须和dll位数一致。很多机器装了64位JDK,却把32位的HCNetSDK.dll放进了lib目录,于是加载直接失败。另外HCNetSDK.dll依赖HCCore.dll和HCNetSDKCom下的组件,这些文件不在同一搜索路径也会报找不到模块。
解决:先确认java -version显示的位数,再替换对应的SDK目录;用-Djna.library.path=lib固定加载路径;HCNetSDKCom子目录保持相对位置不动。我一般会写一个启动脚本,先检测JVM位数再选择SDK目录,避免开发机和服务器环境不一致。
4.2 预览回调收不到数据:回调对象被GC回收了
现象:登录成功,NET_DVR_RealPlay_V40返回了正常的句柄,但回调函数一次也不触发,程序也没有报错。
原因:JNA回调接口用局部变量持有,RealPlay调用结束后,这个局部变量失去强引用,被GC回收了。SDK内部还保存着这个函数指针,回调时跳转到一个已经被回收的内存地址,数据自然丢了。
解决:把回调对象保存为类成员变量,比如HikCamera类里持有一个private final fRealDataCallBack callback,保证和SDK生命周期一致,这几乎是使用JNA回调的铁律。检查代码里是否将回调直接new在方法里,如果是,改为字段引用即可。
4.3 回调线程里直接操作Swing界面卡死
现象:画面刷新时整个窗口无响应,拖动窗口就白屏,日志里也没有报错;严重时画面撕裂甚至程序退出。
原因:SDK回调运行在自己的取流线程,而Swing组件只能在事件分发线程EDT上更新。直接在这个线程里调用JLabel.setIcon或画布重绘,大部分情况下只是卡,偶尔会抛出“Current thread is not EDT”的异常。
解决:回调里只做两件事——把byte[]拷贝到队列、清空队列长度标记。显示线程用SwingUtilities.invokeLater再更新画面,或者用BlockingQueue写一个消费者线程,每取出一帧就刷新一次。想丢掉积压帧时,直接清空队列再继续,保证画面是最新的而不是最慢的。
4.4 登录成功但预览失败:通道号选错了
现象:登录返回userId正常,NET_DVR_RealPlay_V40返回-1,NET_DVR_GetLastError读到一个网络类错误码。
原因:登录返回的deviceInfo里带出了设备的实际通道数,而通道号在SDK里通常从1开始。如果设备只有一个视频通道,却把previewInfo.lChannel写成0,就会失败;反之有些NVR设备通道号从33开始,写成1也会失败。
解决:登录后先打印deviceInfo.byChanNum,预览时用循环从1到byChanNum逐个尝试,最终把成功的通道记录下来。注意部分设备主码流通道号对应的是逻辑通道,和物理通道不一致,不能一概而论。
4.5 程序退出时崩溃:清理顺序反了
现象:点击关闭窗口后,JVM进程不退出,或者直接crash,控制台打印一大堆native memory相关的报错。
原因:退出时没有先停止预览、注销登录,直接调用了NET_DVR_Cleanup甚至让JVM强制退出。SDK内部的取流线程和播放库资源还没释放,Cleanup就把底层的句柄表清了,线程再回来访问已经失效的句柄就崩了。
解决:严格按反方向释放:NET_DVR_StopRealPlay(realHandle) → NET_DVR_Logout(userId) → NET_DVR_Cleanup()。写一个shutdown()方法,在关闭窗口事件里先置一个退出标志,让消费线程自然结束,再逐步释放,最后再调用System.exit或等待JVM自然退出。
5. 工程化改造:从单路预览到多路视频接入模块
5.1 设计一个设备连接池来管理多路登录与注销
单路预览的代码写完了,下一步自然要面对真实需求:一台机器要同时接入十几路甚至几十路摄像机。如果每来一路就重新new一个SDK实例,资源和线程都会失控。常见做法是维护一个按userId管理的连接池,每个连接对应一台设备,并记录登录时间、通道数、最近活跃时间。
public class CameraManager { private final Map<Integer, HikCamera> cameraMap = new ConcurrentHashMap<>(); private final ScheduledExecutorService monitor = Executors.newScheduledThreadPool(1); public void addCamera(CameraConfig config) { HikCamera camera = new HikCamera(config); int userId = camera.login(); // 内部完成初始化+登录 camera.startPreview(dataQueue); // 开取流 cameraMap.put(userId, camera); // 按userId登记 } public void releaseAll() { for (HikCamera camera : cameraMap.values()) { camera.shutdown(); // 停止预览、注销、清理 } } }连接池还要做断线重连。海康SDK在网络断开后,回调会先停,登录句柄是否有效需要主动探测。常见做法是起一个定时任务,定期调用NET_DVR_GetDVRConfig或直接ping一次设备,发现不可达就调用NET_DVR_Logout,然后等待几秒用原来的账号重新登录,再恢复预览。重连逻辑要带退避:连续失败超过5次,拉长到30秒再试一次,避免所有设备同时打过来造成风暴。
另一个容易踩的坑是:不要把多个设备的登录操作都在回调线程里触发,登录本身是阻塞调用,失败时可能等满超时时间。更稳的做法是把登录动作也提交到独立的线程池,每台设备一个Future,配合Future.get(timeout)做超时控制,不让某台故障设备拖垮界面。
5.2 从回调码流中解析出H.264原始流
回调拿到的数据通常不是纯H.264裸流,主流型号会封装成PS格式,也就是在H.264的NALU外面再包一层PS头。要做本地解码和显示,就要先把PS流拆成一个个NALU,再按H.264的起始码规则提取。
public class H264Parser { // data 是一帧回调原始数据 public List<Nalu> parse(byte[] data) { List<Nalu> nalus = new ArrayList<>(); int start = 0; while (start < data.length) { int naluStart = findStartCode(data, start); // 找 00 00 01 if (naluStart < 0) break; int naluEnd = findStartCode(data, naluStart + 3); if (naluEnd < 0) naluEnd = data.length; byte nalHeader = data[naluStart + 3]; int nalType = nalHeader & 0x1F; // H.264 NAL单元类型 byte[] body = Arrays.copyOfRange(data, naluStart, naluEnd); nalus.add(new Nalu(nalType, body)); start = naluEnd; } return nalus; } }这里用两件事确认解析正确。第一,nalType的值域有业务含义:7表示SPS,8表示PPS,5表示IDR关键帧,1表示普通P帧。第一帧里如果看到连续的SPS和PPS,说明解析已经对齐到了NALU边界。第二,解析出的首个关键帧通常是I帧,在多路并发时可以把I帧缓存下来作为预览恢复的基础帧,省去等待新关键帧的时间。
实际场景里还会遇到码流被截断的情况,回调的边界不一定和NALU边界对齐。解决方法是维护一个跨帧缓冲,把本轮剩余数据接到下一帧的开头,再做start code扫描,这就是“累积缓冲区”的经典做法,UDP取流场景下尤其重要,否则画面会每隔几帧就花屏一次。
5.3 用JavaCV把H.264解码成BufferedImage显示
解析出NALU还不够,Java里的ImageIO不支持H.264硬解,需要借助JavaCV封装FFmpeg的解码能力。常见做法是把解析后的H.264裸流累积成一段字节数组,交给FFmpegFrameGrabber逐帧解码,再用Java2DFrameConverter转成BufferedImage。
import org.bytedeco.javacv.*; import org.bytedeco.javacv.Frame; public class JavaCVDecoder { private final FFmpegFrameGrabber grabber; public JavaCVDecoder(byte[] h264Data) throws Exception { // 从内存流读取H.264裸流,避免写临时文件 grabber = new FFmpegFrameGrabber(new ByteArrayInputStream(h264Data)); grabber.setFormat("h264"); grabber.start(); } public BufferedImage nextImage() { Frame frame = grabber.grabImage(); if (frame == null) return null; // 转成Java图像,供Swing绘制 return Java2DFrameConverter.getConverter().convert(frame); } }grabber从ByteArrayInputStream读取时,setFormat("h264")必须显式指定,否则FFmpeg猜不出裸流格式。每次只喂一小段数据时,重建grabber的代价太高,正确做法是维护一个持续写入的输入缓冲,供grabber连续调用。JavaCV版本和FFmpeg native库要配套,新版本JavaCV已经内置了对应平台的FFmpeg,所以lib目录里只需要放javacv-platform的jar即可。
这套方案有个上限:Java侧软解一路1080p主码流,CPU占用大概在10%到20%之间,多路同时预览时要把解码任务分到线程池,并用有界阻塞队列做背压。当队列积压到上限时主动丢弃旧帧,保证画面延迟不越积越大。能做到这一步,预览模块基本就从代码片段进化成一个可以扛并发的小组件了。
6. 二次开发思路:把Demo长成能交付的组件
6.1 封装成独立JAR的接口边界
如果要把这个预览功能交给另一个项目使用,最好的沉淀方式是抽成独立的SDK模块,对外只暴露配置类和操作接口,把JNA映射、线程池、码流解析全部收进包内部。对外API尽量精简,让调用方不用理解SDK细节。
CameraConfig: ip、port、username、password、channel、streamType CameraService: connect(CameraConfig) -> CameraConnection disconnect(connection) captureJpeg(connection, outputPath) subscribeFrames(connection, FrameListener) enablePtz(connection, PtzCommand)参数表给出建议配置范围,方便对接方按环境调整。
| 参数 | 建议范围 | 说明 |
|---|---|---|
| port | 8000 | 海康设备默认SDK服务端口 |
| connectTimeout | 2000-5000ms | 低于2秒在弱网容易误判离线 |
| recvTimeout | 3000-8000ms | 音视频数据接收超时 |
| streamType | 0主码流 / 1子码流 | 多路预览用子码流 |
| channel | 1至deviceInfo.byChanNum | 从返回的设备信息里读,不要硬编码 |
封装时还要约定线程模型。回调线程和业务线程不能互相占用,所有事件通过一个BlockingQueue传给上层,上层只消费不阻塞SDK内部线程。这样对接方想扩展报警、对讲、智能分析功能时,只需增加回调数据类型的分支处理,并不破坏预览主链路。
6.2 一个验证技巧:先抓图后取流的习惯
我自己做二次开发有个习惯:每接入一台新设备,永远先跑通NET_DVR_CaptureJPEG抓图,再跑预览取流。抓图成功说明登录、通道、权限、网络四个环节都通了,此时再进入预览调试,只排查取流参数即可,问题面缩小了一大半。如果反过来一上来就调预览,黑屏时你会怀疑登录、怀疑通道、怀疑解码、怀疑网络,排查链路太长。每次改动SDK相关代码后,也强制完整走一遍“抓图→单路预览→多路并发→关闭释放”流程,哪怕只改了一行日志打印,也要重跑,因为SDK的底层状态机不受Java代码控制,今天多路正常,明天换台设备可能就翻车。这套流程救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取