简介一份面向中级开发者的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 → OnEndPrintingOnPrepareDC 在每次打印新页之前都会被调用作用类似屏幕绘制时的 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如果不调用 SetMaxPageMFC 默认认为文档只有一页。哪怕你在 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 负责创建窗口和承载 CViewCPrintView 负责把 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_castCPrintFrame*(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_LOENGLISH0.01 英寸有比例尺的工程图MM_HIMETRIC0.01 毫米需要精确尺寸MM_TWIPS1/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 的路由问题也符合大多数工具类软件的交互习惯。本文还有配套的精品资源点击获取
