公司动态

VC++ MFC对话框程序实现完整打印与预览功能实战指南

📅 2026/7/25 5:18:44
VC++ MFC对话框程序实现完整打印与预览功能实战指南
1. 项目概述与核心价值最近在整理老项目时翻出了一个用VCVisual C写的对话框程序核心功能是实现文档打印。这个需求在现在看来可能有点“复古”毕竟很多应用都转向了Web或跨平台框架但在一些特定的工业控制、嵌入式上位机、或者需要与特定硬件如票据打印机、标签机深度绑定的桌面软件中基于MFC对话框的打印功能依然是刚需。网上能找到的完整、可运行的例子不多要么是代码片段讲不清上下文要么是依赖复杂的文档/视图架构对于只想在简单对话框里加个打印按钮的开发者来说学习曲线陡峭。所以我决定把这个项目的完整源码拿出来从头到尾拆解一遍。这不仅仅是一份“能跑”的代码更重要的是理解在VC的MFC框架下从生成打印预览到把数据送到打印机整个流程是如何串联起来的。我们会绕过那些华而不实的理论直接进入实战你会看到如何设置纸张、如何分页、如何绘制内容、如何处理打印对话框的用户选择。无论你是维护遗留系统还是需要开发一个轻量级但功能专业的Windows桌面工具这篇解析都能给你一套可直接复用的解决方案。2. 环境准备与项目骨架搭建2.1 开发环境与工具选型首先明确环境我们使用的是经典的Visual Studio 2019但选择的是MFC项目模板。为什么不选.NET或更现代的C因为很多需要直接与打印机驱动交互、追求极致性能或需要兼容老旧Windows系统的场景原生的MFCC仍然是可靠的选择。它生成的程序体积小不依赖庞大的运行时库执行效率高。在VS2019中创建新项目时选择“MFC应用程序”应用程序类型选择“基于对话框”项目风格保持默认即可。高级功能里务必勾选“打印和打印预览”。这一步非常关键VS会帮我们自动在CWinApp派生类中生成打印相关的框架代码包括OnFilePrint、OnFilePrintPreview等消息映射。这是我们整个功能的基础。创建完成后你会得到一个标准的对话框程序骨架。主对话框类默认叫CXXXDlg。我们的所有工作都将围绕这个主对话框展开。2.2 核心类与消息流梳理在深入代码前必须理解MFC打印功能的几个核心类CPrintInfo: 打印信息类。它是个“信息中心”包含了当前打印作业的所有状态页码范围、当前页号、打印机的画布区域m_rectDraw、是否正在预览等。我们的绘制代码需要根据它提供的信息来决定画什么。CDC: 设备上下文类。无论是屏幕显示还是打印机输出在Windows里都是通过CDC来绘图。打印时MFC会为我们准备好一个指向打印机CDC的指针。CView: 视图类。在文档/视图架构中打印逻辑通常放在CView的派生类里。但我们的对话框程序没有CView这是最大的挑战也是本项目的核心——我们要在对话框类中模拟CView的打印行为。MFC的标准打印流程依赖于OnPreparePrinting,OnBeginPrinting,OnPrepareDC,OnPrint这一系列虚函数它们原本属于CView。为了让对话框支持打印我们需要在主对话框类中重写这些功能并通过消息映射将它们与菜单或按钮关联。3. 打印功能实现全流程拆解3.1 第一步添加打印菜单与消息映射首先我们在对话框资源编辑器里给主对话框的菜单栏加上“文件”-“打印...”和“文件”-“打印预览”两个菜单项其ID分别为标准的ID_FILE_PRINT和ID_FILE_PRINT_PREVIEW。然后在主对话框类的头文件.h中声明必要的消息处理函数和成员变量class CMyPrintDlg : public CDialogEx { // ... 其他代码 protected: // 声明消息处理函数 afx_msg void OnFilePrint(); afx_msg void OnFilePrintPreview(); afx_msg void OnPrintButton(); // 也可以响应一个按钮 // 声明模拟View打印流程的函数 virtual BOOL OnPreparePrinting(CPrintInfo* pInfo); virtual void OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo); virtual void OnPrepareDC(CDC* pDC, CPrintInfo* pInfo); virtual void OnPrint(CDC* pDC, CPrintInfo* pInfo); virtual void OnEndPrinting(CDC* pDC, CPrintInfo* pInfo); // 用于计算页数的成员变量 int m_nTotalPages; // 假设我们要打印的数据 CStringArray m_strDataArray; };在实现文件.cpp中添加消息映射BEGIN_MESSAGE_MAP(CMyPrintDlg, CDialogEx) ON_COMMAND(ID_FILE_PRINT, CMyPrintDlg::OnFilePrint) ON_COMMAND(ID_FILE_PRINT_PREVIEW, CMyPrintDlg::OnFilePrintPreview) ON_BN_CLICKED(IDC_BTN_PRINT, CMyPrintDlg::OnPrintButton) // 按钮ID END_MESSAGE_MAP()OnFilePrint和OnFilePrintPreview的实现本质上是“借用”了MFC框架的打印流程。我们需要手动调用CView中的相关全局函数并传入我们自己的回调void CMyPrintDlg::OnFilePrint() { // 调用MFC框架的打印函数并指定“视图”为this对话框 ::AfxGetApp()-OnFilePrint(); // 但更常见的做法是我们直接启动一个打印作业 CPrintDialog dlgPrint(FALSE); // FALSE表示打印对话框TRUE为打印设置对话框 if (dlgPrint.DoModal() IDOK) { // 用户点击了打印这里开始我们的自定义打印流程 OnPrintButton(); } } void CMyPrintDlg::OnFilePrintPreview() { // 这里需要更复杂的处理来嵌入预览窗口到对话框。 // 一个更实用的方法是我们自己创建一个模态或非模态的预览对话框。 // 简单起见我们可以直接调用框架预览但这在纯对话框程序中可能不稳定。 // ::AfxGetApp()-OnFilePrintPreview(); // 因此本项目源码中采用了一种更稳健的方式自定义一个预览对话框模拟MFC预览逻辑。 }注意直接使用::AfxGetApp()-OnFilePrint()在对话框程序中可能会因为找不到默认的CView而引发断言或错误。因此更可靠的做法是像上面OnFilePrint中那样先弹出标准打印对话框获取打印机DC然后完全接管后续的绘制流程。本项目的完整源码采用了后一种更可控的方式。3.2 第二步计算页数与初始化打印作业打印的核心是分页。我们需要在OnBeginPrinting或更早的OnPreparePrinting中计算出总共需要打印多少页。BOOL CMyPrintDlg::OnPreparePrinting(CPrintInfo* pInfo) { // 默认打印一份 pInfo-SetMinPage(1); // 这里先设为1实际总页数在OnBeginPrinting中根据打印机画布计算后确定 pInfo-SetMaxPage(1); return DoPreparePrinting(pInfo); // 弹出打印对话框 } void CMyPrintDlg::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo) { // 1. 获取打印机可打印区域 // pInfo-m_rectDraw 是以逻辑单位如0.1毫米表示的页面可打印区域。 // 其大小取决于用户在打印对话框中选择的纸张和打印机驱动。 // 2. 根据你的数据内容计算总页数 // 例如我们有一组字符串m_strDataArray要打印每页打印20行。 int nLinesPerPage CalculateLinesPerPage(pDC, pInfo-m_rectDraw); if (nLinesPerPage 0) { m_nTotalPages (m_strDataArray.GetSize() nLinesPerPage - 1) / nLinesPerPage; } else { m_nTotalPages 1; } // 3. 设置最大页码 pInfo-SetMaxPage(m_nTotalPages); } int CMyPrintDlg::CalculateLinesPerPage(CDC* pDC, const CRect rectDraw) { // 设置与打印机DC兼容的字体 CFont fontPrint; fontPrint.CreatePointFont(100, _T(宋体), pDC); // 10磅字体 CFont* pOldFont pDC-SelectObject(fontPrint); TEXTMETRIC tm; pDC-GetTextMetrics(tm); int nLineHeight tm.tmHeight tm.tmExternalLeading; // 行高 pDC-SelectObject(pOldFont); fontPrint.DeleteObject(); // 计算可打印高度能容纳多少行 int nPrintableHeight rectDraw.Height(); return nPrintableHeight / nLineHeight; }关键点解析pInfo-m_rectDraw是逻辑坐标下的区域不是像素。它的单位取决于映射模式后面会讲。在计算之前我们必须先为CDC设置好映射模式。计算总页数必须在OnBeginPrinting中进行因为此时我们已经拥有了打印机CDC可以获取真实的页面尺寸和字体尺寸。在OnPreparePrinting时这些信息还未确定。DoPreparePrinting(pInfo)会弹出Windows标准打印对话框用户可以选择打印机、设置纸张和份数。这个函数的返回值决定了是否继续打印流程。3.3 第三步设置映射模式与坐标转换这是最容易出错的一步。屏幕显示用的是像素但打印机用的是物理尺寸英寸、毫米。直接使用像素坐标在打印机上输出结果会非常小。因此必须使用映射模式来建立逻辑坐标到设备坐标的转换关系。最常用的映射模式是MM_LOENGLISH0.01英寸或MM_HIMETRIC0.01毫米。这里我们使用MM_LOENGLISH因为它更符合常见的度量习惯1逻辑单位0.01英寸。void CMyPrintDlg::OnPrepareDC(CDC* pDC, CPrintInfo* pInfo) { // 这个函数在打印每一页之前都会被调用 if (pDC-IsPrinting()) // 确保只在打印时修改映射模式 { // 设置映射模式为MM_LOENGLISH pDC-SetMapMode(MM_LOENGLISH); // 设置窗口范围和视口范围通常我们只需要设置映射模式即可。 // 更复杂的调整可以通过SetWindowExt和SetViewportExt进行。 } }重要经验一定要在OnPrepareDC中设置映射模式而不是在OnBeginPrinting或OnPrint中。因为MFC框架可能会在打印过程中多次调用OnPrepareDC进行状态准备。使用MM_LOENGLISH后Y轴向上为正。这意味着坐标(0,0)在页面左下角Y值越大位置越高。这与屏幕坐标左上角为原点Y向下为正相反在计算绘制位置时要特别注意。在OnPrint中绘制时使用的坐标值就是基于这个映射模式的逻辑坐标。例如pInfo-m_rectDraw.top可能是-1000表示距离页面顶部10英寸bottom可能是0。3.4 第四步核心绘制逻辑 OnPrint这是打印功能的灵魂所在。OnPrint函数负责在打印机DC上绘制当前页的内容。void CMyPrintDlg::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 0. 确保映射模式已设置已在OnPrepareDC中完成 // 1. 获取当前页号和可绘制区域 int nCurPage pInfo-m_nCurPage; CRect rectDraw pInfo-m_rectDraw; // 注意这里的坐标是MM_LOENGLISH下的逻辑坐标 // 2. 绘制页眉例如在每页顶部画一条线并显示标题 DrawPageHeader(pDC, rectDraw, nCurPage); // 3. 计算本页要显示的数据范围 int nLinesPerPage CalculateLinesPerPage(pDC, rectDraw); int nStartIndex (nCurPage - 1) * nLinesPerPage; int nEndIndex min(nStartIndex nLinesPerPage, m_strDataArray.GetSize()); // 4. 计算正文起始位置页眉下方 int nYStart rectDraw.top 150; // 从顶部向下150逻辑单位1.5英寸开始画正文 // 5. 设置打印字体 CFont fontPrint; fontPrint.CreatePointFont(100, _T(宋体), pDC); // 10磅 CFont* pOldFont pDC-SelectObject(fontPrint); // 6. 循环绘制本页的每一行数据 int nLineHeight 160; // 根据字体计算出的行高单位是逻辑单位MM_LOENGLISH for (int i nStartIndex; i nEndIndex; i) { CString strLine m_strDataArray.GetAt(i); // 注意TextOut的Y坐标在MM_LOENGLISH下数值越大位置越高。 // 所以我们用 nYStart - (i - nStartIndex) * nLineHeight 来计算每行位置。 int nYPos nYStart - (i - nStartIndex) * nLineHeight; pDC-TextOut(rectDraw.left 100, nYPos, strLine); } // 7. 绘制页脚如页码 DrawPageFooter(pDC, rectDraw, nCurPage, m_nTotalPages); // 8. 恢复旧的GDI对象 pDC-SelectObject(pOldFont); fontPrint.DeleteObject(); } void CMyPrintDlg::DrawPageHeader(CDC* pDC, const CRect rectDraw, int nPage) { // 在顶部画一条线 pDC-MoveTo(rectDraw.left, rectDraw.top - 20); pDC-LineTo(rectDraw.right, rectDraw.top - 20); // 打印标题 CFont fontHeader; fontHeader.CreatePointFont(120, _T(黑体), pDC); // 12磅 CFont* pOldFont pDC-SelectObject(fontHeader); CString strHeader _T(我的打印文档); pDC-TextOut(rectDraw.left 100, rectDraw.top - 50, strHeader); pDC-SelectObject(pOldFont); fontHeader.DeleteObject(); }绘制过程中的坑与技巧坐标计算在MM_LOENGLISH模式下rectDraw.top通常是负值如-1000rectDraw.bottom是0或正值。TextOut的Y坐标参数越大文本位置越高。因此计算行位置时是减法。资源管理在OnPrint中创建的GDI对象CFont,CPen,CBrush必须在使用后删除或恢复旧对象否则会造成GDI资源泄漏在打印多页时可能导致失败。性能考量如果打印内容非常复杂如图表、大量图片在OnBeginPrinting中创建好所有必要的GDI资源如字体、画笔在OnPrint中直接使用会比每页都创建销毁效率高得多。在OnEndPrinting中统一清理。分页逻辑复杂的表格打印需要更精细的分页逻辑比如避免将一行数据拆到两页。这需要在CalculateLinesPerPage和计算nStartIndex/nEndIndex时加入更复杂的判断。3.5 第五步实现打印预览功能打印预览的本质是在一个模拟的“打印机DC”上实际上是一个内存DC或屏幕DC按照与真实打印完全相同的逻辑相同的OnPrint函数进行绘制然后将结果显示在屏幕上。MFC提供了CPrintPreviewState和CPreviewView等类来支持预览但在对话框程序中直接使用较为复杂。一个更直观的实现方式是创建一个新的对话框作为预览窗口。在这个预览对话框中用CDC在内存位图中绘制绘制逻辑直接调用主对话框的OnPrint函数但传入一个特制的CPrintInfo和一个指向内存位图CDC的指针。根据缩放比例将位图显示在预览对话框的静态控件或自绘区域上。实现翻页、缩放、关闭等功能按钮。简化版预览核心代码思路// 在预览对话框类中 void CMyPreviewDlg::DrawPreviewPage(int nPage) { // 1. 创建与预览窗口兼容的内存DC和位图 CClientDC dcScreen(this); CDC dcMem; dcMem.CreateCompatibleDC(dcScreen); CBitmap bitmap; CRect rectClient; GetClientRect(rectClient); bitmap.CreateCompatibleBitmap(dcScreen, rectClient.Width(), rectClient.Height()); CBitmap* pOldBitmap dcMem.SelectObject(bitmap); // 2. 用白色填充背景 dcMem.FillSolidRect(rectClient, RGB(255, 255, 255)); // 3. 设置与打印机相同的映射模式关键 dcMem.SetMapMode(MM_LOENGLISH); // 为了在屏幕上显示需要缩放。计算缩放比例。 // 假设打印机分辨率是300 DPI屏幕是96 DPI。 double dScale 96.0 / 300.0; // 这是一个简化的计算实际需根据打印机和屏幕信息调整 dcMem.SetViewportExt((int)(rectClient.Width() * dScale), (int)(rectClient.Height() * dScale)); // 4. 构造一个模拟的CPrintInfo CPrintInfo infoPreview; infoPreview.m_rectDraw.SetRect(0, -1000, 1000, 0); // 模拟A4纸纵向可打印区域 infoPreview.m_nCurPage nPage; // 5. 调用主对话框的绘制逻辑需要将主对话框的OnPrint函数改为public或提供友元/接口 m_pParentDialog-OnPrint(dcMem, infoPreview); // 6. 将内存位图贴到屏幕 dcScreen.BitBlt(0, 0, rectClient.Width(), rectClient.Height(), dcMem, 0, 0, SRCCOPY); // 7. 清理 dcMem.SelectObject(pOldBitmap); bitmap.DeleteObject(); }这个预览实现是原理性的完整的源码包含了比例计算、滚动条、多页预览等细节。它证明了只要正确设置了设备上下文和映射模式同一套绘制代码可以无缝用于屏幕预览和真实打印。4. 常见问题排查与实战心得4.1 打印内容偏移或大小不对症状打印出来的内容没有在纸张的预期位置或者变得特别小/特别大。排查检查映射模式99%的问题出在这里。确认在OnPrepareDC中设置了正确的映射模式如MM_LOENGLISH并且该模式在OnPrint中生效。确认坐标原点记住MM_LOENGLISH的Y轴向上为正。使用pInfo-m_rectDraw作为基准矩形你的绘制坐标应该基于它的top通常是负值和left通常是0或正值进行计算。验证逻辑坐标在OnPrint中使用pDC-GetMapMode()和pDC-GetWindowExt()/GetViewportExt()输出日志检查映射模式是否被意外修改。解决坚持在OnPrepareDC中一次性设置好映射模式并在所有坐标计算中明确意识到你使用的是逻辑单位0.01英寸而不是像素。4.2 打印多页时只有第一页有内容症状设置了很多页但打印机只输出第一页后面的都是空白。排查检查OnPrepareDCMFC在打印每一页前都会调用OnPrepareDC。如果你在这个函数里做了某些“一次性”的设置比如移动了视口原点SetViewportOrg并且没有根据页码进行偏移那么第二页的内容可能会画到第一页的位置甚至画到可见区域外。检查pInfo-m_nCurPage在OnPrint中确保你的绘制逻辑是根据pInfo-m_nCurPage来计算本页数据的起始索引的。一个常见的错误是始终从数据索引0开始画。检查OnBeginPrinting中的总页数设置pInfo-SetMaxPage()设置的值是否正确如果设为1框架自然只会调用一次OnPrint。解决在OnPrepareDC中可以根据当前页码对坐标系进行偏移为每页创建独立的“画布原点”。但更推荐的做法是在OnPrint中根据pInfo-m_nCurPage和固定的pInfo-m_rectDraw来计算本页内容在全局中的位置这样逻辑更清晰。4.3 GDI资源泄漏导致打印失败症状打印几页后程序崩溃或打印作业被系统异常终止。在调试输出中可能看到GDI对象数量激增的警告。排查在OnPrint或任何使用CDC的函数中对于通过CreateFont,CreatePen,CreateSolidBrush等创建的GDI对象是否都配对了DeleteObject是否在SelectObject新对象后保存并最终恢复了旧的GDI对象解决严格遵守GDI对象的“谁创建谁删除”原则。使用CGdiObject的派生类如CFont,CPen时利用其析构函数自动删除。但在OnPrint中更高效的做法是在OnBeginPrinting中创建所有需要的字体和画笔在OnEndPrinting中删除在OnPrint中只进行SelectObject。4.4 打印预览显示异常或闪烁症状预览窗口内容错乱、缩放不对、或者刷新时闪烁严重。排查缩放比例计算错误预览时需要将打印机逻辑坐标映射到屏幕像素。这个比例因子需要根据打印机DPI和屏幕DPI精确计算。公式不准确会导致预览内容过大或过小。双重缓冲未做好直接在预览窗口的OnPaint中绘制复杂内容会导致闪烁。必须使用内存DC进行双重缓冲。映射模式冲突预览DC和打印机DC必须使用完全相同的映射模式和窗口视口范围否则绘制出来的几何关系会变形。解决在预览的绘制函数中严格按照“设置与打印机相同的映射模式 - 计算并设置缩放视口 - 调用同一OnPrint函数”的流程。绘制到内存位图最后一次性BitBlt到屏幕。4.5 实战心得与代码优化建议抽象绘制层不要将数据准备和绘制逻辑硬编码在OnPrint里。最好定义一个纯虚的“可打印接口”IPrintable让对话框或其他需要打印的类去实现这个接口。这样打印框架的代码可以完全复用只需关注如何将IPrintable的对象打印出来。支持多种纸张pInfo-m_rectDraw的大小是由用户选择的打印机和纸张决定的。你的绘制代码应该能自适应这个矩形区域而不是写死坐标。通过计算比例和动态布局来增强兼容性。添加取消打印支持在OnPrint中可以定期调用CPrintInfo::m_bContinuePrinting来检查用户是否取消了打印作业。对于打印大量页面的情况这能提升用户体验。错误处理打印机操作可能失败缺纸、脱机、驱动问题。在OnBeginPrinting、DoPreparePrinting等关键步骤后检查返回值并用AfxMessageBox或日志给用户友好的提示。单元测试打印逻辑很难调试因为不能每次都真实打印。建立一个“虚拟打印机DC”将其输出到增强图元文件EMF或高分辨率位图中然后保存为图片文件进行检查这是非常高效的调试方法。通过以上五个部分的拆解我们从零开始构建了一个VC对话框程序的完整打印功能。从消息映射到分页计算从映射模式到核心绘制再到预览实现和问题排查每一个环节都结合了源码和实战经验。这份代码骨架已经具备了生产级的稳定性你可以直接将其融入你的项目并根据具体的业务数据调整OnPrint中的绘制内容。处理打印功能就像和系统进行一场精确的对话理解了CDC、CPrintInfo和映射模式这套语言你就能让对话框程序输出任何你想要的纸质文档。