📦 Flutter 三方库 printing_ohos 是用于在鸿蒙端打印与光栅化预览 PDF 文档的 package。在按页预览多页文档时,原有鸿蒙实现会把指定第三页渲染成第一页,连续光栅化时的内存占用也偏高。我在原库基础上修正了页码映射、原生资源分层释放与缓冲复用。
完整代码已提交 PR 至官方仓库 CPF-Flutter/fluttertpc_printing,有问题可以到仓库提 Issue,也可以来 鸿蒙系统Flutter开源库社区 交流。

前言

我用 Flutter 做了一个 PDF 预览页,想在鸿蒙模拟器上点一下页码就看对应的内容。测试用的十页 PDF 是程序生成的,每页都印着不同的页码。点到第三页时,页面标题变了,PDF 页眉还写着第一页。我又让同一个进程连续渲染三遍,发现每一轮结束时的内存读数都在往上走。

我顺着插件的取页调用往下看,发现循环用自己的下标取页,单页和两个像素图也没有显式释放。于是我按请求取页,在每页结束时清理对象,再让尺寸相同的页面共用中转缓冲。

改完后,第三页和第十页都显示了对应内容。同一台 Mate X7 模拟器的五对冷启动里,阶段样本最大 PSS 增量中位数从 1,678,810 kB 降到 573,404 kB,少了 65.8%;在华为畅享 100 Pro Max 真机的两对冷启动复测里,等待 1.5 s 后的 PSS 增量均值也从 760,482 kB 降到 270,506 kB,少了 64.4%。我又单独在模拟器上跑了 300 页,内存后来回落了,停下约 30 秒时仍比开始高 56.3 MiB。

一、从一份十页 PDF 开始

1.1 预览页需要完成的操作

📱 我给这个 Example 设定的使用过程很简单:在鸿蒙设备上打开一份 PDF,查看第一页,跳到第三页和最后一页,然后返回。重新打开时,内容应当仍然正确,上一轮不用的临时资源也应进入清理流程。预览组件可能会保留相邻页缓存,但这种保留应当有范围,不能让用户每打开一遍文档都留下整份位图。

为了把交互观察和定量负载分开,Example 提供了两个独立入口:

  • 浏览入口:按用户点击的页码请求图像,直接展示 PDF 自身的蓝色页眉与表格内容,返回时释放页面持有的 ui.Image。
  • 诊断入口:固定连续调用三轮 Printing.raster,逐页解码、读取宽高后立即调用 ui.Image.dispose(),界面只保留第一张缩略图。

具体复现对象是 printing_ohos 1.0.1 的 Printing.raster。指定页码错页的复现步骤如下:

  1. 在本地生成十页带唯一页眉标记的 A4 文档,先执行一次完整光栅化,记录第一页和第三页的图像指纹。
  2. 用同一份 PDF 字节调用 Printing.raster(bytes, pages: [2], dpi: 72),按零基下标请求第三页。
  3. 核对返回内容与页眉标记:原版连续五次都返回了与第一页相同的内容,补丁后五次都与完整渲染中的第三页一致。

性能表中的“三轮”指三次完整光栅化,不是用户手动翻阅三遍。浏览时我看的是实际页码与替换、返回行为。统计时我固定页数、分辨率和取样时点。分开这两条路径之后,既能复现具体操作,也不必把人的停留时间混进固定负载。

1.2 应用释放了图片,原生侧还有哪些对象

Flutter 业务页面只直接持有 PdfRaster、像素数组和 ui.Image,看不到鸿蒙插件内部的对象。一次光栅化请求发出后,原生代码还要创建 PDF 文档,取得单页对象,把页面转成 PixelMap,读取像素,再经平台通道返回 Dart。业务侧调用 ui.Image.dispose(),并不会替原生代码逐一执行这些释放方法。

四阶段 PDF 预览流程:页面请求、插件桥接、鸿蒙处理与图像显示

图1 预览链路

沿着图 1 的调用链路,三个层次的职责边界划分如下:

  • 鸿蒙系统能力层:@kit.PDFKit 负责解析 PDF 文档与渲染单页位图,@kit.ImageKit 提供 PixelMap 缩放、像素字节读取与转换。
  • Flutter 插件桥接层:printing_ohos 负责把上述原生能力接到公开的 Dart 接口上,管理临时文件、原生对象释放与通道消息发送。
  • Example 业务与观测层:负责生成测试文档、消费图像、切换页面和采集观测值。内存观测代码属于宿主诊断逻辑,没有混入 Printing.raster 的生产调用路径。

printing_ohos 已经有公开 Dart API、鸿蒙注册入口和光栅化实现,我就沿用这个包。接入的应用仍然调用 Printing.raster,取页和原生对象清理在插件内部完成。

1.3 固定版本,再看社区已经修到哪里

我先从 DavBfr/dart_pdf 确认 printing 的来源。这个仓库由 David PHAM-VAN(DavBfr)维护,沿用 Apache-2.0 许可:pdf 目录负责生成文档,printing 目录提供打印、分享和光栅化预览能力。本文用到的 Printing.raster 与 net.nfet.printing 通道都来自这套实现。

