☰
Java 通过 JNI 调用 TWAIN 接口操作扫描仪实战
2026/10/20 10:26:53 网站建设 项目流程

简介:这是一份面向Java开发者与桌面应用集成人员的扫描仪控制程序源码,核心思路是用C++封装底层扫描逻辑并生成jtwain.dll,再由Java通过JNI调用该动态库完成图像扫描,支持中文环境且扫描时不弹出系统对话框,适合需要在业务系统中静默集成扫描功能的开发者参考。资源包共32个文件、约205KB,包含8个java源文件与13个class编译文件,另有jtwain.dll动态库、jar包、classpath与project工程配置,以及ini参数文件、doc说明文档和少量图片资源,目录结构完整,可直接导入Eclipse类环境继续调试。目前已有926人学习下载。通过阅读源码,读者可以掌握JNI调用DLL的完整链路、扫描参数配置方式与免弹窗扫描的实现细节,并据此改造出符合自身项目需求的扫描模块,减少从零摸索底层接口的时间成本。

1. 用 Java 调扫描仪这件事,到底卡在哪

很多做企业文档系统的开发者都遇到过这个需求:用户点一下按钮,扫描仪把纸质文件扫进来,直接存进系统。听起来简单,但真动手就会发现,Java 标准库压根没有操作扫描仪的 API。JDK 里翻遍了也找不到 Scanner 相关的硬件控制类,因为扫描仪属于 TWAIN 或 WIA 这类操作系统层面的设备接口,Java 够不着。

这个项目就是来解决这个问题的。它用 Java 编写,通过 JNI 调用底层 TWAIN 接口,让 Java 程序能够枚举扫描仪、设置分辨率、触发扫描、拿回图像数据。适合正在做档案管理、票据识别、医疗影像归档这类需要对接扫描硬件的开发者。如果你之前用 Java 调扫描仪时被 JNI 报错、DLL 加载失败、图像回调拿不到数据这些问题折磨过,这份代码值得拆一拆。

2. 项目架构拆解:JNI 桥接层与 Java 业务层的分工

2.1 整体分层设计

这个项目的核心思路是把扫描仪操作拆成三层:最上面是 Java 业务层,负责调用接口和接收图像;中间是 JNI 桥接层,负责 Java 类型和 C 类型之间的转换;最下面是 TWAIN 数据源层,直接和扫描仪驱动打交道。

Java 层不直接接触任何硬件细节,它只调用一个叫ScannerManager的类,这个类里声明了几个 native 方法。JNI 层用 C 或 C++ 实现这些 native 方法,在方法内部调用 TWAIN 的 DSM(Data Source Manager)接口。TWAIN 的 DSM 是一个动态库,Windows 下通常是twain_32.dll,它负责加载具体扫描仪的驱动。

这种分层的好处是业务代码干净,换扫描仪型号不用改 Java 代码。坏处是调试麻烦,JNI 层出问题的时候堆栈信息不完整,经常要靠日志和断点硬啃。

2.2 目录结构说明

项目目录大致长这样:

scanner-java/ ├── src/ │ └── main/ │ ├── java/ │ │ └── com/ │ │ └── scanner/ │ │ ├── ScannerManager.java // 核心入口类 │ │ ├── ScannerDevice.java // 设备信息封装 │ │ ├── ScanConfig.java // 扫描参数配置 │ │ └── ImageCallback.java // 图像数据回调接口 │ └── resources/ │ └── native/ │ ├── scanner_jni.dll // Windows 动态库 │ └── scanner_jni.so // Linux 动态库 ├── native/ │ ├── scanner_jni.c // JNI 实现 │ ├── twain_bridge.c // TWAIN 封装 │ └── CMakeLists.txt // 编译脚本 └── pom.xml // Maven 配置

ScannerManager.java是唯一暴露给业务方的类,其他几个类都是辅助。native目录放 C 源码和编译脚本,编译出来的动态库放到resources/native下,打包时一起打进 JAR。

2.3 核心类职责划分

