📦 Flutter 三方库 flutter_image_clipboard 是用于将本地图片写入系统剪贴板的 package,但原有鸿蒙实现会在主线程同步读文件和提交剪贴板,复制高清大图时会阻塞事件循环。我在原库基础上做了三处改动:异步文件描述符直解、异步剪贴板写入与原生资源逆序释放。
完整代码已开源在 oh-flutter/flutter_image_clipboard,可直接使用。有问题可以到仓库提 Issue,也可以来 CPF-Flutter 鸿蒙社区 交流 Flutter 鸿蒙适配问题。

前言

我给一个 Flutter 海报预览页加了复制功能,想把生成的高清海报复制到另一个应用里。接入社区版 flutter_image_clipboard 后,我在鸿蒙模拟器里一边复制大图,一边观察滚动列表和心跳游标,发现游标会停顿。复制最终能完成,但等待时的响应不太对,我便顺着插件的调用往下查。

翻看源码后,我发现读文件和提交剪贴板用的都是同步接口,执行时会占住主线程。于是,我把文件打开和剪贴板写入换成异步调用,解码时直接传入文件描述符,也调整图像对象和文件的释放逻辑。

为了看看改动有没有效果,我不仅在本地 Mate X7 模拟器里用同一张 2560×1920 海报交替跑了五组 Profile 对照与双向像素核对,还把两组代码装到 华为畅享 100 Pro Max 真机(型号 YEW-AL80)上补充了真机实测。

在模拟器的 25 轮对照里,平台事件循环最大间隔均值从 300.32 ms 降到了 54.36 ms,减少了 81.90%,复制调用平均耗时则从 493.01 ms 增加到了 662.93 ms,多了 34.47%。在畅享 100 Pro Max 真机上,我还发现了模拟器未暴露的系统剪贴板单条 IPC 容量上限与同步静默失败差异;改用容量内的 1280×960 海报跑五轮 Profile 对照后,真机事件循环最大间隔均值从 133.20 ms 降到了 11.20 ms,减少了 91.59%,复制耗时均值仅从 174.77 ms 微增至 184.28 ms。复制期间的响应改善、完成耗时的变化以及真机容量边界,都得放在一起看。

一、使用场景与卡顿复现

1.1 从一次复制操作开始

📱 我用本地 Mate X7 模拟器测试,样张是一张 2560×1920 的 PNG。应用先把样张保存到自己的缓存目录,点击复制时直接把本地文件传给插件,计时期间不再生成海报。我想看看等待时页面能否继续响应,复制结束后,再切到另一个应用里粘贴图片。

鸿蒙折叠屏复制海报的页面、插件、系统与接收核对流程

图1 复制流程

原版通过 FlutterImageClipboard().copyImageToClipboard(file) 复制图片。我让列表执行一次 650 ms 的滚动动画,并每隔 16 ms 尝试发起一次轻量通道查询,观察等待期间的变化。上一笔查询没有返回,就先不发下一笔;页面上的游标跟着查询返回更新。

整个操作分为三步:准备海报、调用插件复制,再切到接收页面核对。这几步分别由系统、插件和页面完成:

  • 鸿蒙系统层:图像框架负责把文件解码成 PixelMap,剪贴板服务接收 pasteboard.MIMETYPE_PIXELMAP 数据。
  • 三方插件层:通过 clipboard_image 通道接收文件路径,调用系统的解码和写入接口。
  • Flutter 页面与 Example:页面决定复制哪张图、何时禁用按钮、失败后显示什么。用来接收图片并核对内容的代码也放在 Example 中。

仅在页面增加一个加载动画,不能消除插件中的同步等待。

要检查的操作原版的表现我要改动或检查的地方
复制固定海报能返回,并查到剪贴板存在 pixelMap 类型保留写入能力,缩短平台事件循环的长间隔
复制期间查询状态历史采样记录到较长的通道往返时间使用同一探针比较,保留退化轮次
连续复制原生代码没有显式释放图像对象补齐释放,检查失败时是否仍关闭文件
复制后使用图片类型查询无法说明图片内容是否完整在接收端实际读取并显示,再核对尺寸和图像

表1 排查方向

我先把问题限定在大图复制期间的响应上:保持页面和图片不变,只替换插件实现,看测量结果怎么变。改动异步流程时,再一起检查资源能否释放、错误能否传回页面。

1.2 核对社区实现,再确定修改哪一个包

我检查了 flutter_image_clipboard 1.0.0 上游包,以及 oh-Flutter、CPF-Flutter 的相关实现。Flutter 自带的 ClipboardData 提供文本字段,要复制图片,还得看看现有插件接了哪些鸿蒙接口。

pub.dev 上 flutter_image_clipboard 的包说明

图2 上游包页

原包由 Numan Shakir 在 pub.dev 和 GitHub 仓库按 MIT 许可发布,提供公开 Dart 接口和 Android、iOS 实现。oh-Flutter 社区沿用这套 Dart 接口,补上了鸿蒙解码和系统剪贴板写入。CPF-Flutter 的 fluttertpc_image_clipboard 则使用另一套接口,我也一起查了源码。

