简介:这是一套面向Java开发者的扫描仪调用完整项目源码,解决在Java Web应用中集成硬件扫描仪、实现证件扫描与本地存档的问题。项目以S2SH框架搭建,数据库采用MySQL,核心扫描能力由C++编写的dll接口提供,Java层通过调用该dll完成扫描并将来访人员证件文件保存至本地硬盘,典型场景为公司前台来访登记系统。压缩包共397个文件,约1.57MB,包含17个java源文件与18个class编译文件、25个jsp页面、47个js脚本及23个css样式,另有gif、jpg、png等界面素材,以及xml配置、sql建表脚本和dll动态库,结构上覆盖表现层、业务层与持久层。目前已有185人学习下载。读者可从中获得Java调用本地扫描仪dll的完整实现思路、S2SH分层代码组织方式、证件扫描与文件落盘的处理逻辑,以及可直接参考的数据库脚本与前端页面,适合需要对接硬件外设的Java Web开发者借鉴与二次开发。
1. 从 etp.zip 里那个 Scanner 说起:Java 调扫描仪到底卡在哪
手上拿到一个叫etp.zip的压缩包,解压后里面躺着一个Scanner目录,再往里翻是几个.dll和一份语焉不详的说明——这种场景做桌面端集成的同行应该都不陌生。业务方的诉求很直接:Java 写的管理系统里要能点一下按钮就把纸质单据扫进来,而扫描仪厂商给的偏偏只有 Windows 下的动态库。Java 本身跑在 JVM 里,跟这些.dll之间隔着一层本地接口,这就是整件事的核心矛盾。这篇笔记就围绕「Java 调用扫描仪」这条链路,把etp.zip里Scanner这类封装该怎么接、参数怎么设、TWAIN 和 WIA 怎么选、JNA 和 JNI 差在哪,一路讲到能跑通的最小验证。适合正在做档案、票据、医疗单据电子化的后端和桌面端工程师,也适合被厂商 SDK 文档坑过一轮、想找条稳路的人。
2. 先搞清楚 Scanner 目录里的 dll 是什么角色
2.1 扫描仪在 Windows 上的三层调用栈
要调扫描仪,先得明白 Windows 下这套东西是怎么叠起来的。最底层是扫描仪硬件和它的驱动,厂商装完驱动后,系统里会注册一个数据源,TWAIN 叫 Data Source,WIA 叫 Device。中间层是接口标准,TWAIN 是一套老而稳的协议,几乎所有的平板和馈纸式扫描仪都支持;WIA 是微软后来推的,集成度更高但可控参数少。最上层才是应用,也就是我们的 Java 程序。
etp.zip里Scanner目录下的.dll,绝大多数情况下就是厂商或第三方封装的中间层,它把 TWAIN 那套DSM_Entry调用包成几个导出函数,比如打开设备、设置分辨率、抓一页图像、关闭设备。Java 要做的不是直接跟扫描仪对话,而是通过 JNA 或 JNI 去调这几个导出函数。理解这一层,后面选型和排错才不会跑偏。
常见做法是先用一个工具确认设备到底暴露的是 TWAIN 还是 WIA 源。Windows 自带的「传真和扫描」能识别到的,基本是 WIA;而厂商自带工具能扫的,多半走 TWAIN。这一步别省,选错接口后面全是白工。
2.2 TWAIN、WIA 与厂商私有 dll 的取舍
三条路各有各的脾气。TWAIN 胜在兼容性,从十几年前的老机器到新出的高速馈纸机都认,参数也细,分辨率、色彩模式、纸张来源、双面都能控;缺点是调用链长,状态机容易卡死,一旦某一步没按顺序来,界面就假死。WIA 胜在简单,Windows 原生支持,代码量小;缺点是很多专业扫描仪的进阶参数它拿不到,馈纸器多页进纸的处理也弱。厂商私有 dll 则是另一回事,它可能压根不走 TWAIN,直接跟驱动通信,速度快但绑定死,换一台机器就得重来。
我一般会这样判断:如果业务只要求「能扫进来、能存成图片」,优先 WIA,省事;如果要控分辨率、控双面、控进纸方式,或者设备是老型号,走 TWAIN;只有当厂商明确给了私有 dll 且文档齐全时,才考虑第三条路。etp.zip里既然带了Scanner目录和 dll,说明作者大概率已经封装了 TWAIN 或私有协议,我们要做的是先摸清它导出哪些函数,再决定怎么接。
2.3 用 dumpbin 摸清 dll 的导出函数
拿到一个陌生的 dll,第一步不是写 Java,而是看它到底给了什么。Windows 下用dumpbin或者 Dependency Walker 都行,dumpbin更轻。下面这条命令把导出表列出来,函数名和序号一目了然。
# 列出 Scanner.dll 的所有导出函数,只看名字 dumpbin /exports Scanner.dll | findstr /R "^ *[0-9]* *[A-Za-z]"输出里会看到类似OpenScanner、SetResolution、AcquireImage、CloseScanner这样的名字,也可能只有一串序号没有名字。有名字的好办,直接按名字调;只有序号的,就得靠厂商文档或者反查,这种情况最头疼,属于典型的黑匣子。参数说明这块,dumpbin给不了,得结合头文件或者用Dependencies工具看调用约定。注意调用约定很关键,__stdcall和__cdecl在 JNA 里映射方式不同,搞错了就是栈失衡直接崩。
提示:如果 dll 是 32 位的,你的 JVM 也必须是 32 位,反之亦然。位数不匹配是新手最常见的翻车点,报错往往是「找不到指定的模块」或者直接
UnsatisfiedLinkError。
3. 用 JNA 在 Java 里把扫描仪调起来
3.1 为什么优先选 JNA 而不是 JNI
JNI 性能好、控制细,但要写 C 头文件、编译动态库、处理引用和异常,一套下来半天没了,而且每换一个 dll 就得重编。JNA 用 Java 接口直接映射本地函数,改个方法签名就能试,迭代快得多。对于扫描仪这种调用频率不高、单次耗时主要在硬件上的场景,JNA 那点反射开销完全可以忽略。所以除非你有极致的性能要求或者要处理复杂的回调,否则 JNA 是更务实的选择。
引入依赖很简单,Maven 里加一行net.java.dev.jna:jna:5.x,版本按你项目里已有的来,别乱升。然后定义一个接口继承Library,把 dll 里的导出函数按名字和签名声明出来。签名里的类型映射是重点:C 的int对 Java 的int,char*对String或Pointer,void*对Pointer,结构体对Structure子类。
3.2 定义 JNA 接口映射导出函数
下面这段是把Scanner.dll里几个典型函数映射成 Java 接口的写法。函数名和参数得按你dumpbin出来的实际结果改,这里给的是常见形态。
import com.sun.jna.Library; import com.sun.jna.Native; import com.sun.jna.Pointer; public interface ScannerLibrary extends Library { // 加载 Scanner.dll,路径按实际放的位置改 ScannerLibrary INSTANCE = Native.load("Scanner", ScannerLibrary.class); // 打开扫描仪,返回句柄,失败返回 0 Pointer OpenScanner(String deviceName); // 设置分辨率,返回 0 表示成功 int SetResolution(Pointer handle, int dpi); // 抓取一页,buffer 接收图像数据,返回实际字节数 int AcquireImage(Pointer handle, byte[] buffer, int bufferSize); // 关闭设备 int CloseScanner(Pointer handle); }逻辑说明:Native.load会在java.library.path和系统 PATH 里找Scanner.dll,找不到就抛异常。Pointer用来接句柄,别用long,32 位和 64 位下长度不一样,用Pointer最稳。byte[]接图像缓冲区,调用前得先分配好大小,太小会截断,太大浪费内存。参数说明:deviceName传空字符串通常表示用默认设备;dpi常见取值 200、300、600,馈纸式一般 300 够用,平板做 OCR 建议 300 起步。
3.3 一次完整扫描的调用顺序与缓冲区设置
扫描不是调一个函数就完事,它是有状态的:打开、配置、抓取、关闭,顺序错了就卡。下面这段把整个流程串起来,缓冲区大小按 A4、300dpi、24 位彩色估算,大约 25MB 左右,实际按设备返回的尺寸调整。
public class ScanDemo { public static void main(String[] args) { ScannerLibrary lib = ScannerLibrary.INSTANCE; Pointer handle = lib.OpenScanner(""); if (handle == null) { System.out.println("打开扫描仪失败"); return; } try { // 300 dpi,A4 彩色大约 25MB,留点余量 int dpi = 300; int ret = lib.SetResolution(handle, dpi); if (ret != 0) { System.out.println("设置分辨率失败,错误码 " + ret); return; } byte[] buffer = new byte[30 * 1024 * 1024]; int bytes = lib.AcquireImage(handle, buffer, buffer.length); if (bytes <= 0) { System.out.println("抓取失败,错误码 " + bytes); return; } // 把有效数据写文件,后续再解析格式 try (var fos = new java.io.FileOutputStream("scan_out.bin")) { fos.write(buffer, 0, bytes); } System.out.println("扫描完成,字节数 " + bytes); } finally { lib.CloseScanner(handle); } } }逻辑说明:finally里必须关设备,否则下次打开会报「设备忙」,这是血泪经验。缓冲区大小是估算值,真实项目里应该先调一个查询尺寸的函数,或者按设备返回的bytes动态扩容。参数说明:AcquireImage返回负数一般是错误码,具体含义得查厂商文档;返回 0 可能是没进纸或者用户取消。写出来的scan_out.bin通常是裸的位图数据或者带 TWAIN 头的结构,需要再解析成 BMP 或 JPEG,这一步别指望 dll 帮你做完。
3.4 把裸数据转成可用图片
多数封装 dll 返回的是原始像素或者 DIB,直接存成.bin没法用。常见做法是拿到宽、高、位深后自己拼 BMP 头,或者用ImageIO配合BufferedImage转。如果 dll 支持直接输出 JPEG,那最好,省一大截事。下面给一个把 24 位 BGR 裸数据转 PNG 的片段,宽高需要从设备查询接口拿。
// 假设 width、height 已从设备查询得到,data 是 BGR 排列 BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); int idx = 0; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int b = data[idx++] & 0xFF; int g = data[idx++] & 0xFF; int r = data[idx++] & 0xFF; img.setRGB(x, y, (r << 16) | (g << 8) | b); } } ImageIO.write(img, "png", new File("scan_out.png"));逻辑说明:BGR 顺序是 Windows DIB 的惯例,别按 RGB 读,否则颜色会偏。setRGB逐像素在 300dpi 大图上偏慢,生产环境建议用Raster批量搬。参数说明:width、height必须和实际数据严格对应,差一个像素整张图就错位,这种问题排查起来很折磨。
4. 避坑与排查:Java 调扫描仪最常见的五个翻车点
4.1 报 UnsatisfiedLinkError 或找不到指定模块
现象:程序一启动就抛UnsatisfiedLinkError,或者提示「找不到指定的模块」。原因通常有三个:dll 位数和 JVM 不匹配、dll 依赖的其他运行库缺失、java.library.path没指对。解决:先用dumpbin /headers看 dll 是 32 位还是 64 位,和java -version的输出对齐;再用 Dependency Walker 看它依赖哪些系统 dll,缺的补上;最后确认Native.load的路径,必要时写绝对路径。
4.2 设备被占用,第二次打开就失败
现象:第一次扫描正常,第二次点按钮就报设备忙或者直接卡住。原因多半是上一次的句柄没释放,或者 TWAIN 的状态机没回到初始态。解决:把CloseScanner放进finally,确保异常路径也执行;如果 dll 提供了重置函数,在打开失败时先调一次重置。另外别在多个线程里同时操作同一个句柄,扫描仪基本不支持并发。
4.3 扫出来的图全黑或者只有一条边
现象:图片能生成,但内容全黑、全白或者只有很窄一条。原因一般是缓冲区大小不对、宽高参数没设对,或者色彩模式(彩色/灰度/黑白)和位深不匹配。解决:先查设备返回的实际图像尺寸和位深,再按这个值分配缓冲区和解析;色彩模式要在SetResolution之前或同时设好,顺序错了设备会按默认值走。
4.4 馈纸式多页只扫到第一页
现象:放了一叠纸,程序只拿到第一页就结束了。原因是没有循环调用抓取函数,或者没检测「还有下一页」的状态。解决:把AcquireImage放进循环,每次抓完检查返回码或查询剩余页数,直到返回表示结束的码再退出。注意每页之间要留一点间隔,太快有些设备反应不过来。
4.5 中文路径或空格路径导致加载失败
现象:dll 放在带中文或空格的目录下,Native.load直接失败。原因:部分老 dll 对路径编码处理不好。解决:把 dll 放到纯英文无空格的目录,比如C:\scanner\lib,然后在启动参数里加-Djava.library.path=C:\scanner\lib。这个坑不常遇到,但遇到一次能查半天。
5. 进阶:把扫描封装成可复用的服务与验证方法
走到这里,单次扫描已经能跑通了,但真实项目里不会让你在main里扫。更稳的做法是把这套调用封成一个服务类,对外只暴露「扫一页返回图片路径」这样的方法,内部管理句柄生命周期、重试和超时。我一般会加一个超时控制:抓取放在单独线程里,主线程等固定秒数,超时就认为设备无响应,主动关闭句柄并报错,避免界面假死。这个超时值馈纸式设 30 秒、平板设 60 秒比较合适,具体看设备速度。
验证方法上,别只靠肉眼看图。写一个自检流程:连续扫 10 页,检查每页字节数是否在合理区间、图片宽高是否一致、有没有全黑页。全黑页往往意味着进纸没到位或者曝光参数不对,这种问题在批量场景里很致命。下面这个片段演示怎么用简单统计判断一页是不是空白。
// 统计非白像素比例,低于阈值认为是空白页 int nonWhite = 0; for (int y = 0; y < height; y += 10) { for (int x = 0; x < width; x += 10) { int rgb = img.getRGB(x, y); if ((rgb & 0xFFFFFF) < 0xF0F0F0) nonWhite++; } } double ratio = nonWhite / (double) ((width / 10) * (height / 10)); if (ratio < 0.01) { System.out.println("疑似空白页,比例 " + ratio); }逻辑说明:隔 10 个像素采样一次,速度快,够用。阈值 0.01 是经验值,票据类可以调低,照片类要调高。参数说明:0xF0F0F0是接近白的判定线,扫描噪点多的话可以放宽到0xE0E0E0。
还有一个容易被忽略的点:dll 的线程安全性。绝大多数厂商 dll 不是线程安全的,所以服务类里对句柄的操作要加锁,或者干脆用单线程队列串行化。我吃过这个亏,两个请求同时进来,一个在抓图一个在关设备,直接把进程搞崩,日志里啥都看不出来,纯玄学。后来改成队列,世界就清净了。
最后说个习惯:每接一个新 dll,先写一个最小验证程序,只做打开、设参数、抓一页、关闭这四步,跑通了再往业务里嵌。别一上来就集成到 Spring 里,出了问题你连是 dll 的锅还是框架的锅都分不清。这套流程帮我省过很多次返工,希望帮到你。
本文还有配套的精品资源,点击获取