DavBfr dart_pdf 上游仓库及 pdf、printing 目录

图2 上游仓库

🔍 我检查了 oh-Flutter 和 CPF-Flutter 的仓库目录,用 print、pdf 筛选项目名和描述,两份目录分别有 399 和 394 条记录。里面已有 pdfrx、alh_pdf_view 等 PDF 查看候选库。我继续沿着当前项目用到的 Printing.raster 查,重点看同一个 API 在鸿蒙上怎样取页和释放资源。

鸿蒙适配由 CPF-Flutter/fluttertpc_printing 社区仓维护。我核对了它的主分支、公开标签和开发分支,再下载 pub.dev 的 printing_ohos 1.0.1。社区 master 分支的 printing/ohos 目录是自包含鸿蒙子包。标签 5.14.2-ohos-1.0.0 下的 printing 目录对应主包 printing 5.14.2,两条路线有不同的包名和导入路径。

发布包归档的 SHA-256 与 pub.dev API 返回值一致,开头为 f00fdb8a08e3。比对源码后,我确认对照组的 Dart 文件和 rasterPdf 与发布包一致。本文通过 package:printing_ohos/printing.dart 接入。图 3 是核对主包 printing 5.15.1 时的发布页平台列表。

printing上游发布页的平台列表

图3 上游发布页

来源版本、标签或分支本文怎样使用
pub.dev 鸿蒙包printing_ohos 1.0.1光栅化基线。下载归档已校验
社区主分支masterprinting/ohos/ 内的自包含子包声明为 printing_ohos 1.0.1,原生实现与基线对应
社区公开标签5.14.2-ohos-1.0.0另一条主包路线,printing/ 声明为 printing 5.14.2,不是本文子包的来源
社区开发分支br_printing-v5.14.2_ohos_dev、br_printing-v5.15.1_ohos_dev核对主包路线已有的鸿蒙修正,区分社区成果与本次增量
上游发布主包printing 5.15.1发布页的平台列表未包含鸿蒙

表1 来源对照

为让两组都能在 API 26 下构建,我在两组中都移除了 cancelJob 中系统专用的 print.cancelPrintJob 调用,更新了仓库地址和示例入口。对照组保留发布包的原始光栅化逻辑,打印取消不在这组实验里。

社区开发分支已经修过双 PixelMap 释放、pages 下标映射、加载失败回调、临时文件删除和打印流关闭。我把这些已有修正带到 1.0.1 子包,再补上 PdfPage.release()、异步释放等待、同步缩放、有效字节切片和临时文件隔离。

我还把像素和编码缓冲改成可复用的工作区,并补了回执报错、超时和引擎解绑的退出路径。五对实验一起启用了这些改动,表里的降幅对应整份补丁。要量出某一处改动节约多少,还得单独开关它再测。

二、同条件对照与运行结果

2.1 固定负载和采样时点

我在同一 Mate X7 模拟器上做了五对冷启动,顺序为 AB / BA / AB / BA / AB,A 是保留发布包光栅化逻辑的原版,B 是补丁版。两组使用相同的 Dart 页面、依赖和十页样例,包名不同,便于分别安装。每次运行前强停上一进程,确认旧 PID 消失,再启动新进程。十次运行的 PID 各不相同,汇总时间也晚于各自启动时间。

每次连续处理三轮,共三十页,不发起网络请求。页面采用 A4 尺寸,发布包基线把 PDF 点尺寸乘二作为基础栅格宽高,因此 dpi: 72 在本例中产出 1190 × 1682 像素。单页 RGBA 为 8,006,320 字节,约 7.64 MiB。两组都使用这个倍率和相同的输出尺寸。社区开发分支已改过倍率,迁移到那里时应重新测量。

每次冷启动按固定顺序采集以下五个时点:

  1. 处理开始前:记录进程初始基线读数。
  2. 第 1 轮十页结束:采集第一轮样本,随后等待 300 ms。
  3. 第 2 轮十页结束:采集第二轮样本,随后等待 300 ms。
  4. 第 3 轮十页结束:采集第三轮样本,不再追加 300 ms 轮间间隔。
  5. 末尾静置等待后:最后一轮结束后等待约 1500 ms(日志实测间隔为 1500—1509 ms)再采集一次。

宿主里的独立观测插件读取 hidebug 的 PSS、VSS、Native 已分配量,并统计 /proc/self/fd。表中沿用工具单位 kB,换算成 MiB 时除以 1024。

sampledMaxPssKb 取五个固定样本中的最大值。两次采样之间的瞬时峰值没有被记录,最后一次读数也只对应约 1.5 秒的等待点。查看 JSON 时可以结合 measurementScope 确认采样范围。

2.2 五对结果与波动

我将十次运行的摘要与各自 HiLog 对照,再按组计算中位数和范围。括号内列出五次运行的最小值与最大值,降幅按两组中位数计算。

