简介这份资源面向具备一定C与Windows开发基础的MFC学习者聚焦在应用程序中集成打印与打印预览功能这一常见但易踩坑的环节。内容围绕CView派生类的打印机制展开涵盖打印机设备上下文创建、打印作业启停、页面布局设置、OnDraw适配打印机DC、分页处理以及打印对话框与预览实现等关键知识点并配有可直接参考的代码片段帮助读者理解从屏幕绘制到打印输出的完整流程。资源包共50个文件以h头文件、cpp源文件、rc资源脚本、vcxproj工程文件及sln解决方案为主另含exe可执行程序、pdb调试符号、manifest清单与日志等编译中间产物整体约22.82MB工程结构完整可直接打开编译运行对照调试。目前已有346人学习下载适合需要为MFC项目补充打印模块、或希望系统梳理打印与预览实现思路的开发者参考借鉴。1. 从一张没人愿意接的工单说起MFC打印功能到底难在哪接手过一个老工控上位机的维护界面是标准的 MFC 单文档结构功能跑得挺稳唯独打印这块一直空着。前一位维护者留了句“打印太麻烦让客户截图贴 Word 里打”客户当然不干。我花了两天把 MFC 的打印链路重新捋了一遍才发现它其实不是难而是绕——绕在设备上下文DC的切换、打印机的分页逻辑、以及预览和实际输出共用一套绘制代码这三件事上。MFC 打印功能说白了就是让 OnDraw 里那套 GDI 绘制代码既能画到屏幕上也能画到打印机上还能画到预览窗口里。它解决的是“同一份数据三种输出目标”的问题适合手里有 MFC 老项目、需要补打印模块的桌面端开发者。下面把我踩过的路和能直接抄的代码摊开讲。2. 打印功能的底层逻辑为什么 MFC 要绕这么大一圈2.1 屏幕 DC 和打印机 DC 的本质差异很多人第一次写打印习惯性地把屏幕上的CDC* pDC直接拿来用结果要么打出来一片空白要么尺寸完全对不上。原因在于屏幕 DC 和打印机 DC 是两种物理特性完全不同的设备上下文。屏幕的坐标单位是像素分辨率通常 96 DPI而打印机的坐标单位取决于驱动常见 300 DPI 到 600 DPI同一段逻辑坐标映射到物理设备上比例差了三四倍。MFC 用OnPrint和OnDraw的分离来应对这件事。框架在打印时会调用OnPrint把打印机 DC 传进来同时通过CPrintInfo结构体携带当前页码、打印范围等信息。如果你把所有绘制逻辑都塞在OnDraw里框架在屏幕刷新和打印时会走同一条路径但传入的 DC 不同这就是“一份代码三处复用”的设计意图。关键点在于映射模式。默认MM_TEXT下一个逻辑单位等于一个设备像素打印时这个“像素”是打印机的点尺寸会严重偏小。常见做法是在OnPrint里设置MM_LOENGLISH或MM_HIMETRIC让逻辑单位对应物理尺寸0.01 英寸或 0.01 毫米这样屏幕和打印机的输出比例才能一致。// 在 OnPrint 中设置映射模式保证打印尺寸可控 void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 保存原映射模式避免影响后续绘制 int nOldMapMode pDC-SetMapMode(MM_LOENGLISH); // MM_LOENGLISH 下 y 轴向上为正需要翻转 pDC-SetViewportOrg(0, 0); pDC-SetWindowOrg(0, 0); // 调用统一的绘制函数传入 DC 和打印信息 DrawContent(pDC, pInfo); pDC-SetMapMode(nOldMapMode); }这段代码的逻辑是进入打印回调后先切换映射模式让后续所有绘制命令都按物理尺寸解释。MM_LOENGLISH的好处是 1 个单位等于 0.01 英寸A4 纸宽约 8.27 英寸对应 827 个单位算起来直观。参数pInfo里带着m_rectDraw表示当前页可打印区域绘制时应该以它为边界而不是硬编码纸张尺寸。2.2 打印流程的四个阶段与对应回调MFC 的打印流程由框架驱动开发者只需要在几个关键回调里填逻辑。整个流程分四步启动打印、创建 DC、逐页绘制、结束清理。对应到代码里OnPreparePrinting负责设置页数和打印范围OnBeginPrinting创建打印专用的 GDI 资源比如字体、画笔OnPrint逐页绘制OnEndPrinting释放资源。新手最容易忽略的是OnPreparePrinting里的SetMaxPage。如果不设框架默认只打一页多页数据会被截断。我见过一个报表模块数据明明有 200 行打出来只有 40 行查了半天发现是没设最大页数框架只调了一次OnPrint。BOOL CMyView::OnPreparePrinting(CPrintInfo* pInfo) { // 根据数据量计算总页数每页固定行数 int nTotalLines m_dataList.GetSize(); int nLinesPerPage 45; // A4 纸在 MM_LOENGLISH 下大约能放 45 行 int nPageCount (nTotalLines nLinesPerPage - 1) / nLinesPerPage; // 设置打印范围至少一页 pInfo-SetMinPage(1); pInfo-SetMaxPage(nPageCount 0 ? nPageCount : 1); return DoPreparePrinting(pInfo); }这里SetMaxPage的参数就是总页数框架据此决定调用OnPrint的次数。DoPreparePrinting会弹出打印对话框让用户选打印机和页码范围。注意nLinesPerPage不是随便定的它取决于映射模式、字体大小和页边距需要根据实际排版微调。我一般会先打一页测试数一下实际行数再回填这个值。2.3 打印预览与打印的代码复用策略打印预览是 MFC 送的一个便利框架用屏幕 DC 模拟打印机 DC把OnPrint的输出渲染到预览窗口。这意味着只要OnPrint写对了预览基本不会出问题。但有个坑预览时的映射模式和实际打印必须一致否则预览看着正常打出来比例不对。常见做法是把所有绘制逻辑抽到一个独立函数比如DrawPage(CDC* pDC, int nPage)OnDraw和OnPrint都调它。OnDraw里传屏幕 DCOnPrint里传打印机 DC通过pInfo-m_nCurPage决定画哪一页。这样屏幕显示、预览、实际打印三处共用同一套代码改一处全生效。void CMyView::OnDraw(CDC* pDC) { // 屏幕显示时只画当前可见区域不涉及分页 DrawPage(pDC, 0); // 0 表示屏幕模式不分页 } void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 打印和预览时按页码绘制 DrawPage(pDC, pInfo-m_nCurPage); } void CMyView::DrawPage(CDC* pDC, int nPage) { if (nPage 0) { // 屏幕模式直接画全部内容依赖滚动条 DrawAllContent(pDC); } else { // 打印模式只画第 nPage 页对应的数据段 int nStart (nPage - 1) * 45; DrawContentRange(pDC, nStart, 45); } }这个结构的好处是职责清晰OnDraw管屏幕OnPrint管打印DrawPage做分发。参数nPage为 0 时表示屏幕模式大于 0 时表示打印页码。实际项目中屏幕模式可能需要处理滚动和裁剪打印模式需要处理分页边界两者在DrawPage里分流互不干扰。3. 从零接入打印一份可编译的 MFC 打印模块3.1 工程配置与打印相关的类结构假设你手里是一个标准的 MFC 单文档工程视图类继承自CView。要接入打印首先确认CView的派生类已经重写了OnDraw因为打印框架默认会调用它。然后通过类向导添加OnPreparePrinting、OnBeginPrinting、OnPrint、OnEndPrinting四个虚函数的重写。如果用的是CScrollView打印逻辑基本一致但要注意滚动偏移在打印时应该忽略。工程配置上不需要额外链接库MFC 的打印支持是内置的。但有一个编译期注意点如果项目用了 Unicode 字符集打印文本时要用_T()宏包裹字符串否则中文会乱码。另外OnPrint的签名必须是void OnPrint(CDC* pDC, CPrintInfo* pInfo)参数类型不能改否则框架不会调用。// 头文件中声明重写函数 class CMyView : public CView { protected: virtual BOOL OnPreparePrinting(CPrintInfo* pInfo); virtual void OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo); virtual void OnPrint(CDC* pDC, CPrintInfo* pInfo); virtual void OnEndPrinting(CDC* pDC, CPrintInfo* pInfo); // 自定义绘制函数 void DrawPage(CDC* pDC, int nPage); void DrawContentRange(CDC* pDC, int nStart, int nCount); // 打印专用字体在 OnBeginPrinting 中创建 CFont m_printFont; int m_nLinesPerPage; };类结构上把打印专用的 GDI 对象作为成员变量在OnBeginPrinting里创建OnEndPrinting里销毁。这样避免每次OnPrint都重新创建字体既浪费资源又可能导致 GDI 句柄泄漏。m_nLinesPerPage作为成员变量方便在OnPreparePrinting和DrawContentRange之间共享。3.2 OnBeginPrinting 里创建打印专用 GDI 资源OnBeginPrinting在整个打印任务开始时调用一次适合做资源初始化。这里创建字体、画笔、刷子等 GDI 对象并计算每页能放多少行。计算行数时用GetTextMetrics获取字体高度再用可打印区域高度除以行高。void CMyView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo) { // 创建打印专用字体比屏幕字体稍大保证清晰度 m_printFont.CreatePointFont(100, _T(宋体), pDC); // 选入字体测量行高 CFont* pOldFont pDC-SelectObject(m_printFont); TEXTMETRIC tm; pDC-GetTextMetrics(tm); // 计算可打印区域能放多少行 // pInfo-m_rectDraw 是逻辑坐标下的可打印矩形 int nHeight pInfo-m_rectDraw.Height(); m_nLinesPerPage nHeight / (tm.tmHeight tm.tmExternalLeading); // 恢复原字体 pDC-SelectObject(pOldFont); }CreatePointFont的第一个参数是字体大小的十分之一磅值100 表示 10 磅。GetTextMetrics返回的tmHeight是字符高度tmExternalLeading是行间距两者相加才是实际行高。m_rectDraw在OnBeginPrinting时已经由框架根据打印机和纸张设置好了直接用它算行数最准确。注意这里选入字体后要恢复否则会影响后续绘制。3.3 OnPrint 中按页绘制与坐标映射OnPrint是核心每页调用一次。里面要做三件事设置映射模式、计算当前页的数据范围、调用绘制函数。映射模式建议用MM_LOENGLISH或MM_HIMETRIC前者单位 0.01 英寸后者 0.01 毫米都比MM_TEXT更适合打印。void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 设置映射模式为 0.01 英寸y 轴向上 int nOldMode pDC-SetMapMode(MM_LOENGLISH); // 选入打印字体 CFont* pOldFont pDC-SelectObject(m_printFont); // 计算当前页的数据起始索引 int nPage pInfo-m_nCurPage; int nStart (nPage - 1) * m_nLinesPerPage; // 绘制页眉 pDC-SetTextColor(RGB(0, 0, 0)); CString strHeader; strHeader.Format(_T(报表 第 %d 页), nPage); pDC-TextOut(0, -100, strHeader); // y 为负表示向下 // 绘制正文内容 DrawContentRange(pDC, nStart, m_nLinesPerPage); // 恢复 GDI 对象 pDC-SelectObject(pOldFont); pDC-SetMapMode(nOldMode); }注意MM_LOENGLISH下 y 轴向上为正所以TextOut的 y 坐标用负数表示向下偏移。页眉画在 -100 的位置即距顶部 1 英寸。正文从nStart开始画m_nLinesPerPage行。DrawContentRange里逐行TextOut每行 y 坐标递减tmHeight。这里的关键参数是m_nLinesPerPage它决定了分页边界必须和OnPreparePrinting里算页数时用的值一致否则会出现某页空白或数据截断。3.4 打印预览的启用与调试技巧打印预览不需要额外代码MFC 框架自带。在菜单里加一个“打印预览”项映射到CView::OnFilePrintPreview即可。调试预览时常见问题是预览窗口一片空白原因通常是OnPrint里没选入字体或者映射模式设错了导致内容画到了可视区域外。我一般会在OnPrint开头加一句TRACE输出当前页码和m_rectDraw的尺寸在 Debug 模式下看输出窗口。如果页码对但内容不显示检查m_rectDraw的坐标范围确认绘制坐标落在矩形内。另一个技巧是临时把背景刷成浅色比如pDC-FillSolidRect(pInfo-m_rectDraw, RGB(240, 240, 240))这样能直观看到可打印区域的位置。// 调试用在 OnPrint 开头输出关键信息 #ifdef _DEBUG TRACE(_T(OnPrint: page%d, rect(%d,%d,%d,%d)\n), pInfo-m_nCurPage, pInfo-m_rectDraw.left, pInfo-m_rectDraw.top, pInfo-m_rectDraw.right, pInfo-m_rectDraw.bottom); #endif这段TRACE只在 Debug 下生效Release 编译时自动移除。输出窗口会显示每页的页码和可打印矩形对照绘制坐标就能判断内容是否画偏。如果m_rectDraw的 bottom 是负数MM_LOENGLISH下正常说明映射模式已生效。4. 打印模块的避坑清单五个让我加班到凌晨的坑4.1 现象打印出来只有一页后面数据全丢原因OnPreparePrinting里没有调用SetMaxPage框架默认最大页数为 1只调用一次OnPrint。解决在OnPreparePrinting里根据数据总量和每页行数计算页数显式调用pInfo-SetMaxPage(n)。注意SetMinPage也要设通常为 1。4.2 现象预览正常实际打印比例缩小或放大原因预览和打印用了不同的映射模式。预览时框架可能用屏幕 DC 的默认模式打印时用了MM_LOENGLISH两者单位不一致。解决在OnPrint和OnDraw里统一设置映射模式或者把映射模式设置放在DrawPage函数开头确保两条路径一致。4.3 现象中文打印出来是乱码或问号原因项目是 Unicode 字符集但TextOut传了 ANSI 字符串或者字体不支持中文。解决字符串用_T()包裹字体创建时指定_T(宋体)或_T(微软雅黑)。如果用的是CString确认它是以 Unicode 方式构造的。4.4 现象打印多页时第二页开始内容偏移或重叠原因OnPrint里没有根据页码重置坐标原点或者m_nLinesPerPage在OnPreparePrinting和OnPrint里计算不一致。解决在OnPrint开头调用pDC-SetViewportOrg(0, 0)重置原点并确保m_nLinesPerPage是成员变量只在一处计算。4.5 现象打印任务发出后打印机没反应程序卡死原因OnBeginPrinting里创建的 GDI 对象没有在OnEndPrinting里释放导致 GDI 句柄耗尽后续打印任务无法创建 DC。解决在OnEndPrinting里调用m_printFont.DeleteObject()并检查所有SelectObject都有对应的恢复操作。5. 进阶把打印做成可配置的报表输出5.1 用 CPrintDialog 自定义打印参数MFC 默认的打印对话框够用但如果想控制纸张大小、打印方向、边距需要自己调CPrintDialog。常见做法是在OnPreparePrinting里先弹自定义对话框拿到用户选择后设置DEVMODE结构再传给DoPreparePrinting。BOOL CMyView::OnPreparePrinting(CPrintInfo* pInfo) { CPrintDialog dlg(FALSE); // FALSE 表示打印对话框 dlg.m_pd.Flags | PD_PRINTSETUP; // 允许设置纸张和方向 if (dlg.DoModal() ! IDOK) { return FALSE; // 用户取消 } // 把用户选择的 DEVMODE 传给打印信息 pInfo-m_pPD-m_pd.hDevMode dlg.m_pd.hDevMode; pInfo-m_pPD-m_pd.hDevNames dlg.m_pd.hDevNames; // 设置页数 pInfo-SetMaxPage(CalcPageCount()); return DoPreparePrinting(pInfo); }PD_PRINTSETUP标志让对话框显示纸张和方向选项。hDevMode和hDevNames是 Windows 打印系统的核心结构分别描述打印机的设备模式和驱动名称。把它们从自定义对话框转移到pInfo-m_pPD后DoPreparePrinting会用这些参数创建打印机 DC。5.2 页眉页脚与页码的自动排版页眉页脚的关键是留出边距。在MM_LOENGLISH下A4 纸高约 11.69 英寸对应 1169 个单位。页眉占顶部 100 个单位页脚占底部 100 个单位正文区域就是中间 969 个单位。页码放在页脚居中用GetTextExtent测量字符串宽度再算居中坐标。void CMyView::DrawFooter(CDC* pDC, CPrintInfo* pInfo) { CString strPage; strPage.Format(_T(第 %d 页 / 共 %d 页), pInfo-m_nCurPage, pInfo-GetMaxPage()); // 测量文本宽度计算居中位置 CSize sz pDC-GetTextExtent(strPage); int x (pInfo-m_rectDraw.Width() - sz.cx) / 2; int y pInfo-m_rectDraw.bottom 50; // 底部向上 0.5 英寸 pDC-TextOut(x, y, strPage); }GetMaxPage返回总页数m_nCurPage是当前页。m_rectDraw.bottom在MM_LOENGLISH下是负数加 50 表示向下偏移 0.5 英寸。居中算法是(可用宽度 - 文本宽度) / 2这是最稳妥的做法不依赖字体度量。5.3 打印数据的验证先预览再出纸打印最怕的是浪费纸。我的习惯是每次改完打印代码先走一遍打印预览确认三件事页数对不对、每页内容有没有截断、页眉页脚位置是否正常。预览没问题再实际打印而且第一张一定用草稿模式。从那以后我每次改打印相关代码都强制走一遍“预览 → 草稿打印 → 正式打印”的流程再也没出现过整包纸打废的情况。希望帮到你。本文还有配套的精品资源点击获取