在这里插入图片描述

一、PC 打印,从来不是一件简单的事

在桌面端应用中,"打印"看似是一个基础功能,实际上涉及一整套复杂的链路。用户期望的不只是把文字扔到纸上——他们要选择纸张大小、调整页边距、控制缩放比例、决定单双面,甚至还要预览每一页的效果并把多页内容装订成册。对于开发者来说,这意味着需要在应用层处理打印机发现、能力协商、文档分页、布局计算、图像渲染、任务状态跟踪等诸多环节。

HarmonyOS NEXT(API 12+)为 PC 应用提供了全新的 @ohos.print 模块。它并非浏览器里那种被大幅阉割的 window.print(),而是一套完整的打印框架,涵盖从打印机发现、能力探测、文档构建、布局控制到任务提交的完整链路。更重要的是,它采用声明式 API 与回调结合的设计——你只需要告诉系统"我要打什么"和"怎么排版",剩下的设备匹配和驱动交互都由框架层处理。

本文聚焦这个框架的核心抽象层,用几个短小精悍的代码片段,把打印流程讲透。


二、@ohos.print 模块全景

在进入代码之前,先记住五个核心角色。它们是整个打印框架的骨架。

角色 类/接口 职责
打印入口 PrintManager 启动打印任务,管理打印队列
打印机描述 PrinterCapability 描述打印机能力(纸张、色彩、装订等)
文档构建 PrintDocumentAdapter 将应用内容转为可打印的文档页
布局控制 PrintLayoutCallback 控制每页的布局参数(边距、缩放、方向)
任务句柄 PrintTask 跟踪打印任务的状态和进度

这五个角色之间的协作关系是一条清晰的流水线,可以概括为一句话:

PrintManager 获取 PrinterCapability → 创建 PrintTaskPrintDocumentAdapter 提供内容 → 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 包含 namewidthheight(单位 mm)。常见值有 PageSize.A4PageSize.A3PageSize.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');
  }
}

生命周期的流转顺序非常清晰:

onStartonLayout(报告总页数)→ onWrite(按页码逐页提供内容,一页回调一次)→ onFinish(清理资源)

这里有一个性能关键点:不要在 onLayout 中做耗时渲染。onLayout 只负责告诉系统"我有多少页"和"每页多大",它是一个轻量回调。实际的画图操作应该延迟到 onWrite 中执行,或者更理想的做法——在 Worker 线程中提前渲染好所有页面的 PixelMaponWrite 只做缓存读取。

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 — 四周边距(topbottomleftright,单位 mm)。它定义了内容的安全区域。
  • scale: number — 缩放比例。1.0 表示原大,0.5 表示缩小到一半。当报表内容尺寸超过纸张大小时,可以通过缩放来确保内容完整呈现。
  • orientation: PageOrientationPORTRAIT(纵向)或 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;
}

注意这里的绘图操作使用了精确的坐标系统。pageWidthpageHeight 应该由 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 探测打印机能力,再到 PrintDocumentAdapterPrintLayoutCallback 构建文档内容与布局——这套流程覆盖了 PC 打印的全部关键环节。它足够灵活,可以应对从简单的单据打印到复杂的图文报表排版的各种场景;它也足够健壮,省去了你直接与打印机驱动打交道的底层工作。

对于鸿蒙 PC 应用的开发者来说,理解这一套流程的节奏——能力探测→参数协商→内容构建→逐页渲染→状态跟踪——比死记硬背任何 API 都更重要。因为不管未来框架怎么演进,这个流程本身不会变。


Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