我选择 oh-Flutter 的 1.0.0-ohos.1 作为基线,修改鸿蒙侧的文件输入、剪贴板提交和资源清理,并将核心补丁与单测向社区仓库 oh-flutter/flutter_image_clipboard 提交了 PR。采样与跨应用接收代码放在独立的 diagnostic_example 中,保存在公开复现分支供对照验证。查过的发行版本列在表 2。

来源版本 / tag源码核对结果
pub.dev/flutter_image_clipboard1.0.0提供 FlutterImageClipboard().copyImageToClipboard(File);该发布包包含 Android、iOS 实现
oh-flutter/flutter_image_clipboard1.0.0-ohos.1使用相同 Dart 接口;鸿蒙侧解码文件,再调用系统剪贴板写入
CPF-Flutter/fluttertpc_image_clipboard1.1.0_1-ohos-1.0.0API 为 ImageClipboard.copyImage(String),检查到的调用路径读取 assets 字节,鸿蒙实现未调用系统 setData

表2 版本核查

我也检查了分支里的代码:oh-Flutter 分支中的原生插件文件与 1.0.0-ohos.1 一致;CPF 的 br_3.27_dev 开发分支同样没有补上系统剪贴板写入。

这些包名字相近,用法却有差别。CPF 候选库虽然接收字符串,查到的路径实际读取的是 assets,不能直接把沙箱文件路径传进去当作同一种用法。相比之下,表中的 oh-flutter/flutter_image_clipboard 已经能把本地图片写入系统剪贴板,我可以直接从它的执行方式入手。

基线的 copyImage 内部按以下四步顺序执行:

  1. 调用 openSync、statSync 和 readSync,在当前调用线程把整张 PNG 读进 ArrayBuffer。
  2. 调用 await source.createPixelMap() 异步解码生成位图。
  3. 调用 setDataSync 同步向系统剪贴板提交数据并等待返回。
  4. 在 finally 中仅执行 closeSync(file) 关闭文件。

这里容易看漏的是:第 2 步已经异步解码,但前后的读文件和提交操作仍是同步的。函数带了 async,也不会自动把内部的同步调用变成异步。

1.3 固定样张、页面与运行条件

我给 baseline 与 patched 配了相同的 diagnostic_example,两组使用同一套页面、图像生成算法和观测插件。为了同时安装,包名分别设为 com.example.imageclipboard.baseline 与 com.example.imageclipboard.patched,再用构建参数 VARIANT 区分实验组。页面有调整时,两组一起改。

页面上有六条画稿列表。测试海报用 PictureRecorder、Canvas 和固定随机种子 20261111 生成,包含渐变、18,000 个半透明圆斑及五道圆环,方便重复测试同一张复杂图片。我在开始计时前生成样张,有缓存就直接复用,这样复制耗时里就不会混进绘图和 PNG 编码时间。

我生成的 PNG 是 5,942,090 字节,约 5.67 MiB。按 2560×1920×4 算,RGBA 像素数据有 19,660,800 字节,约 18.75 MiB 或 19.66 MB;这是像素数据的大小,进程峰值内存还需要另外测。固定随机种子能让图像内容一致,不过 Flutter 引擎或编码器版本不同,PNG 字节也可能不同,复现时要记下实际文件大小。

权限也要按操作分开。HarmonyOS Pasteboard API 参考中,写入剪贴板和调用 hasData()、getMimeTypes() 都不需要申请 ohos.permission.READ_PASTEBOARD,性能采样只用到这几项。我在 Example 里声明读取权限,是为了让“粘贴并核对”按钮能读出图片;用户点这个按钮时才申请授权。

插件注册和 Example 的配置放在图 3 中。

插件注册和接收核对页面的配置

图3 插件配置

二、五对 Profile 实测结果

2.1 重新打包,再看复制时的表现

改完资源释放和 Example 的错误处理后,我用 flutter build hap --profile --no-codesign 重新打包。两组 Profile 包使用同一页面与探针,正式采样时的源码摘要保存在 source-manifest.json。下面用的是 schemaVersion: 2 的数据,旧探针的数据和画面另外归档。

采样结束后,我还改了两个不准确的页面标签,两组一起更新。这两处只改显示文字,计时、样本归属和复制逻辑都没变:

  • 5,942,090 字节原来标成了 5.67 MB,改为 5.67 MiB,解码后的像素数据标为 18.75 MiB。
  • 16 ms 帧频探针 改为 16 ms 通道探针,它测的是通道查询,不是屏幕刷新帧率。

改完标签后的 main.dart 摘要另存在 ui_label_correction_hashes.json。因此,截图版本和正式采样版本的文件摘要有差别,核对源码时要找对应的那份记录。

我选择 Profile 模式,是为了减少调试机制对时间测量的影响。排查功能问题可以用 Debug,但两种模式测出的耗时不宜直接比较,关掉 DEBUG 横幅也不会切换运行模式。两组都跑在同一台 Mate X7 模拟器上,版本列在第五节。

图 4、图 5 记录了原版和异步版复制时的游标与列表变化。

原版复制海报的模拟器操作记录

图4 原版实录

异步版复制海报的模拟器操作记录

图5 异步实录

