多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

WPF打印功能深度解析:从架构设计到性能优化的完整实践指南

WPF打印功能深度解析:从架构设计到性能优化的完整实践指南 1. 项目概述为什么WPF打印值得深挖在桌面应用开发里打印功能就像空气平时感觉不到它的存在但一旦缺失用户立刻会感到窒息。我接手过不少从WinForms或MFC迁移到WPF的项目发现打印模块往往是迁移的“重灾区”和“性能黑洞”。用户可能只是轻点一下“打印预览”一个设计不当的WPF打印模块就能让UI线程卡死好几秒或者生成出来的文档页码错乱、内容缺失。这不仅仅是调用一个PrintDialog那么简单。WPF的打印体系核心在于其基于Visual的渲染流水线。这与GDI/GDI那种基于句柄和绘图指令的即时模式截然不同。WPF采用保留模式图形你构建的是一个视觉树Visual Tree打印时系统会将指定的Visual可能是一个Window一个Grid或者一个自定义的DrawingVisual发送给打印子系统进行光栅化。这套机制赋予了极大的灵活性——理论上屏幕上能显示的就能原样打印——但同时也引入了复杂性分页逻辑需要手动控制、大量数据虚拟化渲染容易内存溢出、非UI线程打印的同步问题等等。因此用WPF实现一个健壮、高效、用户体验良好的打印及预览功能是一个融合了WPF数据绑定、布局系统、自定义渲染以及Windows打印后台处理程序Spooler交互的综合性工程。它考验的是开发者对WPF底层渲染机制和Windows打印体系的理解深度。接下来我将拆解从设计到实现的完整链条分享如何避开那些我踩过的“坑”。2. 核心架构与设计思路在动手写代码之前设计阶段决定了整个打印模块的成败。一个糟糕的设计会导致代码难以维护、性能低下且无法应对需求变化。2.1 打印数据与UI呈现的分离这是最重要的原则没有之一。绝对不要为了打印去直接抓取主界面上某个Grid或Canvas的视觉树。这样做的耦合度太高且会受主界面样式、缩放、动画等无关因素的干扰。正确做法是构建一个专用于打印的“文档模型”Document Model和对应的“打印视图模型”Print ViewModel。文档模型定义打印内容的纯数据结构。例如一个报表的文档模型可能包含ReportHeader、ListReportRow、ReportFooter等属性。它不包含任何WPF相关的类型。打印视图模型基于文档模型生成用于构造WPF视觉树的中间数据。它负责将原始数据转换为打印所需的格式如计算分页、汇总每页的数据行、生成页码等。打印视图Visual Builder一个独立的类接收打印视图模型使用WPF的DrawingVisual或组合现有的FrameworkElement如TextBlock、Rectangle来动态构建视觉树。这个视图与主UI完全隔离。这种分离的好处是巨大的可测试性你可以在单元测试中轻松验证文档模型和视图模型的逻辑无需启动WPF应用。灵活性同一份文档模型可以轻松切换不同的打印视图如简版、详细版、横向、纵向。性能在后台线程准备打印视图模型和构建轻量级的DrawingVisual避免阻塞UI。2.2 分页策略流式布局 vs 固定布局WPF打印本质上是将内容绘制到一系列固定大小的页面PageMediaSize上。如何处理超出单页的内容是核心挑战。固定布局Fixed Layout适用于内容尺寸已知且精确的场景如票据、标签、固定格式的证书。你通常使用Canvas进行绝对定位精确控制每个元素的位置。分页简单直接按物理尺寸切割即可。但灵活性差难以适应数据量变化。流式布局Flow Layout适用于报表、列表、长文档。内容像水流一样依次排列排满一页后自动换到下一页。这是最常见的需求也是难点所在。实现流式分页的关键在于“度量”Measure和“排列”Arrange。你不能直接渲染所有数据然后简单切割。WPF的布局系统是两阶段的先Measure确定所需尺寸再Arrange进行实际布局。对于打印我们需要模拟这个过程创建一个与打印页面内容区扣除边距同样大小的“虚拟画布”通常是一个自定义的FrameworkElement或ContainerVisual。将数据项逐个添加到这个虚拟画布的子视觉树中并调用Measure和Arrange。持续添加直到下一个数据项的DesiredSize.Height会导致总高度超出画布高度。此时当前画布内容构成一页。记录下这一页的数据范围并开始为下一页准备新的画布。重复此过程直到所有数据处理完毕。这个过程需要在打印视图模型中完成其结果是一个ListPrintPageViewModel每个PrintPageViewModel包含了该页要渲染的数据项集合以及页码等信息。2.3 打印预览的两种实现路径打印预览不是简单的弹出一个窗口显示图片。它需要真实地模拟打印输出的分页效果。基于DocumentViewer的快速实现 WPF内置了DocumentViewer控件它专门用于显示分页的FixedDocument。你可以将你的打印内容构造成一个FixedDocument里面包含多个FixedPage然后将FixedDocument赋值给DocumentViewer.Document属性。DocumentViewer会自动提供缩放、翻页、搜索等标准UI。这是最快捷的方式适合内容格式固定、对预览UI要求不高的场景。注意FixedDocument是重量级对象一旦生成修改成本高。且其样式定制性较弱。自定义预览窗口推荐用于复杂场景 当需要高度定制化的预览界面如显示打印边距线、允许拖拽调整边距、实时显示页码信息时需要自己实现。通常做法是创建一个Window作为预览窗口。在窗口中放置一个ItemsControl将其ItemsPanel设置为一个VirtualizingStackPanel启用虚拟化以应对大量页数。将ItemsControl的ItemTemplate定义为一个Border其内部放置一个用于渲染单页内容的UserControl或自定义控件。在后台根据之前分页计算得到的ListPrintPageViewModel为每一页生成对应的预览视觉对象可以复用打印视图的构建逻辑并绑定到ItemsControl的ItemsSource。 这种方式灵活性极高你可以完全控制预览的交互和外观并且可以复用打印视图的渲染逻辑确保“所见即所得”。3. 核心实现细节与关键代码解析理论讲完我们进入实战环节。我将以一个典型的“销售数据报表”打印为例展示核心代码。3.1 构建打印文档模型与视图模型首先定义纯数据的文档模型。// 打印文档模型 (纯数据) public class SalesReportDocument { public string ReportTitle { get; set; } public DateTime PeriodStart { get; set; } public DateTime PeriodEnd { get; set; } public string GeneratedBy { get; set; } public ListSalesRecord Records { get; set; } // 销售记录列表 } public class SalesRecord { public string ProductName { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } public decimal TotalAmount Quantity * UnitPrice; }接着创建打印视图模型它负责分页和准备每页的数据。// 打印视图模型 (负责分页逻辑) public class SalesReportPrintViewModel { private SalesReportDocument _document; private Size _pageContentSize; // 单页内容区尺寸 (已扣除边距) private double _rowHeight; // 预估的每行数据行高度 public ListPrintPage Pages { get; private set; } public SalesReportPrintViewModel(SalesReportDocument document, Size pageContentSize, double rowHeightEstimate) { _document document; _pageContentSize pageContentSize; _rowHeight rowHeightEstimate; Paginate(); } private void Paginate() { Pages new ListPrintPage(); int currentRecordIndex 0; int pageNumber 1; // 计算表头、表尾、页码行等固定内容的高度 double fixedContentHeight CalculateFixedContentHeight(); while (currentRecordIndex _document.Records.Count) { var page new PrintPage { PageNumber pageNumber }; // 添加固定内容标题、表头等 page.FixedContent GenerateFixedContentForPage(pageNumber); // 计算本页能容纳多少行数据 double availableHeightForData _pageContentSize.Height - fixedContentHeight; int rowsPerPage (int)Math.Floor(availableHeightForData / _rowHeight); rowsPerPage Math.Max(1, rowsPerPage); // 至少一行 int takeCount Math.Min(rowsPerPage, _document.Records.Count - currentRecordIndex); var pageRecords _document.Records.Skip(currentRecordIndex).Take(takeCount).ToList(); page.DataRecords pageRecords; Pages.Add(page); currentRecordIndex takeCount; pageNumber; } } private double CalculateFixedContentHeight() { // 这里需要根据实际用于渲染固定内容的元素如TextBlock的字体、边距来估算。 // 更精确的做法是在打印视图构建器中实际测量。 // 此处返回一个估算值例如标题(30) 表头(20) 页脚(20) 70像素 return 70; } private object GenerateFixedContentForPage(int pageNumber) { // 返回用于构建固定内容的数据对象 return new { Title _document.ReportTitle, Period ${_document.PeriodStart:yyyy-MM-dd} 至 {_document.PeriodEnd:yyyy-MM-dd}, PageInfo $第 {pageNumber} 页 / 共 {Pages?.Count ?? 0} 页, GeneratedBy _document.GeneratedBy }; } } public class PrintPage { public int PageNumber { get; set; } public object FixedContent { get; set; } // 固定内容数据 public ListSalesRecord DataRecords { get; set; } // 本页数据行 }3.2 实现打印视图构建器Visual Builder这是将视图模型转换为WPF视觉树的核心。我们使用轻量级的DrawingVisual以获得最佳性能。public class SalesReportPrintVisualBuilder { private static Typeface _defaultTypeface new Typeface(微软雅黑); private static double _defaultFontSize 12; private static Brush _defaultBrush Brushes.Black; public Visual BuildPageVisual(PrintPage page, Size pageSize, Thickness margin) { // 1. 创建DrawingVisual DrawingVisual visual new DrawingVisual(); // 2. 获取DrawingContext进行绘制 using (DrawingContext dc visual.RenderOpen()) { // 设置背景为白色 dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, pageSize.Width, pageSize.Height)); // 计算内容区矩形扣除边距 Rect contentRect new Rect( margin.Left, margin.Top, pageSize.Width - margin.Left - margin.Right, pageSize.Height - margin.Top - margin.Bottom ); // 3. 绘制固定内容标题、页眉等 DrawFixedContent(dc, page.FixedContent, contentRect); // 4. 绘制数据表格 DrawDataTable(dc, page.DataRecords, contentRect); } return visual; } private void DrawFixedContent(DrawingContext dc, object fixedContent, Rect contentRect) { // 这里简化处理实际应根据fixedContent对象的具体属性绘制 FormattedText titleText new FormattedText( 销售报表, // 应从fixedContent获取 CultureInfo.CurrentCulture, FlowDirection.LeftToRight, _defaultTypeface, 16, _defaultBrush, VisualTreeHelper.GetDpi(Application.Current.MainWindow).PixelsPerDip ); dc.DrawText(titleText, new Point(contentRect.Left, contentRect.Top 10)); // 绘制其他固定内容如日期、页码等... } private void DrawDataTable(DrawingContext dc, ListSalesRecord records, Rect contentRect) { if (records null || records.Count 0) return; double startY contentRect.Top 50; // 从固定内容下方开始 double rowHeight 25; double[] columnWidths { 200, 100, 100, 100 }; // 列宽 // 绘制表头 double currentX contentRect.Left; string[] headers { 产品名称, 数量, 单价, 总金额 }; for (int i 0; i headers.Length; i) { FormattedText headerText new FormattedText( headers[i], CultureInfo.CurrentCulture, FlowDirection.LeftToRight, _defaultTypeface, _defaultFontSize, _defaultBrush, VisualTreeHelper.GetDpi(Application.Current.MainWindow).PixelsPerDip ); dc.DrawText(headerText, new Point(currentX, startY)); // 绘制表头下划线 dc.DrawLine(new Pen(_defaultBrush, 1), new Point(currentX, startY rowHeight), new Point(currentX columnWidths[i], startY rowHeight)); currentX columnWidths[i]; } // 绘制数据行 for (int rowIdx 0; rowIdx records.Count; rowIdx) { var record records[rowIdx]; double rowY startY (rowIdx 1) * rowHeight; currentX contentRect.Left; // 产品名称 dc.DrawText(CreateFormattedText(record.ProductName), new Point(currentX, rowY)); currentX columnWidths[0]; // 数量 dc.DrawText(CreateFormattedText(record.Quantity.ToString()), new Point(currentX, rowY)); currentX columnWidths[1]; // 单价 dc.DrawText(CreateFormattedText(record.UnitPrice.ToString(C)), new Point(currentX, rowY)); currentX columnWidths[2]; // 总金额 dc.DrawText(CreateFormattedText(record.TotalAmount.ToString(C)), new Point(currentX, rowY)); // 绘制行分隔线虚线 if (rowIdx records.Count - 1) { var dashPen new Pen(_defaultBrush, 0.5); dashPen.DashStyle DashStyles.Dash; dc.DrawLine(dashPen, new Point(contentRect.Left, rowY rowHeight), new Point(contentRect.Right, rowY rowHeight)); } } } private FormattedText CreateFormattedText(string text) { return new FormattedText( text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, _defaultTypeface, _defaultFontSize, _defaultBrush, VisualTreeHelper.GetDpi(Application.Current.MainWindow).PixelsPerDip ); } }3.3 集成打印与预览功能有了文档模型、视图模型和视觉构建器最后一步就是将它们与WPF的打印API和预览界面连接起来。打印控制器PrintControllerpublic class SalesReportPrintController { private SalesReportPrintViewModel _printViewModel; private SalesReportPrintVisualBuilder _visualBuilder new SalesReportPrintVisualBuilder(); private Size _pageSize new Size(96 * 8.27, 96 * 11.69); // A4, 96 DPI private Thickness _margin new Thickness(96 * 0.5, 96 * 0.5, 96 * 0.5, 96 * 0.5); // 0.5英寸边距 public void Print(SalesReportDocument document) { // 1. 准备视图模型分页 var contentSize new Size(_pageSize.Width - _margin.Left - _margin.Right, _pageSize.Height - _margin.Top - _margin.Bottom); _printViewModel new SalesReportPrintViewModel(document, contentSize, 25); // 预估行高25 // 2. 弹出系统打印对话框 PrintDialog printDialog new PrintDialog(); if (printDialog.ShowDialog() true) { // 3. 创建并配置 Paginator var paginator new CustomDocumentPaginator(_printViewModel, _visualBuilder, _pageSize, _margin); printDialog.PrintDocument(paginator, 销售报表); } } public Window CreatePreviewWindow(SalesReportDocument document) { // 1. 准备视图模型 var contentSize new Size(_pageSize.Width - _margin.Left - _margin.Right, _pageSize.Height - _margin.Top - _margin.Bottom); _printViewModel new SalesReportPrintViewModel(document, contentSize, 25); // 2. 创建预览窗口 var previewWindow new PrintPreviewWindow(); previewWindow.DataContext _printViewModel; // 将视图模型绑定到预览窗口 return previewWindow; } } // 自定义的 DocumentPaginator连接打印子系统 public class CustomDocumentPaginator : DocumentPaginator { private SalesReportPrintViewModel _viewModel; private SalesReportPrintVisualBuilder _builder; private Size _pageSize; private Thickness _margin; public CustomDocumentPaginator(SalesReportPrintViewModel viewModel, SalesReportPrintVisualBuilder builder, Size pageSize, Thickness margin) { _viewModel viewModel; _builder builder; _pageSize pageSize; _margin margin; PageSize pageSize; } public override DocumentPage GetPage(int pageNumber) { // pageNumber 是0-based索引 if (pageNumber 0 || pageNumber _viewModel.Pages.Count) return DocumentPage.Missing; var page _viewModel.Pages[pageNumber]; Visual visual _builder.BuildPageVisual(page, _pageSize, _margin); // 返回的DocumentPage需要指定页面尺寸和视觉内容 return new DocumentPage(visual, _pageSize, new Rect(_margin.Left, _margin.Top, _pageSize.Width - _margin.Left - _margin.Right, _pageSize.Height - _margin.Top - _margin.Bottom), new Rect(_pageSize)); } public override bool IsPageCountValid true; public override int PageCount _viewModel.Pages.Count; public override Size PageSize { get; set; } public override IDocumentPaginatorSource Source null; }自定义预览窗口PrintPreviewWindow.xamlWindow x:ClassYourNamespace.PrintPreviewWindow xmlnshttp://schemas.microsoft.com/winfx/2006/xaml/presentation xmlns:xhttp://schemas.microsoft.com/winfx/2006/xaml Title打印预览 Height600 Width800 Grid Grid.RowDefinitions RowDefinition HeightAuto/ RowDefinition Height*/ RowDefinition HeightAuto/ /Grid.RowDefinitions !-- 工具栏 -- ToolBar Grid.Row0 Button Content打印 ClickPrintButton_Click/ ComboBox x:NameZoomComboBox SelectedIndex2 ComboBoxItem50%/ComboBoxItem ComboBoxItem75%/ComboBoxItem ComboBoxItem100%/ComboBoxItem ComboBoxItem150%/ComboBoxItem ComboBoxItem200%/ComboBoxItem /ComboBox /ToolBar !-- 页面预览区域 -- ScrollViewer Grid.Row1 VerticalScrollBarVisibilityAuto HorizontalScrollBarVisibilityAuto ItemsControl x:NamePagesItemsControl ItemsSource{Binding Pages} ItemsControl.ItemsPanel ItemsPanelTemplate VirtualizingStackPanel OrientationVertical/ /ItemsPanelTemplate /ItemsControl.ItemsPanel ItemsControl.ItemTemplate DataTemplate !-- 为每一页添加一个带阴影和边距的容器 -- Border Margin20,10 BorderBrushLightGray BorderThickness1 BackgroundWhite Effect{StaticResource DropShadowEffect} Padding10 !-- 这里放置单页内容的呈现 -- !-- 可以复用VisualBuilder但更常见的是用XAML定义样式然后绑定数据 -- ContentControl Content{Binding} ContentTemplate{StaticResource PageTemplate}/ /Border /DataTemplate /ItemsControl.ItemTemplate /ItemsControl /ScrollViewer !-- 状态栏 -- StatusBar Grid.Row2 StatusBarItem TextBlock Run Text页码:/ Run Text{Binding ElementNamePagesItemsControl, PathItems.CurrentPosition}/ Run Text// Run Text{Binding ElementNamePagesItemsControl, PathItems.Count}/ /TextBlock /StatusBarItem /StatusBar /Grid /Window在预览窗口的后台代码中你需要处理缩放、打印按钮事件并为PageTemplate资源定义如何将PrintPage对象渲染为视觉元素。这通常可以通过另一个UserControl来实现其内部使用与SalesReportPrintVisualBuilder类似的逻辑但用XAML声明式布局或者直接使用DrawingVisual并嵌入到VisualBrush中。4. 高级技巧与性能优化实战实现基础功能只是第一步要让打印模块在生产环境中稳定高效还需要一系列“踩坑”后总结出的技巧。4.1 异步打印与后台生成永远不要在UI线程上执行耗时的分页和视觉构建操作。这会导致界面“假死”用户体验极差。public async Task PrintAsync(SalesReportDocument document) { // 显示等待指示器 IsBusy true; try { // 在后台线程执行分页和视觉构建准备 var printViewModel await Task.Run(() { var contentSize new Size(_pageSize.Width - _margin.Left - _margin.Right, _pageSize.Height - _margin.Top - _margin.Bottom); return new SalesReportPrintViewModel(document, contentSize, 25); }); // 准备完成后回到UI线程弹出打印对话框 await Application.Current.Dispatcher.InvokeAsync(() { PrintDialog printDialog new PrintDialog(); if (printDialog.ShowDialog() true) { var paginator new CustomDocumentPaginator(printViewModel, _visualBuilder, _pageSize, _margin); // PrintDocument 调用本身是同步的但因为它只是发送数据到打印后台处理程序通常很快。 printDialog.PrintDocument(paginator, 销售报表); } }); } catch (Exception ex) { // 处理异常 MessageBox.Show($打印准备失败: {ex.Message}); } finally { IsBusy false; } }4.2 处理大量数据与虚拟化当报表有成千上万行时一次性构建所有页面的视觉树会消耗巨大内存。预览时尤其如此。解决方案是“按需生成”。在自定义预览窗口的ItemsControl中我们已经使用了VirtualizingStackPanel。关键在于ItemsControl的ItemTemplate不应该直接包含复杂的渲染逻辑。相反应该为每个PrintPage项创建一个轻量级的容器仅在容器进入视图时才触发实际页面的渲染。我们可以修改PageTemplate使用一个LazyVisualHost用户控件// LazyVisualHost.xaml.cs public partial class LazyVisualHost : UserControl { public static readonly DependencyProperty PageViewModelProperty DependencyProperty.Register(PageViewModel, typeof(PrintPage), typeof(LazyVisualHost), new PropertyMetadata(null, OnPageViewModelChanged)); public PrintPage PageViewModel { get { return (PrintPage)GetValue(PageViewModelProperty); } set { SetValue(PageViewModelProperty, value); } } private static void OnPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var host d as LazyVisualHost; if (host.IsLoaded host.IsVisible) // 仅当控件已加载且可见时才生成视觉内容 { host.GenerateVisual(); } } protected override void OnIsVisibleChanged(DependencyPropertyChangedEventArgs e) { base.OnIsVisibleChanged(e); if ((bool)e.NewValue PageViewModel ! null) { GenerateVisual(); } else { // 当不可见时可以清理视觉内容以释放资源 Content null; } } private void GenerateVisual() { if (PageViewModel null) return; // 在后台线程生成Visual Task.Run(() { var visual _visualBuilder.BuildPageVisual(PageViewModel, _pageSize, _margin); // 切换回UI线程设置内容 Dispatcher.Invoke(() Content new VisualBrush(visual)); }); } }这样只有当前滚动到视图范围内的页面才会被实际渲染极大地提升了预览大量页面的性能。4.3 精确分页与内容测量前面提到的分页策略依赖于预估的行高_rowHeight。这在字体、行间距固定时可行但如果内容高度可变如单元格内文本换行预估就不准了会导致内容被切断或留白过多。更精确的方法是使用WPF的布局系统进行真实测量。我们可以在一个离屏的、与打印页面内容区同尺寸的Canvas或Grid中动态添加数据行对应的UI元素如TextBlock、Border然后调用Measure和Arrange根据其ActualHeight来累加直到放不下为止。这个过程虽然比预估慢但能保证100%准确。private ListPrintPage PaginateWithRealMeasure(SalesReportDocument document, Size contentSize) { var pages new ListPrintPage(); // 创建一个用于测量的临时容器 var measuringPanel new StackPanel() { Width contentSize.Width }; // 假设我们有方法 CreateDataRowVisual 来创建一行的视觉树 FuncSalesRecord, FrameworkElement createRowVisual CreateDataRowVisual; int currentIndex 0; while (currentIndex document.Records.Count) { measuringPanel.Children.Clear(); double usedHeight 0; int rowsInThisPage 0; while (currentIndex document.Records.Count) { var rowVisual createRowVisual(document.Records[currentIndex]); measuringPanel.Children.Add(rowVisual); // 强制测量和排列 rowVisual.Measure(new Size(contentSize.Width, double.PositiveInfinity)); rowVisual.Arrange(new Rect(0, 0, contentSize.Width, rowVisual.DesiredSize.Height)); double newHeight usedHeight rowVisual.DesiredSize.Height; if (newHeight contentSize.Height) { // 放不下了移除刚加的这一行 measuringPanel.Children.Remove(rowVisual); break; } usedHeight newHeight; rowsInThisPage; currentIndex; } // 根据 currentIndex 和 rowsInThisPage 创建本页数据 var pageRecords document.Records.Skip(currentIndex - rowsInThisPage).Take(rowsInThisPage).ToList(); pages.Add(new PrintPage { DataRecords pageRecords, PageNumber pages.Count 1 }); } return pages; }重要提示真实测量虽然准确但性能开销大尤其对于超大数据集。一个折中方案是首次使用预估分页快速生成预览当用户确认打印时再在后台线程执行一次精确分页用于最终打印输出。4.4 打印机设置与纸张处理用户可能拥有多个打印机或者需要特殊的纸张如信封、标签纸。PrintDialog提供了基本的打印机选择但更精细的控制需要用到PrintTicket。public void PrintWithCustomSettings(SalesReportDocument document) { PrintDialog printDialog new PrintDialog(); // 1. 尝试获取用户的默认打印机设置 PrintQueue defaultQueue LocalPrintServer.GetDefaultPrintQueue(); if (defaultQueue ! null) { printDialog.PrintQueue defaultQueue; // 2. 创建并配置PrintTicket PrintTicket printTicket defaultQueue.DefaultPrintTicket; printTicket.CopyCount 1; // 份数 printTicket.PageOrientation PageOrientation.Landscape; // 横向 // 尝试设置纸张大小 foreach (PageMediaSize pageSize in defaultQueue.GetPrintCapabilities().PageMediaSizeCapability) { if (pageSize.PageMediaSizeName PageMediaSizeName.ISOA4) { printTicket.PageMediaSize pageSize; break; } } printDialog.PrintTicket printTicket; } if (printDialog.ShowDialog() true) { // 打印逻辑... // 注意最终打印时你的Paginator需要根据PrintDialog.PrintTicket中的PageOrientation和PageMediaSize动态调整_pageSize和布局。 var paginator new CustomDocumentPaginator(_printViewModel, _visualBuilder, new Size(printDialog.PrintableAreaWidth, printDialog.PrintableAreaHeight), _margin); printDialog.PrintDocument(paginator, 报表); } }处理纸张方向变化时你的分页逻辑和视觉构建器需要能动态适应。这意味着_pageSize和内容布局如列宽、起始位置需要根据PageOrientation重新计算。5. 常见问题排查与实战避坑指南即使按照最佳实践开发在实际部署中还是会遇到各种奇怪的问题。下面是我总结的一些典型“坑”及其解决方案。5.1 打印或预览时内容模糊、发虚这是WPF打印最常见的问题之一根源在于DPI和单位换算。问题根源WPF使用与设备无关的单位1/96英寸而打印机通常有更高的DPI如600 DPI。如果你直接使用以像素为单位的固定数值如在DrawingContext中写死DrawText的位置为(10, 10)在高DPI打印机上这些坐标会被缩放可能导致文本位置微偏更重要的是FormattedText在创建时如果未考虑目标DPI其渲染精度会下降。解决方案始终使用VisualTreeHelper.GetDpi获取系统的DPI缩放因子并在创建FormattedText时传入。正如前面代码示例所示。这确保了文本在任何DPI下都能清晰渲染。所有坐标和尺寸都基于物理单位英寸或毫米计算最后转换为WPF单位。例如定义左边距为0.5英寸则在代码中应为marginLeftInches * 96。这样在不同DPI的打印机上都能保持物理尺寸一致。在DocumentPage构造函数中务必正确设置BleedBox和ContentBox。BleedBox通常是整个页面大小ContentBox是扣除边距后的可打印区域。这有助于打印驱动程序进行正确定位。5.2 打印多份时只有第一份有内容后面是空白这个问题通常出现在使用同一个Visual对象进行多次打印时。问题根源Visual是一个一次性渲染对象。当你将它用于一个DocumentPage并打印后其内部状态可能已改变或者与打印后台处理程序的交互导致它无法被重复使用。解决方案确保GetPage方法每次被调用时都返回一个全新的、独立渲染的Visual对象。绝对不要在GetPage内部缓存和复用Visual。我们的SalesReportPrintVisualBuilder.BuildPageVisual方法每次调用都创建新的DrawingVisual正是为了避免此问题。5.3 预览正常但打印出来缺少部分元素或样式不对这通常是打印路径和屏幕渲染路径不一致导致的。排查清单检查画笔和颜色是否使用了Transparent透明画笔打印机可能不处理透明。是否使用了系统主题颜色或动态资源在非UI线程构建打印视觉时这些资源可能无法正确解析。打印视觉应使用纯色画笔Brushes.Black或明确的SolidColorBrush。检查图像资源如果打印内容包含图片BitmapImage确保图片的UriSource是绝对路径或者图片已正确嵌入资源并且在打印上下文中可访问。有时需要将图片数据流提前解码并缓存为BitmapFrame。验证分页逻辑确保用于打印的分页算法和用于预览的分页算法完全一致。最好复用同一套分页视图模型。打印机驱动问题某些老旧或非PCL/PostScript打印机驱动可能对WPF的XPS-based打印支持不佳。尝试将打印输出先保存为XPS文件然后用系统XPS查看器打印如果正常则问题很可能在驱动程序。可以提示用户更新驱动。5.4 内存泄漏Memory Leak长时间运行或频繁打印预览后应用程序内存持续增长。主要嫌疑事件绑定未解除如果在打印预览的DataTemplate中使用了事件绑定如MouseDown并且DataTemplate被频繁创建/销毁可能导致事件持有对象引用阻止垃圾回收。优先使用命令Command绑定。Visual Brush 持有引用VisualBrush会保持对其Visual源的强引用。如果预览中大量使用VisualBrush来显示页面并且页面内容不断更新旧的VisualBrush及其引用的Visual可能未被释放。确保在页面不可见时如OnIsVisibleChanged中清理VisualBrush的内容。静态引用检查打印服务类或视觉构建器中是否有静态集合缓存了页面数据或Visual且未及时清理。5.5 “打印后台处理程序服务没有运行”或权限错误这类错误通常在部署到某些服务器或受限制的客户端环境时出现。原因WPF打印依赖于Windows的打印后台处理程序Spooler服务。在某些服务器核心版或安全策略严格的机器上该服务可能被禁用。应对策略优雅降级在PrintDialog.ShowDialog()或PrintQueue相关操作时捕获PrintServerException等特定异常。捕获后可以向用户提供“另存为PDF”或“导出为XPS”作为备选方案。环境检测在应用程序启动时可以尝试检查打印服务状态通过ServiceController类并提前告知用户。虚拟打印机对于无物理打印机的环境可以引导用户安装“Microsoft Print to PDF”这类虚拟打印机它不依赖物理设备但提供了完整的打印管道。5.6 表格线打印过细或不显示在WPF中使用Pen绘制线条时如果线条宽度设置为1在高DPI打印机上可能会因为亚像素渲染而变得几乎看不见。解决方案将线条宽度设置为一个与DPI无关的最小物理宽度。例如设置线条宽度为0.5点point1点等于1/72英寸。在WPF单位中就是0.5 / 72.0 * 96。或者更简单地直接设置为1.01像素在大多数打印机上也能清晰可见因为打印机DPI远高于屏幕DPI。// 推荐使用物理单位定义线宽 double lineWidthInPoints 0.5; double lineWidthInWpfUnits lineWidthInPoints / 72.0 * 96.0; Pen tableLinePen new Pen(Brushes.Black, lineWidthInWpfUnits); // 或者简单粗暴但通常有效 Pen tableLinePen new Pen(Brushes.Black, 1.0);最后关于性能一个很实用的技巧是对于极其复杂的报表考虑将整个页面渲染成一个RenderTargetBitmap然后打印这个位图。虽然会损失一些矢量清晰度特别是文字但可以完全规避复杂布局在打印时的渲染问题并且速度可能更快。这可以作为处理“疑难杂症”的终极备用方案。
返回列表