ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

VC++ MFC中基于TWAIN协议实现扫描仪接入的完整实践

VC++ MFC中基于TWAIN协议实现扫描仪接入的完整实践 简介VC下基于TWAIN协议实现的扫描仪完整源码项目面向Windows应用开发者与图像采集相关技术人员展示了如何在MFC工程中集成TWAIN库完成设备枚举、扫描参数设置、图像采集与数据保存等完整流程。资源包共66个文件包含解决方案文件、C源代码、头文件、资源脚本、编译日志及调试中间文件整体约21.79MB目录结构清晰便于按模块定位TwainCpp、MyTwainDlg、DIB等核心代码。读者可借此学习TWAIN的初始化、选择设备、设置分辨率与色彩模式、启动扫描、处理位图数据以及正确关闭会话等关键环节适合正在开发文档扫描或图像数字化工具的中级VC程序员参考。项目已有1573人学习浏览从源码组织方式到与界面交互的控制逻辑均有体现对需要直接改造或移植到实际项目中的开发者极具参考价值。 做桌面工具接扫描仪最烦的就是厂商SDK接口五花八门换个牌子就要重新读一遍文档。去年我在做一个票据自动采集系统时需要在VC的MFC程序里快速接入惠普、佳能、中晶好几台扫描仪统一用TWAIN协议一次搞定省掉了大量重复适配工作。这篇文章把这套方案的完整思路和核心代码拆开讲从协议选型、DSM加载到弹出扫描界面、内存传输、保存为BMP再到实际开发中踩过的坑一次性说清楚。适合正在做文档管理系统、图像采集工具或者准备把扫描功能集成进桌面应用的VC开发者参考。我在做这个项目之前也犹豫过到底选TWAIN还是WIA后来实际对比过两台设备才确定方向。TWAIN对大批量、高分辨率、精细控制的场景更合适WIA则偏向轻量调用。下面从选型逻辑开始说起。1. 选型思考为什么是TWAIN协议1.1 TWAIN和WIA怎么选Windows平台上接入扫描仪常规选项就两个TWAIN和WIA。WIA是微软基于Windows Image Acquisition架构推出的简版方案优点是调用简单几行COM代码就能从扫描仪拿图对普通入门设备、日常拍照扫描够用。但WIA有两个突出短板一是对图像参数的精细控制弱很多专业级扫描仪的色深、网点、去网纹、双面扫描等特性无法直接暴露二是不同驱动厂商对WIA的支持程度参差不齐经常出现某台设备在WIA里能枚举到但拿到的图像分辨率或色彩空间不对又找不出原因。TWAIN由TWAIN工作组维护是扫描行业的老牌标准几乎所有专业扫描仪和多功能一体机都带TWAIN驱动。它把“应用软件”和“数据源”之间的交互定义得非常完整DSM负责统一调度应用只需要通过一个入口函数发送操作码就可以完成打开设备、弹UI、传输图像、取消等动作。我选TWAIN的核心原因是可预期性更高同样一段代码在这台设备上验证过换另一台TWAIN驱动的设备行为基本一致。1.2 先搞懂TWAIN里几个关键概念TWAIN的架构分三层Application应用、Source Manager数据源管理器简称DSM、Source数据源即扫描仪驱动。应用不直接操作驱动而是通过DSM中转。DSM在Windows上就是一个DLL32位系统是老牌twain_32.dll64位体系下是TWAINDSM.dll。我采用动态加载方式不静态链接就是为了兼容这两类环境。协议调用的核心是DSM_Entry入口函数所有操作都通过它下发。它的原型是TW_UINT16 FAR PASCAL DSM_Entry( pTW_IDENTITY pOrigin, pTW_IDENTITY pDest, TW_UINT32 DG, TW_UINT16 DAT, TW_UINT16 MSG, TW_MEMREF pData );参数里的DG、DAT、MSG组合起来决定一次具体操作。DG是数据组比如DG_CONTROL表示控制类操作、DG_IMAGE表示图像类操作DAT是具体数据类型比如DAT_IDENTITY代表设备身份、DAT_IMAGEINFO代表图像信息MSG是命令比如MSG_OPENDSM、MSG_GET、MSG_SET、MSG_ENABLEDS。初看可能觉得这套组合复杂但它把“对谁操作、操作什么、做什么”拆得很清楚排错时很容易定位到具体环节。TWAIN还有一套状态机从State 1到State 7。粗略理解就是必须先打开DSM才能枚举设备枚举到设备并打开后才能弹UI或设置参数只有源处于传输状态时才能收图像数据。我在写代码时并不需要死记状态编号只要严格按照初始化、打开、使能、传输、关闭的顺序调用驱动和DSM会替我们判断当前状态是否合法。2. 环境准备与DSM加载2.1 项目文件准备环境方面我用的是Visual Studio 2017MFC对话框工程字符集使用Unicode。TWAIN的头文件名叫twain.hWindows SDK里自带路径通常在Windows Kits的Include目录下。我直接把工程里的#include twain.h写上不需要额外下载。链接方面如果采用静态链接方式需要twain_32.lib我更建议动态加载后面会给出代码。要注意twain.h里很多结构体字段用的还是char数组不是TCHAR在Unicode工程下填充TW_IDENTITY的厂商、产品名时直接拷贝字符串会报类型不匹配需要做窄字符转换。我用的是strncpy_s(appId.Manufacturer, MyCompany, sizeof(appId.Manufacturer) - 1); strncpy_s(appId.ProductName, MyScanTool, sizeof(appId.ProductName) - 1);同时twain.h依赖#include windows.h所以include顺序最好保证windows.h在前。2.2 动态加载DSM入口的实现用动态加载的好处是同一套代码可以同时处理32位和64位环境。在64位系统下64位应用找TWAINDSM.dll32位应用通过系统重定向会找到SysWOW64下的twain_32.dll。我在LoadLibrary时先尝试TWAINDSM.dll失败后再试twain_32.dll实测覆盖了绝大多数开发机环境。加载入口函数的辅助类可以这样封装#include twain.h typedef TW_UINT16(FAR PASCAL* DSMEntryProc)( pTW_IDENTITY, pTW_IDENTITY, TW_UINT32, TW_UINT16, TW_UINT16, TW_MEMREF); class CTwainDsm { public: BOOL Load() { m_hModule LoadLibrary(_T(TWAINDSM.dll)); if (m_hModule NULL) { m_hModule LoadLibrary(_T(twain_32.dll)); } if (m_hModule NULL) return FALSE; m_pEntry (DSMEntryProc)GetProcAddress(m_hModule, DSM_Entry); return (m_pEntry ! NULL); } void Unload() { if (m_hModule) { FreeLibrary(m_hModule); m_hModule NULL; m_pEntry NULL; } } TW_UINT16 Call(pTW_IDENTITY origin, pTW_IDENTITY dest, TW_UINT32 dg, TW_UINT16 dat, TW_UINT16 msg, TW_MEMREF data) { if (m_pEntry NULL) return TWRC_FAILURE; return m_pEntry(origin, dest, dg, dat, msg, data); } private: HMODULE m_hModule NULL; DSMEntryProc m_pEntry NULL; };这套封装在项目里很实用后续所有DSM调用都走m_dsm.Call(...)统一做空指针检查也方便在函数入口打断点观察每次DG、DAT、MSG的值是否正常。3. 完整实现初始化、枚举、扫描、传输3.1 打开DSM并枚举数据源先把应用自身的身份信息填充好。TW_IDENTITY结构体里需要设置协议版本、支持的数据组、厂商名、产品名。协议版本常量和数据组宏在twain.h里都有定义我直接用BOOL InitTwain(HWND hwndParent, CTwainDsm dsm, TW_IDENTITY appId) { if (!dsm.Load()) return FALSE; ZeroMemory(appId, sizeof(appId)); appId.Version.MajorNum 1; appId.Version.MinorNum 0; appId.Version.Language TWLG_ENGLISH; appId.Version.Country TWCY_USA; strncpy_s(appId.Manufacturer, MyCompany, sizeof(appId.Manufacturer) - 1); strncpy_s(appId.ProductFamily, MyScanTool, sizeof(appId.ProductFamily) - 1); strncpy_s(appId.ProductName, MyScanTool, sizeof(appId.ProductName) - 1); appId.ProtocolMajor TWON_PROTOCOLMAJOR; appId.ProtocolMinor TWON_PROTOCOLMINOR; appId.SupportedGroups DG_CONTROL | DG_IMAGE; TW_UINT16 rc dsm.Call(appId, NULL, DG_CONTROL, DAT_PARENT, MSG_OPENDSM, (TW_MEMREF)hwndParent); return (rc TWRC_SUCCESS); }这里DSM通过DAT_PARENT和MSG_OPENDSM通知系统应用需要使用TWAIN服务需要把父窗口句柄传进去后续源弹出的模态UI会以这个窗口为宿主。枚举设备就是一个循环调用操作码的过程。先MSG_GETFIRST拿第一个再MSG_GETNEXT逐个翻int GetTwainDevices(CTwainDsm dsm, const TW_IDENTITY appId, std::vectorTW_IDENTITY devices) { TW_IDENTITY srcId {0}; TW_UINT16 rc dsm.Call((pTW_IDENTITY)appId, NULL, DG_CONTROL, DAT_IDENTITY, MSG_GETFIRST, (TW_MEMREF)srcId); while (rc TWRC_SUCCESS) { devices.push_back(srcId); ZeroMemory(srcId, sizeof(srcId)); rc dsm.Call((pTW_IDENTITY)appId, NULL, DG_CONTROL, DAT_IDENTITY, MSG_GETNEXT, (TW_MEMREF)srcId); } return (int)devices.size(); }枚举到的每个TW_IDENTITY里都有ProductName可以显示到下拉框让用户选择。注意枚举时pDest参数传NULL因为此时还在和DSM而不是具体源交互。3.2 打开数据源并弹出扫描UI拿到用户选中的TW_IDENTITY后把它复制一份作为当前源身份再调用MSG_OPENDS打开设备。这一步之后DSM会把源驱动加载进来我们可以设置参数也可以弹UIBOOL OpenSource(CTwainDsm dsm, const TW_IDENTITY appId, TW_IDENTITY srcId) { TW_UINT16 rc dsm.Call((pTW_IDENTITY)appId, srcId, DG_CONTROL, DAT_IDENTITY, MSG_OPENDS, (TW_MEMREF)srcId); return (rc TWRC_SUCCESS); }打开源后设置扫描界面的模式。TWAIN提供了两种弹UI的方式模态和非模态。ModalUI设为TRUE比较省心源会显示自己的扫描对话框用户在界面里调分辨率、选颜色模式、点扫描按钮过程都被源接管。设置起来很简单TW_USERINTERFACE twUI; ZeroMemory(twUI, sizeof(twUI)); twUI.hwnd hwndParent; twUI.ShowUI TRUE; twUI.ModalUI TRUE; TW_UINT16 rc dsm.Call((pTW_IDENTITY)appId, srcId, DG_CONTROL, DAT_USERINTERFACE, MSG_ENABLEDS, (TW_MEMREF)twUI);在这里有一个很容易忽略的点MSG_ENABLEDS调用成功返回后源UI已经挂到消息系统里了但应用程序本身必须进入消息循环否则UI会假死。这个“返回不代表操作完成”的异步特性是TWAIN初学者最常踩的坑。3.3 消息循环处理与事件分发源UI弹出后应用程序要把Windows消息逐条交给DSM处理。处理方式是把MSG结构体内嵌到TW_EVENT里再调用DG_CONTROL/DAT_EVENT/MSG_PROCESSEVENT。DSM处理后会在TW_EVENT.TWMessage里返回事件类型例如MSG_XFERREADY表示准备传输、MSG_CLOSEDSREQ表示用户在UI上请求关闭。我用一个acquire循环来处理这一整套流程直到用户关闭UI或取消扫描才退出BOOL AcquireLoop(HWND hwnd, CTwainDsm dsm, TW_IDENTITY appId, TW_IDENTITY srcId, LPCTSTR savePath) { MSG msg; while (GetMessage(msg, NULL, 0, 0)) { if (msg.hwnd hwnd) { TW_EVENT twEvent; ZeroMemory(twEvent, sizeof(twEvent)); twEvent.pEvent msg; twEvent.TWMessage MSG_NULL; TW_UINT16 rc dsm.Call((pTW_IDENTITY)appId, srcId, DG_CONTROL, DAT_EVENT, MSG_PROCESSEVENT, (TW_MEMREF)twEvent); if (rc TWRC_SUCCESS) { if (twEvent.TWMessage MSG_XFERREADY) { if (!DoTransfer(dsm, appId, srcId, savePath)) { return FALSE; } // 只扫一页就结束禁用源UI并退出 TW_UINT16 rc2 dsm.Call((pTW_IDENTITY)appId, srcId, DG_CONTROL, DAT_USERINTERFACE, MSG_DISABLEDS, (TW_MEMREF)twUI); return TRUE; } else if (twEvent.TWMessage MSG_CLOSEDSREQ) { TW_UINT16 rc2 dsm.Call((pTW_IDENTITY)appId, srcId, DG_CONTROL, DAT_USERINTERFACE, MSG_DISABLEDS, (TW_MEMREF)twUI); return TRUE; } } } TranslateMessage(msg); DispatchMessage(msg); } return FALSE; }注意这里不能直接TranslateMessage/DispatchMessage后再处理MSG_PROCESSEVENT要先把消息交给DSM。只有DSM识别为“不感兴趣”的消息才走常规分发流程。顺序反了的话源UI收不到事件点击扫描按钮没反应。3.4 内存传输与BMP保存收到MSG_XFERREADY后源进入传输状态。TWAIN提供三种传输方式内存传输、文件传输、原生传输。最通用的是内存传输几乎每个驱动都支持我项目里用的就是这种方式。传输前先拿图像信息TW_IMAGEINFO里面包含图像宽度、高度、每像素位数和像素类型。再拿一次TW_SETUPMEMXFER里面有驱动建议的缓冲区大小Preferred和最小缓冲区大小MinBufSize。分配内存时我直接使用Preferred如果它为0则退回MinBufSize再不行就用64KB兜底。核心传输循环如下BOOL DoTransfer(CTwainDsm dsm, TW_IDENTITY appId, TW_IDENTITY srcId, LPCTSTR savePath) { TW_IMAGEINFO imgInfo {0}; if (dsm.Call(appId, srcId, DG_IMAGE, DAT_IMAGEINFO, MSG_GET, (TW_MEMREF)imgInfo) ! TWRC_SUCCESS) return FALSE; TW_SETUPMEMXFER setupMem {0}; if (dsm.Call(appId, srcId, DG_IMAGE, DAT_SETUPMEMXFER, MSG_GET, (TW_MEMREF)setupMem) ! TWRC_SUCCESS) return FALSE; DWORD bufSize setupMem.Preferred 0 ? setupMem.Preferred : (setupMem.MinBufSize 0 ? setupMem.MinBufSize : 65536); HGLOBAL hBuf GlobalAlloc(GMEM_MOVEABLE, bufSize); if (!hBuf) return FALSE; std::vectorBYTE rawData; BOOL bDone FALSE; while (!bDone) { TW_IMAGEMEMXFER imgMem {0}; imgMem.Memory.Flags TWMF_APPOWNS; imgMem.Memory.Length bufSize; imgMem.Memory.Handle hBuf; TW_UINT16 rc dsm.Call(appId, srcId, DG_IMAGE, DAT_IMAGEMEMXFER, MSG_GET, (TW_MEMREF)imgMem); if (rc TWRC_FAILURE || rc TWRC_CANCEL) { GlobalFree(hBuf); return FALSE; } if (imgMem.BytesWritten 0) { BYTE* pData (BYTE*)GlobalLock(hBuf); if (pData) { rawData.insert(rawData.end(), pData, pData imgMem.BytesWritten); GlobalUnlock(hBuf); } } if (imgMem.Memory.Flags TWMF_EOF) bDone TRUE; if (rc TWRC_XFERDONE) bDone TRUE; } GlobalFree(hBuf); TW_PASSTHRU passthru {0}; dsm.Call(appId, srcId, DG_CONTROL, DAT_PASSTHRU, MSG_ENDXFER, (TW_MEMREF)passthru); return SaveAsBitmap(savePath, imgInfo, rawData); }保存BMP时我根据imgInfo里的BitsPerPixel和ImageWidth计算每行字节数。BMP要求每行4字节对齐而TWAIN内存传输给到的数据不一定对齐所以我写了一个按行拷贝的逻辑先把rawData里的像素行逐行补齐到4字节的边界再写文件。24位RGB和8位灰度需要在信息头里设置正确的biBitCount和颜色表这部分代码比较基础但biHeight的符号要特别注意。我习惯用负值表示自顶向下存储这样和扫描数据在内存中的顺序一致省去倒置处理。如果拿到图像发现上下颠倒改成正值并把行逆序写入就行。4. 常见问题与排查技巧4.1 按错误码定位问题TWAIN的返回值TW_UINT16里有几个常见常量TWRC_SUCCESS表示成功TWRC_FAILURE表示失败TWRC_CANCEL表示用户取消TWRC_XFERDONE表示传输完成。出问题时单看返回值不够需要从DSM里拿详细状态码用DG_CONTROL/DAT_STATUS/MSG_GET查询TW_STATUS结构体里面的ConditionCode能告诉具体原因。我把几个高频ConditionCode整理了一下ConditionCode含义对应处理TWCC_NODS找不到数据源检查驱动是否安装、架构是否匹配TWCC_LOWMEMORY系统内存不足降低缓冲区大小或分辨率重试TWCC_CAPUNSUPPORTED不支持该能力设置参数前应先查询支持范围TWCC_OPERATIONERROR驱动内部操作错误重新打开源或重启应用TWCC_BUMMER未知错误重点看驱动事件日志如果ConditionCode仍是0但操作失败我会检查调用参数里的DG、DAT、MSG组合是否合法。TWAIN文档里每个DAT都规定了可用的MSG比如DAT_USERINTERFACE只支持MSG_ENABLEDS和MSG_DISABLEDS传错就会出现诡异行为。4.2 我踩过的六个高频坑第一个坑是64位程序找不到设备。64位应用只能加载64位的源驱动但很多老式扫描仪只提供32位TWAIN驱动。解决方式是对外发行32位版本或者检测到当前进程是64位时提示用户安装64位驱动。我最后直接选择了32位编译方案兼容性最好。第二个坑是源UI不弹出来。多数情况下是因为调用MSG_ENABLEDS后没有立刻进入消息循环或者在循环里先做了TranslateMessage/DispatchMessage而不是先走MSG_PROCESSEVENT。另一个被忽视的原因是传入twUI.hwnd的窗口句柄在UI显示前被销毁了。我在调试时会输出MSG_ENABLEDS的返回值配合ConditionCode来区分是窗口问题还是驱动问题。第三个坑是读取缓冲区时崩溃。TW_IMAGEMEMXFER里的Memory.Length传入的是缓冲区大小但驱动返回的数据可能小于或等于这个值。有些驱动如果发现缓冲区不够会返回TWRC_FAILURE并置TWCC_SEQERROR这时应该检查缓冲区大小设置。还有个小细节是每次循环都要把imgMem.Memory.Flags重置为TWMF_APPOWNS表示缓冲由应用分配和管理如果漏掉某些驱动会尝试自己分配结果就是Access Violation。第四个坑是图像颜色不对。同一台设备在“黑白文档扫描”和“照片彩色扫描”两个模式下的BitsPerPixel完全不同。如果应用按固定24位处理灰度8位和黑白1位都会出问题。通用做法是读取imgInfo.PixelType和imgInfo.BitsPerSample根据实际值决定创建哪种格式的DIB。我在保存BMP的函数里加了判断TWPT_RGB走24位TWPT_GRAY走8位灰度并创建灰度调色板TWPT_BW走1位黑白并创建黑白调色板实测三种模式都稳定。第五个坑是扫描大尺寸图像时内存疯涨。内存传输模式下如果整个图像一次性塞进std::vectorA3幅面、600DPI的彩色图可能达到上百兆程序会卡顿甚至被系统杀掉。如果业务允许我建议直接按行写入文件而不是全部缓存或者用TW_IMAGELAYOUT设置扫描区域和分辨率来限制数据量。这个坑在票据批量扫描时尤其明显。第六个坑是MSG_ENDXFER没有调用。传输完成后如果不通知源结束传输状态源会一直认为应用还在收数据导致后续操作卡死或UI无法关闭。我在DoTransfer函数最后统一调用DG_CONTROL/DAT_PASSTHRU/MSG_ENDXFER并在UI线程里做绝不能放到后台线程。5. 最后的几点心得这套方案做完后我又在另外两个项目里复用了这块代码一次是证件扫描存档一次是和OCR服务对接的批量采集工具。整体上TWAIN的稳定性比预期好只要定位到“状态机顺序正确、DSM调用统一入口、事件循环不阻塞”这三条主线就能覆盖绝大多数使用场景。最后再分享一个小技巧调试初期建议先用“显示源UI”模式跑通整条链路确认设备能出图后再切换到ShowUIFALSE并手动设置分辨率和像素类型。因为源UI本身是一个很好的错误提示器很多驱动在UI模式下会自动给出更友好的报错信息比直接看ConditionCode直观得多。如果只需要基础扫描功能UI模式其实已经足够未必非要自己封装复杂的参数面板。本文还有配套的精品资源点击获取
返回列表