五对正式实验时,Mate X7 处于半展开状态。后来录制图 4 至图 7 时,我把模拟器完全展开,去掉了折痕叠层。两种姿态的分辨率相同,这几次手动操作没有计入性能统计;界面显示的是“事件循环最大间隔”和“折算量”。

原版应用的模拟器采样界面

图6 原版界面

异步版应用的模拟器采样界面

图7 异步界面

2.2 把响应和复制耗时放在一起看

先预热,再交替运行

我按 AB、BA、AB、BA、AB 的顺序启动应用,A 是 baseline,B 是 patched。每次启动先跑第 0 轮预热,再正式复制五轮,两组各得到 25 次观测,不计预热。十次运行的 PID 各不相同,我也记下了启动时间和顺序。期间定期检查宿主进程,没有看到并发编译任务。

每次都重新启动应用进程,但复制前已经预热,样张文件也已存在,所以这组数据测的不是冷文件缓存。两组复制相同的 2560×1920 PNG,生成图片和计算接收端像素摘要都放在计时窗口之外。

这些数字分别测了什么

每次复制,我记录下面几项:

  • maxEventLoopGapMs:原生 4 ms 定时器相邻回调的最大时间差。
  • totalLongGapMs:同一观测窗口内所有超过 16.7 ms 的间隔累加和。
  • estimated60HzIntervals:将超阈值间隔按 floor(gap / (1000 / 60)) 累加折算的阻塞量。
  • 已完成 ping 最大往返:只统计在当前窗口发出、且在窗口关闭前完成的请求最大耗时。
  • 复制调用耗时与原生观测窗口:复制耗时用 Dart Stopwatch 测量从发起复制到通道调用返回;原生观测窗口还包含调用前后的预留时间,两个区间长度不同,不能相减去推算某个未单独测量的阶段耗时。

totalLongGapMs 和 estimated60HzIntervals 用的是同一组定时器间隔,只是算法不同。这套探针没有记录 VSync 或实际呈现帧,因此能看的是事件循环响应,折算量不能当作真实丢帧数。

25 轮汇总与五对批次比较

我把每组 25 个单轮读数整理成表 3,同时列出均值、中位数和范围。以最大间隔为例,先取每轮窗口里的最大值,再算这 25 个值的均值。把中位数也放进来,能看出均值有没有被少数慢样本拉高。

指标原版:25轮均值/中位数/范围异步版:25轮均值/中位数/范围均值变化
最大事件循环间隔300.32 / 262.00 / 199~841 ms54.36 / 27.00 / 9~428 ms🔴↓ -81.90%
超阈值间隔累计336.32 / 285.00 / 199~876 ms128.44 / 55.00 / 0~615 ms🔴↓ -61.81%
按60Hz折算的阻塞量19.32 / 16 / 11~526.44 / 3 / 0~35🔴↓ -66.67%
已完成ping最大往返148.75 / 22.34 / 4.40~850.65 ms37.12 / 21.22 / 3.97~129.69 ms🔴↓ -75.04%
复制调用耗时493.01 / 469.32 / 334.00~1018.25 ms662.93 / 596.00 / 373.89~1231.40 ms🟢↑ +34.47%
原生观测窗口长度582.48 / 557.00 / 418~1108 ms758.20 / 684.00 / 471~1324 ms🟢↑ +30.17%
存在及MIME校验25/2525/25均查到 pixelMap

表3 采样汇总

百分比按原始单轮记录计算,表里的小数做了舍入。ping 的均值降了 75.04%,但中位数只从 22.34 ms 变到 21.22 ms,可见均值受少数慢样本的影响较大,并非每次查询都快了这么多。异步组也出现过 428 ms 的最大间隔,偶尔仍有较长等待。

我又把每个批次的五轮读数分别取均值,看看同一对实验里有没有变慢的情况,结果放在表 4。这里三项都用毫秒,比较的是批次均值,与表 3 的单轮中位数不同。

对次及顺序原版:最大间隔/ping/复制耗时异步版:最大间隔/ping/复制耗时
1,AB276.60 / 21.49 / 440.9741.40 / 34.50 / 598.69
2,BA438.60 / 389.29 / 642.1574.80 / 77.79 / 916.10
3,AB305.00 / 257.63 / 536.9932.40 / 23.72 / 629.28
4,BA222.00 / 12.38 / 363.18108.40 / 41.92 / 756.10
5,AB259.40 / 62.93 / 481.7614.80 / 7.68 / 414.49

表4 五对明细

五对的最大间隔均值都下降了,超阈值累计和折算量也一样,说明这份补丁确实减少了这组实验中的较长事件循环间隔。不过,ping 有两对变慢:第 1 对的批次均值增加了 60.54%,第 4 对增加了 238.61%。

复制耗时的变化更明显:前四对都增加了,第 4 对达到 +108.19%,只有第 5 对缩短。整体平均多等了约 170 ms,所以接入时还得考虑完成时间。异步调度、FD 输入路径、额外的释放操作和模拟器调度都可能影响耗时。我还没有逐阶段耗时、线程 CPU 和调度 Trace,暂时分不清这几部分各占多少。

2.3 再到另一个应用里粘贴看看

