简介:一份面向中级开发者的MFC打印编程技术文档,重点讲解如何借助MFC文档视图框架实现打印及打印预览功能。文档从打印准备到收尾,逐步分析OnPreparePrinting、OnBeginPrinting、OnPrepareDC、OnPrint、OnEndPrinting五个关键步骤,并结合MFC源码探讨在对话框应用中脱离文档视图、独立构建打印流程的实现方案;同时说明如何通过内存设备上下文模拟预览输出,如何利用CPrintInfo指针获取当前页数、总页数与绘制区域,如何针对屏幕显示与打印机输出差异编写不同的绘图代码。资源为单个PDF文件,大小约900KB,内容涵盖打印流程详解、CPrintInfo数据结构说明、自定义打印命令ID以及替换默认消息映射的进阶方法。这份资料将原来分散的打印知识点梳理成清晰的学习路径,能帮助开发者减少对底层图形接口的依赖,快速搭建稳定的打印模块。目前该文档已有145人学习,适合正在学习Visual C++与MFC、需要编写报表打印或定制打印格式的开发者参考。
1. 打印不是画屏幕:MFC 打印流程为什么值得单独拆
这份由戎亚新写的《使用 MFC 编写打印程序》,没有停在“打印就是 GDI 绘图”这种正确但没用的结论上。它把 CView 的打印调用链从 OnPreparePrinting 一路拆到 OnEndPrinting,又在对话框程序里做了一个虚拟框架窗口,让没有文档视图结构的工程也能复用 MFC 打印预览。如果你用 VS 写过报表、票据或工程曲线打印,应该体会过打印机 DC 和屏幕 DC 的差异:屏幕 96 DPI,打印机可能 300/600 DPI,直接套 OnDraw 往往打出来小得没法看。这套方案适合正在做 MFC 维护、准备给现有对话框程序加打印功能的中级读者,也适合想理解 CView 内部消息路由的人。网上很多 MFC 教程只给一个“重载 OnPrint 就能打”的结论,真正卡住人的是命令怎么路由、页数哪里来、预览窗口为什么按钮无效。
2. 文档视图下的 MFC 打印调用链:从 ON_COMMAND 到 OnPrint 的每一步
MFC 的文档视图框架能在向导生成后直接获得打印功能,靠的不是魔法,而是 CView 类里已经写好的命令处理函数。搞清楚这条调用链,后面在对话框里“借”打印能力时才不会改得莫名其妙。
2.1 打印命令从哪来:ID_FILE_PRINT 与 OnFilePrint 的消息映射
OnFilePrint 并不是虚函数,它是响应 WM_COMMAND 消息的处理函数。MFC 的视图类消息映射里有一项ON_COMMAND(ID_FILE_PRINT, CView::OnFilePrint),所以向导生成的菜单和工具栏底下的打印按钮,最终都会走到同一个入口。这意味着你可以不用默认 ID,换成自己的 ID 仍然能走完整的打印流程。
BEGIN_MESSAGE_MAP(CMyView, CView) ON_COMMAND(ID_FILE_PRINT, CView::OnFilePrint) // 默认打印命令 ON_COMMAND(ID_MY_PRINT, CView::OnFilePrint) // 自定义 ID 复用同一实现 END_MESSAGE_MAP()这里的关键是 OnFilePrint 的调用者是 CView,而不是视图外部。把自定义菜单 ID 也映射到 CView::OnFilePrint,等于跳过了自己写打印循环这一步。Windows 菜单命令路由会沿着文档、视图、框架、应用逐级查找,只要消息映射里有这一项,菜单和快捷键都能触发。
2.2 CPrintInfo:打印过程中的“上下文结构”
CPrintInfo 不是普通的数据类,它贯穿整个打印过程,保存了当前页、页码范围、可绘制区域和预览状态。我一般会在重写 OnPrint 之前先把这个结构体的字段过一遍,很多分页问题都出在对这些成员理解不到位。
| 字段或方法 | 含义 | 典型用途 |
|---|---|---|
| m_nCurPage | 当前正在打印的页码 | 按页输出不同内容 |
| m_bPreview | 是否处于打印预览状态 | 预览时跳过打印机初始化 |
| m_rectDraw | 当前可用页面区域 | 绘制页边距、表格线时用它裁剪 |
| GetFromPage / GetToPage | 用户在打印对话框中选择的起止页 | 只打印指定页码范围 |
| SetMaxPage / GetMaxPage | 文档总页数上下限 | 在 OnBeginPrinting 里设定总页数 |
m_rectDraw 是最容易被忽略的。它已经排除了打印机物理不可打印区域,直接按它的坐标绘制不会出现内容跑到纸外面的情况。在 MM_TEXT 映射模式下,它给出的是像素坐标;如果改了映射模式,它也会跟着转成逻辑坐标。
2.3 五个回调的执行顺序:为什么 OnPrepareDC 常被漏掉
很多人以为打印流程就是 OnPrint 里画图,实际顺序是:
OnPreparePrinting → DoModal 打印对话框 → OnBeginPrinting → 每页循环:OnPrepareDC → OnPrint → OnEndPrinting
OnPrepareDC 在每次打印新页之前都会被调用,作用类似屏幕绘制时的 OnPrepareDC,用来调整视区原点、映射模式或裁剪区域。漏掉它的一个典型后果是:你在 OnBeginPrinting 里设置的映射模式会被 OnPrepareDC 内部逻辑覆盖,导致打印越界。
BOOL CMyView::OnPreparePrinting(CPrintInfo* pInfo) { return DoPreparePrinting(pInfo); // 弹出打印对话框并校验收到的选择 } void CMyView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo) { pInfo->SetMaxPage(12); // 告诉框架一共 12 页 // 在这里创建打印字体,避免每页重复创建 } void CMyView::OnPrepareDC(CDC* pDC, CPrintInfo* pInfo) { CView::OnPrepareDC(pDC, pInfo); // 有滚动视图时按滚动偏移调整视区原点 if (pInfo != NULL) pDC->SetMapMode(MM_LOENGLISH); // 按 0.01 英寸为单位排版 } void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 按 pInfo->m_nCurPage 绘制当前页 } void CMyView::OnEndPrinting(CDC* pDC, CPrintInfo* pInfo) { // 释放 OnBeginPrinting 中创建的 GDI 对象 }OnPreparePrinting 必须返回 DoPreparePrinting 的结果,不能直接 return TRUE。DoPreparePrinting 内部会显示打印对话框,并把用户选择的打印机、份数、页码范围写回 CPrintInfo。OnPrepareDC 的 pInfo 参数在屏幕绘制时为 NULL,打印时非空,这个判断是区分“画屏幕”和“画纸上”的最简单手段。
2.3.1 先设页数再进循环:OnPreparePrinting / OnBeginPrinting
如果不调用 SetMaxPage,MFC 默认认为文档只有一页。哪怕你在 OnPrint 里用了 pInfo->m_nCurPage 判断第二页,框架也不会给你第二次调用 OnPrint 的机会。常见做法是先在 OnPreparePrinting 里初步估算页数,再到 OnBeginPrinting 里根据打印机 DC 的实际可打印宽度做最终设定。
BOOL CMyView::OnPreparePrinting(CPrintInfo* pInfo) { pInfo->SetMaxPage(100); // 先给一个上限 return DoPreparePrinting(pInfo); } void CMyView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo) { int pageCount = ComputePageCount(pDC); // 根据纸张宽度计算实际页数 pInfo->SetMaxPage(pageCount); }注意 SetMaxPage 必须在 DoPreparePrinting 之前调用,否则用户在打印对话框里看到的页码范围可能是错的。打印是流式的,框架并不知道你的文档有多长,页数由开发者在 OnBeginPrinting 里告诉它。
2.3.2 OnPrint 是真正的“绘图入口”
OnPrint 是 CView 的虚函数,默认实现会调用 OnDraw。如果屏幕显示内容恰好就是打印内容,那这行默认调用很方便;但多数业务打印和屏幕显示有差异,比如屏幕上画的是曲线,打印时要输出表格和统计数据。这个时候要重写 OnPrint,不要再碰 OnDraw。
void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { CRect rcPage = pInfo->m_rectDraw; // 当前可用页面区域 CString pageNo; pageNo.Format(_T("Page %d"), pInfo->m_nCurPage); pDC->TextOut(rcPage.left, rcPage.top, pageNo); if (pInfo->m_nCurPage == 1) PrintCover(pDC, rcPage); // 第一页打印封面 else PrintBody(pDC, pInfo->m_nCurPage, rcPage); }pDC 可能是打印机 DC,也可能是打印预览用的内存 DC。在 MFC 里这两者的接口一致,所以同一份 OnPrint 代码既支持真实打印,也支持预览。
2.4 OnEndPrinting 里必须释放的东西
OnBeginPrinting 里创建的 CFont、CPen、CBrush 等 GDI 对象,最好在 OnEndPrinting 里释放。常见坑是在 OnBeginPrinting 里 new 了一个字体指针,却忘了 delete。打印任务执行几次之后,GDI 句柄会被耗尽,现象表现为字体时好时坏、预览空白或 DC 选择对象失败。
void CMyView::OnEndPrinting(CDC* pDC, CPrintInfo* pInfo) { if (m_pPrintFont != NULL) { delete m_pPrintFont; m_pPrintFont = NULL; } }如果只是临时打印一个字符串,也可以把字体声明成局部变量,但记住要先 SelectObject 旧的 GDI 对象,再让局部对象析构。否则字体句柄仍然挂在 DC 上,析构时不会真正释放。
3. 在对话框中复用 CView 打印能力:虚拟框架窗口方案
文档视图结构下的打印虽然方便,但现实中有大量 VC++ 工程是对话框风格的,比如上位机参数配置、曲线显示、小工具软件。这种情况下没有 CView,也没有 ID_FILE_PRINT 菜单。要得到同样的打印预览效果,常见的做法是建立一个“虚拟框架窗口”,把 CView 的打印流程借过来。
3.1 为什么对话框程序不能直接用 CView 打印
CView 对象是一个子窗口,它必须有一个框架窗口作为父窗口。CFrameWnd 提供的消息路由、命令传递、打印预览状态管理都是 CView 正常工作的前提。对话框本身不是 CFrameWnd,直接给 CView 指定对话框为父窗口虽然能创建成功,但打印预览无法进入,因为 DoPrintPreview 内部依赖框架窗口的布局逻辑。
最简单的绕过方式不是重写打印预览,而是偷偷创建一个小到不可见的框架窗口,让 CView 挂上去,再把 CView 收到的打印消息转回给对话框。这样实际业务代码不需要知道 CView 存在,只需要响应几个自定义消息。
3.2 CPrintFrame + CPrintView 的职责划分
这套方案由两个类组成:CPrintFrame 负责创建窗口和承载 CView,CPrintView 负责把 CView::OnPrint 等函数的参数转发给对话框。业务代码全部放在对话框的 WM_MY_PRINT 消息处理函数里。
// PrintFrame.h class CPrintFrame : public CFrameWnd { public: CPrintView* m_pView; // 虚拟视图 CWnd* m_pCallerDlg; // 真正执行业务绘图的对话框 BOOL Create(LPCTSTR lpszClassName, LPCTSTR lpszWindowName, DWORD dwStyle, const RECT& rect); }; // PrintView.h class CPrintView : public CView { public: CPrintFrame* m_pFrame; void OnMyPrint(); void OnMyPrintPreview(); afx_msg void OnPrint(CDC* pDC, CPrintInfo* pInfo); afx_msg void OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo); afx_msg void OnEndPrinting(CDC* pDC, CPrintInfo* pInfo); };m_pCallerDlg 是一个普通 CWnd 指针,保存调用方对话框句柄。之所以要在 CPrintView 里转发,而不是直接在 OnPrint 里写业务逻辑,是为了让这两个类可以复制到任何工程里复用。开发者只要把 PrintFrame.h、PrintView.h、PrintFrame.cpp、PrintView.cpp 四个文件加入工程,再按固定步骤接入即可。
3.3 五步接入现有对话框代码
原始文档总结的步骤非常紧凑,我实际落地时一般拆成五步,每一步都有对应代码。
- 在对话框类中声明三个自定义消息:WM_BEGIN_PRINTING、WM_END_PRINTING、WM_MY_PRINT。
- 在点击打印按钮时 new 一个 CPrintFrame。
- 把对话框指针赋给 pFrame->m_pCallerDlg。
- 调用 Create 创建一个窗口矩形全 0 的不可见框架窗口。
- 分别调用 m_pView->OnMyPrint 或 OnMyPrintPreview。
#define WM_MY_PRINT (WM_APP + 101) #define WM_BEGIN_PRINTING (WM_APP + 102) #define WM_END_PRINTING (WM_APP + 103) BEGIN_MESSAGE_MAP(CDlgPrintDlg, CDialog) ON_MESSAGE(WM_BEGIN_PRINTING, OnBeginPrinting) ON_MESSAGE(WM_END_PRINTING, OnEndPrinting) ON_MESSAGE(WM_MY_PRINT, OnMyPrint) END_MESSAGE_MAP() void CDlgPrintDlg::OnPrint() { CPrintFrame* pFrame = new CPrintFrame; pFrame->m_pCallerDlg = this; pFrame->Create(NULL, _T("CurvePrint"), WS_OVERLAPPEDWINDOW, CRect(0, 0, 0, 0)); pFrame->m_pView->OnMyPrint(); } void CDlgPrintDlg::OnPrintPreview() { CPrintFrame* pFrame = new CPrintFrame; pFrame->m_pCallerDlg = this; pFrame->Create(NULL, _T("CurvePrintPreview"), WS_OVERLAPPEDWINDOW, CRect(0, 0, 0, 0)); pFrame->m_pView->OnMyPrintPreview(); }Create 的第四个参数是窗口矩形,全 0 表示这个框架窗口不会被用户看到,但窗口句柄依然有效。窗口名只影响 Spy++ 调试时的可读性,不影响打印结果。OnMyPrint 和 OnMyPrintPreview 在 CPrintView 内部会分别调用 CView::OnFilePrint 和 CView::OnFilePrintPreview,从而复用 MFC 自带的打印循环和预览工具条。
3.4 消息路由:把绘图权交还给对话框
CPrintView 的 OnPrint 是核心转发点。它拿到打印机 DC 和 CPrintInfo 之后,直接通过 SendMessage 发给对话框的 WM_MY_PRINT 处理函数。
void CPrintView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { CPrintFrame* pFrame = static_cast<CPrintFrame*>(GetParent()); if (pFrame && pFrame->m_pCallerDlg) { pFrame->m_pCallerDlg->SendMessage( WM_MY_PRINT, (WPARAM)pDC, (LPARAM)pInfo); } }对话框端:
LRESULT CDlgPrintDlg::OnMyPrint(WPARAM wParam, LPARAM lParam) { CDC* pDC = (CDC*)wParam; CPrintInfo* pInfo = (CPrintInfo*)lParam; CRect rc = pInfo->m_rectDraw; pDC->Rectangle(rc.left, rc.top, rc.right, rc.bottom); pDC->TextOut(rc.left + 100, rc.top + 100, _T("Hello from Dialog")); return 0; }wParam 和 lParam 传的是指针,在 SendMessage 调用期间有效,所以对话框处理函数里可以直接使用。不能改成 PostMessage,因为 PostMessage 是异步的,等消息到达时 pDC 可能已经销毁。WM_BEGIN_PRINTING 和 WM_END_PRINTING 也走同样的转发逻辑,这样 OnBeginPrinting 里设置页数、创建资源,OnEndPrinting 里释放资源,业务闭环完全落在对话框里。
4. 踩坑实录:打印预览按钮失效与静态链接崩溃
这套方案的思路很清晰,但网上讨论和评论区反馈最多的不是功能做不出来,而是几个隐蔽的边界问题。它们不是偶发现象,而是 MFC 打印框架本身就有的路由和资源归属问题。
4.1 预览窗口的“打印”按钮为什么只是关掉窗口
在 CPreviewView 默认实现里,预览工具条上的“打印”按钮对应的命令 ID 是 AFX_ID_PREVIEW_PRINT。它的默认处理不是发送 ID_FILE_PRINT 给文档/视图,而是结束预览。只有当前命令路由能够在 CView 层找到 ID_FILE_PRINT 处理函数时,这个按钮才会真正触发打印。虚拟框架窗口方案的框架窗口并没有接入对话框的菜单路由,所以按钮行为退化成关闭预览窗口。
这不是虚拟视图方案独有的,很多文档视图程序也会踩到。关键在于:预览只是把打印输出画到内存 DC 上,工具条上的打印仍然要走一遍完整的 OnFilePrint 流程。
4.2 修复一:在对话框里拦截 ID_FILE_PRINT
最简单粗暴的做法是让对话框自己响应 ID_FILE_PRINT 命令,并在处理函数里调用你的打印入口。
BEGIN_MESSAGE_MAP(CDlgPrintDlg, CDialog) ON_COMMAND(ID_FILE_PRINT, OnFilePrint) END_MESSAGE_MAP() void CDlgPrintDlg::OnFilePrint() { Print(); // 你自己的打印入口 }这样用户在预览窗口点击打印按钮时,命令会从框架窗口路由到主窗口,对话框捕获后直接执行真正的打印。缺点是这个打印按钮不会再问你“是打印还是预览”,而是立刻进入打印机选择对话框。如果你希望先让用户选择打印机,这个行为是可以接受的。
4.3 修复二:继承 CPreviewView 重载 OnPreviewPrint
更符合 MFC 设计思路的做法是派生出自己的预览视图类,重载 AFX_ID_PREVIEW_PRINT 的处理函数,并调用打印视图的 OnFilePrint。
class CNewPreviewView : public CPreviewView { protected: afx_msg void OnPreviewPrint(); DECLARE_MESSAGE_MAP() }; BEGIN_MESSAGE_MAP(CNewPreviewView, CPreviewView) ON_COMMAND(AFX_ID_PREVIEW_PRINT, OnPreviewPrint) END_MESSAGE_MAP() void CNewPreviewView::OnPreviewPrint() { CView* pView = GetPrintView(); // 从预览状态里取回打印视图 ASSERT(pView != NULL); pView->SendMessage(WM_COMMAND, ID_FILE_PRINT); }在视图类的 OnFilePrintPreview 里,不再调用 CView 的默认实现,而是用 DoPrintPreview 指定新预览视图类。
void CMyView::OnFilePrintPreview() { CPrintPreviewState* pState = new CPrintPreviewState; DoPrintPreview(AFX_IDD_PREVIEW_TOOLBAR, this, RUNTIME_CLASS(CNewPreviewView), pState); }DoPrintPreview 的第三个参数是运行时类指针,MFC 会用它动态创建预览视图窗口。改成 CNewPreviewView 后,工具条打印按钮就走你的重载函数,不会再关闭预览。
4.4 静态链接 MFC 时缺少 afxprint.rc 资源
另一个常见崩溃点是静态链接 MFC 时,点击打印预览后程序在VERIFY(pInfo->m_strPageDesc.LoadString(AFX_IDS_PREVIEWPAGEDESC))位置断言失败。原因是 MFC 打印相关的资源没有链接进可执行文件。打印预览需要几个字符串资源,比如“第 X 页,共 Y 页”的格式串,它们定义在 afxprint.rc 里。
解决办法是在工程的 .rc 文件末尾加入一行:
#include "afxprint.rc"如果你的 VS 是中文版资源树,可能需要换成:
#include "l.chs\\afxprint.rc"动态链接 MFC 时,这个资源由 MFC DLL 提供,所以不会暴露问题。静态链接时必须手动引入。判断方法很简单:编译后用资源视图搜索 AFX_IDS_PREVIEWPAGEDESC,找不到就说明资源没进来。
4.5 重复创建预览窗口导致退出异常
有读者反馈:先按“打印预览”弹出预览,再去按原对话框的“打印预览”,又弹一个预览窗口,关掉任意一个会崩溃。这通常是因为每次按钮都 new 一个 CPrintFrame,但旧的 frame 没有被释放。
改进思路是让对话框保存唯一的框架指针,预览已经打开时不再重复创建,并在 WM_END_PRINTING 里统一清理。
void CDlgPrintDlg::OnPrintPreview() { if (m_pPrintFrame != NULL) { AfxMessageBox(_T("预览窗口已经打开")); return; } m_pPrintFrame = new CPrintFrame; m_pPrintFrame->m_pCallerDlg = this; m_pPrintFrame->Create(NULL, _T("CurvePrintPreview"), WS_OVERLAPPEDWINDOW, CRect(0, 0, 0, 0)); m_pPrintFrame->m_pView->OnMyPrintPreview(); } LRESULT CDlgPrintDlg::OnEndPrinting(WPARAM, LPARAM) { if (m_pPrintFrame) { m_pPrintFrame->DestroyWindow(); delete m_pPrintFrame; m_pPrintFrame = NULL; } return 0; }WM_END_PRINTING 在打印或预览结束时会通过虚拟 CPrintView 转发回来。在这里销毁框架窗口,比在对话框的析构函数里统一清理更及时,也避免用户关闭预览窗口后 m_pPrintFrame 还挂着无效句柄。
5. 打印质量与边界:从 GDI 映射模式到可打印区域
最后说一个和细节相关的高频坑:打印出来字太小、坐标错位。这和 GDI 映射模式直接相关,值得单独列出来。
5.1 打印机 DC 的映射模式与分辨率
屏幕 DC 的逻辑分辨率通常是 96 DPI,打印机 DC 是 300 或 600 DPI。如果直接用 MM_TEXT 模式按像素画,相同像素尺寸在纸上看起来只有屏幕上的四分之一到六分之一。解决方式是切换映射模式。
| 映射模式 | 单位 | 适用场景 |
|---|---|---|
| MM_TEXT | 像素 | 屏幕预览,不适合打印 |
| MM_LOENGLISH | 0.01 英寸 | 有比例尺的工程图 |
| MM_HIMETRIC | 0.01 毫米 | 需要精确尺寸 |
| MM_TWIPS | 1/1440 英寸 | 排版,与字体点阵配合 |
void CMyView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo) { pDC->SetMapMode(MM_LOENGLISH); // 设置后 m_rectDraw 会自动转换为逻辑坐标 }SetMapMode 要放在绘制之前,并且只在 pInfo 非空时调用。有些程序在 OnDraw 里也设置了映射模式,屏幕坐标会被一起改掉,建议把打印相关设置放在 OnPrepareDC 或 OnBeginPrinting 里。
5.2 不要把 OnPrint 当成 OnDraw 的替身
CView::OnPrint 的默认实现是调用 OnDraw,很多早期代码直接在 OnDraw 里画图,导致屏幕预览和打印输出混在一起。常见做法是把绘图拆成两个入口,共享数据但使用不同的目标矩形。
void CMyView::OnDraw(CDC* pDC) { DrawChart(pDC, m_screenRect); // 屏幕坐标 } void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { DrawChart(pDC, pInfo->m_rectDraw); // 打印逻辑坐标 }这样屏幕显示和打印输出可以有不同的缩放、不同的页边距,甚至不同的内容,比如屏幕显示曲线,打印时输出数据表格。
5.3 让预览工具条的打印按钮变灰或干脆换自己的按钮
如果不准备修复第 4 章的按钮行为,也可以让预览工具条上的打印按钮变灰,避免用户误触。触发条件是让命令状态立即更新。
AfxGetApp()->m_pMainWnd->SendMessageToDescendants( WM_IDLEUPDATECMDUI, (WPARAM)TRUE, 0, 0, TRUE);然后在视图类里把 ID_FILE_PRINT 的更新处理设置为不可用。
void CMyView::OnUpdateFilePrint(CCmdUI* pCmdUI) { pCmdUI->Enable(FALSE); // 预览期间禁止 }更省事的方案是干脆忽略预览工具条上的打印按钮,只在对话框上放自己的“打印”按钮。预览只负责看效果,真实打印走对话框按钮,这样既能避开 CPreviewView 的路由问题,也符合大多数工具类软件的交互习惯。
本文还有配套的精品资源,点击获取