📦 Flutter 三方库 pasteboard 用于跨端读写剪贴板图片、文件列表与富文本。在鸿蒙平板双栏画布批注场景中,社区原版在写入完成后立即回读大图时返回 null。我让写入等待系统服务完成,并补上标准位图与图片 URI 的读取。
完整代码已开源在 oh-flutter/pasteboard,修复分支与适用范围见第五节,欢迎提交 Issue 或前往 鸿蒙系统Flutter开源库社区 交流。

前言

我给鸿蒙平板搭了一个双栏批注样例:左边复制画布,右边显示粘贴结果。画布由程序生成。在 MatePad Pro 13 模拟器上,await Pasteboard.writeImage 已经返回,右边却读到 null。我顺着调用检查源码,发现图片还在后台编码,插件就把文件地址交给了剪贴板。改成等待位图写入完成后,同一张画布连续五次都能回读,解码后的像素也一致。代价是调用耗时更长了:原版很快返回空值,修复版则要等图片处理完。

一、平板高分画布剪贴场景与原库故障定位

1.1 平板双栏图表批注场景的操作诉求

页面左边是一张 2400×1500 的批注画布,右边是粘贴预览和核验信息。横屏时,两块区域可以同时显示,不用来回切页。我把导出 PNG、等待复制完成、读取图片这几个动作连在同一个按钮里,专门检查“复制一结束就粘贴”会怎样。这样每次测试的调用顺序都相同,也不用依赖手动点击的快慢。

平板画布与剪贴板流程

图1 画布剪贴链路

我想先弄清楚:插件通知写入完成时,图片究竟准备好了没有?如果通知发早了,页面用了 await 还是会读空。给页面加延时或许能躲开一次失败,却很难选出合适的等待时间。小图、系统空闲时够用的时长,换成大图或较高负载就未必够了。于是我把排查重点放在插件什么时候返回成功。

我还给 Example 加了两个输入,分开检查读取格式:一个由宿主预置 800×500 的标准 PixelMap,另一个把本应用 filesDir 中的 640×400 JPG 写成文件 URI,再交给插件读取。两份数据都来自同一个应用。图库、文件管理器或备忘录写入的格式,以及跨应用 URI 的访问授权,这里还没测。

1.2 上游与鸿蒙社区候选仓库逐一核查

动手改之前,我检查了 pub.dev 的 pasteboard 主包,也看了 CPF-Flutter、oh-Flutter 两个组织下的对应仓库,想先确认哪份代码已经接通鸿蒙调用。

pasteboard 的 pub.dev 包页面

图2 上游包页面

pasteboard 与 desktop_drop 放在 MixinNetwork 的同一个多包仓库,由 mixin.dev 按 Apache-2.0 许可维护发布,提供剪贴板图像、文件列表和文本接口。我查看的 pub.dev 0.6.0 页面列出了 Android、iOS、Linux、macOS、Web、Windows 六个平台,不含鸿蒙实现。

鸿蒙代码有两个查找入口:CPF-Flutter/fluttertpc_desktop_drop 延续了上游目录,把实现放在 packages/pasteboard;oh-flutter/pasteboard 则是独立仓库。我对照源码整理了下面这张表。

仓库来源分支或版本标签鸿蒙端图片读写核查结论
pub.dev/pasteboard0.6.0仅覆盖桌面、移动与 Web 六端,无 ohos 原生目录
oh-flutter/pasteboard0.6.0-ohos.1Dart 分发层漏加 Platform.isOhos,读写图片静默空跑
CPF-Flutter/fluttertpc_desktop_droppasteboard-0.4.0-ohos.1.0.0-beta.1早期测试标签,原生图片读写存在异步竞态与协议限制
CPF-Flutter/fluttertpc_desktop_dropbr_3.27_dev(packages/pasteboard)Dart 分发可达;图片完成时序和读取协议需处理

表1 候选库核查

⚠️ oh-flutter/pasteboard 的版本号是 0.6.0-ohos.1,仓库里也有 ohos 目录,但我在 lib/src/pasteboard_platform_io.dart 发现,html、image 和 writeImage 的平台判断都漏了 Platform.isOhos。这份实现调用图片写入时直接返回,不会向通道发消息;读取图片也会在函数末尾返回 null。只看版本号和目录,还看不出调用是否真的走到了鸿蒙端。