图 8 放了 ClipboardBench 第 1 对的 ROUND 1/2,以及两次接收的 PASTE_VERIFY 日志。十份原始性能日志、逐轮数据和统计结果都已保存。每组日志里有第 0 轮预热和五轮正式采样,SUMMARY 只汇总后五轮。

第 1 对采样与双向接收的日志

图8 日志摘录

每次复制前,Example 先清空剪贴板,确认 hasBefore=false;复制后,再检查 hasAfter=true,以及 MIME 列表里有没有 pixelMap。两组各 25 轮都通过了,日志中的 allMetadataVerified 记录的就是这项元数据检查。

查到类型以后,还得看看图片尺寸和像素对不对。我把接收核对单独放在自动采样之后,两个方向各试一次:

  1. baseline → patched:先用 baseline 复制高清海报,再切到 patched 点击“粘贴并核对”。系统请求读取权限时,我选了“本次使用时允许”,接收日志在 19:18:24.480 记下 PASTE_VERIFY match=true。
  2. patched → baseline:换成 patched 复制同一张海报,再切回 baseline 接收,19:25:04.392 的记录也是 PASTE_VERIFY match=true。

两次接收尺寸都是 2560×1920,完整像素缓冲区均为 19,660,800 字节。接收图与本地参考图的 SHA-256 一致:7e8f81caa37ce6c0497a7d6233cac40ec8f2b3890323d359ef670a392a7d95a4。图 9 展示 baseline 接收 patched 图片后的尺寸、两份摘要和实际接收缩略图。

baseline 接收 patched 图片后的完整像素摘要与缩略预览

图9 接收核对

2.4 华为畅享 100 Pro Max 真机实测与容量边界

只在模拟器上跑还不够,我又通过 USB 连接了一台 华为畅享 100 Pro Max 实体真机(型号 YEW-AL80,固件 YEW-AL80 7.0.0.100,系统回读 OpenHarmony-7.0.0.105,API 26),在纯英文隔离目录用本地调试签名打出两组 Profile 包做真机核验,一下试出了三处模拟器没有暴露的真实差异:

  1. 受限权限与插件边界:原诊断宿主为了跨应用粘贴核对声明了 ohos.permission.READ_PASTEBOARD,在真机使用普通调试签名安装时直接被包管理拦截并报 code:9568289。移除宿主的这项受限读取权限后,应用顺利安装运行,证实插件自身的图片写入以及 hasData()、getMimeTypes() 元数据检查并不依赖读取权限。
  2. 真机剪贴板单条载荷上限与同步静默失败:在 Mate X7 模拟器能正常写入的 2560×1920 样张(序列化载荷 19,661,196 字节,约 18.75 MiB)和 1920×1440 样张(11,059,596 字节,约 10.55 MiB),到了畅享 100 Pro Max 真机均触发系统剪贴板客户端 C01C01/PBC 报错 WritePasteData# invalid data size 拒绝写入。更值得注意的是,原版调用的同步 setDataSync 在超限时居然不抛异常静默返回(主线程事件循环最大间隔仍达 130 ms,直至后续 hasAfter=false 才发现未写入),而补丁版改用的异步 await setData 能将底层失败作为 PlatformException(IMAGE_ERROR) 传回 Dart,由页面在 finally 中收尾并恢复按钮。
  3. 真机容量内海报的五轮 Profile 对照:我将样张调整到真机剪贴板单条容量上限之内的 1280×960 PNG(固定随机种子不变,文件 1,945,056 字节,约 1.85 MiB,RGBA 像素约 4.69 MiB),在同一台畅享 100 Pro Max 真机上各执行 1 轮预热加 5 轮正式采样,结果汇总在表 5。
指标(真机 1280×960)原版:5轮均值/中位数/范围异步版:5轮均值/中位数/范围均值变化
最大事件循环间隔133.20 / 141.00 / 107~153 ms11.20 / 10.00 / 9~17 ms🔴↓ -91.59%
超阈值间隔累计133.20 / 141.00 / 107~153 ms3.40 / 0.00 / 0~17 ms🔴↓ -97.45%
按60Hz折算的阻塞量7.60 / 8 / 6~90.20 / 0 / 0~1🔴↓ -97.37%
已完成ping最大往返2.94 / 1.10 / 0.93~7.01 ms2.63 / 1.84 / 0.81~4.95 ms🔴↓ -10.54%
复制调用耗时174.77 / 181.15 / 143.84~197.02 ms184.28 / 181.51 / 149.30~214.50 ms🟢↑ +5.44%
存在及MIME校验5/55/5均查到 pixelMap

表5 真机五轮

在畅享 100 Pro Max 真机物理闪存与多核调度下,异步 FD 直解版的五轮最大事件循环间隔有四轮落在 9~11 ms 之间,没有超过 16.7 ms 阈值,均值从 133.20 ms 降到 11.20 ms;同时复制调用耗时均值只从 174.77 ms 变到 184.28 ms(中位数 181.15 ms 对 181.51 ms),没有重现模拟器上平均多等 170 ms 的大幅放慢。图 10 是畅享 100 Pro Max 真机经 DevEco Studio 投屏(顶栏设备序列号已做不可逆像素马赛克脱敏)运行异步 FD 直解版复制 1280×960 海报后的画面。