指标原版中位数(范围)补丁中位数(范围)中位数变化
阶段样本最大 PSS / kB1,830,366(1,829,435—1,830,439)725,133(535,866—727,594)🔴↓ -60.4%
阶段样本最大 ΔPSS / kB1,678,810(1,678,008—1,679,333)573,404(385,385—575,095)🔴↓ -65.8%
等待 1.5 s 后 PSS / kB1,766,365(1,736,972—1,829,686)725,085(457,917—725,730)🔴↓ -59.0%
等待 1.5 s 后 ΔPSS / kB1,614,846(1,585,738—1,678,653)573,148(307,436—573,478)🔴↓ -64.5%
Native 已分配增量 / kB1,231,888(1,231,652—1,232,508)568,505(289,493—568,857)🔴↓ -53.9%
VSS 增量 / kB2,049,784(2,003,620—2,162,436)834,184(705,292—852,568)🔴↓ -59.3%
阶段样本最大 FD 增量+65(+64—+65)+5(+3—+6)🔴↓ -92.3%

表2 五对结果

这五次原版的阶段最大值比较接近,都在 1,830,000 kB 左右。等待后的读数则分成了两段。前三次回落到 1,737,000—1,766,000 kB 附近,后两次仍约为 1,830,000 kB。Native 与 VSS 增量也取最后一次等待采样,与处理前的读数相减。

补丁第三次在第二轮末尾读到 535,866 kB,第三轮降到 457,745 kB,等待后为 457,917 kB。其余四次的阶段最大值在 724,407—727,594 kB 之间。那次较早的回落也计入中位数和范围,日志里没有与它对应的 GC 事件。

第五对的两张运行截图如下。原版等待后 PSS 为 1,829,686 kB,补丁为 725,730 kB。它们是这一对的结果,表 2 则汇总全部五对,二者不要混用。

第五对原版诊断完成后的模拟器画面

图4 原版记录

第五对补丁诊断完成后的模拟器画面

图5 补丁记录

2.3 用 hidumper 检查分项

我也用 hidumper 看了第五对的分项。

  • dev 分项 PSS:原版为 23,489 kB,补丁为 7,849 kB,下降约 66.6%。
  • native heap 分项 PSS:原版为 1,245,106 kB,补丁降到 637,461 kB。

这两项都是 hidumper 的分项 PSS,不能与表 2 中 hidebug 读取的 Native 已分配增量互换。

第五对 hidumper 内存分项与阶段日志

图6 内存分项

我从 dev 和 FD 的变化着手查 PixelMap,再从 native heap 看原生缓冲。hidumper 的这张表没有分配调用栈,暂时无法分辨 PdfPage、通道编码和图像解码各占多少。

脚本在诊断摘要和页码子集核验结束后才执行 hidumper --mem。第五对外部总 PSS 从 1,389,493 kB 降至 766,440 kB。图 4、图 5 取的是更早的进程内等待点,两套读数各自按同工具比较。

2.4 构建和双环境配置

环境项版本或条件
Flutter / Dart3.41.9+ohos-1.0.2 / 3.11.5
DevEco Studio26.0.0.865
SDK / API26.0.0.105 / API 26
模拟器环境Mate X7,arm64,镜像配置 7.0.0.107,系统回读 OpenHarmony-7.0.0.105
实体真机环境华为畅享 100 Pro Max(型号 YEW-AL80),固件 7.0.0.100,系统回读 OpenHarmony-7.0.0.105,arm64-v8a
插件printing_ohos 1.0.1 及修订版
构建模式Profile(模拟器无签名;真机配本地调试签名,每次切换前先卸载旧包)
文档负载十页生成的 A4 表格,各页带不同页码

表3 运行环境

我先从 Mate X7 模拟器采集五对冷启动基准和连续切页录像,随后又通过 USB 连接华为畅享 100 Pro Max 实体真机做了两对冷启动与浏览复测。图 7 对照了插件注册和 Example 的路径依赖,两组均使用 Profile HAP。

插件注册与 Example 路径依赖

图7 工程配置

2.5 实际切页与返回

接着我打开浏览页,按同一顺序核对页码显示与路由退出:

  1. 进入浏览页,默认请求并查看第 1 页。
  2. 点击切换到第 3 页,核对 PDF 蓝色页眉里的 Page 标记与表格编号:原版页面标题虽已变化,文档内部仍显示 Page 1 / 10。补丁版显示 Page 3 / 10,表格编号也从 INV-01 变为 INV-03。
  3. 继续点击第 10 页:原版仍停留在第一页内容,补丁版正确显示 Page 10 / 10。
  4. 点击顶部“返回监测”回到诊断页,核对路由销毁与图像释放日志。

原版请求第三页但文档仍显示第一页

图8 原版错页

补丁请求第三页并显示第三页内容

图9 补丁页码

下面两段连续录像保留了进入浏览页、切页和返回的完整操作,都在三十秒以内。两组从诊断完成页进入浏览入口,录像不参加冷启动统计,也不用来比较操作耗时。返回画面另见图 12。

原版依次请求第1、3、10页的连续操作

图10 原版切页

补丁版依次查看第1、3、10页的连续操作

图11 补丁切页