MixinNetwork 上游 pasteboard 源码目录

图3 上游源码

在上游源码页面可以看到包目录及各平台实现。我用 pub.dev 核对发布版本,用 GitHub 查看上游代码,再到 CPF-Flutter 仓库检查鸿蒙适配,避免把不同来源的版本混为一谈。

CPF-Flutter 仓库的 br_3.27_dev 分支已经补了 Dart 层的鸿蒙判断,调用能够到达 PasteboardPlugin.ets。我就以这份连通的通道代码作为排查起点,修好原生图片写入等待与读取协议后,一并把 Dart 层 Platform.isOhos 路由和原生修复同步回独立仓库 oh-flutter/pasteboard,继续使用原包名、公开 Dart 接口和 Apache-2.0 许可。

1.3 固定原版、复现步骤与问题分类

下文的“原版”都指 br_3.27_dev 的固定快照,子包 pubspec.yaml 标记为 pasteboard 0.4.0,完整提交映射保存在工程说明里。复现时要同时认准仓库、分支和子包;换成 pub.dev 0.6.0 或另一个社区的 0.6.0-ohos.1,拿到的就不是同一份实现了。

复现时我先让宿主取得读取权限,再清空剪贴板和本测试包的 PNG 缓存。随后生成固定画布,连续调用图片写入和读取。预期是写入 Future 完成后,读取能拿到可解码的图片;实际是读到 null,稍后缓存目录才出现 PNG。提前授权排除了权限拒绝的干扰,后台文件出现的时序则帮助我把问题缩小到写入端。

🔍 我顺着原版 handleWriteImage 往下看:图片编码放在 packToData(...).then(...) 中,回调执行后才打开文件、写入字节;外层却继续创建文件 URI,并调用 result.success(null)。Dart 收到完成通知时,编码和写文件可能都没结束。我又检查了接口注释、README 和变更记录,没有找到“写入后还要自行等一段时间”的约定。所以要修的是完成通知发早了;是否每次读空,还取决于图片和设备当时的处理速度。

读取端还有一处空值处理问题。原版只接受 PNG URI,判断写成了 record?.uri.endsWith(".png"):可选链保护了 record,却没有保护 uri,记录存在但没有路径时仍会抛错。标准 PixelMap 没有文件路径,JPG 又过不了后缀判断,所以这两种输入也读不到。空值处理需要修正,支持 PixelMap 和 JPG 则属于补充读取能力,原版并没有承诺覆盖所有外部格式。

原版只保存应用上下文,授权弹窗需要的 UIAbilityContext 得由宿主提供,因此先在宿主授权也是可行的接法。我把按需申请放进插件,是想减少接入代码。检查时还发现,同步读取放在异常保护之外,部分图形对象没有显式释放,错误回执后也有继续执行的情况,这些都需要单独处理。资源释放的改动有源码依据,不过我没有采集 Native 内存曲线,内存占用能降多少还没有测量结果。

二、MatePad Pro 13 同条件实测对照

2.1 同条件验证与像素比较方法

我把原版和修复版分别接入同一套双栏页面,用两个包名区分:com.example.pgc23base 和 com.example.pgc23fix。测试环境如下:

  • Flutter 与工具链:Flutter 3.41.9+ohos-1.0.2(Dart 3.11.5),DevEco Studio 26.0.0.865,HarmonyOS SDK 26.0.0.105(API 26,最低兼容 API 18)。
  • 平板模拟器:MatePad Pro 13(物理分辨率 2880×1920,屏幕密度 320,系统镜像 7.0.0.107)。
  • 修复基线:CPF-Flutter/fluttertpc_desktop_drop 仓库 br_3.27_dev 分支下的 packages/pasteboard 子包。

两组都用同一段 Canvas 代码生成源图,PNG 为 2,463,201 B。比较时,我让 Flutter 图像解码器取左右图片的第一帧,转成 rawRgba,先检查宽高与数组长度,再逐字节比较全部 14,400,000 B。页面还会显示完整的 16 位十六进制 FNV-1a 摘要,方便认出样本;真正决定是否通过的是像素数组,摘要相同并不足够。