华为畅享 100 Pro Max 真机运行异步 FD 直解版复制 1280×960 海报

图10 真机实测

三、同步调用的位置与补丁实现

3.1 文件描述符输入减少了哪一层中转

🧩 我先对照了两条管线,如图 11 所示。原版把 PNG 全部读入 ArkTS 创建的 ArrayBuffer;修改后,我把已打开文件的描述符传给 image.createImageSource(file.fd),省去插件分配和填充整块压缩文件缓冲区的步骤。图像框架内部是否还会缓存、缓存多少,这次没有测量。

原版缓冲区输入与异步文件描述符输入的调用对照

图11 管线对照

接着看等待发生在哪里:一处是打开和读取文件,另一处是等待 setDataSync 返回。我分别换成异步文件打开与 setData,让调用方在等待结果时交还执行权。不过,返回 Promise 的接口内部仍可能有同步工作,传入 fd 也不等于“零拷贝”。

原版还没有显式释放 PixelMap 和 ImageSource,我也补上了这部分清理。是否已经造成内存泄漏、积压了多少内存,还需要进程内存曲线或重复复制后的对象计数来判断。这次没有采集这些数据,按尺寸算出的像素大小也不是实际驻留内存。

3.2 修改复制函数,并让每项资源都能尝试释放

基线实现的同步等待

下面是社区标签 1.0.0-ohos.1 中 FlutterImageClipboardPlugin.ets 的完整 copyImage 函数。复现原版宿主时,按第 5.1 节复制示例工程并反向应用原生补丁。

  private async copyImage(path: string): Promise<void> {
    const file = fs.openSync(path, fs.OpenMode.READ_ONLY);
    try {
      const stat = fs.statSync(path);
      const buffer = new ArrayBuffer(stat.size);
      fs.readSync(file.fd, buffer);
      const source = image.createImageSource(buffer);
      const pixelMap = await source.createPixelMap();
      const data = pasteboard.createData(pasteboard.MIMETYPE_PIXELMAP, pixelMap);
      pasteboard.getSystemPasteboard().setDataSync(data);
    } finally {
      fs.closeSync(file);
    }
  }

这里的 await source.createPixelMap() 已经是异步调用。需要改变的是它前后的文件读取和剪贴板提交,以及整个流程结束时如何清理资源。给函数加 async 而不替换内部同步操作,不会改变那些操作的等待方式。

异步直解与独立释放实现

我在 onMethodCall 入口拦截空路径,返回 INVALID_ARGUMENT,有效路径再进入下面的函数。包名和 copyImageToClipboard 通道方法保持不变,Android、iOS 代码也没有修改。清理失败时如何继续执行,我一并做了调整;第二章测的就是这份实现。

  private async copyImage(path: string): Promise<void> {
    const file = await fs.open(path, fs.OpenMode.READ_ONLY);
    let source: image.ImageSource | null = null;
    let pixelMap: image.PixelMap | null = null;
    let copyError: Error | null = null;
    const cleanupErrors: string[] = [];
    try {
      source = image.createImageSource(file.fd);
      pixelMap = await source.createPixelMap();
      const data = pasteboard.createData(pasteboard.MIMETYPE_PIXELMAP, pixelMap);
      await pasteboard.getSystemPasteboard().setData(data);
    } catch (error) {
      copyError = error as Error;
    } finally {
      if (pixelMap !== null) {
        try {
          await pixelMap.release();
        } catch (error) {
          cleanupErrors.push(`PixelMap.release: ${(error as BusinessError).code ?? 'unknown'}`);
        }
      }
      if (source !== null) {
        try {
          await source.release();
        } catch (error) {
          cleanupErrors.push(`ImageSource.release: ${(error as BusinessError).code ?? 'unknown'}`);
        }
      }
      try {
        await fs.close(file);
      } catch (error) {
        cleanupErrors.push(`fs.close: ${(error as BusinessError).code ?? 'unknown'}`);
      }
    }
    // 每项清理独立尝试;清理失败不能覆盖最初的复制错误。
    if (cleanupErrors.length > 0) {
      hilog.error(0x0000, 'ImageClipboard', 'Cleanup failed: %{public}s', cleanupErrors.join('; '));
    }
    if (copyError !== null) {
      throw copyError;
    }
    if (cleanupErrors.length > 0) {
      throw new Error(`Image copied, but resource cleanup failed: ${cleanupErrors.join('; ')}`);
    }
  }
异常优先级与清理流程

file 打开成功后才进入需要清理的范围;source 和 pixelMap 只在确实创建成功时释放。finally 块按依赖逆序分三步独立尝试清理:

  1. 先执行 pixelMap.release() 释放解码后的位图对象。
  2. 再执行 source.release() 释放持有文件描述符的图像源。
  3. 最后执行 fs.close(file) 关闭底层只读文件句柄。

三项清理各有自己的 try/catch。即使位图释放失败,代码仍会尝试释放图像源和关闭文件。清理结束后,再根据 copyError 与 cleanupErrors 决定返回哪个错误。

我先把复制过程中的错误保存在 copyError,等全部清理尝试结束后再抛出。若清理也出错,HiLog 会记录步骤名与错误码,通道仍优先收到复制时的原始异常。如果图片已经写入、只是清理失败,则抛出 Image copied, but resource cleanup failed;收到这个错误时,剪贴板里可能已经有了图片。

