易语言打印API调用实战:GDI与后台打印流详解

📅 发布时间:2026/10/13 13:44:55
易语言打印API调用实战:GDI与后台打印流详解
1. 从打印这个词说起为什么易语言开发者绕不开API调用很多刚接触易语言的朋友第一次看到打印函数这四个字脑子里浮现的往往是调试输出那种在控制台里刷一行字的效果。但真正做过桌面软件、进销存系统、票据工具的人都知道这里的打印指的是把内容送到打印机纸面上——小票、发票、报表、条码标签这些才是实际项目里天天要面对的东西。易语言自带了一套打印相关的组件比如打印机、打印预览这类封装好的控件拖到窗口上配几个属性就能跑起来。对于简单的文本打印这套东西确实够用。但问题在于一旦需求稍微复杂一点比如要精确控制每一行的坐标、要调用系统里已经装好的特定打印机、要设置纸张大小和打印方向、要在打印前拿到打印机的详细能力列表自带组件就开始捉襟见肘了。这时候调用Windows系统提供的打印API就成了绕不过去的一步。这篇文章面向的是已经会写易语言基础代码、但对API调用还比较陌生或者调用过但总是踩坑的开发者。我会把易语言调用打印相关API这件事拆开讲清楚底层到底发生了什么、哪些API是必须掌握的、参数怎么传、结构体怎么定义、字符串和缓冲区怎么处理、实际项目里最容易翻车的地方在哪里。看完之后你应该能独立写出一个稳定可用的打印模块而不是每次遇到问题就去论坛翻别人的半成品代码。需要先说明一点易语言调用API的本质是通过它提供的调用DLL命令机制去访问系统动态库里的函数。打印相关的函数绝大多数都在gdi32.dll和winspool.drv这两个库里。前者负责图形设备接口管的是画线、画字、画图这些绘制动作后者是打印后台处理程序管的是打印机枚举、打印任务提交、打印队列这些系统级操作。搞清楚这个分工后面选函数的时候就不会乱。2. 打印API的两条技术路线GDI绘制流与后台打印流2.1 GDI路线拿到设备上下文像画画一样打印GDI这条路线的核心思路是先把打印机当成一块画布拿到它的设备上下文句柄也就是常说的DC然后在这个句柄上用各种绘制函数往上画内容画完之后通知系统这一页画完了系统就会把这一页送到打印机。整个过程和你在窗口上绘图几乎一模一样区别只是目标设备从屏幕变成了打印机。这条路线涉及的关键函数有这么几个。CreateDC用来根据驱动名和设备名创建一个打印机设备上下文StartDoc开始一个打印任务StartPage和EndPage标记一页的开始和结束EndDoc结束整个任务DeleteDC释放设备上下文。绘制内容则用TextOut输出文字、Rectangle画矩形、MoveToEx和LineTo画线、Ellipse画圆等等这些都是标准的GDI绘图函数。为什么这条路线值得掌握因为它给了你像素级的控制权。每一行文字放在哪个坐标、字体多大、颜色是什么、要不要加粗全部由你说了算。做票据打印、标签打印这类对位置要求苛刻的场景GDI路线几乎是唯一选择。自带组件那种自动排版的方式在这种场景下根本没法用。2.2 后台打印流路线把任务丢给打印队列另一条路线走的是winspool.drv里的函数代表函数是OpenPrinter、StartDocPrinter、StartPagePrinter、WritePrinter、EndPagePrinter、EndDocPrinter、ClosePrinter这一套。它的思路和GDI完全不同你不是在画内容而是把已经准备好的数据比如一段原始打印指令、一段ESC/POS指令、或者一个已经生成好的文档数据流直接写进打印队列由打印机自己去解释这些数据。这条路线适合什么场景最典型的就是小票打印机。很多热敏小票机支持ESC/POS指令集你只要把指令拼成字节数组通过WritePrinter写进去打印机就会按照指令打印出对应的内容速度快、控制精确而且不依赖系统图形渲染。再比如一些工业标签机厂商提供了自己的指令格式也是走这条路。两条路线不是互斥的实际项目里经常混用。比如一个报表系统主体内容用GDI绘制保证排版美观但页脚的条码用后台流方式直接写指令效率更高。对比维度GDI绘制路线后台打印流路线核心库gdi32.dllwinspool.drv控制粒度像素级可精确到坐标依赖打印机指令集适用场景报表、票据、复杂排版小票、标签、指令打印开发难度中等需理解DC概念较高需懂打印机指令字体支持系统字体全部可用取决于打印机内置字库2.3 两条路线的选择逻辑我一般是这样判断的如果打印内容需要复杂排版、要用系统字体、要画图形表格选GDI如果打印的是固定格式的小票、需要极快的出纸速度、或者打印机有专用指令集选后台流。如果两者都要就混用。这个判断标准在实际项目里帮我省了很多纠结的时间。3. 易语言里定义打印API命令的完整写法3.1 DLL命令定义的基本结构易语言调用API的入口是插入菜单里的DLL命令。定义一条命令需要填几个关键信息命令名称你自己起的名字、返回值类型、库文件名、在库中的命令名也就是API的原始函数名、参数列表。参数列表里每个参数要指定类型还要标注是传值还是传址。这里有个新手最容易搞错的地方Windows API里大量参数是指针类型比如LPCSTR、LPVOID、结构体指针。在易语言里指针类型通常用整数型来接收地址或者用传址方式传变量。具体用哪种取决于这个参数是输入还是输出。输入字符串一般直接传文本型易语言会自动转换输出缓冲区则必须自己申请内存传内存地址进去。3.2 关键API的命令定义示例以CreateDC为例它的C语言原型是HDC CreateDC(LPCWSTR pwszDriver, LPCWSTR pwszDevice, LPCWSTR pszPort, const DEVMODEW *pdm);在易语言里定义成这样.版本 2 .DLL命令 CreateDC, 整数型, gdi32.dll, CreateDCW .参数 驱动名, 文本型 .参数 设备名, 文本型 .参数 端口名, 文本型 .参数 设备模式, 整数型注意函数名用的是CreateDCW带W后缀表示宽字符版本。易语言的文本型内部是Unicode所以调用带W的版本更自然不容易出现乱码。这一点很多人不知道用了不带W的CreateDC结果打印机名字里有中文就出问题。再看StartDocint StartDocW(HDC hdc, const DOCINFOW *lpdi);这里第二个参数是结构体指针需要在易语言里先定义对应的结构体类型。DOCINFOW结构体长这样typedef struct { int cbSize; LPCWSTR lpszDocName; LPCWSTR lpszOutput; LPCWSTR lpszDatatype; DWORD fwType; } DOCINFOW;在易语言里定义自定义数据类型.数据类型 DOCINFO .成员 cbSize, 整数型 .成员 lpszDocName, 文本型 .成员 lpszOutput, 文本型 .成员 lpszDatatype, 文本型 .成员 fwType, 整数型然后StartDoc的定义里第二个参数用传址方式传这个结构体变量。3.3 字符串与缓冲区的处理要点打印API里字符串参数分两类。一类是输入字符串比如文档名、打印机名这类直接传文本型就行易语言会处理好编码转换。另一类是输出缓冲区比如EnumPrinters要你把打印机列表写到一块内存里这种就必须自己用取空白字节集或者申请内存来准备缓冲区然后把地址传进去。我个人的习惯是凡是输出缓冲区统一用字节集来管理。先申请一个足够大的字节集调用API时传它的地址用取变量地址或者直接传字节集变量配合传址调用完再按结构体解析这块内存。这样做的好处是内存由易语言自己管理不用手动释放不容易漏。提示调用任何会写入缓冲区的API之前一定要先给缓冲区一个足够大的初始大小。太小会导致API返回失败错误码通常是122缓冲区不足。可以先调用一次传0大小从返回的错误码里读出需要的实际大小再分配。4. 一个完整的GDI打印流程从创建DC到出纸4.1 获取目标打印机名称打印之前得先知道往哪台打印机打。系统里可能装了多台打印机默认打印机不一定是你要用的那台。获取打印机列表用EnumPrinters获取默认打印机用GetDefaultPrinter。GetDefaultPrinter的用法比较直接先传空缓冲区拿到需要的长度再分配缓冲区拿名字。代码逻辑大致是.局部变量 长度, 整数型 .局部变量 缓冲, 字节集 长度 0 GetDefaultPrinter (0, 长度) 第一次调用拿长度 缓冲 取空白字节集 (长度 × 2) GetDefaultPrinter (缓冲, 长度) 第二次调用拿名字拿到名字之后注意它是宽字符要转成易语言文本才能显示或比较。4.2 创建打印机设备上下文有了打印机名字就可以创建DC了。调用CreateDC时驱动名一般传空设备名传打印机名字端口名传空设备模式传0表示用默认设置。返回的句柄如果为0说明创建失败这时候要用GetLastError看错误码。创建成功之后建议立刻调用GetDeviceCaps查一下这台打印机的物理参数比如水平分辨率、垂直分辨率、可打印区域宽度高度。这些数值决定了你后面绘制时的坐标范围。不同打印机的分辨率差别很大普通激光打印机可能是600×600热敏小票机可能是203×203如果不查清楚就按固定坐标画换一台机器内容就跑到纸外面去了。4.3 开始文档与页面创建DC之后先填充DOCINFO结构体cbSize填结构体大小lpszDocName填一个文档名会显示在打印队列里其他字段传空或0。然后调用StartDoc开始任务再调用StartPage开始第一页。这里有个细节StartDoc返回的是任务ID大于0表示成功。如果返回小于等于0说明任务启动失败这时候不要继续往下走直接清理资源。4.4 绘制内容的核心操作绘制文字用TextOut参数是DC句柄、X坐标、Y坐标、字符串、字符串长度。坐标单位取决于DC的映射模式默认是像素。如果要按毫米或英寸来定位可以用SetMapMode切换映射模式这样坐标计算会直观很多。绘制之前通常要设置字体。用CreateFont创建一个逻辑字体指定字面名、高度、宽度、粗细、斜体等属性然后用SelectObject选进DC。用完记得把旧字体选回来否则字体对象泄漏长时间运行会出问题。画线、画框用MoveToEx配合LineTo或者直接用Rectangle。表格线、边框这些用这些函数画出来比用文字拼要整齐得多。4.5 结束与资源释放一页画完调用EndPage所有页画完调用EndDoc最后DeleteDC释放设备上下文。这三步的顺序不能乱也不能省。我见过有人图省事不调EndDoc结果打印任务一直挂在队列里不消失打印机也不出纸。字体、画笔、画刷这些GDI对象创建了就要删除。DeleteObject是通用的删除函数。虽然程序退出时系统会回收但在长时间运行的服务类程序里不主动删除会累积成资源泄漏。5. 后台打印流实操WritePrinter的字节级控制5.1 打开打印机与启动文档后台流路线的第一步是OpenPrinter传入打印机名字拿到打印机句柄。这个句柄和GDI的DC句柄不是一回事别混用。拿到句柄后填充DOC_INFO_1结构体包含文档名、输出文件一般传空、数据类型比如RAW表示原始数据。然后调用StartDocPrinter开始任务StartPagePrinter开始页面。5.2 构造打印数据这一步是后台流路线的核心难点。你要写进打印机的数据必须是打印机认识的格式。对于支持ESC/POS的小票机数据就是一系列指令字节。比如初始化打印机是1B 40换行是0A切纸是1D 56 00。文字内容则按打印机支持的编码转成字节。构造数据用字节集最方便。易语言里可以用到字节集把文本转成字节但要注意编码问题。很多小票机用的是GBK编码而易语言文本是Unicode直接转可能乱码。稳妥的做法是用编码转换命令先转成目标编码的字节集再拼接指令字节。5.3 写入数据与结束任务数据准备好之后调用WritePrinter传入打印机句柄、数据地址、数据长度以及一个接收实际写入字节数的变量。这个函数可能一次写不完所有数据所以要用循环直到全部写完。写完调用EndPagePrinter、EndDocPrinter、ClosePrinter收尾。这一套流程比GDI路线短但数据构造的复杂度高适合对打印机指令比较熟悉的开发者。注意WritePrinter写入的数据不会被系统做任何解释原样送到打印机。如果指令格式错了打印机可能打出乱码也可能完全没反应。调试阶段建议先用厂商提供的测试工具验证指令再写进代码。6. 打印开发中最容易翻车的几个地方6.1 坐标算错导致内容偏移或截断这是最高频的问题。原因通常是没考虑打印机的物理边距。打印机不是从纸的最边缘开始打印的左右上下都有不可打印区域。用GetDeviceCaps查PHYSICALOFFSETX和PHYSICALOFFSETY能拿到这个偏移量绘制时要把这个偏移减掉。另一个原因是分辨率没换算。你在屏幕上按96 DPI设计的坐标直接拿到600 DPI的打印机上用内容会缩得很小。正确做法是按目标打印机的实际分辨率重新计算坐标或者用SetMapMode切到MM_TEXT以外的模式让系统帮你换算。6.2 中文乱码乱码基本都出在编码上。GDI路线用带W的宽字符函数一般不会乱码。后台流路线如果打印机只认GBK而你传了Unicode字节就会乱。解决办法是在写数据之前做一次编码转换把文本转成GBK字节集再写。还有一种情况是字体问题。GDI绘制时如果选了一个打印机不支持的字体系统会做字体替换替换后的字体可能显示效果和预期差很远。稳妥的做法是选常用字体比如宋体、黑体或者干脆用打印机内置字体。6.3 打印任务卡在队列里任务提交了但打印机不动队列里一直显示正在打印。这种情况通常是EndDoc没调用或者StartDoc和EndDoc不配对。也有可能是打印机本身离线或卡纸但软件层面先排查调用顺序。排查方法打开系统的打印队列窗口看任务状态。如果状态是正在打印但进度不动先检查代码里EndDoc有没有执行到。可以在EndDoc前后加日志输出确认执行路径。6.4 资源句柄泄漏DC句柄、字体句柄、打印机句柄这些系统资源都是有限的。创建了不释放程序跑一段时间就会失败。我一般会在模块里维护一个资源列表创建时加入释放时移除程序退出前统一清理一遍确保没有遗漏。7. 调试打印代码的实用技巧7.1 用虚拟打印机做无纸测试调试阶段不可能每次都真出纸太浪费。系统里可以装一个打印到文件的虚拟打印机或者用微软自带的XPS文档写入器。把打印目标指向它内容会输出成文件打开文件就能看到排版效果。这样调试效率高很多也不费纸。7.2 打印预览先于实际打印正式打印之前最好先做一个预览功能。GDI路线做预览不难创建一个内存DC把内容画到内存位图上再把位图显示到窗口里。预览确认没问题了再走真实打印流程。这个习惯能帮你省下大量纸张和调试时间。7.3 错误码要打印出来每个API调用之后都应该检查返回值失败时用GetLastError拿错误码。错误码是定位问题的关键线索。比如5表示拒绝访问通常是打印机权限问题122表示缓冲区不足1801表示打印机名无效。把这些错误码和含义整理成一张对照表放在代码里排查问题时直接查表比盲目搜索快得多。错误码含义常见原因5拒绝访问打印机权限不足122缓冲区不足输出缓冲区太小1801打印机名无效打印机名拼写错误或不存在1722RPC服务器不可用打印后台服务未启动1931打印机驱动问题驱动未正确安装7.4 分阶段验证不要一次性把整个打印流程写完再测。先验证能不能拿到打印机列表再验证能不能创建DC再验证能不能画出一行字最后验证完整流程。每个阶段单独测试出问题时范围小好定位。这是我在多个项目里总结出来的最有效的调试策略。8. 关于打印模块封装的一点个人经验把打印相关的API调用封装成一个独立的模块是长期项目里必须做的事。我一般会封装这么几个对外接口获取打印机列表、获取默认打印机、设置当前打印机、开始打印任务、绘制文字、绘制线条、绘制矩形、结束打印任务。内部把DC句柄、字体句柄、当前坐标状态都管理起来外部调用者不需要关心底层细节。封装的时候有个设计取舍是每次打印都重新创建DC还是复用DC我的经验是每次任务重新创建。复用虽然省了一点创建开销但状态管理复杂容易出问题。打印任务本身不是高频操作创建DC的开销完全可以接受。另外打印参数纸张大小、方向、边距最好做成可配置的不要硬编码在代码里。不同客户用的打印机型号不一样纸张规格也不一样做成配置项之后换机器只需要改配置不用改代码。这个设计在后期维护时能省很多事。字体对象的管理也值得单独说一句。每次打印任务里创建的字体任务结束后要全部删除。我见过一个项目因为字体对象没删连续打印几百次之后程序直接崩溃。后来加了资源计数和清理逻辑才解决。这种问题在测试阶段不容易发现上线后才暴露排查起来很痛苦。所以从一开始就把资源管理做对比事后补救划算得多。