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

📅 2026/7/25 5:38:08 👁️ 阅读次数 📝 编程学习
VC++ MFC对话框程序实现完整打印与预览功能实战指南

1. 项目概述与核心价值

最近在整理老项目时,翻出了一个用VC++(Visual C++)写的对话框程序,核心功能是实现文档打印。这个需求在现在看来可能有点“复古”,毕竟很多应用都转向了Web或跨平台框架,但在一些特定的工业控制、嵌入式上位机、或者需要与特定硬件(如票据打印机、标签机)深度绑定的桌面软件中,基于MFC对话框的打印功能依然是刚需。网上能找到的完整、可运行的例子不多,要么是代码片段讲不清上下文,要么是依赖复杂的文档/视图架构,对于只想在简单对话框里加个打印按钮的开发者来说,学习曲线陡峭。

所以,我决定把这个项目的完整源码拿出来,从头到尾拆解一遍。这不仅仅是一份“能跑”的代码,更重要的是理解在VC++的MFC框架下,从生成打印预览到把数据送到打印机,整个流程是如何串联起来的。我们会绕过那些华而不实的理论,直接进入实战,你会看到如何设置纸张、如何分页、如何绘制内容、如何处理打印对话框的用户选择。无论你是维护遗留系统,还是需要开发一个轻量级但功能专业的Windows桌面工具,这篇解析都能给你一套可直接复用的解决方案。

2. 环境准备与项目骨架搭建

2.1 开发环境与工具选型

首先明确环境,我们使用的是经典的Visual Studio 2019,但选择的是MFC项目模板。为什么不选.NET或更现代的C++?因为很多需要直接与打印机驱动交互、追求极致性能或需要兼容老旧Windows系统的场景,原生的MFC+C++仍然是可靠的选择。它生成的程序体积小,不依赖庞大的运行时库,执行效率高。

在VS2019中创建新项目时,选择“MFC应用程序”,应用程序类型选择“基于对话框”,项目风格保持默认即可。高级功能里,务必勾选“打印和打印预览”。这一步非常关键,VS会帮我们自动在CWinApp派生类中生成打印相关的框架代码,包括OnFilePrintOnFilePrintPreview等消息映射。这是我们整个功能的基础。

创建完成后,你会得到一个标准的对话框程序骨架。主对话框类默认叫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_PRINTID_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()

OnFilePrintOnFilePrintPreview的实现,本质上是“借用”了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_LOENGLISH(0.01英寸)或MM_HIMETRIC(0.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中设置映射模式,而不是在OnBeginPrintingOnPrint中。因为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(); }

绘制过程中的坑与技巧

  1. 坐标计算:在MM_LOENGLISH模式下,rectDraw.top通常是负值(如-1000),rectDraw.bottom是0或正值。TextOut的Y坐标参数越大,文本位置越。因此,计算行位置时是减法
  2. 资源管理:在OnPrint中创建的GDI对象(CFont,CPen,CBrush)必须在使用后删除或恢复旧对象,否则会造成GDI资源泄漏,在打印多页时可能导致失败。
  3. 性能考量:如果打印内容非常复杂(如图表、大量图片),在OnBeginPrinting中创建好所有必要的GDI资源(如字体、画笔),在OnPrint中直接使用,会比每页都创建销毁效率高得多。在OnEndPrinting中统一清理。
  4. 分页逻辑:复杂的表格打印需要更精细的分页逻辑,比如避免将一行数据拆到两页。这需要在CalculateLinesPerPage和计算nStartIndex/nEndIndex时加入更复杂的判断。

3.5 第五步:实现打印预览功能

打印预览的本质是:在一个模拟的“打印机DC”上(实际上是一个内存DC或屏幕DC),按照与真实打印完全相同的逻辑(相同的OnPrint函数)进行绘制,然后将结果显示在屏幕上。

MFC提供了CPrintPreviewStateCPreviewView等类来支持预览,但在对话框程序中直接使用较为复杂。一个更直观的实现方式是:

  1. 创建一个新的对话框作为预览窗口。
  2. 在这个预览对话框中,用CDC在内存位图中绘制,绘制逻辑直接调用主对话框的OnPrint函数,但传入一个特制的CPrintInfo和一个指向内存位图CDC的指针。
  3. 根据缩放比例,将位图显示在预览对话框的静态控件或自绘区域上。
  4. 实现翻页、缩放、关闭等功能按钮。

简化版预览核心代码思路