这样写能让每项清理都得到执行机会,底层释放本身仍可能失败。日志只记录清理步骤和错误码,不包含图片内容或用户路径。正常复制已经跑过,但系统 release() 失败的情况还没有实际注入测试。

3.3 记录事件循环间隔和通道响应

事件循环间隔记录

我把观测代码放在 diagnostic_example 中,随宿主注册。业务插件只负责接收路径、解码和写入,普通应用接入时不会带上这些测试定时器。探针使用下面的阈值和函数记录间隔:

const LONG_GAP_THRESHOLD_MS = 16.7;
const SIXTY_HZ_INTERVAL_MS = 1000 / 60;

  private recordGap(gap: number): void {
    if (gap > this.maxEventLoopGapMs) {
      this.maxEventLoopGapMs = gap;
    }
    if (gap > LONG_GAP_THRESHOLD_MS) {
      this.totalLongGapMs += gap;
      this.estimated60HzIntervals += Math.floor(gap / SIXTY_HZ_INTERVAL_MS);
    }
  }

宿主每 4 ms 调度一次定时器,把相邻回调的 Date.now() 差值传给 recordGap。maxEventLoopGapMs、totalLongGapMs 和 estimated60HzIntervals 分别记录最大间隔、超阈值间隔总和与折算量;sampleWindowMs 记录每轮实际窗口长度。汇总 JSON 使用 schemaVersion: 2,与旧日志格式区分。

这里的 gap 包含正常定时器周期和回调延迟。宿主机负载、虚拟机调度、运行时工作以及同步调用,都可能让回调变晚,所以它不是严格的线程阻塞时间。代码在超过阈值后累加的是整个间隔,不只是超出帧预算的那一部分;折算出的间隔数也不是真实呈现帧统计。

窗口代次与异常收尾

为了分清请求属于哪一轮,我给观测窗口加了代次编号。Dart 发出 ping 时记下当前代次,只有在同一代次的活动窗口内返回,才更新最大往返时间。窗口开始前发出的请求、关闭后才返回的请求都不计入。因此,这个数是窗口内发出且已完成的 ping 最大往返时间,并没有覆盖所有请求的等待时间,不能当作延迟上界。

原生 finishMonitor 也会检查代次,过期调用返回 STALE_MONITOR。复制失败时,Dart 在 finally 中通过 abortMonitor 结束对应定时器和跟踪区间,再恢复按钮。清理窗口时即使出错,也保留原来的复制错误。这样,页面可以继续重试,下一轮也不会带着上一轮的探针运行。

我这里只用 hiTraceMeter 标记观测区间,还没有导出和分析系统 Trace,因此没有各个工作线程、IPC 阶段或 CPU 占用的数据。

3.4 接收核对读取实际像素

我把接收核对放在单独的“粘贴并核对”按钮里,由它调用宿主的 pasteAndVerify。采样正在运行时,按钮不能发起核对;用户主动点击后,应用才请求 READ_PASTEBOARD 权限。拒绝授权会显示错误,之后可以重试,应用启动时不会读取剪贴板正文。

获得授权后,宿主调用 getData(),确认包含 MIMETYPE_PIXELMAP,取出实际位图和图像信息。随后用相同像素格式解码本地参考图,比较宽、高以及完整像素缓冲区的 SHA-256。下面是读取并计算摘要的完整辅助函数:

  private async digestPixels(pixelMap: image.PixelMap): Promise<string> {
    const pixels = new ArrayBuffer(pixelMap.getPixelBytesNumber());
    await pixelMap.readPixelsToBuffer(pixels);
    const digest = cryptoFramework.createMd('SHA256');
    await digest.update({ data: new Uint8Array(pixels) });
    const hash = await digest.digest();
    let hex = '';
    for (let i = 0; i < hash.data.length; i++) {
      hex += hash.data[i].toString(16).padStart(2, '0');
    }
    return hex;
  }

我比较的是解码后的完整像素缓冲区,而不是两份 PNG 的压缩字节。接收图来自系统剪贴板,参考图则用接收图的像素格式解码,两边的数据才能直接比较。匹配说明这个接收端拿到了与参考图相同的尺寸和像素,其他聊天应用的粘贴行为还要各自测试。

页面还会显示接收位图的缩略图和两份摘要。我可以先看图,再核对完整像素摘要。第二章的双向复制、粘贴走的就是这条路径;权限拒绝后的按钮恢复用 Widget mock 测试检查,模拟器上验证的是授权后的成功接收。

另外,我还运行了这些检查:

  • 插件 Dart 测试(4 项通过):检查 clipboard_image 通道方法名、路径参数传递与异常分支。
  • 两组宿主 Widget 测试(各 3 项通过):覆盖样张准备失败后重试、复制失败后终止观测并重试、粘贴权限错误后恢复按钮。
  • 静态分析与构建(全部通过):两份宿主的 flutter analyze 没有发现问题,原版与修订版 Profile HAP 均构建通过。

Dart 和 Widget 测试使用 mock 通道,不会打开鸿蒙文件或访问系统剪贴板,系统读写结果要看前面的模拟器操作。