两个预置输入的检查简单一些:返回图片能否解码,宽高是否分别为 800×500 和 640×400。通过后页面显示“图像解码与尺寸通过”。这里没有源图逐字节比较,JPG 本身也是有损编码,因此“无损”只用于前面的画布测试。图像检查限于第一帧,动画、ICC 色彩管理和完整的透明度组合都还没覆盖。

为了避免其他应用测试改写剪贴板,我另开了同模板的模拟器实例,装入两份调试 HAP。两组共用页面和宿主探针,只替换插件实现,采样前通过调试包权限工具授予读取权限。自动集成测试点击页面按钮,调用实际经过 Dart 通道、ArkTS 和系统剪贴板,原生返回没有 mock。这组结果对应已授权状态,首次授权体验留到权限边界一节说明。

2.2 运行画面与核验范围

两组实录均从桌面启动应用并先清空剪贴板与缓存,再点击左侧主按钮执行复制与立即粘贴。原版在 writeImage 返回后马上读取时,后台 PNG 编码尚未写完,右栏提示返回 null,稍后应用级缓存目录里留下 1 个临时 PNG 文件。

基线组画布复制后回读失败

图4 原版失败实录

基线组平板回读结果

图5 原版失败界面

修复版等 PixelMap 写入系统剪贴板完成后才结束 Future,紧随其后的读取进入同一条串行任务队列,右栏直接显示回读画布并通过 14,400,000 B 全量 RGBA 逐字节比较,双缓存目录保持零残留。调用之间不用再加人为延时,不过图像编解码与跨进程传递本身仍需要耗时。

修复组画布复制后回读通过

图6 修复版实录

修复组平板像素比较结果

图7 修复版界面

2.3 如何理解日志、次数与耗时

两组实测日志摘录

图8 日志对照

我在两组中各执行五次画布操作,全部结束后再等两秒,检查缓存目录。这两秒只用于观察文件残留,没有插在复制与读取之间。修复组使用的是恢复文件顺序、修正资源所有权后的实现,结果如下:

核验项原版基线当前修复版
画布完整 RGBA 比较通过次数0/5(均返回空)5/5 🟢↑
五次操作后缓存 PNG 数50 🔴↓
画布回读 PNG 字节数0 B3,216,499 B
同应用预置 PixelMap读空解码为 800×500
同应用预置 JPG URI读空解码为 640×400
三个文件 URI 的回读顺序A、B、CA、B、C

表2 复测结果

基线五次调用耗时为 371、378、301、549、317 ms;修复组为 1149、1245、667、1112、726 ms。每组只有五次,宿主负载也会波动,所以我保留了逐次值。随后又检查了修复版的三个行为:传入 Dart 图片子视图能解码,无效编码能返回错误,报错后紧接着写入正常图片也能成功。

Example 从发起操作开始计时,到 Pasteboard.image 返回字节就停止。画布操作包含写入和读取,预置操作还包含宿主构造输入。诊断快照、Flutter 解码、RGBA 比较和页面更新都在计时之后,用户看到图片还要再等这些工作做完。原生日志中的 costMs 范围更小,只记录对应的原生方法;看日志时要把这两个计时范围分清。

画布返回的 PNG 是重新编码的 3,216,499 B,比源文件更大,但 RGBA 仍能一致。PNG 压缩结果不同不等于像素变化,直接比较两个 PNG 文件的字节或大小会误判。反过来,尺寸相同也不保证每个像素一致,所以 Example 把尺寸验证和像素验证作为两种结果展示。

从调用时间看,修复版确实更长。原版提前返回了空值,修复版则完成了解码、系统传递和 PNG 编码,两者做完的工作不同,不适合拿来比较处理速度。对这张固定画布,修复带来的变化是五次都能回读、像素一致,应用缓存中也没有留下 PNG。数据只来自这台模拟器和这五次操作,真机性能与分布式剪贴板同步还没测。

三、跨端剪贴板通道与鸿蒙原生修复实现

3.1 Dart 跨端分发层与标准通道契约

确定问题在鸿蒙端后,我沿着一次图片调用整理了流程:页面调用 Dart 接口,通道把参数交给 ArkTS 插件,再由插件读写系统剪贴板。图 9 把返回结果和资源清理的先后也放了进去。

跨端剪贴板架构

图9 跨端通道图

