鸿蒙PC打印框架深度实战:从 PrintManager 到报表输出

一、PC 打印,从来不是一件简单的事
在桌面端应用中,"打印"看似是一个基础功能,实际上涉及一整套复杂的链路。用户期望的不只是把文字扔到纸上——他们要选择纸张大小、调整页边距、控制缩放比例、决定单双面,甚至还要预览每一页的效果并把多页内容装订成册。对于开发者来说,这意味着需要在应用层处理打印机发现、能力协商、文档分页、布局计算、图像渲染、任务状态跟踪等诸多环节。
HarmonyOS NEXT(API 12+)为 PC 应用提供了全新的 @ohos.print 模块。它并非浏览器里那种被大幅阉割的 window.print(),而是一套完整的打印框架,涵盖从打印机发现、能力探测、文档构建、布局控制到任务提交的完整链路。更重要的是,它采用声明式 API 与回调结合的设计——你只需要告诉系统"我要打什么"和"怎么排版",剩下的设备匹配和驱动交互都由框架层处理。
本文聚焦这个框架的核心抽象层,用几个短小精悍的代码片段,把打印流程讲透。
二、@ohos.print 模块全景
在进入代码之前,先记住五个核心角色。它们是整个打印框架的骨架。
| 角色 | 类/接口 | 职责 |
|---|---|---|
| 打印入口 | PrintManager | 启动打印任务,管理打印队列 |
| 打印机描述 | PrinterCapability | 描述打印机能力(纸张、色彩、装订等) |
| 文档构建 | PrintDocumentAdapter | 将应用内容转为可打印的文档页 |
| 布局控制 | PrintLayoutCallback | 控制每页的布局参数(边距、缩放、方向) |
| 任务句柄 | PrintTask | 跟踪打印任务的状态和进度 |
这五个角色之间的协作关系是一条清晰的流水线,可以概括为一句话:
PrintManager 获取 PrinterCapability → 创建 PrintTask → PrintDocumentAdapter 提供内容 → PrintLayoutCallback 控制布局 → 提交到打印队列
每一个环节都给了开发者充分的定制空间。下面逐一拆解。
三、一切从 PrintManager 开始
打印的起点是 PrintManager 实例。在 API 12+ 中,通过静态方法 getPrintManager 获取,它需要一个 Context——在 UIAbility 或 Page 中都可以拿到。
import { print } from '@kit.ArkGraphics2D';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
function startPrint(context: common.Context) {
const printManager: print.PrintManager =
print.getPrintManager(context);
// 配置并启动一个打印任务
const printTask: print.PrintTask =
printManager.startPrint({
taskId: 'sales_report_20260721',
taskName: '月度销售报表',
adapter: createDocumentAdapter(),
layoutCallback: createLayoutCallback(),
capability: {
colorMode: print.ColorMode.COLOR,
pageSize: print.PageSize.A4,
copies: 1,
duplexMode: print.DuplexMode.NONE,
},
});
// 注册任务生命周期回调
printTask.on('success', () => {
console.info('Print task completed');
});
printTask.on('fail', (err: BusinessError) => {
console.error('Print failed:', err.message);
});
printTask.on('progress', (pct: number) => {
console.info(`Print progress: ${pct}%`);
});
}
这段代码展示了最核心的调用模式。值得注意的是 capability 字段——它既是你的打印需求声明,也是系统用来匹配打印机能力的关键依据。你声明"我要用 A4 彩色打印,单面,1份",系统就会自动筛选出满足条件的打印机。如果没有任何打印机匹配这个声明,系统会向用户给出友好的提示而非直接崩溃。
PrintManager 本身还提供了其他实用方法:
// 查询打印队列中的所有任务
const tasks: print.PrintTask[] =
printManager.queryPrintTasks();
// 取消指定任务
printManager.cancelPrintTask(taskId);
// 获取系统默认打印机
const defaultPrinter: string =
printManager.getDefaultPrinterName();
这些方法让你可以在应用层构建一个完整的打印管理界面,而不仅仅是一键打印。
四、探知打印机能力:PrinterCapability
在实际打印之前,你通常需要知道用户的打印机到底支持什么。如果不做能力探测就硬编码配置,很可能遇到打印机根本不支持你指定的纸张类型或色彩模式。
queryPrinterCapability 就是用来解决这个问题的:
import { print } from '@kit.ArkGraphics2D';
async function queryCapability(context: common.Context) {
const printManager = print.getPrintManager(context);
const caps: print.PrinterCapability =
await printManager.queryPrinterCapability();
console.info('Supported page sizes:', caps.pageSizes);
console.info('Color modes:', caps.colorModes);
console.info('Duplex modes:', caps.duplexModes);
console.info('Min physical margin:', caps.minMargin);
// 动态构建 UI 选项
const pageSizeOptions = caps.pageSizes.map(ps => ({
label: `${ps.name} (${ps.width}×${ps.height}mm)`,
value: ps,
}));
const colorOptions = caps.colorModes.map(cm => ({
label: cm === print.ColorMode.COLOR ? '彩色' : '黑白',
value: cm,
}));
}
PrinterCapability 包含以下关键字段:
pageSizes: PageSize[]— 打印机支持的纸张规格列表,每个PageSize包含name、width、height(单位 mm)。常见值有PageSize.A4、PageSize.A3、PageSize.LETTER等,但实际打印机的支持范围远比这几个常量丰富。colorModes: ColorMode[]— 支持的颜色模式:COLOR|MONOCHROME。duplexModes: DuplexMode[]— 双面打印模式:NONE|LONG_EDGE(长边翻页) |SHORT_EDGE(短边翻页)。minMargin: PrintMargin— 打印机的最小物理页边距,单位 mm。不同打印机的进纸机制不同,最小边距差异很大。激光打印机的四边最小边距通常为 3~5mm,而喷墨打印机的底部最小边距可能达到 15mm 以上。resolution: number— 打印机支持的打印分辨率,单位 DPI。supportsBorderless: boolean— 是否支持无边距打印。常用于照片打印场景。
拿到这些信息后,你就可以动态构建打印设置面板,让用户在打印机的物理能力范围内自由选择参数,而不是写死一个 A4 黑白的硬编码配置。
这里有一个容易被忽视的细节:minMargin 和你在 LayoutResult 中设置的 margin 是两个不同的概念。minMargin 是打印机硬件的物理极限,而 LayoutResult.margin 是你在应用中期望的内容边距。系统最终会取两者的较大值来保证内容不会被裁切。所以即使你在应用中设置 margin: { top: 0, left: 0, bottom: 0, right: 0 },实际打印时还是会有至少 minMargin 大小的留白。
五、构建打印内容:PrintDocumentAdapter
有了配置参数,接下来就是核心问题:要打印的内容从哪来?PrintDocumentAdapter 就是用来回答这个问题的。
它是一个协议(接口),定义了完整的文档构建生命周期。理解它的关键是把打印过程看作一个按需渲染的流水线——系统不会一次性要求你提供所有页面,而是逐页回调:
import { print } from '@kit.ArkGraphics2D';
import { image } from '@kit.ImageKit';
class SalesReportAdapter implements print.PrintDocumentAdapter {
private pages: print.PrintPage[] = [];
constructor(data: ReportData) {
// 在构造函数中完成数据准备,不渲染
this.pages = this.preparePageData(data);
}
onStart?(callback: () => void): void {
console.info('Document preparation started');
callback(); // 告知系统准备完成
}
onLayout?(
attributes: print.PrintAttributes,
callback: (result: print.LayoutResult) => void
): void {
// 系统询问文档布局:总页数 + 纸张规格
callback({
totalPages: this.pages.length,
pageSize: attributes.pageSize,
});
}
onWrite?(
pageNumber: number,
callback: (result: print.WriteResult) => void
): void {
// 系统请求第 N 页的内容
// 这里才真正渲染对应页面的 PixelMap
callback({
page: this.pages[pageNumber - 1],
written: true,
});
}
onFinish?(): void {
// 所有页面已提交,清理资源
this.pages = [];
console.info('Adapter resources released');
}
}
生命周期的流转顺序非常清晰:
onStart → onLayout(报告总页数)→ onWrite(按页码逐页提供内容,一页回调一次)→ onFinish(清理资源)
这里有一个性能关键点:不要在 onLayout 中做耗时渲染。onLayout 只负责告诉系统"我有多少页"和"每页多大",它是一个轻量回调。实际的画图操作应该延迟到 onWrite 中执行,或者更理想的做法——在 Worker 线程中提前渲染好所有页面的 PixelMap,onWrite 只做缓存读取。
PrintPage 是打印页的内容载体:
interface PrintPage {
pixelMap: image.PixelMap; // 渲染好的页面内容
offsetX?: number; // 内容在纸张上的水平偏移
offsetY?: number; // 内容在纸张上的垂直偏移
scale?: number; // 页面的缩放比例
}
你只需要把要打印的内容渲染成 PixelMap,设置好偏移和缩放,系统会负责把它正确发送给打印机驱动。
六、页面排版由你掌控:PrintLayoutCallback
光有内容还不够——用户希望在纸面上控制内容如何呈现。是纵向还是横向?页边距留多大?内容要不要缩放以适应纸张?这些都由 PrintLayoutCallback 来回答。
import { print } from '@kit.ArkGraphics2D';
class ReportLayoutCallback implements print.PrintLayoutCallback {
private userConfig: {
margin: print.PrintMargin;
scale: number;
orientation: print.PageOrientation;
};
constructor(userConfig: {
margin?: print.PrintMargin;
scale?: number;
orientation?: print.PageOrientation;
}) {
this.userConfig = {
margin: userConfig.margin ?? { top: 20, left: 20, bottom: 20, right: 20 },
scale: userConfig.scale ?? 1.0,
orientation: userConfig.orientation ?? print.PageOrientation.PORTRAIT,
};
}
onLayout?(
attrs: print.PrintAttributes,
callback: (result: print.LayoutResult) => void
): void {
// 根据用户配置和打印机能力计算最终布局
callback({
totalPages: this.estimatePageCount(attrs),
pageSize: attrs.pageSize,
margin: this.userConfig.margin,
scale: this.userConfig.scale,
orientation: this.userConfig.orientation,
});
}
// 根据内容量和纸张大小估算页数
private estimatePageCount(attrs: print.PrintAttributes): number {
const usableWidth = attrs.pageSize.width
- this.userConfig.margin.left
- this.userConfig.margin.right;
const usableHeight = attrs.pageSize.height
- this.userConfig.margin.top
- this.userConfig.margin.bottom;
const contentArea = usableWidth * usableHeight;
const totalContent = 800 * 600 * 10; // 模拟内容总量
return Math.ceil(totalContent / contentArea);
}
}
LayoutResult 决定了每一页的物理呈现参数:
margin: PrintMargin— 四周边距(top、bottom、left、right,单位 mm)。它定义了内容的安全区域。scale: number— 缩放比例。1.0表示原大,0.5表示缩小到一半。当报表内容尺寸超过纸张大小时,可以通过缩放来确保内容完整呈现。orientation: PageOrientation—PORTRAIT(纵向)或LANDSCAPE(横向)。纵向适合文档和文字报表,横向适合宽表格和图表。pageSize: PageSize— 目标纸张规格,通常与PrinterCapability中用户选中的规格一致。
系统拿到 LayoutResult 后,会把它与打印机驱动的能力进行二次校验,确保实际输出不超出硬件限制。比如你设置 margin.top = 3mm 但打印机最小顶边距是 5mm,系统会自动把你的边距修正为 5mm 并给出一个提示。
七、实战:输出一份销售报表
理论讲完,来一个完整的实战场景。假设我们要打印一份月度销售报表——包含表头、数据行和汇总统计。这是 PC 商务应用中最常见的打印需求之一。
7.1 渲染报表内容为 PixelMap
先把报表数据渲染成一张位图,这是 onWrite 方法最终需要返回的核心内容。这里用 @kit.ArkGraphics2D 的 Canvas API 来绘制:
import { drawing } from '@kit.ArkGraphics2D';
import { image } from '@kit.ImageKit';
async function renderReportPage(
data: ReportData,
pageWidth: number,
pageHeight: number
): Promise<image.PixelMap> {
const pixelMap: image.PixelMap =
await image.createPixelMap({
width: pageWidth,
height: pageHeight,
pixelFormat: image.PixelMapFormat.RGBA_8888,
alphaType: image.AlphaType.PREMUL,
});
const canvas = drawing.Canvas.createFromPixelMap(pixelMap);
// 清理画布,白色背景
canvas.clear(0xFFFFFFFF);
// 标题
const titleStyle = new drawing.TextStyle();
titleStyle.fontSize = setFontSize(28);
canvas.drawText(setText('月度销售报表 — 2026年7月'),
setPoint(80, 40), titleStyle);
// 绘制表格表头
const headerFont = new drawing.TextStyle();
headerFont.fontSize = setFontSize(14);
const headerY = 100;
const colDefs = [
{ label: '产品名称', x: 60, width: 180 },
{ label: '销量(件)', x: 260, width: 100 },
{ label: '金额(元)', x: 380, width: 120 },
{ label: '利润(元)', x: 520, width: 120 },
];
for (const col of colDefs) {
canvas.drawText(setText(col.label),
setPoint(col.x, headerY), headerFont);
}
return pixelMap;
}
注意这里的绘图操作使用了精确的坐标系统。pageWidth 和 pageHeight 应该由 LayoutResult 中的纸张尺寸减去边距后计算得出,确保内容落在安全区域内。
7.2 计算内容安全区
一个健壮的报表渲染函数应该先计算安全区域,再排布内容:
function calculateSafeArea(
pageSize: print.PageSize,
margin: print.PrintMargin
): { safeX: number; safeY: number;
safeW: number; safeH: number } {
// 假设分辨率 300 DPI,mm 转像素
const mmToPx = (mm: number): number =>
Math.round(mm * 300 / 25.4);
return {
safeX: mmToPx(margin.left),
safeY: mmToPx(margin.top),
safeW: mmToPx(pageSize.width - margin.left - margin.right),
safeH: mmToPx(pageSize.height - margin.top - margin.bottom),
};
}
这个安全区域就是你所有绘制操作的边界。任何超出这个区域的图形都可能被打印机裁切掉。在复杂的报表布局中,先计算安全区、再排布元素,是确保打印质量的基本功。
7.3 完整的打印流程串联
把前面所有的碎片拼在一起,就构成了一次完整的打印调用:
async function printReport(context: common.Context) {
const reportData = fetchMonthlySalesReport();
const printManager = print.getPrintManager(context);
// 第一步:探测打印机能力
const caps = await printManager.queryPrinterCapability();
// 第二步:选择最合适的纸张规格
const pageSize = caps.pageSizes.includes(print.PageSize.A4)
? print.PageSize.A4
: caps.pageSizes[0];
const userConfig = {
margin: { top: 20, left: 20, bottom: 20, right: 20 },
scale: 1.0,
orientation: print.PageOrientation.PORTRAIT,
};
// 第三步:构建适配器和布局回调
const adapter = new SalesReportAdapter(reportData);
const layout = new ReportLayoutCallback(userConfig);
// 第四步:启动打印
const task = printManager.startPrint({
taskId: `report_${Date.now()}`,
taskName: '月度销售报表',
adapter,
layoutCallback: layout,
capability: {
pageSize,
colorMode: caps.colorModes.includes(
print.ColorMode.COLOR
) ? print.ColorMode.COLOR
: print.ColorMode.MONOCHROME,
copies: 1,
duplexMode: print.DuplexMode.NONE,
},
});
monitorTask(task);
}
这个函数的执行流程反映了打印的最佳实践:先探测能力,再协商参数,最后提交任务。每一步都有回退策略——如果 A4 不支持就用第一个可用的纸张,如果不支持彩色就降级到黑白。
7.4 监听任务状态
打印是异步操作。一个多页报表的打印可能持续几十秒,用户需要知道当前进度:
function monitorTask(task: print.PrintTask) {
task.on('success', () => {
// 打印完成,清理临时缓存文件
clearTempResources();
showToast('打印任务已完成');
});
task.on('fail', (err: BusinessError) => {
showToast(`打印失败:${err.message}`);
logPrintError(err);
});
task.on('progress', (progress: number) => {
// progress 取值范围 0~100
updateProgressIndicator(progress);
});
task.on('cancel', () => {
// 用户在打印队列中取消了任务
showToast('打印已取消');
});
}
事件 progress 返回的数值是 0~100 的整数百分比,你可以用它来驱动一个进度条组件。注意在 fail 事件中,除了给用户弹 Toast,还建议把错误信息记录到本地日志或远程监控中,以便排查打印机连接异常、驱动缺失等硬件层面的问题。
八、打印预览——用户最后的确认
在提交打印之前,让用户预览效果是 PC 应用的基本体验要求。如果你直接 startPrint,系统会弹出一个简单的打印确认对话框,但不会展示逐页内容。对于报表应用,用户需要翻页查看排版是否合理。HarmonyOS NEXT 提供了系统级的打印预览对话框:
import { print } from '@kit.ArkGraphics2D';
async function showPrintPreview(context: common.Context) {
const printManager = print.getPrintManager(context);
const previewDialog = print.createPrintPreviewDialog(context);
previewDialog.setTitle('打印预览 — 月度销售报表');
previewDialog.setAdapter(adapter);
previewDialog.setLayoutCallback(layout);
try {
await previewDialog.show();
console.info('Preview closed');
} catch (err) {
console.error('Preview error:', err);
}
}
预览对话框内部会自动调用 onLayout 获取总页数,然后依次调用 onWrite 获取每一页的 PixelMap 并显示。用户可以在对话框中:
- 左右翻页浏览
- 缩放查看细节
- 切换纸张和边距设置
- 点击"打印"按钮确认提交
- 点击"取消"返回修改
预览对话框打开时,PrintManager 会暂时 hold 住页面显示,你不需要担心页面的生命周期管理。
九、从屏幕到纸张:完整流程总览
把整条链路串起来,一个标准的打印流程应该是这样的:
用户点击「打印」按钮
│
▼
获取 PrintManager 实例
│
▼
queryPrinterCapability() → 获取打印机能力数据
│
▼
动态展示打印设置面板(纸张/份数/色彩/双面/边距)
用户调整并确认参数
│
▼
【可选】展示打印预览(用户翻页检查)
│
▼
创建 PrintDocumentAdapter
创建 PrintLayoutCallback
│
▼
printManager.startPrint({ capability, adapter, layoutCallback })
│
▼
系统匹配打印机 → 逐页回调 onLayout → onWrite
│
▼
监听 success / fail / progress / cancel
│
▼
onFinish → 清理资源 → 结束
每一步都提供了回调或事件接口,方便你嵌入自己的业务逻辑。比如在 onStart 中记录日志,在 onWrite 中统计打印量,在 fail 中触发错误报告。
十、进阶:应对复杂报表场景
当报表内容超过一页时,分页逻辑是绕不开的课题。简单的做法是内容全部渲染在一个大的 PixelMap 上然后裁剪分页,但这个方案在内容量较大时会消耗大量内存。
更实用的做法是按需渲染:先计算数据行数,确定每页能容纳的行数,然后在 onWrite 中只渲染当前页的行数据。
class PagedReportAdapter implements print.PrintDocumentAdapter {
private data: ReportData;
private rowsPerPage: number = 30;
onLayout?(_: print.PrintAttributes,
callback: (r: print.LayoutResult) => void): void {
const totalPages = Math.ceil(
this.data.rows.length / this.rowsPerPage
);
callback({
totalPages: Math.max(totalPages, 1),
pageSize: print.PageSize.A4,
});
}
onWrite?(pageNumber: number,
callback: (r: print.WriteResult) => void): void {
const startIdx = (pageNumber - 1) * this.rowsPerPage;
const pageData = this.data.rows.slice(
startIdx, startIdx + this.rowsPerPage
);
const pixelMap = renderPageWithData(
this.data.header,
pageData,
this.data.summary
);
callback({ page: { pixelMap }, written: true });
}
}
这种设计的好处是内存占用只跟一页的数据量有关,不管你打印 10 页还是 100 页。对于上万行的大报表,这是唯一可行的方案。
十一、最佳实践清单
经过以上的代码拆解,这里整理一份实践中值得遵守的 checklist:
1. 渲染分辨率
屏幕是 72~96 DPI,打印至少需要 300 DPI。创建 PixelMap 时,宽度和高度应该是:
width_px = paperWidth_mm × (300 / 25.4)
height_px = paperHeight_mm × (300 / 25.4)
否则打印出来的文字边缘会发虚。
2. 分页与渲染分离onLayout 只计算页数,onWrite 才渲染。不要在 onLayout 中分配大对象或调用 Canvas API,这会阻塞 UI 线程。
3. 边距安全区
始终用 capability.minMargin 来校验你的边距设置。如果你设置 margin.top = 2 但打印机 minMargin.top = 5,最终结果以打印机为准——你的内容可能会被向上裁切。
4. 资源释放onFinish 不是可选回调,建议始终实现它。在回调中释放 PixelMap、Canvas 和临时文件资源。打印一个包含图片的报表时,onWrite 可能创建数 MB 到数十 MB 的图形对象,不及时释放会导致内存压力。
5. 异步渲染
一个常见的优化手段是在 Worker 线程中提前渲染好所有页面的 PixelMap,然后通过某种 IPC 机制传递给主线程。onWrite 直接从缓存中读取已渲染好的数据,实现几乎零延迟的页面提交。
// Worker 侧:预渲染所有页面
worker.onmessage = (event) => {
const { data, pageSize, margin } = event.data;
const allPages = data.map(page =>
renderPageToPixelMap(page, pageSize, margin)
);
worker.postMessage(allPages);
};
6. 错误处理
打印的设备交互层存在很多不可控因素——打印机离线、缺纸、卡纸、驱动不兼容等。始终监听 fail 事件并给用户友好的错误提示,而不是直接崩溃或静默失败。
十二、与 PDF 导出的对比思考
文章聚焦打印框架,但顺带提一句:如果你的应用需要在"打印"之外支持"保存为 PDF",可以使用 @ohos.document 模块或 Canvas 的 toBase64 结合 PDF 库来生成 PDF 文件。打印和 PDF 导出在渲染层面是相通的——都是把内容画到某个输出目标上,区别只在于输出目标是一张纸还是一个文件。
在一些场景中,先导出 PDF 再打印 PDF,反而更容易控制排版的一致性。因为 onWrite 每次只给一页的内容,而 PDF 可以全局控制多页的连续排版(比如跨页表格、页眉页脚等)。选择哪种方案,取决于你的报表复杂度。
写在最后
@ohos.print 框架的设计核心可以概括为:声明你的需求,提供你的内容,控制你的排版,剩下交给系统。
从 PrintManager 获取入口,到 PrinterCapability 探测打印机能力,再到 PrintDocumentAdapter 和 PrintLayoutCallback 构建文档内容与布局——这套流程覆盖了 PC 打印的全部关键环节。它足够灵活,可以应对从简单的单据打印到复杂的图文报表排版的各种场景;它也足够健壮,省去了你直接与打印机驱动打交道的底层工作。
对于鸿蒙 PC 应用的开发者来说,理解这一套流程的节奏——能力探测→参数协商→内容构建→逐页渲染→状态跟踪——比死记硬背任何 API 都更重要。因为不管未来框架怎么演进,这个流程本身不会变。
更多推荐


所有评论(0)