四、错误处理与适用边界

4.1 为什么先用系统现有的异步接口

这段插件没有自己遍历像素,主要是在串联文件、图像和剪贴板接口。源码里有同步读文件和同步提交,我就先替换这两处,改动小,也能保留原有 Dart 接口。至于要不要再用 TaskPool,得先看剩余耗时花在哪里,再确认任务能否转移、对象如何传递,以及收益能否抵过调度开销。

我还没有做 TaskPool 与直接调用系统异步接口的等条件对照,也没有留下可核对的首次派发计时,所以目前无法确定它的启动开销和两种方案的快慢。如果后续 Trace 显示还有明显的 CPU 密集工作,再考虑增加并发机制。

4.2 await 返回不一定代表业务成功

原包的 lib/flutter_image_clipboard.dart 捕获 PlatformException 后只打印信息,没有继续抛给调用者。

💣 这意味着,await copyImageToClipboard(file) 正常返回时,底层仍可能已经失败。如果这时直接弹出“复制成功”,页面就会误报成功。

为了兼容原有调用方,我保留了原封装捕获并打印异常的行为。Example 则直接调用公开的 platform 通道,让 PlatformException 传回页面处理。复制正常返回后,页面再核对剪贴板类型;无论成功还是失败,最后都在 finally 中恢复按钮。

直接调用通道的函数如下,方法名和参数都与原包一致。异常会继续交给调用方,由页面在 try/catch/finally 中设置成功、失败和按钮状态。

Future<void> copyPosterWithError(File file) async {
  await FlutterImageClipboard.platform.invokeMethod<void>(
    'copyImageToClipboard',
    <String, Object>{'path': file.path},
  );
}

这个函数所在文件需要导入 dart:io 和 package:flutter_image_clipboard/flutter_image_clipboard.dart。直调通道只是把错误传回来,图片是否能完整接收仍要核对。读取剪贴板时,仍需在用户点击后申请权限。空路径、文件不存在和损坏图片应分别检查原生返回,真实解码器的错误码可能不同,不能只按 mock 中的一个错误码处理。

观测插件回传映射时,我使用 Map<string, Object>,Dart 侧用 invokeMapMethod 接收。这与当前工程的通道编码方式一致;普通 ArkTS 对象即使写法相似,也要先确认编码器是否支持,不能直接替换。

4.3 模拟器与真机双环境验证后的适用边界

接入其他页面之前,还需要留意这几个条件:

  • 构建与测量隔离:我先构建完两组 HAP,再开始测量,避免编译工作与目标设备争用资源。交替顺序能减少总是先测同一组的偏差,但温度、缓存和调度仍会变化,样本仍有波动。
  • 设备与跨平台范围:我同时在 Mate X7 模拟器和华为畅享 100 Pro Max 真机(型号 YEW-AL80)完成了 Profile 采样。真机普通调试签名不含受限 READ_PASTEBOARD ACL,因此跨应用像素摘要核对在模拟器完成,真机核对了剪贴板元数据与交互复制;Android、iOS 实现保持原样,未做运行回归。
  • 图片尺寸与系统剪贴板容量边界:模拟器通过了 2560×1920(约 18.75 MiB RGBA),但畅享 100 Pro Max 真机对 2560×1920 与 1920×1440 位图均触发 WritePasteData# invalid data size 限制,降到 1280×960(约 4.69 MiB RGBA)后全部写入成功。接入时必须根据目标真机的剪贴板载荷上限控制导出分辨率,并把超限报错提示给用户。
  • 并发调用顺序:两个调用同时提交不同图片时,业务要决定最终保留哪张图。示例靠禁用按钮避免重复触发,顺序测试也只是依次 await,尚未验证并发请求能否保持发起顺序。

五、复现与接入建议

5.1 从同一份源码构建两组应用

我测量时使用的模拟器与实体真机双环境列在表 6。准备复现时,可以先对照框架、SDK、设备型号与构建模式,这样比较结果时更容易找到条件上的差异。

环境项记录
Flutter 鸿蒙发行版3.41.9+ohos-1.0.2
Dart3.11.5
DevEco Studio26.0.0.865
SDK/API26.0.0.105,API 26
模拟器环境Mate X7,镜像 7.0.0.107,系统回读 OpenHarmony 7.0.0.105,arm64(五对 2560×1920 采样与双向像素核对)
实体真机环境华为畅享 100 Pro Max(型号 YEW-AL80),固件 7.0.0.100,系统回读 OpenHarmony-7.0.0.105,arm64-v8a,USB 直连(2560×1920 超限核验与 1280×960 五轮采样)
性能包构建模拟器 --profile --no-codesign;真机 --profile 配本地调试签名
原版 tag1.0.0-ohos.1
补丁与诊断示例perf/ohos-async-fd-pasteboard 分支

表6 环境记录

我把工程放在中文路径下时,Hvigor 报了 00306003 Invalid project path。所以复现时也建议直接克隆到纯英文目录。核心补丁已向社区仓库 oh-flutter/flutter_image_clipboard 提交;在合入并发布新 tag 前,下面从包含完整补丁与同款诊断宿主的公开复现分支固定 commit 取到测试代码,再构建补丁版。仓库根目录就是插件,diagnostic_example/ 通过路径依赖接入它。