Dart 的非 Web 入口会调用 lib/src/pasteboard_platform_io.dart 中的 PasteboardPlatformIO,它持有一个名称为 pasteboard 的 MethodChannel。为了兼容标准 Dart SDK 与工具链检查,这里通过 _isOhos 读取 Platform.operatingSystem == 'ohos',同时保留 @visibleForTesting 的系统覆盖开关以便在主机单测中切换平台分支。macOS、iOS、Android 和 HarmonyOS 的图片读写都通过这个通道传递 Uint8List,鸿蒙端对应 Uint8Array。下面是平台判定与 image、writeImage 两个完整方法。

  
  static String? debugOperatingSystemOverride;

  bool get _isOhos =>
      (debugOperatingSystemOverride ?? Platform.operatingSystem) == 'ohos';

  
  Future<Uint8List?> get image async {
    final image = await _channel.invokeMethod<Object>('image');

    if (image == null) {
      return null;
    }
    // 鸿蒙端与移动、桌面主流平台一致,直接通过通道接收标准编码图像字节流
    if (_isOhos ||
        (debugOperatingSystemOverride == null &&
            (Platform.isMacOS ||
                Platform.isLinux ||
                Platform.isIOS ||
                Platform.isAndroid))) {
      return image as Uint8List;
    } else if (debugOperatingSystemOverride == null && Platform.isWindows) {
      final file = File(image as String);
      final bytes = await file.readAsBytes();
      await file.delete();
      return bytes;
    }
    return null;
  }

  
  Future<void> writeImage(Uint8List? image) async {
    if (image == null) {
      return;
    }
    if (_isOhos ||
        (debugOperatingSystemOverride == null &&
            (Platform.isIOS || Platform.isMacOS || Platform.isAndroid))) {
      await _channel.invokeMethod<void>('writeImage', image);
    } else if (debugOperatingSystemOverride == null && Platform.isWindows) {
      final file = await File(_getTempFileName()).create();
      file.writeAsBytesSync(image);
      await _channel
          .invokeMethod<Object>('writeImage', {'fileName': file.path});
      file.delete();
    }
  }

页面依赖这两个接口来安排后续操作:等待写入 Future 完成后,就可以发起读取;读回的字节则应当能直接交给 Flutter 图像解码器。原来的文件编码回调没有被等待,恰好破坏了第一个前提。修复时,我保留 Dart 接口,把“什么时候算写完”落实到原生方法里。

3.2 AbilityAware 上下文绑定与队列外按需权限预检

我先让 PasteboardPlugin.ets 实现 AbilityAware:挂载 Ability 时保存 UI 上下文,卸载时清空,按需授权时才有可用的窗口上下文。同时引入串行任务链,把通道读写交给 enqueueTask 排队,等前一项结束再处理下一项,避免读写在解码途中交错。