点击“返回监测”后,两组都记录了 route_disposed。日志中的 displayedRequests 是 [1,3,10],disposedImages 为 3,loadingAtClose 为 false。这说明宿主在图像显示完成后退出,并释放了所持有的三张 ui.Image。加载中退出由宿主测试覆盖。

补丁版退出PDF浏览后返回诊断页面

图12 返回监测

2.6 华为畅享 100 Pro Max 真机复测

只在模拟器上跑还不够,我又把原版与补丁版在纯英文隔离目录配合本地调试签名打成 Profile 包,装到 华为畅享 100 Pro Max 实体真机(型号 YEW-AL80,固件 7.0.0.100,系统回读 OpenHarmony-7.0.0.105)上,按 AB / BA 交替跑了两对共四个独立进程的十页三轮光栅化诊断,结果见表 4。

指标(畅享 100 Pro Max 真机)原版两次均值(样本)补丁两次均值(样本)均值变化
阶段样本最大 PSS / kB932,852(928,937 / 936,767)543,389(542,799 / 543,979)🔴↓ -41.8%
阶段样本最大 ΔPSS / kB764,223(759,783 / 768,663)374,042(373,094 / 374,989)🔴↓ -51.1%
等待 1.5 s 后 PSS / kB929,111(921,454 / 936,767)439,854(439,180 / 440,527)🔴↓ -52.7%
等待 1.5 s 后 ΔPSS / kB760,482(752,300 / 768,663)270,506(269,642 / 271,370)🔴↓ -64.4%
Native 已分配增量 / kB540,371(531,916 / 548,825)302,704(302,356 / 303,052)🔴↓ -44.0%
VSS 增量 / kB1,407,892(1,397,364 / 1,418,420)537,520(524,608 / 550,432)🔴↓ -61.8%
阶段样本最大 FD 增量+39(+39 / +39)+6(+6 / +6)🔴↓ -84.6%

表4 真机两对

真机上的读数有两处值得对照:一是原版在畅享 100 Pro Max 上的阶段最大 PSS 约为 933 MB,约为 Mate X7 模拟器的一半,说明底层图形与分配器实现在不同形态设备上有明显基数差异;二是等待 1.5 s 后的额外 PSS 增量均值从 760,482 kB 降到 270,506 kB(下降 64.4%),与模拟器的 -64.5% 非常接近,阶段最大 FD 增量也从 +39 降到 +6。第 1 对摘要后的 hidumper --mem 还显示,共享内存 Total Ashmem 从原版的 15,692 kB 降到了补丁版的 52 kB。

页码子集核验与真实浏览结果也与模拟器一致:两次原版请求 pages: [2] 均落回第 1 页指纹,两次补丁均匹配全量第 3 页指纹;在真机补丁进程(PID 49943)进入浏览页依次点击第 1、3、10 页并返回后,日志同样记录 displayedRequests: [1,3,10] 与 disposedImages: 3。图 13 和图 14 是畅享 100 Pro Max 真机经 DevEco Studio 投屏(顶栏设备序列号已做不可逆像素马赛克脱敏)完成三轮光栅化后的原版与补丁对照画面。

华为畅享 100 Pro Max 真机原版诊断结果与错页提示

图13 真机原版

华为畅享 100 Pro Max 真机补丁诊断结果与页码通过

图14 真机补丁

三、沿着一页图像的生命周期修改

3.1 先找到谁申请、谁释放

3.1.1 发布包光栅化路径的资源与边界缺口

🐛 发布包的 rasterPdf 先将 PDF 写入临时文件,再创建 PdfDocument,循环取得每一页。循环里生成两个 PixelMap,并分配两个 ArrayBuffer 读取像素。单页处理结束时,这两个 PixelMap 和单页 PdfPage 对象都没有显式释放,文档只在循环正常结束后调用 releaseDocument()。

除此之外,原版这段逻辑还存在四处容易被常规成功路径掩盖的问题:

  • 失败缺少结束通知:loadDocument 解析失败时直接 return,Dart 侧 Stream 收不到结束事件。
  • 子集页码下标错位:调用方传入 pages: [2] 时,循环仍按自身递增下标 0 去取页,导致请求第三页却拿到第一页。
  • 缩放调用未等待异步结果:pixelMap.scale() 返回 Promise,原版未等待完成就紧接着读取图像宽高。
  • 底层缓冲边界越界风险:写文件时直接传入 data.buffer,当 Uint8Array 只是更大缓冲区上的切片视图时,会把视图区间之外的字节一并写入。
3.1.2 按页与按作业分层清理的核心实现

我先把资源归属放回各自的生命周期:一次作业负责临时文件和 PdfDocument,一次单页循环负责两个 PixelMap 与 PdfPage。单页无论发送成功、回执报错还是抛出异常,都在内层 finally 尝试清理。整个作业结束后,再在外层 finally 释放文档并删除临时文件。成功完成通知放在外层清理结束之后发出,避免 Dart 已经启动下一轮,原生还在回收上一轮的资源。图 15 展示单页正常完成时的清理顺序。

单页接收回执与资源清理的正常顺序

图15 清理顺序