ScannerManager负责设备枚举、打开设备、启动扫描、关闭设备。它内部维护一个设备句柄,所有操作都基于这个句柄。ScannerDevice是一个简单的 POJO,存设备名称、厂商、类型这些信息。ScanConfig存分辨率、色彩模式、纸张大小这些参数。ImageCallback是一个接口,扫描到图像后通过它回调给业务层。

这种设计让业务层只需要关心两件事:配置参数和接收图像。中间的 JNI 调用、TWAIN 状态机流转、内存管理全部藏在 native 层。

3. 核心模块代码逐段解析:从枚举设备到拿到图像

3.1 设备枚举的 Java 侧实现

先看ScannerManager里枚举设备的方法:

public class ScannerManager { static { // 加载动态库,路径根据操作系统自动选择 String libName = System.getProperty("os.name") .toLowerCase().contains("win") ? "scanner_jni.dll" : "scanner_jni.so"; System.loadLibrary(libName.replace(".dll", "").replace(".so", "")); } // native 方法声明,由 C 层实现 private native int nativeEnumDevices(List<ScannerDevice> devices); private native long nativeOpenDevice(String deviceName); private native int nativeStartScan(long handle, ScanConfig config, ImageCallback callback); private native void nativeCloseDevice(long handle); // 对外暴露的枚举方法 public List<ScannerDevice> listDevices() { List<ScannerDevice> devices = new ArrayList<>(); int count = nativeEnumDevices(devices); if (count < 0) { throw new ScannerException("枚举设备失败,错误码:" + count); } return devices; } }

static代码块在类加载时执行,根据操作系统加载对应的动态库。System.loadLibrary的参数是库名,不带扩展名,JVM 会自动补全。这里有个坑:如果动态库不在java.library.path里,会抛UnsatisfiedLinkError。常见做法是把库放在 JAR 包内,启动时解压到临时目录再加载,但这个项目为了简单,要求把库放在系统库路径或启动参数指定。

nativeEnumDevices接收一个List,C 层通过 JNI 往这个列表里添加ScannerDevice对象。返回值是设备数量,负数表示错误。nativeOpenDevice返回一个long类型的句柄,后续操作都靠它。

3.2 JNI 层枚举设备的 C 实现

C 层的枚举逻辑大概是这样的:

JNIEXPORT jint JNICALL Java_com_scanner_ScannerManager_nativeEnumDevices (JNIEnv *env, jobject obj, jobject deviceList) { TW_UINT16 rc; TW_IDENTITY appId, srcId; int deviceCount = 0; // 初始化 TWAIN 应用标识 memset(&appId, 0, sizeof(appId)); appId.Id = 0; appId.Version.MajorNum = 1; appId.Version.MinorNum = 0; appId.ProtocolMajor = TWON_PROTOCOLMAJOR; appId.ProtocolMinor = TWON_PROTOCOLMINOR; appId.SupportedGroups = DG_IMAGE | DG_CONTROL; strcpy(appId.Manufacturer, "JavaScanner"); strcpy(appId.ProductFamily, "ScannerBridge"); strcpy(appId.ProductName, "JavaScanner"); // 打开 DSM rc = DSM_Entry(&appId, NULL, DG_CONTROL, DAT_PARENT, MSG_OPENDSM, &hwnd); if (rc != TWRC_SUCCESS) { return -1; } // 获取第一个数据源 memset(&srcId, 0, sizeof(srcId)); rc = DSM_Entry(&appId, NULL, DG_CONTROL, DAT_IDENTITY, MSG_GETFIRST, &srcId); while (rc == TWRC_SUCCESS) { // 把 srcId 转换成 Java 的 ScannerDevice 对象 jclass deviceClass = (*env)->FindClass(env, "com/scanner/ScannerDevice"); jmethodID constructor = (*env)->GetMethodID(env, deviceClass, "<init>", "(Ljava/lang/String;Ljava/lang/String;)V"); jstring name = (*env)->NewStringUTF(env, srcId.ProductName); jstring manufacturer = (*env)->NewStringUTF(env, srcId.Manufacturer); jobject device = (*env)->NewObject(env, deviceClass, constructor, name, manufacturer); // 添加到 List jclass listClass = (*env)->GetObjectClass(env, deviceList); jmethodID addMethod = (*env)->GetMethodID(env, listClass, "add", "(Ljava/lang/Object;)Z"); (*env)->CallBooleanMethod(env, deviceList, addMethod, device); deviceCount++; // 获取下一个数据源 rc = DSM_Entry(&appId, NULL, DG_CONTROL, DAT_IDENTITY, MSG_GETNEXT, &srcId); } // 关闭 DSM DSM_Entry(&appId, NULL, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, &hwnd); return deviceCount; }

这段代码的关键点在于 TWAIN 的状态机。DSM_Entry是 TWAIN 的统一入口函数,所有操作都通过它转发。DG_CONTROL表示控制组,DAT_IDENTITY表示数据源标识,MSG_GETFIRST和MSG_GETNEXT用来遍历设备。每次拿到一个srcId,就通过 JNI 创建一个 Java 对象并加到列表里。

FindClass和GetMethodID是 JNI 里比较耗时的操作,如果设备很多,可以缓存这些引用。但扫描仪一般就一两个,所以这个项目没做缓存,代码更直观。

3.3 扫描参数配置与传递

ScanConfig类封装了扫描参数:

public class ScanConfig { private int resolution = 300; // DPI,默认 300 private int colorMode = 0; // 0=黑白 1=灰度 2=彩色 private int paperSize = 0; // 0=A4 1=A3 2=Letter private boolean duplex = false; // 是否双面扫描 private String outputFormat = "jpg"; // 输出格式 // getter/setter 省略 }

C 层拿到这个对象后,需要把参数转成 TWAIN 的TW_CAPABILITY结构。以分辨率为例:

// 设置分辨率 TW_CAPABILITY cap; TW_FIX32 resolution; cap.Cap = ICAP_XRESOLUTION; cap.ConType = TWON_ONEVALUE; cap.hContainer = GlobalAlloc(GHND, sizeof(TW_ONEVALUE)); TW_ONEVALUE *oneValue = (TW_ONEVALUE *)GlobalLock(cap.hContainer); oneValue->ItemType = TWTY_FIX32; resolution.Whole = config->resolution; resolution.Frac = 0; oneValue->Item = resolution; GlobalUnlock(cap.hContainer); rc = DSM_Entry(&appId, &srcId, DG_CONTROL, DAT_CAPABILITY, MSG_SET, &cap); GlobalFree(cap.hContainer);

TW_FIX32是 TWAIN 的定点数格式,整数部分和高位小数部分分开存。ICAP_XRESOLUTION是水平分辨率的 capability ID。设置完所有参数后,调用MSG_ENABLEDS打开数据源,再调用MSG_ENABLEDS开始扫描。

3.4 图像回调与内存管理

扫描到图像后,TWAIN 会通过回调函数通知应用。C 层需要把图像数据转成 Java 的BufferedImage或字节数组,再通过ImageCallback回调给业务层。

// TWAIN 回调函数 TW_UINT16 CALLBACK TwainCallback(pTW_IDENTITY pOrigin, TW_IDENTITY *pAppID, TW_UINT32 DG, TW_UINT16 DAT, TW_UINT16 MSG, TW_MEMREF pData) { if (DG == DG_IMAGE && DAT == DAT_IMAGENATIVEXFER && MSG == MSG_XFERREADY) { // 有图像数据准备好了 HANDLE hBitmap = (HANDLE)pData; // 把 bitmap 转成 RGB 字节数组 // ... 转换逻辑 ... // 回调 Java 层 jbyteArray imageData = (*g_env)->NewByteArray(g_env, dataSize); (*g_env)->SetByteArrayRegion(g_env, imageData, 0, dataSize, rgbData); (*g_env)->CallVoidMethod(g_env, g_callbackObj, g_callbackMethod, imageData, width, height); } return TWRC_SUCCESS; }

这里有个血泪经验:JNI 回调 Java 方法时,JNIEnv指针不能跨线程复用。如果 TWAIN 回调在别的线程触发,必须用AttachCurrentThread获取当前线程的JNIEnv。这个项目里 TWAIN 回调通常在主线程,所以直接用缓存的g_env没问题,但换到某些扫描仪驱动上就可能翻车。

内存管理也是坑。GlobalAlloc分配的内存必须GlobalFree,否则扫一张图泄漏一点,扫几百张就 OOM。图像数据从 C 传到 Java 后,C 侧的内存要及时释放,Java 侧的byte[]由 GC 管理。

4. 跑起来:环境准备与编译步骤

4.1 依赖清单

跑这个项目需要以下环境:

依赖项版本要求说明
JDK8 或 11需要支持 JNI
Maven3.6+构建工具
CMake3.10+编译 native 库
TWAIN DSM2.0+Windows 自带,Linux 需单独装
扫描仪驱动厂商提供确保 TWAIN 驱动已安装

Windows 下twain_32.dll在C:\Windows下,一般不用管。Linux 下需要装libtwain或厂商提供的.so。

4.2 编译 native 库

进入native目录,执行:

mkdir build && cd build cmake .. make

编译成功后会生成scanner_jni.dll或scanner_jni.so。把这个文件复制到src/main/resources/native/下。如果编译报错找不到twain.h,需要把 TWAIN 开发头文件放到 include 路径里。

4.3 打包与运行

mvn clean package

打包后会生成一个可执行的 JAR。运行前确保动态库在java.library.path里:

java -Djava.library.path=./native -jar scanner-java-1.0.jar

如果报UnsatisfiedLinkError,检查三点:库文件名对不对、路径在不在java.library.path、库的架构和 JVM 架构是否一致(32 位 JVM 只能加载 32 位库)。

4.4 一个最小调用示例

public class Demo { public static void main(String[] args) { ScannerManager manager = new ScannerManager(); List<ScannerDevice> devices = manager.listDevices(); if (devices.isEmpty()) { System.out.println("没找到扫描仪"); return; } ScannerDevice device = devices.get(0); System.out.println("使用设备:" + device.getName()); ScanConfig config = new ScanConfig(); config.setResolution(300); config.setColorMode(2); // 彩色 manager.scan(device, config, (imageData, width, height) -> { // 保存图像 BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); // ... 填充像素 ... ImageIO.write(image, "jpg", new File("output.jpg")); }); } }

这个示例展示了完整流程:枚举设备、选第一个、配置参数、扫描并保存。scan方法是阻塞的,扫描完成后才返回。如果需要异步,可以在业务层用线程池包一层。

5. 避坑与排查:JNI 调扫描仪的五个常见翻车现场

5.1 动态库加载失败

现象:启动时报UnsatisfiedLinkError: no scanner_jni in java.library.path。

原因:JVM 找不到动态库,或者库名不对。System.loadLibrary("scanner_jni")在 Windows 下找scanner_jni.dll,Linux 下找libscanner_jni.so。如果编译出来的是libscanner_jni.so,加载时写scanner_jni就行,JVM 会自动补lib前缀。

解决:用System.load指定绝对路径,或者把库放到java.library.path包含的目录里。启动时加-Djava.library.path=/your/path。

5.2 扫描仪枚举为空

现象:listDevices()返回空列表,但系统里明明装了扫描仪。

原因:TWAIN DSM 没初始化成功,或者扫描仪驱动不是 TWAIN 兼容的。有些新款扫描仪只支持 WIA,不支持 TWAIN。

解决:先用厂商自带的扫描软件确认设备能正常工作。如果厂商软件用的是 WIA,这个项目就跑不通,需要换 WIA 方案。另外检查DSM_Entry的返回值,TWRC_FAILURE说明 DSM 打开失败。

5.3 扫描回调不触发

现象:调用scan后程序卡住,回调一直不执行。

原因:TWAIN 的状态机没走到MSG_XFERREADY。可能是参数设置不对,比如分辨率超出了扫描仪支持的范围,或者纸张大小选错了。

解决:在 C 层加日志,打印每次DSM_Entry的返回码。TWRC_CHECKSTATUS表示部分参数被调整,TWRC_FAILURE表示操作失败。根据返回码定位是哪一步出了问题。

5.4 图像数据乱码或全黑

现象:回调拿到的byte[]转成BufferedImage后是乱码或全黑。

原因:TWAIN 传回来的图像格式和代码里假设的不一致。DAT_IMAGENATIVEXFER传回来的是 DIB 格式,需要正确解析 BITMAPINFOHEADER。如果扫描仪设置的是 JPEG 压缩传输,拿到的就是 JPEG 数据,不能按 DIB 解析。

解决:先确认扫描仪的输出格式。如果是 DIB,按BITMAPINFOHEADER的biWidth、biHeight、biBitCount解析像素数据。注意 DIB 的行是 4 字节对齐的,每行末尾可能有填充字节。

5.5 多次扫描后内存泄漏

现象:连续扫描几十张后,程序内存暴涨,最终 OOM。

原因:C 层分配的GlobalAlloc内存没释放,或者 JNI 的局部引用创建太多没删除。

解决:每次DSM_Entry调用后,检查hContainer是否要GlobalFree。JNI 里FindClass、NewStringUTF创建的局部引用在方法返回后会自动释放,但在循环里创建大量局部引用会撑爆引用表,需要手动DeleteLocalRef。

6. 二次开发:换扫描仪、加图像处理、做异步扫描

6.1 适配不同品牌的扫描仪

这个项目默认走 TWAIN 通用接口,理论上支持所有 TWAIN 兼容设备。但不同厂商的驱动实现有差异,常见的有佳能、爱普生、富士通。换品牌时主要改两个地方:一是ScanConfig里的 capability 设置,有些厂商不支持某些分辨率或纸张大小;二是图像传输格式,有的默认传 DIB,有的支持 JPEG 压缩传输。

我一般会先跑一遍枚举和参数设置,把DSM_Entry的返回码全部打出来,确认哪些 capability 被支持。然后在ScanConfig里加一个vendorSpecific的 Map,存厂商特有的参数。

6.2 集成图像处理

扫描出来的原始图像往往需要处理:去黑边、纠偏、二值化、OCR 预处理。可以在ImageCallback里接一个处理链:

public interface ImageProcessor { BufferedImage process(BufferedImage input); } // 在回调里串联处理 manager.scan(device, config, (imageData, width, height) -> { BufferedImage image = convertToImage(imageData, width, height); for (ImageProcessor processor : processors) { image = processor.process(image); } saveImage(image); });

常见的处理器包括DeskewProcessor(纠偏)、CropProcessor(裁边)、BinarizeProcessor(二值化)。这些用 Java 的BufferedImageAPI 就能实现,不需要再走 JNI。

6.3 改成异步扫描

默认的scan方法是阻塞的,在 UI 线程调用会卡界面。改成异步很简单,把扫描逻辑丢到线程池里:

ExecutorService executor = Executors.newSingleThreadExecutor(); public CompletableFuture<Void> scanAsync(ScannerDevice device, ScanConfig config, ImageCallback callback) { return CompletableFuture.runAsync(() -> { scan(device, config, callback); }, executor); }

注意 TWAIN 的 DSM 不是线程安全的,多个扫描任务不能并发执行。用单线程池保证串行化。如果要多台扫描仪同时工作,每台设备需要独立的ScannerManager实例和独立的 DSM 会话。

6.4 一个验证技巧

改完代码后,别急着接真实扫描仪。先用 TWAIN 的模拟数据源测试,Windows 下有个叫TWAIN Data Source Manager的测试工具,可以模拟扫描仪返回固定图像。确认 Java 层和 JNI 层的逻辑没问题后,再接真实设备。这样能把软件问题和硬件问题分开,省得来回折腾。

从那以后我每次改 JNI 相关代码,都强制走一遍「模拟数据源 → 真实设备 → 连续扫描 50 张」的流程,确认没有内存泄漏和状态机卡死。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询