mkdir -p "$HOME/harmony-pgc-bench"
cd "$HOME/harmony-pgc-bench"
git clone --single-branch --branch perf/ohos-async-fd-pasteboard \
  https://atomgit.com/abaoge/flutter_image_clipboard.git
cd flutter_image_clipboard
git switch --detach 45b45c963e4330057b3dd723cfb36b56db49fa4b
flutter pub get
flutter test --reporter expanded

cd diagnostic_example
flutter pub get
flutter build hap --profile --no-codesign --dart-define=VARIANT=patched

做原版对照时,我建议从这个提交再复制一份工程,反向应用原生补丁,并给宿主换一个包名。两份应用便可以并排安装,页面、样张和探针也能保持相同。反向补丁的完整命令,以及手动粘贴、自动采样的操作步骤,都放在 复现指南 中。

从固定提交分出两套工程并按 AUTO_BENCH 参数分流两种实验的复现路线

图12 复现路线

我把手动粘贴和自动采样分开构建,如图 12 所示。手动双向粘贴时,要设为 AUTO_BENCH=false,否则接收应用一启动就会自动清空并改写剪贴板,刚复制的图片还没来得及核对就被覆盖了。跑五对交替采样时,再改为 AUTO_BENCH=true。两份包构建好后,按下面的顺序安装和操作。

  1. 执行 hdc list targets 查询本机在线设备标识。我这里是 127.0.0.1:5557,复现时使用自己查到的地址。
  2. 将两份无签名 Profile HAP 安装到同一台模拟器;若需自动运行五轮基准,构建时追加 --dart-define=AUTO_BENCH=true。
  3. 分别保存手动操作和自动采样的记录,统计时只使用对应批次的日志。

如果已经在项目里用了这个包,我会先在固定基线的副本上检查补丁,再通过路径依赖接入自己的页面。观测探针和接收核对留在 Example 中,业务只接入需要的插件能力,同时保留上游许可。

5.2 复制按钮还要处理哪些状态

示例里,用户点下复制后,我先让页面显示正在处理,并禁用重复点击。至于什么时候可以提示成功,要看页面实际检查到了哪一步:通道返回、类型查询和图片接收各自能确认的内容不同,见表 7。

验证信号实际能确认的事实不能直接替代的结论
通道调用正常返回原生 setData 完成且清理步骤未报错不能单独确认剪贴板当前仍保留该条目
hasData 与 getMimeTypes系统剪贴板存在数据且含 pixelMap 类型不能确认位图宽高与像素内容未损坏
接收端 getData 与像素摘要当前接收宿主读出的宽高及 RGBA 像素与参考一致不能代表所有第三方聊天或文档应用的粘贴行为

表7 结果信号

接到自己的页面时,我会先检查失败后还能不能重试。异常需要显示给用户,而恢复按钮的代码放在 finally 中,避免一次失败就让按钮一直不可点击。临时图片也要等复制调用 await 结束后再删除或改写,否则异步解码可能还在读取它。

如果要连续复制多张图片,我会先按用户选择的顺序串行处理,把最终保留哪张图、某一张失败后如何继续说清楚,再考虑并发。大图本来就占用不少内存,多份一起解码还会增加资源压力。

以后遇到类似的响应停顿,我仍会先沿调用路径找同步文件和系统服务接口,再用实际操作和测量确认影响。方法名里的 Sync 能给出排查线索,但是否值得修改,还得看它在具体页面里造成了什么问题。

小结

回到海报复制这一步,我想改善的是点下按钮后的等待过程。社区原版已经能把图片写进鸿蒙剪贴板,我沿用它的包名和 Dart 接口,改用异步文件描述符直解与异步剪贴板提交,并补齐原生资源逆序释放和页面失败后的重试收尾。在 Mate X7 模拟器的五对 Profile 对照中,最大事件循环间隔均值从 300.32 ms 降到 54.36 ms,复制调用均值从 493.01 ms 增加到 662.93 ms,双向跨应用粘贴核对像素摘要完全一致;在华为畅享 100 Pro Max 真机的 1280×960 五轮 Profile 对照中,最大事件循环间隔均值进一步从 133.20 ms 降到 11.20 ms,复制耗时均值仅从 174.77 ms 变到 184.28 ms 🎉。

真机实测还帮我摸清了模拟器里看不到的系统剪贴板载荷上限与同步静默失败现象,再次印证了异步错误回传与写入后元数据核对的必要性。接入自己的业务页面时,建议同时结合目标真机的剪贴板容量控制导出图片尺寸,并确认下游应用能否正常粘贴接收。

参考资料

  1. oh-flutter/flutter_image_clipboard 标签 1.0.0-ohos.1:本文原生实现的固定基线。
  2. flutter_image_clipboard 1.0.0:上游公开 API 与许可。
  3. CPF-Flutter/fluttertpc_image_clipboard:候选实现核对。
  4. HarmonyOS Pasteboard API:剪贴板写入、类型查询与读取约束。
  5. HarmonyOS Image API:文件描述符输入和图像对象接口。
Logo

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

更多推荐