临时文件也要严格归属到具体的一次作业。Dart 侧作业编号会在引擎重绑后重新从零分配,不同插件实例也可能拿到相同编号。若只拼接作业编号命名,旧作业延迟执行的 finally 就可能删掉新作业正在读取的文件。这里从 @kit.ArkTS 导入 util,每次调用 util.generateRandomUUID() 生成独立文件名,让清理只针对本次创建的路径。

我先看作业入口、结束通知和字节切片三个函数。入口等内部作业结束后再通知 Dart,异常也走同一个结束出口。单页循环和两层 finally 见 PrintingJob.ets。

  private static sliceExactBuffer(data: Uint8Array): ArrayBuffer {
    // 只复制当前视图的有效字节,避免把底层缓冲中的其他数据写入 PDF。
    return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
  }

  rasterPdf(data: Uint8Array, pages: number[] | null, scale: number): void {
    // 等内部清理结束再通知 Dart,失败也通过同一个出口结束流。
    this.rasterPdfInternal(data, pages, scale).then(
      () => this.finishRaster(null),
      (error: Object) => this.finishRaster(`${error}`)
    );
  }

  private finishRaster(error: string | null): void {
    if (error !== null) {
      Log.e(TAG, 'Failed to raster pdf: ' + error);
    }
    try {
      this.printing.onPageRasterEnd(this, error);
    } catch (sendError) {
      Log.e(TAG, `Failed to report raster completion (job ${this.index}): ${sendError}`);
    }
  }
3.1.3 引擎代次校验与清理异常保留

assertRasterActive 检查作业所属的引擎代次。作业创建时记下代次,引擎解绑或重新绑定后代次递增,旧作业就不能继续往新通道发送数据。sliceExactBuffer 只复制视图的有效区间,它处理的是字节边界,不改 PDF 内容。

内部作业用请求数组里的下标调用 getPage(),跳过小于零或超出总页数的下标。缩放改用 scaleSync(),读取宽高和像素时缩放已经完成。

我在内部实现里按资源分别捕获清理错误。比如转换后的 PixelMap.release() 抛错,源 PixelMap 和 PdfPage 仍会进入清理,外层继续尝试释放文档和删除临时文件。最初的作业错误与 cleanupErrors 会合并后报告,日志能指出哪一步出错。

清理函数返回错误时,系统句柄的实际状态还需要进一步观察。进程被强停后,应用的清理代码也无法继续执行。

3.2 减少同一作业里的重复分配

十页样例的每页尺寸相同。原版在每一页里都新建两块约 7.64 MiB 的 ArrayBuffer,三轮共六十次大块申请。补丁把这两个中转缓冲提到页循环外:第一次按实际字节数申请,后续页长度一致就直接复用,遇到尺寸变化再替换为对应长度。它减少的是同尺寸页面的重复分配次数,不能把累计少申请的字节数直接当成 PSS 降幅。

通道编码同样有一块临时工作缓冲。当前鸿蒙 Flutter 嵌入层的 StandardMethodCodec 从较小的初始容量开始写消息,写入整页像素时需要扩容。为了避免每页重复分配这块编码工作区,我为光栅消息定制了 RasterAwareMethodCodec,普通控制消息仍交给父类实现。

const RASTER_CODEC_OVERHEAD_BYTES = 256;

class RasterAwareMethodCodec extends StandardMethodCodec {
  private scratchBuffer: ArrayBuffer | null = null;

  constructor() {
    super(StandardMessageCodec.INSTANCE);
  }

  override encodeMethodCall(methodCall: MethodCall): ArrayBuffer {
    if (methodCall.method === 'onPageRasterized' && methodCall.args instanceof Map) {
      const argMap = methodCall.args as Map<String, Object>;
      const img = argMap.get('image');
      if (img instanceof Uint8Array) {
        const needed = img.byteLength + RASTER_CODEC_OVERHEAD_BYTES;
        // 容量够用就复用工作区,遇到更大的页面才扩容。
        if (this.scratchBuffer === null || this.scratchBuffer.byteLength < needed) {
          this.scratchBuffer = new ArrayBuffer(needed);
        }
        const stream = ByteBuffer.from(this.scratchBuffer);
        StandardMessageCodec.INSTANCE.writeValue(stream, methodCall.method);
        StandardMessageCodec.INSTANCE.writeValue(stream, methodCall.args);
        // ByteBuffer 的 getter 返回独立切片,后续复用不会改写已发送的消息。
        return stream.buffer;
      }
    }
    return super.encodeMethodCall(methodCall);
  }

  releaseScratch(): void {
    // 作业结束或引擎解绑后放开引用,避免空闲时仍持有整页工作缓冲。
    this.scratchBuffer = null;
  }
}

这个类复用的是编码过程中的 scratchBuffer 临时空间。stream.buffer 会复制出独立切片,下一页可以继续使用工作区,已发出的消息仍保留原内容。作业结束或引擎解绑时会调用 releaseScratch() 清空引用,空闲期间不长期保留这块缓冲区。