如果把交互式权限弹窗塞进串行任务链内部,一旦系统弹窗长时间没有响应,后续写入和其他调用就会整条队列卡死。因此读取入口改为经由 enqueueReadTask 先在队列外通过 prepareAndRunReadTask 与 tryRequestReadPermissionWithTimeout(12 秒超时上限且带 permissionRequestInFlight 单飞去重)触发按需授权;即便前置权限预检超时或失败,也不直接拦截后续的剪贴板读取,再把真正读取交回 enqueueTask 串行执行:

  private async prepareAndRunReadTask(
    methodName: string,
    result: MethodResult,
    task: (safeResult: MethodResult) => Promise<void>
  ): Promise<void> {
    try {
      await this.tryRequestReadPermissionWithTimeout(12000);
    } catch (_permErr) {
      // Preflight failures must not skip attempting clipboard read.
    }
    this.enqueueTask(methodName, result, task);
  }

  private async tryRequestReadPermissionWithTimeout(timeoutMs: number): Promise<void> {
    if (this.permissionRequestInFlight !== null) {
      await this.permissionRequestInFlight;
      return;
    }
    const runPrompt = async (): Promise<void> => {
      try {
        const hasData: boolean = await this.systemPasteboard.hasData();
        if (!hasData) {
          return;
        }
        const bundleInfo: bundleManager.BundleInfo = bundleManager.getBundleInfoForSelfSync(
          bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION
        );
        const tokenId: number = bundleInfo.appInfo.accessTokenId;
        const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
        const grantStatus: abilityAccessCtrl.GrantStatus = atManager.checkAccessTokenSync(
          tokenId,
          'ohos.permission.READ_PASTEBOARD'
        );
        if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
          return;
        }
        const ctx: common.UIAbilityContext | null = this.uiContext;
        if (ctx === null) {
          hilog.warn(LOG_DOMAIN, LOG_TAG, 'UIAbilityContext is null; skip permission prompt');
          return;
        }
        await new Promise<void>((resolve) => {
          let settled: boolean = false;
          let timerId: number | undefined;
          const finish = (): void => {
            if (!settled) {
              settled = true;
              if (timerId !== undefined) {
                clearTimeout(timerId);
                timerId = undefined;
              }
              resolve();
            }
          };
          timerId = setTimeout(() => {
            hilog.warn(LOG_DOMAIN, LOG_TAG, 'requestPermissionsFromUser timed out after %{public}dms', timeoutMs);
            finish();
          }, timeoutMs);
          atManager.requestPermissionsFromUser(ctx, ['ohos.permission.READ_PASTEBOARD']).then(
            () => {
              finish();
            },
            () => {
              finish();
            }
          );
        });
      } catch (error) {
        const err: BusinessError = error as BusinessError;
        hilog.warn(
          LOG_DOMAIN,
          LOG_TAG,
          'tryRequestReadPermission failed: code=%{public}s msg=%{public}s',
          String(err.code ?? 'UNKNOWN'),
          err.message ?? ''
        );
      }
    };
    const executeAndClear = async (): Promise<void> => {
      try {
        await runPrompt();
      } finally {
        if (this.permissionRequestInFlight === inFlight) {
          this.permissionRequestInFlight = null;
        }
      }
    };
    const inFlight: Promise<void> = executeAndClear();
    this.permissionRequestInFlight = inFlight;
    await inFlight;
  }

页面最终拿到的空结果,可能来自空剪贴板,也可能来自系统在读取时拒绝了权限或缺少有效图像数据。业务若要分别提示,还需要结合自己的页面状态判断。前面的对照测试预先授予了权限,我还没有重新测完首次授权、永久拒绝和 Ability 重建的各种组合。

3.3 写入链路改造:MIMETYPE_PIXELMAP 直写与缓冲区切片对齐

写入时,我改用系统支持的 PixelMap,省去临时 PNG 文件。处理顺序是:按字节视图的偏移和长度取出有效数据,解码成位图,再以 pasteboard.MIMETYPE_PIXELMAP 类型写入剪贴板。这里必须等待 systemPasteboard.setData 完成才返回成功;无论中途成功还是失败,finally 都会释放已经创建的图像资源。

  private async handleWriteImage(args: Uint8Array | null, result: MethodResult): Promise<void> {
    if (!args || args.byteLength === 0) {
      result.error('NoArgs', 'Missing or empty image bytes', null);
      return;
    }
    let imageSource: image.ImageSource | null = null;
    let pixelMap: image.PixelMap | null = null;
    try {
      // 截取精确视图区间,防止 Dart 子视图切片导致底层共享 ArrayBuffer 偏移错位
      const exactBuffer: ArrayBuffer = args.buffer.slice(
        args.byteOffset,
        args.byteOffset + args.byteLength
      );
      imageSource = image.createImageSource(exactBuffer);
      pixelMap = await imageSource.createPixelMap();
      if (!pixelMap) {
        result.error('DecodeError', 'Failed to create PixelMap from input bytes', null);
        return;
      }
      const pasteData: pasteboard.PasteData = pasteboard.createData(
        pasteboard.MIMETYPE_PIXELMAP,
        pixelMap
      );
      // 必须等待跨进程剪贴板服务落稳数据后再回调 Dart,消除即拷即贴竞态
      await this.systemPasteboard.setData(pasteData);
      result.success(null);
    } catch (error) {
      const err: BusinessError = error as BusinessError;
      result.error(
        String(err.code ?? 'WRITE_IMAGE_ERROR'),
        err.message ?? 'Failed to write image to clipboard',
        null
      );
    } finally {
      await this.safeReleasePixelMap(pixelMap);
      await this.safeReleaseImageSource(imageSource);
    }
  }

3.4 读取链路改造:PixelMap 与多格式文件 URI 双轨解析