// 在预览对话框类中 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 打印内容偏移或大小不对

  • 症状:打印出来的内容没有在纸张的预期位置,或者变得特别小/特别大。
  • 排查
    1. 检查映射模式:99%的问题出在这里。确认在OnPrepareDC中设置了正确的映射模式(如MM_LOENGLISH),并且该模式在OnPrint中生效。
    2. 确认坐标原点:记住MM_LOENGLISH的Y轴向上为正。使用pInfo->m_rectDraw作为基准矩形,你的绘制坐标应该基于它的top(通常是负值)和left(通常是0或正值)进行计算。
    3. 验证逻辑坐标:在OnPrint中,使用pDC->GetMapMode()pDC->GetWindowExt()/GetViewportExt()输出日志,检查映射模式是否被意外修改。
  • 解决:坚持在OnPrepareDC中一次性设置好映射模式,并在所有坐标计算中明确意识到你使用的是逻辑单位(0.01英寸),而不是像素。

4.2 打印多页时只有第一页有内容

  • 症状:设置了很多页,但打印机只输出第一页,后面的都是空白。
  • 排查
    1. 检查OnPrepareDC:MFC在打印每一页前都会调用OnPrepareDC。如果你在这个函数里做了某些“一次性”的设置(比如移动了视口原点SetViewportOrg),并且没有根据页码进行偏移,那么第二页的内容可能会画到第一页的位置(甚至画到可见区域外)。
    2. 检查pInfo->m_nCurPage:在OnPrint中,确保你的绘制逻辑是根据pInfo->m_nCurPage来计算本页数据的起始索引的。一个常见的错误是始终从数据索引0开始画。
    3. 检查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 打印预览显示异常或闪烁

  • 症状:预览窗口内容错乱、缩放不对、或者刷新时闪烁严重。
  • 排查
    1. 缩放比例计算错误:预览时,需要将打印机逻辑坐标映射到屏幕像素。这个比例因子需要根据打印机DPI和屏幕DPI精确计算。公式不准确会导致预览内容过大或过小。
    2. 双重缓冲未做好:直接在预览窗口的OnPaint中绘制复杂内容会导致闪烁。必须使用内存DC进行双重缓冲。
    3. 映射模式冲突:预览DC和打印机DC必须使用完全相同的映射模式和窗口视口范围,否则绘制出来的几何关系会变形。
  • 解决:在预览的绘制函数中,严格按照“设置与打印机相同的映射模式 -> 计算并设置缩放视口 -> 调用同一OnPrint函数”的流程。绘制到内存位图,最后一次性BitBlt到屏幕。

4.5 实战心得与代码优化建议

  1. 抽象绘制层:不要将数据准备和绘制逻辑硬编码在OnPrint里。最好定义一个纯虚的“可打印接口”(IPrintable),让对话框或其他需要打印的类去实现这个接口。这样,打印框架的代码可以完全复用,只需关注如何将IPrintable的对象打印出来。
  2. 支持多种纸张pInfo->m_rectDraw的大小是由用户选择的打印机和纸张决定的。你的绘制代码应该能自适应这个矩形区域,而不是写死坐标。通过计算比例和动态布局来增强兼容性。
  3. 添加取消打印支持:在OnPrint中,可以定期调用CPrintInfo::m_bContinuePrinting来检查用户是否取消了打印作业。对于打印大量页面的情况,这能提升用户体验。
  4. 错误处理:打印机操作可能失败(缺纸、脱机、驱动问题)。在OnBeginPrintingDoPreparePrinting等关键步骤后检查返回值,并用AfxMessageBox或日志给用户友好的提示。
  5. 单元测试:打印逻辑很难调试,因为不能每次都真实打印。建立一个“虚拟打印机DC”,将其输出到增强图元文件(EMF)或高分辨率位图中,然后保存为图片文件进行检查,这是非常高效的调试方法。

通过以上五个部分的拆解,我们从零开始构建了一个VC++对话框程序的完整打印功能。从消息映射到分页计算,从映射模式到核心绘制,再到预览实现和问题排查,每一个环节都结合了源码和实战经验。这份代码骨架已经具备了生产级的稳定性,你可以直接将其融入你的项目,并根据具体的业务数据调整OnPrint中的绘制内容。处理打印功能就像和系统进行一场精确的对话,理解了CDCCPrintInfo和映射模式这套语言,你就能让对话框程序输出任何你想要的纸质文档。