多作业交错运行时,同一插件实例仍共用这块 scratchBuffer。编码本身同步完成,而且返回独立切片,因此复用工作区不会篡改已发出的页消息。但不同尺寸的页面可能引发扩容,前一个作业收尾清空引用后,并行作业在下一页也可能重新申请一次。本文没有把多文档高并发纳入性能验证范围,不能用串行结果替并发场景作保证。

3.3 等待的是接收回执,不是图片消费完成

原生调用 onPageRasterized 后,Dart 处理器创建 PdfRaster,将它加入 StreamController,随即返回接收响应。应用接下来才会消费事件,并在需要时执行 toImage()。

⚠️ 回执语义边界:StreamController.add() 不等待异步订阅者消费完成。界面解码慢时,已收到回执的像素仍可能排在 Dart 队列里。应用可以按需分批请求页码;若要让原生按实际消费速度发下一页,还需要单独的消费完成协议。

下面的完整函数处理成功、错误、未实现和超时响应。30 秒是回执的异常超时。编码器、回执和引擎解绑逻辑均可在 PrintingPlugin.ets 中查看。

const RASTER_ACK_TIMEOUT_MS = 30000;

  onPageRasterizedAsync(printJob: PrintingJob, imageData: Uint8Array, width: number, height: number): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      this.assertRasterActive(printJob.engineGeneration);
      const channel = this.channel;
      if (channel === null) {
        reject(new Error('Raster acknowledgement unavailable: Flutter engine detached'));
        return;
      }
      let data: Map<String, Object> = new Map();
      data.set('image', imageData);
      data.set('width', width);
      data.set('height', height);
      data.set('job', printJob.index);

      const ackId = this.nextAckId++;
      let settled = false;
      let timeoutId = -1;
      const finish = (error: Error | null): void => {
        // 超时、解绑和通道响应可能先后到达,只处理第一次结束。
        if (settled) {
          return;
        }
        settled = true;
        clearTimeout(timeoutId);
        this.pendingRasterAcks.delete(ackId);
        if (error !== null) {
          reject(error);
        } else {
          resolve();
        }
      };
      this.pendingRasterAcks.set(ackId, (error: Error): void => finish(error));
      timeoutId = setTimeout(() => {
        finish(new Error(`Raster acknowledgement timed out after ${RASTER_ACK_TIMEOUT_MS} ms (job ${printJob.index})`));
      }, RASTER_ACK_TIMEOUT_MS);

      const ack: MethodResult = {
        success: (_res: Any): void => {
          // 只确认 Dart 方法处理器已接收并入队,不代表 Stream 消费者已完成解码或绘制。
          finish(null);
        },
        error: (code: string, message: string, _details: Any): void => {
          finish(new Error(`Raster acknowledgement failed (job ${printJob.index}, ${code}): ${message}`));
        },
        notImplemented: (): void => {
          finish(new Error(`Raster acknowledgement not implemented (job ${printJob.index})`));
        }
      };
      try {
        channel.invokeMethod("onPageRasterized", data, ack);
      } catch (error) {
        finish(new Error(`Failed to send raster page (job ${printJob.index}): ${error}`));
      }
    });
  }

pendingRasterAcks 记录尚未完成的等待,引擎解绑时逐个拒绝,让挂起的作业立刻进入 finally 清理流程。定时器和通道回调可能先后到达,settled 标志保证每个等待只结算一次。正常响应后立即移除记录并清除定时器,迟到的回调不会唤醒已经终止的作业。

我保留了通道名称 net.nfet.printing、方法名称和字段含义。调用方仍然使用 Printing.raster,不用为这次释放修补重写业务接口。不过异常处理变得更严格:过去被当作成功忽略的通道错误,现在会明确结束当前作业。这是有意收紧的行为,接入页面应当处理流错误并允许用户重新打开文档。

四、剩余占用与异常边界

4.1 把同一进程继续跑到三百页

三轮结束时占用仍然偏高,我又单独启动了一个补丁进程,把同一份十页文档连续处理三十轮,共三百页。进程 PID 为 25625,中间没有重启。页码内容核验仍然通过,阶段样本中的最大 FD 增量为 5。这个实验只有一次,用来延长观察,不与第二节五对冷启动合并计算降幅。

采样位置PSS / kBFD
处理前151,799105
第 1 轮结束353,869108
第 2 轮结束544,464110
第 3 轮结束724,175109
第 4 轮结束592,686108
第 6 轮结束794,888108
第 7 轮结束233,507108
第 10 轮结束188,956108
第 20 轮结束195,924108
第 30 轮结束213,087108

表5 延长观察

前三轮仍在增长,第四轮回落,随后又继续升高。全程阶段样本的最大值出现在第六轮,为 794,888 kB。第七轮才降到 233,507 kB。第十轮之后的样本在 185,584—214,992 kB 之间波动,末段还有小幅抬升,不能据此画一条固定水平线。处理结束以后,我又在四个等待时点采样,实际间隔由宿主记录,结果见表 6。

目标等待实际间隔 / msPSS / kBFD
1.5 s1,504213,361108
5 s5,002213,313109
15 s15,002213,677108
30 s30,002209,413108

表6 等待采样

