Java扫描仪JNI实战:TWAIN取图与DIB转BufferedImage
简介面向需要在Java应用中集成扫描仪控制能力的开发者这份源码包围绕JNI调用jtwain.dll实现扫描流程重点解决Java端直接操作扫描设备、扫描时不弹出系统对话框以及中文环境兼容等问题适合具备一定Java基础、希望学习本地库调用与TWAIN集成的读者参考。包内共32个文件以java源码与class编译文件为主体配有dll动态链接库、jar包、工程配置文件及doc说明文档另有少量图片与数据库文件辅助运行压缩包约205KB结构紧凑便于快速导入IDE查看调用关系。已有926人学习说明该方案在扫描仪对接场景中具有一定参考价值。读者可从中梳理Java层如何加载jtwain.dll、封装扫描参数与返回结果并理解无对话框扫描和中文支持的处理思路为桌面端扫描模块开发提供可复用的实现线索。1. 从一段 JNI 调用说起Java 扫描仪程序到底卡在哪上个月帮人看一个 java 扫描仪项目界面跑得好好的点“扫描”却一直转圈日志里只剩一行 UnsatisfiedLinkError。它底层走的是 JNI 加 TWAIN 那条老路跟 jtwain 这类库的做法大同小异——Java 负责界面和归档一小段 C 负责跟驱动对话。坑不在算法在边界窗口句柄、事件泵、全局内存句柄这三样 Java 天生拿不到必须靠原生层搬。这个扫描仪程序把整条链路串起来了——枚举设备、协商能力、取 DIB、转 BufferedImage、落盘成多页文件。适合手上有一台或几台扫描仪、要把纸质件批量扫进自己业务系统的开发者也适合想弄清 jtwain 内部到底在干什么的人。2. 三层会话与 JNI 边界为什么不能纯 Java 调扫描仪2.1 TWAIN 的三角结构应用、管理器、数据源理解这套东西先记住三个角色。应用程序也就是你的 Java 进程不直接跟扫描仪说话中间有个数据源管理器通常就是一个动态库负责把机器上所有已安装的驱动列出来最下面才是数据源也就是厂商提供的那个驱动真正下发指令让灯管亮、进纸轮转的是它。所有交互只有一个入口函数签名固定成五个参数调用方身份、目标身份、数据组、数据类型、消息。数据组和数据类型的组合决定了这条消息是管控制流还是管图像流。状态推进是有严格顺序的跳步一定失败阶段典型消息干的事常见返回打开管理器控制组 / 父窗口 / OPENDSM加载管理器库绑定窗口句柄失败多半是位数不匹配选设备控制组 / 身份 / OPENDS按产品名打开某个驱动失败是设备被别的进程占用使能控制组 / 用户界面 / ENABLEDS弹厂商 UI 或静默就绪用户点取消会返回取消码事件泵控制组 / 事件 / PROCESSEVENT把 Windows 消息翻译成扫描事件非本驱动事件是常态取图图像组 / 原生取图 / GET返回一个指向 DIB 的全局内存句柄取消、失败、成功三态收尾DISABLEDS → CLOSEDS → CLOSEDSM逆序释放顺序错了下次打不开这张表建议直接抄进注释里。我见过太多“第一次能扫第二次打不开”的问题全是收尾顺序乱掉——中间某一步没走完就关了管理器驱动内部状态没复位下一次 OPENDS 直接返回失败。收尾必须严格反着来先禁用数据源再关数据源最后关管理器。提示身份结构体里的产品名、厂商名都有长度上限超了会被截断甚至写入越界字符串拷贝时务必带长度参数。2.2 为什么一定要落一层 JNI纯 Java 走不通不是因为没人封装而是因为三样东西天然在 JVM 之外。第一是窗口句柄TWAIN 的事件模型建立在 Windows 消息机制上驱动弹自己的界面、发自己的通知都需要一个真实的 HWND 来承接Java 的窗口对象背后虽然也有句柄但取出来要在原生层做类型转换。第二是全局内存句柄原生取图返回的不是字节数组而是一个操作系统级的句柄得先锁定拿到指针再按长度整块拷进 JVM拷完立刻解锁释放这一套操作 Java 侧完全没有对应语义。第三是线程亲和驱动的事件泵必须在创建窗口的那个线程上跑而 Java 的界面线程是事件分发线程在里面跑模态扫描会直接把界面冻住。JNA 能不能做能但结构体里带指针、带联合体、带容器句柄的时候字段对齐和内存布局要手写映射调试成本比写一层薄 JNI 高得多。JNI 的代价是要维护头文件和编译产物好处是这一层代码总共也就几百行逻辑一眼看得穿出问题打日志到标准错误就能定位。我一般的取舍是能力设置、取图这类高频且结构体复杂的调用走 JNI偶尔调一次的信息查询怎么方便怎么来。2.3 现成封装库和自建桥的取舍维度现成 jtwain 类库自建 JNI 桥上手速度快接口直接可用慢要写 C 和编译脚本位数支持老版本多限 32 位自己编管理器支持 64 位即可事件循环被库封装插不进自定义逻辑完全可控能加超时和中断特殊能力双面、空纸检测未必暴露直接发消息想设什么设什么排错异常信息常被吞成一句通用错误每步可打返回码维护跟随第三方节奏自己扛但代码量小选型的判断点其实很朴素如果只是“点一下扫一张”现成库足够如果要做进纸器批量、要判断空纸、要在扫描过程中显示进度、要支持双面那就得自己控事件泵现成库的封装反而挡路。先把 Java 侧的契约定下来原生层照着实现这一步定了后面就顺public interface NativeTwain { /** 打开数据源管理器hwnd 可为 0但取图阶段必须给真实窗口句柄 */ int openDsm(long hwnd); /** 枚举本机已安装的数据源产品名失败返回 null */ String[] listSources(); /** 按产品名打开数据源成功返回句柄失败返回 0 */ long openDs(String productName); /** 设置数值型能力type 是 TWTY_* 常量value 已按目标类型转换 */ int setCap(long ds, int capId, int type, long value); /** 触发一次取图返回打包好的 DIB 字节取消或失败返回 null */ byte[] acquire(long ds, long hwnd, int timeoutMs); int closeDs(long ds); int closeDsm(); }接口里刻意没有暴露句柄和指针取图直接返回字节数组——把内存搬运收在原生层内部Java 侧看到的永远是干净的对象。acquire的timeoutMs是我加的私货事件泵必须有超时兜底否则驱动不回消息的时候线程会永远挂在那里用户只能杀进程。setCap的value用 long 是为了同时容纳布尔、整数和定点数定点数在调用前按精度换算好再传。3. 把环境搭起来位数对齐、库路径与最小枚举3.1 位数不匹配是第一个大坑扫描驱动这套生态是有历史包袱的。老的管理器库只有 32 位版本很多厂商的数据源也只编了 32 位而你现在装的 JDK 十有八九是 64 位。结果就是管理器能加载枚举能出设备名一打开就失败或者干脆返回“找不到扫描仪”日志里干干净净什么都没有。这不是代码写错是两个进程地址空间宽度对不上。先确认自己站在哪一边# 查看当前 JVM 的位数32 或 64 java -XshowSettings:properties -version 21 | grep sun.arch.data.model # Windows 下看某个库的机器类型x86 还是 x64 dumpbin /headers TWAINDSM.dll | findstr machine两条路可选。第一条把扫描模块单独做成一个 32 位进程主程序通过进程间通信把参数和图像传过去——多一层但老设备兼容性最好主程序也能继续用 64 位 JVM。第二条换支持 64 位的管理器库并且确认厂商提供了 64 位数据源很多近几年的设备已经给了再老一点的没有。我的做法是先枚举一遍看设备名出不出来出得来但打不开就基本锁定是位数问题别在代码里瞎找。注意不要试图用一个 64 位进程去加载 32 位库这是操作系统层面拒绝的不是配置能绕过去的。3.2 库路径与依赖链原生库不是扔进 classpath 就完事。加载方式有两种按名字加载会走系统属性里的库搜索路径按绝对路径加载则直接定位文件。我强烈建议用绝对路径把原生库放在一个固定目录启动时算出路径再加载避免“开发机能跑、打包后找不到”的经典翻车。更隐蔽的是二级依赖。你的桥接库自己可能依赖编译器运行库那个运行库不在系统目录里加载时就会报一个跟扫描仪毫无关系的错。判断方法很简单拿依赖查看工具打开你的库看它引用了哪些模块缺哪个补哪个全放同一个目录。public final class NativeLoader { private static volatile boolean loaded; public static synchronized void load() { if (loaded) return; String arch System.getProperty(sun.arch.data.model); // 32 / 64 String os System.getProperty(os.name).toLowerCase(); if (!os.contains(win)) { throw new IllegalStateException(当前桥接层只实现了 Windows 数据源); } // 约定原生库放在 工作目录/native/位数/ 下避免多环境混用 Path lib Paths.get(System.getProperty(user.dir), native, arch, scanbridge.dll).toAbsolutePath(); if (!Files.isRegularFile(lib)) { throw new IllegalStateException(原生库缺失: lib); } System.load(lib.toString()); // 绝对路径绕开搜索路径的玄学 loaded true; } }这段代码的关键就一句System.load收绝对路径。按名字加载时搜索顺序跟当前工作目录、系统目录、环境变量都有关不同启动方式结果不一样是典型的环境玄学。另外目录里带了位数分层同一份发行包能同时装 32 位和 64 位两套库运行时按属性挑省掉一次“发错包”的事故。3.3 最小可跑枚举本机数据源不要一上来就写取图。先写枚举能列出设备名说明管理器通了、库加载对了、位数大概率也对。原生侧实现如下#include jni.h #include windows.h #include string.h #include twain.h /* 规范附带的头文件随项目放一份即可 */ static TW_IDENTITY g_app; /* 应用身份整个生命周期复用同一份 */ static TW_UINT16 dsm_open(HWND hwnd) { memset(g_app, 0, sizeof(g_app)); g_app.Version.MajorNum 2; g_app.Version.MinorNum 4; g_app.Version.Language TWLG_ENGLISH_USA; g_app.Version.Country TWCY_USA; g_app.ProtocolMajor TWON_PROTOCOLMAJOR; g_app.ProtocolMinor TWON_PROTOCOLMINOR; g_app.SupportedGroups DG_IMAGE | DG_CONTROL; /* 三个名字都要限长拷贝超长会写坏结构体后面的字段 */ strncpy(g_app.Manufacturer, ScanBridge, 32); strncpy(g_app.ProductFamily, ScanBridge, 32); strncpy(g_app.ProductName, ScanBridge, 32); return DSM_Entry(g_app, NULL, DG_CONTROL, DAT_PARENT, MSG_OPENDSM, hwnd); } JNIEXPORT jobjectArray JNICALL Java_com_example_scan_NativeTwain_listSources(JNIEnv *env, jclass cls) { TW_IDENTITY ds; TW_UINT16 rc; jobject names[64]; int n 0; if (dsm_open(NULL) ! TWRC_SUCCESS) return NULL; /* 枚举阶段允许无窗口 */ /* GETFIRST 返回成功表示有设备返回 ENDOFLIST 表示一个都没有 */ rc DSM_Entry(g_app, NULL, DG_CONTROL, DAT_IDENTITY, MSG_GETFIRST, ds); while (rc TWRC_SUCCESS n 64) { names[n] (*env)-NewStringUTF(env, (const char *) ds.ProductName); rc DSM_Entry(g_app, NULL, DG_CONTROL, DAT_IDENTITY, MSG_GETNEXT, ds); } DSM_Entry(g_app, NULL, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, NULL); jclass strCls (*env)-FindClass(env, java/lang/String); jobjectArray out (*env)-NewObjectArray(env, n, strCls, NULL); for (int i 0; i n; i) { (*env)-SetObjectArrayElement(env, out, i, names[i]); (*env)-DeleteLocalRef(env, names[i]); /* 局部引用要及时删批量枚举会撑爆引用表 */ } return out; }几个参数值得单独说。SupportedGroups只声明用到的两组多声明没坏处但没必要。ProductName是驱动安装时写进注册表的名字用它来打开数据源比用索引稳因为索引顺序在不同机器上不保证一致。NewStringUTF假定产品名是 UTF-8绝大多数驱动的名字是纯 ASCII但遇到非 ASCII 的厂商名可能乱码稳妥做法是在原生侧转成 UTF-16 再走宽字符构造。循环上限设 64 是防呆正常机器不会超过十台。4. 一次完整取图从打开数据源到 BufferedImage 落盘4.1 能力协商分辨率、色彩模式、进纸方式取图之前必须先把参数谈好。所有能力都走同一组消息控制组、能力类型、读或写数据装在一个容器结构里。容器有四种形态——单值、范围、数组、枚举写之前先查支持形态不然驱动会告诉你“不支持”而你以为是代码错。常用的几项能力 ID含义数据类型取值说明ICAP_XRESOLUTION水平分辨率定点数300 表示 300 dpiICAP_YRESOLUTION垂直分辨率定点数一般与水平保持一致ICAP_PIXELTYPE像素类型无符号短整型黑白、灰度、彩色三档ICAP_BITDEPTH位深无符号短整型彩色通常 24ICAP_XFERMECH传输方式无符号短整型选原生方式拿 DIBICAP_AUTOFEED自动进纸布尔进纸器批量扫描必开ICAP_DUPLEXENABLED双面布尔不支持就别硬设CAP_XFERCOUNT本次传输张数有符号短整型批量场景常设成 1逐张拉分辨率是定点数换算方式是整数部分和小数部分分开存整数部分取整小数部分乘 65536 再取整。写 300 的时候别直接塞 300塞进去驱动会理解成 300/65536 dpi扫出来是一条线。/** 把 dpi 转成 TWAIN 的定点数整数部分 小数部分/65536 */ static long toFix32(double dpi) { long whole (long) dpi; long frac Math.round((dpi - whole) * 65536.0); if (frac 65536) { whole; frac 0; } return (whole 16) | (frac 0xFFFF); } // 典型设置序列先关自动进纸以外的干扰项再逐项确认 tw.setCap(ds, CAP_XFERCOUNT, TWTY_INT16, 1); tw.setCap(ds, ICAP_XFERMECH, TWTY_UINT16, TWSX_NATIVE); tw.setCap(ds, ICAP_PIXELTYPE, TWTY_UINT16, TWPT_RGB); tw.setCap(ds, ICAP_BITDEPTH, TWTY_UINT16, 24); tw.setCap(ds, ICAP_XRESOLUTION, TWTY_FIX32, toFix32(300)); tw.setCap(ds, ICAP_YRESOLUTION, TWTY_FIX32, toFix32(300));每一项设置都要看返回码返回失败不代表要中断——分辨率设不上可以退回默认值继续但像素类型设不上就必须中断否则后面按 24 位解析一张单色图出来的是满屏雪花。CAP_XFERCOUNT在进纸器场景里设成 1然后每取一张再等一次就绪事件这样能感知到“纸没了”比一次性设成大数字可靠得多。4.2 原生取图事件泵与内存句柄搬运使能数据源之后有两条路径取决于驱动实现。有的驱动在使能调用里直接把模态界面跑完返回时就可以取图有的立刻返回靠事件泵推送就绪事件。稳妥写法是两种都兜住先查一次就绪标志没有再进泵。JNIEXPORT jbyteArray JNICALL Java_com_example_scan_NativeTwain_acquire(JNIEnv *env, jclass cls, jlong hwnd, jint timeoutMs) { TW_EVENT evt; HANDLE dib NULL; jbyteArray out NULL; DWORD start GetTickCount(); memset(evt, 0, sizeof(evt)); /* 事件泵把 Windows 消息逐条喂给管理器翻译直到就绪或超时 */ for (;;) { MSG msg; while (PeekMessage(msg, (HWND) hwnd, 0, 0, PM_REMOVE)) { evt.pEvent msg; evt.TWMessage MSG_NULL; DSM_Entry(g_app, g_ds, DG_CONTROL, DAT_EVENT, MSG_PROCESSEVENT, evt); TranslateMessage(msg); DispatchMessage(msg); } if (evt.TWMessage MSG_XFERREADY) break; /* 可以取图了 */ if (evt.TWMessage MSG_CLOSEDSREQ) return NULL; /* 用户关了界面 */ if ((DWORD)(GetTickCount() - start) (DWORD) timeoutMs) return NULL; Sleep(10); /* 空转别占满 CPU10ms 的粒度对人手操作足够 */ } /* 原生方式取图返回的是全局内存句柄不是像素数组 */ TW_UINT16 rc DSM_Entry(g_app, g_ds, DG_IMAGE, DAT_IMAGENATIVEXFER, MSG_GET, dib); if (rc ! TWRC_XFERDONE || dib NULL) return NULL; void *p GlobalLock(dib); SIZE_T size GlobalSize(dib); out (*env)-NewByteArray(env, (jsize) size); (*env)-SetByteArrayRegion(env, out, 0, (jsize) size, (const jbyte *) p); GlobalUnlock(dib); GlobalFree(dib); /* 拿到就放漏一次就是整张图的内存 */ return out; }PeekMessage的窗口参数要传真实句柄传 0 会把整个线程的消息都捞出来容易跟别的事件处理打架。Sleep(10)是必要的纯空转会把这个核心跑满。GlobalFree放在拷贝完成之后、返回之前不放在 Java 侧的 finally 里——句柄是原生资源跨语言传递只会让谁负责释放变得模糊我宁愿在这一层就了结。返回的字节流是打包的 DIB先是 40 字节的位图信息头接着是调色板彩色 24 位没有最后是按 4 字节对齐的像素行。注意行序高度字段为正表示自下而上存为负表示自上而下同一个厂商的不同型号可能不一样。4.3 把 DIB 拆成 BufferedImagestatic BufferedImage toImage(byte[] dib) { ByteBuffer bb ByteBuffer.wrap(dib).order(ByteOrder.LITTLE_ENDIAN); int headerSize bb.getInt(0); // 位图信息头长度通常 40 int width bb.getInt(4); int rawHeight bb.getInt(8); int planes bb.getShort(12) 0xFFFF; int bitCount bb.getShort(14) 0xFFFF; int compression bb.getInt(16); // 0 才是无压缩 int clrUsed bb.getInt(32); if (compression ! 0) throw new IllegalStateException(驱动返回了压缩 DIB); boolean topDown rawHeight 0; int height Math.abs(rawHeight); // 调色板8 位及以下才有条目数默认 2^位深 int[] palette null; if (bitCount 8) { int count clrUsed 0 ? clrUsed : (1 bitCount); palette new int[count]; for (int i 0; i count; i) { int base headerSize i * 4; int b dib[base] 0xFF, g dib[base 1] 0xFF, r dib[base 2] 0xFF; palette[i] 0xFF000000 | (r 16) | (g 8) | b; } } int offset headerSize (bitCount 8 ? palette.length * 4 : 0); int stride ((width * bitCount 31) / 32) * 4; // 每行补到 4 字节 BufferedImage img bitCount 24 ? new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) : new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED); for (int y 0; y height; y) { int srcRow topDown ? y : (height - 1 - y); // 自下而上的要翻过来 int base offset srcRow * stride; for (int x 0; x width; x) { if (bitCount 24) { int p base x * 3; int b dib[p] 0xFF, g dib[p 1] 0xFF, r dib[p 2] 0xFF; img.setRGB(x, y, (r 16) | (g 8) | b); } else { int bitPos x * bitCount; int p base (bitPos 3); int shift 8 - bitCount - (bitPos 7); int idx (dib[p] shift) ((1 bitCount) - 1); img.setRGB(x, y, palette[idx]); } } } return img; }关键参数就三个compression必须判非 0 说明驱动做了压缩按裸像素解析一定是花屏stride必须按 4 字节补齐算直接拿宽度乘位深除以 8 会在宽度不是 4 的倍数时逐行错位画面斜着劈开rawHeight的符号决定要不要翻转。setRGB逐像素写在大图上会慢A4 300 dpi 彩色是 2480×3508八百多万次调用大概要一两秒可接受如果要更快就拼成整型数组一次性setRGB。4.4 进纸器批量与中断批量场景的结构是外层循环套单张取图每轮等就绪事件取一张转图写盘然后再等下一轮。空纸时驱动会推一次就绪事件但取图返回取消这是正常结束信号不要当异常。用户中途点界面的取消按钮做法是往事件泵里塞一个自定义标志让它提前退出循环然后照常走禁用、关闭的收尾流程——不这么做下次打开数据源会卡很久甚至失败。5. 避坑与排查五类现场翻车记录现象一启动就抛库找不到路径明明写对了。原因基本在二级依赖桥接库自己依赖的编译器运行库不在同目录或系统目录。做法是用依赖查看工具列出所有被引用模块缺什么补什么全放在原生库旁边顺便确认 32/64 位一致。现象二能列出设备一打开就失败或者列表为空但设备管理器里能看到扫描仪。原因就是位数不匹配64 位进程加载不了 32 位驱动。做法是查 JVM 位数和驱动位数吻合不了就把扫描模块拆成独立 32 位进程用进程间通信传图像或者改换支持 64 位的管理器加 64 位驱动。现象三点扫描界面直接冻住任务管理器里进程无响应。原因是在界面事件分发线程里调了模态扫描事件泵和界面线程互相等待。做法是把整个扫描流程扔到独立工作线程界面只负责收进度回调同时事件泵必须带超时驱动不吭声的时候能自己退出。现象四扫出来的图上下颠倒或者整幅画面斜着错位或者一片雪花。三个不同病因颠倒看高度字段的符号斜劈查行补位宽度乘位深要向上补到 4 的倍数雪花查压缩标志和位深把 8 位灰度按 24 位解析就是典型雪花。做法是在解析函数入口把这几项打进日志一眼定位。现象五第一张正常第二张开始报错或者越扫内存越高。原因是全局内存句柄没释放或者收尾顺序错了导致驱动内部状态没复位。做法是把释放压进原生函数尾部跟拷贝紧挨着每轮取图前重新确认一次状态收尾严格按禁用、关源、关管理器的逆序走。注意所有返回码都要落日志尤其打开、使能、取图这三步。驱动给的信息很少返回码是唯一的黑匣子。6. 收尾三件事分辨率取舍、底色处理与多页归档能扫出来只是及格线能不能扫得能用是另一回事。先说分辨率300 dpi 的 A4 彩色单页大约 25 MB 的位图转成 JPEG 质量 0.85 落到 800 KB 到 1.5 MB600 dpi 像素数翻四倍OCR 准确率提升却有限纯文字件 300 dpi 完全够票据和手写件再上 400。我的习惯是按用途分档正文归档 300需要放大看的细密表格才给 600别一刀切。底色是第二个坑。扫描件常见灰底直接二值化会出现大片噪点。做法是先用灰度直方图估一个背景峰值把高于峰值的部分压成白再做局部阈值比全局阈值稳得多。这一步放在转 BufferedImage 之后做不要试图在原生层动手内存和调试都难受。多页归档推荐一页一页写最后合成一个多页 TIFF而不是全攒在内存里。常见做法是拿一个支持多页 TIFF 的 ImageIO 插件每页写一个目录项写完关闭内存占用只跟单页有关扫五百页也不炸。验证方法很土但有效扫一张标准 A4读回图像量一下宽度300 dpi 应该是 2481 像素左右差得远说明分辨率没设进去再扫一张双面件看页数是不是等于物理页数的两倍不等就查双面能力有没有真正生效。用途分辨率色彩单页体积量级合同、公文归档300 dpi彩色1 MB 上下纯文字 OCR300 dpi灰度300 KB 上下票据、手写400 dpi彩色2 MB 上下图纸、细表格600 dpi灰度1.5 MB 上下那次“第一张好、第二张崩”的事故之后我每次接扫描项目第一步必定先跑三张单页、双面、空纸把三种返回码全打出来再往下写业务。这个习惯救过不止一次——驱动之间的差异比文档写的要大只有自己机器上跑出来的返回码是可信的。希望帮到你。本文还有配套的精品资源点击获取