简介一套基于Java语言开发的扫描仪控制程序重点演示如何借助JNI技术调用C生成的jtwain.dll在Java桌面应用中实现稳定扫描。这套程序支持中文环境扫描时不弹出系统扫描对话框适合需要将扫描功能嵌入到信息系统、档案管理或办公流程中的开发者参考。包内共32个文件包括8个Java源码和13个class编译文件便于对照源码理解实现细节还包括jtwain.dll动态库、My.jar打包文件、Config.ini配置项、Eclipse工程设置及系统说明文档大小只有205KB结构精炼上手门槛低。目前已有926人学习下载。通过阅读源码和说明读者可以掌握Java与C本地库之间的桥接方法、扫描仪参数的配置方式以及不依赖扫描界面直接出图的调用思路对于想快速集成扫描功能或学习JNI调用的开发者来说是一份小巧而完整的实践样例稍作修改即可移植到自己的项目里。1. 从一个归档需求说起Java怎么把扫描仪搬进桌面程序桌面扫描在Java生态里一直有点尴尬业务系统是Java写的扫描仪驱动却是C的世界。某公司资料管理系统升级时希望业务员在软件里点一下“扫描归档”就把纸质单据直接扫进系统而不是弹出一个外部程序扫完再手工找文件。找了一圈方案才发现这事绕不开JNI而扫描仪驱动普遍认的是TWAIN协议于是基于JTwain做封装成了最稳妥的路线。这篇笔记要拆的就是一个用Java写的扫描仪程序用JTwain管理TWAIN会话通过JNI调底层驱动把扫描仪变成业务系统里的一个普通控件。适合要做OA、档案、进销存这类深度集成扫描功能的人读也适合刚接触TWAIN协议的朋友当入门样例。2. 技术选型拆解为什么是JNI JTwain而不是给C程序裹一层壳当时拿到这个需求第一反应其实是写一个独立的C扫描器Java这边用Socket或者命令行去调。后来被否决了一是业务端部署环境不能多装一个常驻进程二是扫描结果要直接进Java内存做OCR预处理中间绕一道文件落盘会很别扭。所以最终回到JNI这条路Java进程直接加载本地库数据从驱动到Java堆只有一次拷贝。2.1 三类接入方案对比TWAIN、WIA与SANE在Windows上接入扫描仪常见选择是这三条路。我把当时的对比结论放在表格里方案协议/接口优缺点适用场景TWAIN行业事实标准DSM由系统提供几乎所有专业扫描仪都支持能力项Capability完备但回调机制对新手有点绕本项目选它兼容面最广WIA微软自带Windows Image Acquisition系统内置无需额外装组件但很多高速扫描仪的ADF、双面扫描、高分辨率在WIA下都受限家用一体机、简单拍照扫描SANELinux下的扫描标准跨平台但Windows支持很弱Java调用还得再走一层JNI服务器端、Linux环境最后选TWAIN还有个实际原因本项目要接的扫描仪是带ADF自动进纸器的必须用TWAIN原生能力做连续传输。WIA在很多老驱动上只能一张一张扫ADF完全用不起来。TWAIN还有一个优势是协议本身不绑定扫描仪的USB接口形式。无论是USB、网口还是老式并口驱动层都转换成统一的TWAIN能力项应用代码不需要关心传输物理通道。这对在多办公区部署同一个Java客户端的场景很友好换一台不同接口的扫描仪不用改代码。2.2 三层调用架构UI层、门面层、本地桥接层这个项目没有把JNI调用散落在界面代码里而是按三层来组织。UI层就是普通的Swing窗体门面层是一个ScannerManager把设备枚举、打开、扫描、关闭这些动作收敛成几个公开方法本地桥接层才是真正跟TWAIN DSM打交道的地方JTwain的jar和它自带的DLL都归在这一层。调用链路是这样的Swing按钮点击 - ScannerManager.scanOnePage() - JTwain的Source.acquire() - JNI进入jtwain.dll - TWAIN DSM - 扫描仪驱动 - 硬件。写过JNI的人应该能感觉到这条链上每一环都可能出问题所以门面层设计得薄一点出问题时好定位。public class ScannerManager { // JTwain的数据源管理器整个JVM只要这一个实例 private SourceManager sourceManager; private Source currentSource; public void init() { // 加载jtwain本地库相当于System.loadLibrary的封装 SourceManager.loadLibrary(); sourceManager SourceManager.instance(); } // 枚举当前机器上所有TWAIN源通常就是扫描仪型号名 public ListString listDevices() { return sourceManager.getSourceNames(); } // 打开指定设备name来自listDevices()的返回 public void openDevice(String name) throws Exception { closeDevice(); currentSource sourceManager.getSource(name); currentSource.open(); } // 扫描一页结果通过回调对象回传 public void scanOnePage(ScanListener listener) throws Exception { currentSource.setTransferMode(TransferMode.MEMORY); currentSource.acquire(listener); } }这段代码里最需要注意的是SourceManager.instance()的单例语义。JTwain底层对应的是系统TWAIN DSM的一个进程级引用多搞几个实例出来最直观的表现就是扫描窗口弹两次。openDevice里先closeDevice再开新的是为了避免换了设备之后旧设备的TWAIN会话还挂在DSM上。TransferMode.MEMORY表示图像数据通过内存块传给应用这是Java侧最容易处理的模式回调里拿到的就是像素字节数组后面可以直接转成BufferedImage。与之对应的还有FILE和NATIVE模式前者让驱动直接写文件省Java内存但少了预处理机会后者走Windows图形对象转Java图像反而多一道手续。2.3 项目目录与依赖清单jtwain.jar为什么不在中央仓库拿到这个项目的源码第一件事先看它的目录。扫描程序不是个复杂工程但依赖的摆放位置直接决定了能不能顺利跑起来scanner-demo/ ├── pom.xml ├── lib/ │ ├── jtwain.jar │ └── native/ │ └── win32-x86/ │ └── jtwain.dll └── src/main/java/ └── com/demo/scanner/ ├── MainFrame.java ├── core/ │ ├── ScannerManager.java │ ├── TwainSession.java │ └── ScanListener.java └── callback/ └── ScanResult.javalib目录里同时放了jar和dll这个结构在Maven项目里属于“本地依赖”的典型处理。jtwain这个库年头比较早没有发布到中央仓库所以pom里引用它用的是system scopedependency groupIdcom.dyomedea/groupId artifactIdjtwain/artifactId scopesystem/scope systemPath${project.basedir}/lib/jtwain.jar/systemPath /dependency用system scope打包时有个坑maven-assembly或spring-boot插件默认不会把system scope的jar打进最终产物所以这个项目的构建配置里jtwain.jar是靠maven-dependency-plugin手动复制到输出目录的。如果你拿到源码后自己改构建方式记得处理这一步否则换台机器跑就会报NoClassDefFoundError。DLL这边更敏感。jtwain.dll有32位和64位两种取决于当初用的是哪个版本的JDK。项目里native目录按win32-x86命名说明这套源码是按照32位JDK配的。后面讲运行时会详细说怎么避免在这个地方翻车。3. 核心代码走读TWAIN会话、图像回调与JNI桥接的三层切片把架构图翻到代码层面就变成了三个类的事。TwainSession负责TWAIN会话的生命周期ScanListener负责图像回传ScannerManager是最大门面。这三个对象配合的时序基本就是一次扫描从点击按钮到拿到图片的完整过程。3.1 会话生命周期从SourceManager到AcquireTWAIN的会话一旦建立整条链路都围绕Source对象转。以下是TwainSession里枚举设备和打开设备的实现这类代码在项目里基本只需要写一次后面所有扫描都复用它。public class TwainSession { private Source source; // 从系统DSM里按名字取一个TWAIN源 public boolean open(String deviceName) { SourceManager manager SourceManager.instance(); source manager.getSource(deviceName); if (source null) { return false; } // 打开会话这一步会触发扫描仪驱动初始化 source.open(); return source.isOpen(); } public void configureDefault() throws Exception { // 分辨率设为300dpi印刷品扫描的通用值 source.setCapability(Capability.ICAP_RESOLUTION, 300); source.setCapability(Capability.ICAP_PIXELTYPE, PixelType.RGB); // 纸张宽度用千分之一英寸为单位A4宽度约8.27英寸 source.setCapability(Capability.ICAP_PHYSICALWIDTH, 8300); // 高度约11.69英寸 source.setCapability(Capability.ICAP_PHYSICALHEIGHT, 11690); } public void close() { if (source ! null) { source.close(); source null; } } }这段代码的关键点getSource(name)按名称从DSM拿数据源name必须和listDevices枚举出来的完全一致大小写都不能错否则source为null。configureDefault里设置的四个Capability是扫描项目的标配分辨率控制输出像素密度颜色类型决定每个像素占几个字节物理宽高决定扫描区域大小。如果扫描区域设置比纸张大驱动会直接按最大物理范围输出出来的图会有大块空白边。如果设置太小扫描件边缘会被切掉。这两个参数最值得按实际纸张微调。3.2 一次扫描的完整链路传输模式与图像回调acquire方法回传图片靠的是监听器不是返回值。这个设计一开始容易让Java开发者不适应但它是TWAIN的传输模型决定的驱动在后台从硬件读数据写完一块通知一次应用应用处理完再告诉驱动“缓冲区可以释放了”然后继续下一块。public class ScanListener implements TransferListener { private ListBufferedImage images new ArrayList(); Override public void imageTransferred(TransferEvent event) { try { // 从事件里取像素数据、宽高和颜色深度 byte[] pixelData event.getPixelData(); int width event.getWidth(); int height event.getHeight(); // 重要立即把像素数据转成BufferedImage不要在回调里做重活 BufferedImage image new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); convertPixelData(image, pixelData); images.add(image); // 通知TWAIN DSM这块缓冲区用完了可以继续传输 } finally { event.close(); } } // 拿到全部页供调用方在acquire结束后读取 public ListBufferedImage getImages() { return images; } }回调里的event.close()是必须调的漏掉的后果是扫描仪卡在等待状态下一次acquire没有任何响应这是最容易踩的坑之一。之所以强调“不要做重活”是因为TWAIN的回调线程和UI线程不是同一条如果在这里写文件或者跑OCR既拖慢传输节奏又可能在UI刷新时出现并发问题。正确姿势是回调里只转图像、放进内存列表等整个acquire结束再统一处理。convertPixelData这一步还有个常见陷阱驱动输出的像素顺序是BGR而不是RGB而且每一行末尾可能有多余的填充字节行宽不一定是width*3。转换时必须按“每一行的实际字节数”去拷贝否则图片会出现斜线错位或者整体偏色。TransferEvent里除了getPixelData还有getWidth、getHeight、getBitsPerPixel、getSamplesPerPixel这几个常用方法。很多TWAIN封装都有类似的结构拿到图片后先读颜色位深和采样数再决定按什么格式构造BufferedImage比写死TYPE_INT_RGB要稳妥。黑白扫描的图位深是1不判断直接按RGB处理出来的就是一片规律的黑色块这也是全黑图像的另一大来源。3.3 JNI桥接层jtwain.jar里看不到的那一截JTwain把JNI细节藏起来了使用者只看得见jar里的Java类。但了解这一层对排查崩溃和性能问题帮助很大。JTwain的DLL本质上做了一件标准的事把Java的native方法注册到C函数再由C函数去调TWAIN DSM的API。以下是这类桥接的典型骨架很多扫描仪项目自己写JNI也是照着这个套路public class TwainNativeBridge { static { // 项目里通常放在init()里调用这里演示加载本地库 System.loadLibrary(twain_jni); } // 打开TWAIN数据源管理器返回会话句柄parentWnd用于弹扫描界面 public static native int dsmOpen(int parentWnd); // 触发一次扫描sessionId来自dsmOpen public static native int dsmAcquire(int sessionId); // 关闭会话释放DSM资源 public static native void dsmClose(int sessionId); }C那侧对应的注册代码长这样static const JNINativeMethod methods[] { {dsmOpen, (I)I, (void*)Java_TwainNativeBridge_dsmOpen}, {dsmAcquire,(I)I, (void*)Java_TwainNativeBridge_dsmAcquire}, {dsmClose, (I)V, (void*)Java_TwainNativeBridge_dsmClose} }; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env; vm-GetEnv((void**)env, JNI_VERSION_1_8); jclass clazz env-FindClass(TwainNativeBridge); env-RegisterNatives(clazz, methods, 3); return JNI_VERSION_1_8; }JNI方法签名里“(I)I”表示接收一个int参数、返回一个intV代表void。签名写错会在运行时直接抛NoSuchMethodError这类问题在JNI层排查比Java层痛苦得多。所以我每新增一个native方法都会先用javap -s核对签名再把C函数对照检查一遍。RegisterNatives在JNI_OnLoad里统一注册比靠函数命名规则让JVM自动查找要可靠对32位Windows下那种带下划线的导出名尤其友好。理解这一层还有一个实际价值当扫描崩溃在native层hs_err日志里经常能看到twain_32.dll或者jtwain.dll的调用栈。知道哪一层出问题就能往驱动还是JNI封装上定向排查。4. 运行与避坑Windows下的依赖顺序和四个常见翻车点JNI项目最大的特点就是“环境对了什么都顺环境不对到处报错”。这个扫描仪程序的运行门槛不高但依赖顺序必须捋清楚先有TWAIN驱动再配JDK位数然后考虑ClassPath最后才是业务代码。顺序反了报错信息往往很迷惑。4.1 环境准备与编译运行步骤我在这台Windows测试机上按下面这个清单准备环境依赖项要求说明操作系统Windows 10/11系统自带TWAIN DSM即twain_32.dllJDK32位JDK 1.8必须与lib/native/win32-x86里的DLL位数一致扫描仪驱动安装完整版驱动包驱动包里要含TWAIN接口不能只装WIA构建工具Maven 3.6用于打jar和拷依赖由于jtwain.jar不在中央仓库第一步是把它装进本地Maven仓库让pom能正常解析# 版本号这里是占位符实际用你自己约定的版本即可 mvn install:install-file \ -Dfilelib/jtwain.jar \ -DgroupIdcom.dyomedea \ -DartifactIdjtwain \ -Dversion1.0 \ -Dpackagingjar然后把native目录里的jtwain.dll加进java.library.path再启动不加这一步会直接报找不到本地库mvn package java -Djava.library.path./lib/native/win32-x86 \ -cp target/scanner-demo.jar:lib/jtwain.jar \ com.demo.scanner.MainFrame在集成开发环境里跑的话等价操作是把java.library.path填进运行配置的VM options或者把lib/native/win32-x86目录加到项目的native library path。这一步通常在Windows下会报错原因不是环境变量没生效就是JDK位数和DLL对不上。如果你用的是Gradle而不是Maven等价处理是在build.gradle里声明一个fileTree依赖把lib目录整目录引进来再给application插件配置applicationDefaultJvmArgs把java.library.path指到native目录。IDEA用户则可以在Run Configuration里直接填VM options比改系统环境变量干净。我见过的典型反例是有人把dll直接拷到系统目录下项目确实能跑但换一台机器就漏了这个手动步骤。相对推荐的做法是让构建脚本每次自动把native目录复制到输出目录并用相对路径引用这样项目跟着仓库走不依赖每台机器的全局环境。4.2 四个高频问题排查记录这个项目我实际跑过几轮和同事一起踩出来的坑主要集中在下面四条每条都是“现象 - 原因 - 解决”的结构。第一个是运行时报UnsatisfiedLinkError日志提示no jtwain in java.library.path。原因是JVM启动时没找到DLL或者DLL的位数和当前JDK不一致32位JDK配64位DLL会在加载时就报错。解决方式是先确认java.library.path确实指向了native目录然后看JDK位数用java -version看输出里有没有“32-Bit”字样没有就去换32位JDK。第二个是扫描结果全黑偶尔还带彩色噪点条纹。原因是驱动返回的像素格式是BGR且行字节有填充而转换代码按RGB和三字节行宽处理每一行的数据都错位了。解决方式是按event里返回的实际行字节数去拷贝并在生成BufferedImage时按TYPE_INT_RGB手动交换R和B分量不要直接拿驱动字节数组填图像。第三个是点一次扫描弹出两个扫描窗口有时还伴随第二次扫描超时。原因是SourceManager.instance()被调用了多次或者上一次扫描的Source没有close就重新open。解决方式是把SourceManager做成进程级单例所有扫描共用同一个实例同时ScannerManager的openDevice里先close旧的Source再开新的保证同一时间只有一个TWAIN会话存在。第四个是ADF连续扫描只进第一页就停住手动拉出纸再放进去又能扫一张。原因是TWAIN的CAP_XFERCOUNT传输计数默认是1驱动认为只传一张图就结束。解决方式是在配置阶段把传输计数设为-1表示由应用持续接收直到进纸器无纸source.setCapability(Capability.CAP_XFERCOUNT, -1);设成-1之后ADF里剩下的页会被驱动一张张推过来每次对应一次imageTransferred回调直到无纸触发传输结束事件。4.3 怎么验证“桥通了”三层日志法JNI项目有个特点出错时经常不是Java栈而是底层直接崩或者什么提示都不给就卡住。我的排查习惯是在三层各埋一条日志组成完整的“链路日志”。第一层是ScannerManager的入口日志记录“开始扫描、设备名、参数”第二层是ScanListener的回调日志记录每张图的宽高和接收序号第三层是native初始化日志确认DLL加载成功。只要这三条日志按顺序出现基本能断定从Java到驱动的链路是通的。哪一层缺了问题就锁定在哪一层缺第一层是调用方问题缺第二层是TWAIN传输卡住缺第三层是DLL没加载。这套方法帮我在遇到“扫描按钮点了没反应”的时候十分钟内定位到是驱动会话没释放而不是去翻界面代码。5. 二次开发实战批量连续扫描与保存策略的改造做扫描仪程序单页扫描只是第一步实际业务里大量场景是“一摞合同放进去一次性扫完”。改造点集中在两个地方一是把传输计数改成连续模式二是在回调里累积图片而不是覆盖。前者一行配置就够后者需要把ScanListener的images列表暴露给调用方并在acquire结束后统一处理。5.1 批量扫描的改造思路原来的scanOnePage每次只收一张图批量版本只需要改成调用一个scanBatch方法。核心代码变化很小但要注意两点连续传输时不能再人为设置分辨率之外的纸质大小因为ADF送纸时会直接读取纸张传感器另外批量扫描建议把每页图先按时间戳归档避免后续OCR时找不到对应页。public ListBufferedImage scanBatch() throws Exception { // 连续传输模式配ADF自动进纸器使用 source.setCapability(Capability.CAP_XFERCOUNT, -1); ScanListener listener new ScanListener(); source.acquire(listener); return listener.getImages(); }一次批量扫描的图片是同步返回的意味着如果扫50页内存里会同时有50张BufferedImage。我一般会在回调里做一次尺寸压缩把300dpi的原图先降采样到150dpi存入内存既能保证OCR质量又不会把堆内存吃爆。5.2 保存策略与后续扩展批量扫描之后接什么通常决定了这个项目的真实价值。最稳的保存策略是这三选一按单据编号建文件夹每页按时间戳命名或者把所有页合成一个PDF再或者直接交给OCR管线。前两种在这个项目里改造成本最低第三种需要引入额外的图像处理库。保存方式优点需要注意原始图片落盘最简单便于二次处理命名不能重建议用时间戳页码合成PDF适合归档打印需要PDF库压缩参数要调接OCR可直接生成可检索文本对图像质量要求高300dpi起步说到这想起来一个教训有一次在新换的办公电脑上怎么扫都是白屏折腾了半天才发现那台机器只装了打印驱动扫描仪TWAIN驱动没装全DSM根本枚举不到设备。从那次以后我每次接到扫描类项目开机第一件事就是先跑一遍listDevices()看到设备名再动业务代码能省掉大半玄学问题。希望帮到你。本文还有配套的精品资源点击获取