约三十秒后的 PSS 比开始值高 57,614 kB(🟢↑ +56.3 MiB)。Native 已分配量从 66,878 kB 增到 116,403 kB。这次延长运行里占用有所回落,但仍未回到处理前水平。摘要里的兼容字段 deltaPssKb = 61,562 取的是第一个 1.5 s 时点,不能误当成三十秒的差值。

日志没有能对应到回落时点的 GC 事件或堆分配调用栈。若要继续追查剩余占用,我会分别看通道复制、图像解码和原生分配器保留的内存。现有分项只能显示各处读数变化,尚无法确定回收的对象。

托管堆较小也不能排除与 Dart 页面有关的图像内存。纹理和外部字节数组不一定落在普通托管堆分类里。我及时销毁 ui.Image,只是减少宿主主动保留图像的影响。这一次三百页观察补上了短测之后的变化,长期运行上限和其他文档的表现仍需各自测量。

4.2 页数正确不代表页码正确

请求 pages: [2] 后,即便收到了一个 PdfRaster,也还没有证实它来自第三页。原版错误返回第一页时,返回数量同样为 1,图像宽高也完全相同,单靠这两个字段无法识别错页。

我在完整光栅化结果中记录第一页和第三页的像素指纹,再单独请求 [2]。子集结果必须同时满足两项条件:与第三页指纹一致,且与第一页指纹不同。这里对全部 RGBA 字节计算两个 32 位校验值,并附带尺寸和字节长度。用途限于同一份文档的内容比对,不作密码学安全校验,也不宣称零碰撞。五次原版的子集指纹都落回第一页,五次补丁都匹配第三页,与图 8、图 9 的可见页眉相互印证。

这里还要留意测试参照物来自哪里。如果主机测试替身收到 [2] 后自己合成一张“第三页”假数据,测试只能说明 Dart 把参数传过了通道,不能证明 ArkTS 真正调用了 pdfDocument.getPage(2)。设备侧内容核验必须经过 PDFKit 的真实渲染路径,才能覆盖本次页码修复。

4.3 失败路径怎样检查

我先在主机上跑了三组测试,再用模拟器看实际页面。测试各自覆盖这些行为:

  • Dart 通道与模型测试(3 个文件共 8 项):覆盖能力信息、光栅模型与通道信封格式,确认错误字段如何进入 Stream,但不触发鸿蒙 PDFKit 或 PixelMap。
  • 原生逻辑故障注入测试(14 项):直接加载修改后的 .ets 实现,用系统 API 替身注入发送失败、回执报错、等待超时、引擎解绑和清理抛错,检查后续资源清理是否继续执行、结束通知是否只发一次,以及旧作业是否会在重绑后误发数据。
  • Example 宿主测试(4 项):检查等待采样参数解析与页码指纹比对判定逻辑。

在 14 项原生测试中,有两项专门验证临时文件隔离:模拟引擎重绑后复用相同作业编号,以及两个插件实例同时使用相同编号,要求旧作业清理完毕后新作业的临时文件仍然完好。将命名逻辑临时退回“只拼作业编号”做负向核对时,恰好就是这两项用例失败。恢复 generateRandomUUID() 后全部通过,证实测试真正锁住了文件归属冲突。

回执超时、释放报错和引擎解绑由主机上的替身注入。模拟器与畅享 100 Pro Max 真机上我实际跑了正常光栅化、指定页切换、图像显示后退出,并在模拟器上完成了 300 页延长观察。

主机三组测试与模拟器四类运行验证的覆盖范围

图16 验证范围

4.4 复现环境也会影响结果

工程依赖与构建路径有两处实操限制需要先对齐:

  • 固定依赖锁文件:本工程使用社区 Flutter 3.41.9+ohos-1.0.2。printing_ohos 原有的 image 版本范围较宽,未固定锁文件时曾解析出与当前 archive 不兼容的组合。配套插件与宿主均保留经验证的锁文件,宿主显式声明 archive: ^3.6.1,实际锁定为 image 4.3.0 与 archive 3.6.1。复现对照时不应一边升级依赖一边并入统计。
  • 使用纯英文 ASCII 构建路径:当前 Hvigor 在含中文的目录路径下会报 00306003 Invalid project path。本地维护目录可保留中文,执行构建前应把干净源码复制到 ASCII 路径,并排除构建产物与工具缓存,避免把旧路径缓存带入新副本。个人签名配置也不放入示例源码。

此外,本文数据覆盖表 3 所列的 Mate X7 模拟器与华为畅享 100 Pro Max 真机及生成样例,以下条件尚未验证:

  • 折叠屏真机与极端低内存条件下的系统强杀阈值及前后台多任务竞争表现。
  • 大幅面扫描件或高 DPI 请求下的瞬时内存水位(高 DPI 按宽高同时放大像素面积,即便单页释放正常,一次请求过多大图仍会拉高瞬时占用)。
  • 实体打印机输出链路以及 Android、iOS 等其他平台的运行回归。

五、把修订版接入自己的预览页

5.1 添加依赖