读取时先找标准 PixelMap,再遍历文件 URI。我把具体的图片处理拆成了三个辅助方法:packPixelMapToPng 把位图编码为 PNG,并释放打包器和位图;isSupportedImageUri 检查 PNG、JPG、JPEG、WebP、BMP 扩展名;readImageBytesFromUri 以只读方式打开文件,PNG 直接读字节,其他格式解码后转成 PNG,最后关闭文件句柄。

主方法 handleImage 先确认剪贴板确有内容,再通过 getData() 进入两轨读取。有位图就先尝试打包返回;即使第一轨取图或编码异常,也由内层捕获后继续尝试文件轨道:逐条跳过空路径和不支持的后缀,分块读入图片文件字节时因 fs.readSync 的目标缓冲偏移由每次新建的块缓冲区决定而传入 offset: 0,由文件描述符自身游标推进读取直到读到有效图片;整段外层异常同样兜底返回空值。

  private async handleImage(result: MethodResult): Promise<void> {
    const startedMs: number = Date.now();
    try {
      const hasData: boolean = await this.systemPasteboard.hasData();
      if (!hasData) {
        result.success(null);
        return;
      }
      const pasteData: pasteboard.PasteData = await this.systemPasteboard.getData();
      if (pasteData.hasMimeType(pasteboard.MIMETYPE_PIXELMAP)) {
        try {
          const primaryMap: image.PixelMap = pasteData.getPrimaryPixelMap();
          const packedPng: Uint8Array | null = await this.packPixelMapToPng(primaryMap);
          if (packedPng != null && packedPng.byteLength > 0) {
            hilog.info(
              LOG_DOMAIN,
              LOG_TAG,
              'readImage ok: branch=pixelMap bytes=%{public}d costMs=%{public}d',
              packedPng.byteLength,
              Date.now() - startedMs
            );
            result.success(packedPng);
            return;
          }
        } catch (pixelErr) {
          const err: BusinessError = pixelErr as BusinessError;
          hilog.warn(
            LOG_DOMAIN,
            LOG_TAG,
            'pixelMap branch failed, fallback to file URI: code=%{public}s msg=%{public}s',
            String(err.code ?? 'PIXELMAP_ERROR'),
            err.message ?? ''
          );
        }
      }
      const recordCount: number = pasteData.getRecordCount();
      for (let i = 0; i < recordCount; i++) {
        const record: pasteboard.PasteDataRecord = pasteData.getRecord(i);
        const rawUri: string = record?.uri ?? '';
        if (rawUri.length === 0 || !this.isSupportedImageUri(rawUri)) {
          continue;
        }
        const uriBytes: Uint8Array | null = await this.readImageBytesFromUri(rawUri);
        if (uriBytes != null && uriBytes.byteLength > 0) {
          hilog.info(
            LOG_DOMAIN,
            LOG_TAG,
            'readImage ok: branch=uri bytes=%{public}d costMs=%{public}d',
            uriBytes.byteLength,
            Date.now() - startedMs
          );
          result.success(uriBytes);
          return;
        }
      }
      result.success(null);
    } catch (error) {
      const err: BusinessError = error as BusinessError;
      hilog.warn(
        LOG_DOMAIN,
        LOG_TAG,
        'readImage failed: code=%{public}s msg=%{public}s',
        String(err.code ?? 'ERROR'),
        err.message ?? ''
      );
      result.success(null);
    }
  }

复核时,我还纠正了自己对文件顺序的判断。鸿蒙客户端的 PasteData.addRecord 会把新记录放到列表头部。输入 A、B、C,要按 C、B、A 的顺序添加,读回才是 A、B、C,原版的倒序循环原本就是对的。我撤回初稿补丁中的正序改动,再让三个真实文件 URI 经过系统剪贴板回读,顺序才与输入一致。之前 Dart mock 只是把数组原样存回,测不到原生的头插行为。

位图交给谁释放,也得约定清楚。packPixelMapToPng 会在 finally 中释放传入的位图,所以 URI 解码函数移交位图时,要先清空自己保存的变量。否则打包抛错后,外层 finally 还会再释放一次。

写入函数的顺序稍有不同:它先返回成功,再执行 finally。Dart 此时虽然已经收到结果,队列仍会等整个异步函数结束,才开始下一项。这样下一次读取不会与上一项的清理过程交错,图 9 也按这个顺序绘制。

四、平板适配踩坑与系统安全边界

4.1 应用级与模块级双 cacheDir 沙箱路径错位陷阱

最初检查缓存时,我看到了一个矛盾:原版粘贴失败,剪贴板里有带时间戳的文件地址,探针却一直报告 PNG 数量为 0。我当时只扫描了 EntryAbility 的模块级缓存目录,也就是 UIAbilityContext.cacheDir 对应的 /data/storage/el2/base/haps/entry/cache。

接着进入设备沙箱核对,才发现原版插件在挂载引擎时拿的是应用级 ApplicationContext.cacheDir,对应 /data/storage/el2/base/cache。初次验证中单张约 3.1 MB 的 PNG 留在这个上层目录里,之前的探针根本没扫到它。

💣 应用如果只清理当前 Ability 的缓存目录,也会漏掉这些文件。我把两个目录都纳入统计后,才得到前面的残留数量。改用 PixelMap 后,插件写入不再生成临时 PNG,所以这两处目录里的计数为零;系统剪贴板服务自身的存储不在统计范围内。

4.2 宿主读权限声明、签名边界与标准页面挂载

原仓示例在 Index.ets 中放了权限遮罩页。我移除这层遮罩,把按需检查交给插件,宿主入口保留标准的 FlutterPage。这并不省去宿主的权限配置,相关位置见图 10。

关键配置图

图10 关键配置

写入剪贴板和用 hasData() 检查是否有数据,都不需要读权限。读取图片、文件列表或 HTML 时,宿主则要在 entry/src/main/module.json5 的 requestPermissions 中声明 ohos.permission.READ_PASTEBOARD,配好中英文权限用途说明,以及指定 EntryAbility、inuse 时机的 usedScene。

READ_PASTEBOARD 还是受限权限。生产应用需要符合官方允许的业务场景,申请权限并配置授权后的签名 Profile,具体要求见受限权限说明。调试包的预授权只解决这次模拟器测试,不能代替真机安装或上架所需的资格。

用户拒绝授权时,队列外预检不会硬拦截后续的读取调用;当系统服务实际拦截读取操作并抛出异常时,各读取入口捕获异常并兜底使图片读取返回 null,文件读取返回空列表。页面要能接住这些结果并给出提示。

4.3 平板横屏方向、共享缓冲区偏移与 Native 像素释放

双栏页面在模拟器上还遇到过方向问题。我把 module.json5 中 EntryAbility 的 orientation 设为 landscape 后,窗口和坐标系旋转了 90 度,视口变得又窄又长;若写死为 landscape_inverted,在摄像头居顶的正置平板上又会倒转 180 度。改用 auto_rotation_landscape 后,MatePad Pro 13 在正反横屏姿态下都能保持 2880×1920 视口,状态栏和底部导航条也维持水平。

图片字节的范围则容易在切片时出错。Dart 可以用 Uint8List.sublistView 或带偏移的 asUint8List,从大块缓冲区中取一段视图。我在测试中给图像字节前后加了哨兵数据,再只传中间的图片视图,经过真实通道后能正常解码。不过通道可能重新复制缓冲区,这还不能说明 ArkTS 最终收到的偏移一定非零。

原生侧仍需要按收到的 byteOffset 和 byteLength 取有效区间。如果视图前面还有别的数据,却把整个 args.buffer 交给解码器,那些无关字节就可能被当成文件头。写入函数中的 slice 是为了守住这个边界,不能只因为 Dart 测试能通过就去掉。

最后是图像资源的释放。这张 2400×1500 画布解码成 RGBA 后,像素数据本身就占约 13.73 MB。ArkTS 对象背后还有 Native 内存,不能只等包装对象被垃圾回收。我在创建或取得 ImageSource、PixelMap、ImagePacker 和文件句柄的位置安排 finally 清理;遇到辅助方法接手释放的情况,则按第三节的约定移交所有权。

五、结论与可直接复用的建议

5.1 接入时应期待什么

如果你的鸿蒙 Flutter 页面也需要复制画布、随后粘贴图片,可以继续使用 pasteboard 原来的图片接口。插件负责等系统写入完成,页面不用再猜编码要多久。画布生成、尺寸控制和结果提示仍由应用处理;Example 里的摘要、缓存计数和预置按钮只为排查服务,接入普通页面时不必一并搬过去。