这份补丁让指定页预览拿到正确内容,并在每页和整次作业结束时收尾原生资源。业务侧继续调用 Printing.raster,不需要另接一套通道。🧩 对于已经使用 printing_ohos 的鸿蒙工程,可以先替换依赖,再保留现有的按页预览逻辑。

社区仓库合并并发布新版本前,下面的依赖固定到公开复现分支 fix/ohos-pdf-raster-pixelmap-release 中已验证的提交。将这些条目合并进应用的 pubspec.yaml,保留工程原有的 Flutter SDK 依赖:

dependencies:
  printing_ohos:
    git:
      url: https://atomgit.com/abaoge/fluttertpc_printing.git
      ref: 76e0f951667036782d61e7acad4cce314482e149
      path: printing/ohos
  image: 4.3.0
  archive: 3.6.1

执行 flutter pub get 后,提交应用自己的 pubspec.lock。这里固定 image 与 archive,是为了保持第 4.4 节验证过的兼容组合。如果现有工程依赖其他版本,先解决约束冲突,再检查实际解析结果。仅把依赖写成 pub.dev 的 printing_ohos 1.0.1,拿到的仍是原发布包,不包含本文补丁。

这个入口对应自包含的 printing_ohos 包,导入路径也使用该包名。若项目原来导入的是 printing 主包,应先核对平台实现和依赖关系,不要同时引入两套使用同名平台通道的实现。补丁沿用 Apache-2.0 许可,源码保留原作者声明。

5.2 请求一页并取得图像

下面的完整函数接收应用已经读到的 PDF 字节和从 1 开始的页码,返回可交给 RawImage 显示的图像。它只请求一页,并等待光栅流正常结束后再解码。PDF 文件选择、读取与页面交互由应用负责。

import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:printing_ohos/printing.dart';

Future<ui.Image> renderPreviewPage(
  Uint8List pdfBytes, {
  required int pageNumber,
}) async {
  if (pdfBytes.isEmpty) {
    throw ArgumentError.value(pdfBytes.length, 'pdfBytes', 'PDF 内容不能为空');
  }
  if (pageNumber < 1) {
    throw ArgumentError.value(pageNumber, 'pageNumber', '页码必须从 1 开始');
  }

  final raster = await Printing.raster(
    pdfBytes,
    pages: [pageNumber - 1],
    dpi: 72,
  ).single;
  // 图像交给调用方持有,替换页面或退出预览时由调用方 dispose。
  return raster.toImage();
}

例如,传入 pageNumber: 3 就会向插件请求零基下标 2。.single 要求这次流只返回一页,并等到流结束才给出结果。如果页码超出文档范围而没有图像返回,它会抛出 StateError;PDF 解析失败、通道错误和图像解码错误也会向调用方传播。页面应在等待时显示加载状态,在捕获错误后给出重试入口,不能把失败结果继续当作上一页内容展示。

💡 返回的图像由页面管理。 新图替换旧图后,释放旧的 ui.Image;离开预览页时,释放当前图像。如果用户在等待解码时返回,结果到达后先检查页面是否仍然存在,已经退出就立即释放新图,不再更新界面。用户快速连续点页码时,还应串行请求或核对请求序号,过期结果同样需要释放。完整的切页与退出处理可以参考同款 Example中的浏览页。

按页请求、保留新图并释放旧图、退出时释放当前图像

图17 图像释放

5.3 选择请求范围和使用条件

我会先按当前可见页请求,再根据实际交互决定是否预取相邻页。长文档不宜在首屏一次请求全部高分辨率图像。插件收到 Dart 的接收回执后就可以处理下一页,接收回执并不代表页面已完成解码,应用仍要控制请求数量与缓存范围。

pages 使用零基页码,省略时会处理整份文档。dpi 越高,图像的宽高和字节量越大;这份基线在 72 DPI 下仍沿用双倍栅格倍率,具体尺寸见第 2.1 节。提高分辨率前,应先在目标设备上确认单页尺寸和内存预算,不能直接套用本文低分辨率样例的占用数据。

这次验证覆盖表 3 所列的鸿蒙模拟器与华为畅享 100 Pro Max 实体真机、按页浏览及固定文档负载。插件无新增权限,PDF 来自应用已有字节,临时文件保存在应用目录。系统打印取消、实体打印机和其他平台不在本次修复的验证范围内。若需要复查五对实验、真机复测或延长运行,完整工程、测试、构建和采样步骤已放在公开复现说明。

小结

起初,我只是想把一个 PDF 预览页在鸿蒙设备上跑顺。顺着错页和内存增长往下查,才逐渐理清了页面、插件与系统对象之间的关系。过程虽然艰难,但优化后的 package 在模拟器与畅享 100 Pro Max 真机上都修好了指定页预览,并让连续处理 PDF 时的额外内存占用 🎉 减少了约三分之二(模拟器最大增量降 65.8%,真机等待点增量降 64.4%)。

这次双环境验证也让我多了一层体会:不同形态设备的内存基数虽然不同,但原生资源逐页收尾和页面退出释放图像这两条边界同样关键。以后再接类似的跨端能力,我会沿着一次完整的使用过程检查这些交接,在模拟器和真机上把打开、切页与退出一起验证。

参考资料

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