5.2 在自己的项目中使用

💡 下面固定到已公开并核验的修复提交,包含本文的图片读写修复与文件列表倒序头插逻辑,来源与社区原分支、pub.dev 主包不同,在社区合并发布新标签前可直接按固定提交引入:

dependencies:
  pasteboard:
    git:
      url: https://atomgit.com/abaoge/pasteboard.git
      ref: ec13e7a82b91a03e68186ddd2343b1e81cb45209

接着检查宿主 ohos/entry/src/main/module.json5:deviceTypes 要包含 "tablet",其他设备类型按项目需要填写。插件自己的 ohos/src/main/module.json5 已经声明了 ohos.permission.READ_PASTEBOARD 与中英文用途文案,但生产发布时宿主仍需按第四节核对受限权限与 usedScene、签名 Profile 配置。文件 URI 写入需要 API 18 及以上。首次接入或改动原生代码后,要完整重新构建 HAP。

业务页面导入 package:pasteboard/pasteboard.dart 后,就可以把 RepaintBoundary 包住的画布导出为 PNG,等待复制完成,再读取图片字节:

import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:pasteboard/pasteboard.dart';

Future<bool> copyCanvasToPasteboard(GlobalKey boundaryKey, {double pixelRatio = 2.0}) async {
  final renderObject = boundaryKey.currentContext?.findRenderObject();
  if (renderObject is! RenderRepaintBoundary) return false;
  final ui.Image captured = await renderObject.toImage(pixelRatio: pixelRatio);
  try {
    final ByteData? byteData = await captured.toByteData(format: ui.ImageByteFormat.png);
    if (byteData == null || byteData.lengthInBytes == 0) return false;
    final Uint8List pngBytes = byteData.buffer.asUint8List(
      byteData.offsetInBytes,
      byteData.lengthInBytes,
    );
    await Pasteboard.writeImage(pngBytes);
    return true;
  } finally {
    // 及时释放 Flutter 引擎侧导出的 ui.Image 纹理句柄
    captured.dispose();
  }
}

Future<Uint8List?> pasteCanvasFromPasteboard() async {
  final Uint8List? bytes = await Pasteboard.image;
  return (bytes == null || bytes.isEmpty) ? null : bytes;
}

5.3 使用时保留这几个判断

  • 页面要处理空结果。剪贴板为空、里面只有文本或用户拒绝读权限时,Pasteboard.image 都可能返回 null。我建议保留明确的状态提示,让用户知道这次没有读到图片。
  • 按清晰度需要设置像素倍率。大画布调用 toImage 时,先确定业务需要的 pixelRatio,导出字节后再用 finally 中的 dispose() 释放引擎图像资源。
  • 真机和其他平台要分别验证。我的运行结果来自 MatePad Pro 13 模拟器(API 26)。Android、iOS、macOS、Windows、Linux 和 Web 的原实现没有改动,也没有运行回归。鸿蒙真机及跨设备剪贴板同步还需满足权限资格和签名要求,再做对应测试。

公开 Example 提供了同款双栏批注页面、全量 RGBA 比较与预置输入实现;完整的工具链、构建与运行说明见鸿蒙适配文档和示例说明。

小结

修好以后,左边复制的画布终于能在右边接着显示,不用靠页面延时来碰运气。排查时最有用的线索,就是写入 Future 已经完成,文件却还没写好。下次再遇到类似的异步接口问题,我会先核对完成通知对应哪一步,再决定该改插件还是调整页面等待。文件顺序那处改错也让我记住:mock 能确认 Dart 调用,原生行为还得经过系统实际读写来核对。

参考资料

  1. pasteboard 上游源码仓库

  2. oh-flutter/pasteboard 鸿蒙社区适配仓库

  3. CPF-Flutter/fluttertpc_desktop_drop 社区多包适配仓库

  4. HarmonyOS 应用开发文档:@ohos.pasteboard(剪贴板)

  5. HarmonyOS 应用开发文档:@ohos.multimedia.image(图片处理)

  6. HarmonyOS 应用开发文档:向用户申请授权

  7. OpenHarmony PasteData 客户端头插实现

Logo

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

更多推荐