开发工具: 华为云码道

本文配套仓库: 上游 salman3xs/clipboard_image;OHOS 适配位于本地仓库提交 b6cb54fohos/example/ohos/README.OpenHarmony_CN.mdREADME.OpenHarmony.mdCHANGELOG.OpenHarmony.mddocs/
鸿蒙适配后仓库https://atomgit.com/oh-flutter/clipboard_image

本文配套仓库:https://atomgit.com/CPF-Flutter/fluttertpc_clipboad_image(TAG:0.0.1-ohos-1.0.0-beta.2,分支:main),文中示例代码位于仓库 example/ 目录。

在这里插入图片描述

剪贴板图片读取是图片处理类应用的高频需求。 用户在浏览器中右键复制一张图片、在图库里选中一张图复制——这些操作都会把图片数据写入系统剪贴板。应用如何读取这张图片?在 Android 上通过 ClipboardManager 获取 primary clip 的 URI,在 iOS 上通过 UIPasteboard.general.image 获取——两端的 API 完全不同。clipboad_image 库将这一差异封装为一个静态方法 ClipboadImage.getImage(),返回 JPEG 编码的 Uint8List?。鸿蒙系统的剪贴板图片存在两种形态——内嵌 PixelMap 和 URI 记录——适配版插件均做了处理,并实现了 AbilityAware 接口自动请求 READ_PASTEBOARD 权限。

本文介绍如何使用鸿蒙化适配后的 Flutter 三方库 clipboad_image,在鸿蒙 App 内通过一个静态方法从系统剪贴板读取图片,并附上 OpenHarmony 6.1.1.120 真机的完整实测记录。


一、最终运行效果

应用启动后展示交互式剪贴板图片读取演示页:在浏览器或图库中复制一张图片,点击"从剪贴板读取"按钮,插件自动请求 READ_PASTEBOARD 权限(首次),授权后从系统剪贴板读取图片并以 JPEG 编码返回,界面展示图片预览和尺寸信息:

验证点结果
应用启动,Flutter 页面正常渲染通过
在浏览器中复制图片,点击"从剪贴板读取",图片正常展示通过
首次读取时自动弹出 READ_PASTEBOARD 权限弹窗通过
用户授权后图片以 JPEG 质量 90 编码返回 Uint8List通过
剪贴板无图片或内容非图片时返回 null,不崩溃通过
点击"选择本地图片",选图后自动复制到剪贴板并读取成功通过
图片尺寸和字节数正确展示(如 1080×1920 · 95832 字节通过
全程 Dart 层零改动通过

获取复制后显示剪贴板内容 获取复制后显示剪贴板内容 获取复制后显示剪贴板内容 获取复制后显示剪贴板内容 获取复制后显示剪贴板内容 获取复制后显示剪贴板内容 获取复制后显示剪贴板内容

操作预期表现
在浏览器或图库中复制一张图片,点击「从剪贴板读取」读取系统剪贴板中的图片,预览框显示该图片
直接点击「选择本地图片」打开图库选图,选图后自动复制到剪贴板并读取,提示 “已选择本地图片并复制到剪贴板,读取成功”
剪贴板为空或内容不是图片时读取getImage 返回 null,预览框保持 “暂无图片预览”
OpenHarmony 首次读取弹出剪贴板权限授权框,允许后成功读取(本次使用允许 / 始终允许 / 不允许)

以下是操作的视屏,可以参考一下:

Example 启动授权 Example 启动授权 Example 启动授权

图一:demo 应用在 OpenHarmony 真机启动,展示标题栏、图片预览区、状态行和操作按钮

图二:点击"从剪贴板读取"后系统弹出 READ_PASTEBOARD 权限请求弹窗

图三:授权后图片成功读取并展示在预览区,底部显示尺寸与字节数

检查要点

  1. 剪贴板图片读取通过 MethodChannel clipboad_imagegetImage 方法完成,由 ArkTS 插件 ClipboadImagePlugin 处理;
  2. 鸿蒙系统的剪贴板图片存在两种形态——内嵌 PixelMap(MIMETYPE_PIXELMAP)和 URI 记录(MIMETYPE_TEXT_URI),插件均做了处理;
  3. 首次读取需要 READ_PASTEBOARD(user_grant)权限,插件实现 AbilityAware 接口自动请求;
  4. 返回值为 JPEG 质量 90 编码的 Uint8List,无图片或读取失败时返回 null,与 Android/iOS 语义完全一致;
  5. 完整实测过程见"六、运行与验证"。

HarmonyOS 技术点:鸿蒙剪贴板图片的两种形态

鸿蒙系统的 @ohos.pasteboard 模块中,剪贴板数据(PasteData)可以包含多种 MIME 类型的记录。对于图片数据,存在两种形态:

  • 内嵌 PixelMap(MIMETYPE_PIXELMAP:图片像素数据直接嵌入剪贴板记录。这是网页/浏览器复制图片时的常见形式——浏览器将图片解码为像素图后写入剪贴板。通过 pasteData.getPrimaryPixelMap() 可直接获取 image.PixelMap 对象。

  • URI 记录(MIMETYPE_TEXT_URI:剪贴板中只保存图片的文件 URI(如 file://docs/storage/...),不嵌入像素数据。这是应用内复制图片(图库、文件管理器等)的常见形式。需要通过 fs.open(uri) 打开文件,再用 image.createImageSource(fd) 解码为 PixelMap

插件通过 pasteData.hasType(MIMETYPE_PIXELMAP)pasteData.hasType(MIMETYPE_TEXT_URI) 判断当前剪贴板中的图片形态,分别走不同的处理分支。


二、clipboad_image 是什么

clipboad_image 原库(GitHub salman3xs/clipboard_image,版本 0.0.1+1)是一个 Flutter 插件,功能单一:从系统剪贴板中获取图片。Dart 层仅暴露一个静态方法 ClipboadImage.getImage(),返回 Uint8List?(图片的 JPEG 编码字节,压缩质量 90;剪贴板无图或内容非图时返回 null)。鸿蒙适配版在其基础上新增了 OpenHarmony / HarmonyOS 平台支持:新增 ohos/ 平台工程与 ArkTS 插件类 ClipboadImagePlugin,通道名和方法名与 Android/iOS 完全一致。

为什么返回 JPEG 而非原图格式?

剪贴板中的图片可能来自不同来源(浏览器截图、相册照片、其他应用分享),原始格式可能是 PNG、JPEG、WEBP 等。统一编码为 JPEG 质量 90 有三个好处:一是格式统一,Dart 层无需判断原图格式;二是压缩体积,JPEG 在保证质量的前提下比 PNG 小很多;三是跨平台一致性——Android 端使用 Bitmap.compress(JPEG, 90),iOS 端使用 jpegData(0.9),鸿蒙端使用 imagePacker.packing(pixelMap, {format: 'image/jpeg', quality: 90}),三端语义完全对齐。

几个对使用者友好的特点:

  1. 极简 API:一个静态方法 ClipboadImage.getImage(),无需实例化、无需配置,一行代码完成剪贴板图片读取;
  2. 自动权限请求:插件实现 AbilityAware 接口,首次读取时自动弹出 READ_PASTEBOARD 权限弹窗,业务代码无需自行处理权限逻辑;
  3. 双形态兼容:自动识别剪贴板中的内嵌 PixelMap 和 URI 记录两种图片形态,分别处理;
  4. 跨平台语义一致:无图返回 null、有图返回 JPEG 质量 90 字节,与 Android/iOS 完全对齐;
  5. 资源安全:所有原生资源(ImagePackerImageSourcePixelMapFile)在 finally 块中释放,避免内存泄漏。

接口说明:

名称描述类型参数类型返回值必填鸿蒙平台支持
ClipboadImage.getImage从系统剪贴板获取图片静态方法Future<Uint8List?>

HarmonyOS 技术点:image.PixelMapImagePacker

鸿蒙系统的图片处理由 @ohos.multimedia.image 模块提供。PixelMap 是像素图对象,包含图片的像素数据和尺寸信息。ImagePacker 是图片编码器,packing(pixelMap, option) 方法将 PixelMap 编码为指定格式(如 JPEG)的字节缓冲区(ArrayBuffer),PackingOption 指定 formatquality。对于 URI 形态的剪贴板图片,还需要 ImageSource——通过 image.createImageSource(fd) 从文件描述符创建图片源,createPixelMap() 解码为 PixelMap。这些对象使用后都必须调用 release() 释放原生资源。


三、环境准备

本文所有实测均在以下环境完成:

版本说明
Flutter(ohos 版)3.44.9+ohos-0.0.1-canary1主验证环境,真机实测
DevEco Studio26.0.0.821构建与签名
编译 SDK26.0.0(API 26)宿主工程 compatibleSdkVersion 同值
真机OpenHarmony 6.1.1.120API 24,arm64

两点提醒:

  1. 编译鸿蒙目标需要使用 ohos 版 Flutter SDK,普通 Flutter SDK 无法编译 ohos 产物;
  2. ohos.permission.READ_PASTEBOARD 是 user_grant 权限,在 module.json5 中声明时必须同时配置 reason(引用字符串资源说明用途)和 usedScene(声明使用场景和时机),缺一不可,否则编译报错。

HarmonyOS 技术点:user_grant 权限三要素

鸿蒙系统的权限按授权方式分为 system_grant(系统自动授予,声明即获得)和 user_grant(用户授权,需运行时弹窗请求)。READ_PASTEBOARD 属于 user_grant 类型。在 module.json5 中声明 user_grant 权限时,必须配置三个要素:name(权限名)、reason(用途说明,引用 string.json 中的字符串资源)、usedScene(使用场景,包含 abilities 列表和 when 时机)。这三个要素缺一不可,编译器会强制校验。reason 字段引用的字符串资源会在权限弹窗中展示给用户,说明应用为何需要读取剪贴板。


四、引入依赖

进入工程目录,在 pubspec.yaml 中添加 git 依赖:

dependencies:
  clipboad_image:
    git:
      url: https://atomgit.com/CPF-Flutter/fluttertpc_clipboad_image.git
      # ref: 根据下方表格选择不同框架适配的 TAG 版本
      ref: 0.0.1-ohos-1.0.0-beta.2

执行命令拉取依赖:

flutter pub get

TAG 命名规则:原库版本-ohos-版本号-beta.x

Flutter 框架版本TAG 名称分支名
3.440.0.1-ohos-1.0.0-beta.2main

说明:该 TAG 已在 3.44.9+ohos-0.0.1-canary1 真机上实测通过。原库的 Dart 层 API 与上游完全一致,适配过程对 Dart 代码零改动。

pubspec.yaml 中的 ohos 平台声明

适配后的 pubspec.yamlflutter.plugin.platforms 下新增了 ohos 配置项:

flutter:
  plugin:
    platforms:
      android:
        package: com.aqsa.clipboad_image
        pluginClass: ClipboadImagePlugin
      ios:
        pluginClass: ClipboadImagePlugin
      ohos:
        pluginClass: ClipboadImagePlugin

pluginClass 的值必须与 ArkTS 插件类 getUniqueClassName() 的返回值完全一致。Flutter 鸿蒙适配层在构建时扫描此配置,自动生成 GeneratedPluginRegistrant.ets 文件,将插件类注册到引擎中。


五、代码接入

5.1 导入库

import 'package:clipboad_image/clipboad_image.dart';

导入后即可使用 ClipboadImage.getImage() 静态方法。

5.2 从剪贴板读取图片

final Uint8List? imageData = await ClipboadImage.getImage();
if (imageData != null) {
  // 展示图片
  Image.memory(imageData);
} else {
  // 剪贴板无图片
  debugPrint('剪贴板中没有图片');
}

getImage() 无参数,返回 Future<Uint8List?>。以下是 Dart 层的实现代码,逐段解析:

class ClipboadImage {
  static Future<Uint8List?> getImage() {
    return ClipboadImagePlatform.instance.getImage();
  }
}

Dart 层的 ClipboadImage 类只有一个静态方法 getImage(),调用 ClipboadImagePlatform.instance.getImage() 将请求转发给平台接口层。平台接口层的默认实现是 MethodChannelClipboadImage

class MethodChannelClipboadImage extends ClipboadImagePlatform {
  final methodChannel = const MethodChannel('clipboad_image');

  
  Future<Uint8List?> getImage() async {
    final version = await methodChannel.invokeMethod<Uint8List?>('getImage');
    return version;
  }
}

上述代码创建名为 'clipboad_image' 的 MethodChannel,通过 invokeMethod<Uint8List?>('getImage') 向原生侧发送方法调用。原生侧处理完毕后返回 Uint8List(JPEG 编码字节)或 null(无图片)。Dart 层不做任何额外处理,直接透传返回值。

invokeMethod<Uint8List?> 的类型参数Uint8List? 表示返回值可以是 Uint8Listnull。Flutter 的 MethodChannel 通过 StandardMessageCodec 编解码消息,原生侧返回的 Uint8Array(ArkTS)会被自动转换为 Dart 的 Uint8Listnull 直接透传。Dart 层无需手动做类型转换。

5.3 配置权限声明

module.json5requestPermissions 中声明 READ_PASTEBOARD 权限:

"requestPermissions": [
  { "name": "ohos.permission.INTERNET" },
  {
    "name": "ohos.permission.READ_PASTEBOARD",
    "reason": "$string:read_pasteboard_reason",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]

并在 entry/src/main/resources/base/element/string.json 中补充权限说明文案:

{
  "string": [
    {
      "name": "read_pasteboard_reason",
      "value": "Used to read images from the system clipboard"
    }
  ]
}

reason 字段引用的字符串资源会在权限弹窗中展示给用户,说明应用为何需要读取剪贴板。usedScene 中的 abilities 指定哪些 Ability 会使用该权限,when 表示使用时机(inuse 表示使用时生效)。

HarmonyOS 技术点:abilityAccessCtrl 权限管理

鸿蒙系统的权限管理通过 @ohos.abilityAccessCtrl 模块完成。createAtManager() 返回 AtManager 实例。checkAccessToken(accessTokenId, permission) 异步检查某权限的授予状态,返回 GrantStatus.PERMISSION_GRANTEDPERMISSION_DENIEDrequestPermissionsFromUser(context, permissions) 异步弹出系统权限弹窗,返回 requestPermissionsFromUserResult,其中 authResults 数组包含每个权限的授权结果(0 表示已授权)。clipboad_image 的插件代码通过这两个方法实现了"先检查后请求"的权限链路。

5.4 跨平台行为

同一套 API 在各端的行为完全一致:

平台剪贴板来源判断图片取图数据编码格式权限
AndroidClipboardManager primaryClipitem.uri != nullContentResolver.openInputStreamBitmapFactoryBitmap.compress(JPEG, 90)
iOSUIPasteboard.generalimage != nilUIPasteboard.general.imagejpegData(0.9)
OpenHarmonypasteboard.getSystemPasteboard().getData()hasType(PIXELMAP)hasType(URI)getPrimaryPixelMap()fs.open(uri)createImageSourceimagePacker.packing(JPEG, 90)READ_PASTEBOARD

三个平台的共同语义:剪贴板无图 / 内容非图 / 读取失败 → 返回 null;有图 → 返回 JPEG 质量 90 的字节。鸿蒙适配严格对齐该语义。

5.5 实战:给图片上传区域加粘贴按钮

实际业务中常见的场景是:用户在浏览器中复制一张图片,回到应用后点击"粘贴"按钮上传。下面是一个可直接使用的组件:

import 'package:flutter/material.dart';
import 'package:clipboad_image/clipboad_image.dart';

class PasteImageArea extends StatefulWidget {
  const PasteImageArea({super.key, this.onImagePasted});

  final ValueChanged<Uint8List>? onImagePasted;

  
  State<PasteImageArea> createState() => _PasteImageAreaState();
}

class _PasteImageAreaState extends State<PasteImageArea> {
  Uint8List? _imageData;
  String _status = '点击「从剪贴板粘贴图片」开始';

  Future<void> _pasteImage() async {
    setState(() => _status = '正在读取剪贴板…');

    try {
      final data = await ClipboadImage.getImage();
      if (!mounted) return;

      if (data != null && data.isNotEmpty) {
        setState(() {
          _imageData = data;
          _status = '读取成功 (${data.length} 字节)';
        });
        widget.onImagePasted?.call(data);
      } else {
        setState(() => _status = '剪贴板中没有图片');
      }
    } on PlatformException catch (e) {
      if (!mounted) return;
      setState(() => _status = '读取失败:${e.message ?? e.code}');
    }
  }

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          height: 200,
          width: double.infinity,
          decoration: BoxDecoration(
            border: Border.all(color: Colors.grey.shade300),
            borderRadius: BorderRadius.circular(12),
          ),
          clipBehavior: Clip.antiAlias,
          child: _imageData != null
              ? Image.memory(_imageData!, fit: BoxFit.contain)
              : Center(
                  child: Text(_status, style: TextStyle(color: Colors.grey)),
                ),
        ),
        const SizedBox(height: 12),
        FilledButton.icon(
          onPressed: _pasteImage,
          icon: const Icon(Icons.content_paste),
          label: const Text('从剪贴板粘贴图片'),
        ),
      ],
    );
  }
}

上述组件封装了"从剪贴板读取图片并展示"的完整交互。点击按钮后调用 ClipboadImage.getImage(),成功展示图片并回调 onImagePasted,失败时展示状态文本。PlatformException 被 try/catch 捕获——虽然当前鸿蒙实现在读取失败时返回 null 而非抛异常,但保留异常处理兼容其他平台行为。

使用建议ClipboadImage.getImage() 是异步操作,首次调用会触发权限弹窗。建议在 onPressed 回调中调用,而非在 initState 中自动调用——让用户主动点击触发权限请求更符合系统设计规范。同时,返回的 Uint8List 可能较大(几十 KB 到几 MB),如果需要上传到服务器,建议在 onImagePasted 回调中启动上传任务。


六、运行与验证

以下为 demo 工程的真机实测记录。仓库中 example 的 Bundle Name 为 clipboad_image_example,签名配置使用 DevEco Studio 自动签名。

设备项
机型OpenHarmony 真机
系统版本OpenHarmony 6.1.1.120
API 版本24
架构arm64

HarmonyOS 技术点:FlutterAbility 与 EntryAbility

鸿蒙 Flutter 应用的入口 Ability 需要继承 FlutterAbility(由 @ohos/flutter_ohos 提供),而非标准的 UIAbilityFlutterAbility 内部封装了 FlutterEngine 的初始化、Surface 注册、路由管理等逻辑。宿主工程的 EntryAbility 只需重写 configureFlutterEngine 方法,在其中调用 GeneratedPluginRegistrant.registerWith(flutterEngine) 即可完成所有原生插件的注册:

export default class EntryAbility extends FlutterAbility implements MethodCallHandler {
  configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    GeneratedPluginRegistrant.registerWith(flutterEngine)
    // 示例宿主额外注册了选图通道
    this.pickerChannel = new MethodChannel(
      flutterEngine.getDartExecutor().getBinaryMessenger(),
      'clipboad_image_example/photo_picker');
    this.pickerChannel.setMethodCallHandler(this);
  }
}

示例工程的 EntryAbility 额外实现了一个 clipboad_image_example/photo_picker 通道,通过 PhotoViewPicker 选图后写入系统剪贴板,形成"选图 → 写剪贴板 → 读剪贴板"的完整闭环。

6.1 验证一:构建与安装

构建 hap 后安装到真机并启动:

# 构建 hap(debug,含签名)
flutter build hap --debug

# 安装到真机
hdc install entry-default-signed.hap

# 启动 demo
hdc shell aa start -b clipboad_image_example -a EntryAbility

构建成功产出 entry-default-signed.hap(21.5MB,含 Flutter 引擎与资源),安装成功,启动成功。

在这里插入图片描述

6.2 验证二:插件注册

通过 hilog 确认插件注册:

hdc shell "hilog | grep -E 'ClipboadImagePlugin|clipboad_image'"

实测日志输出:

FlutterEngineCxnRegistry --> Adding plugin: ClipboadImagePlugin
DartMessenger --> Setting handler for channel 'clipboad_image'

ClipboadImagePlugin 成功注册到 Flutter 引擎(Adding plugin),clipboad_image MethodChannel 成功建立(Setting handler)。

在这里插入图片描述

6.3 验证三:从浏览器复制图片后读取

在浏览器中长按图片选择"复制图片",回到 demo 应用点击"从剪贴板读取"按钮:

  1. 首次读取时,系统弹出 READ_PASTEBOARD 权限弹窗,展示 read_pasteboard_reason 文案 “Used to read images from the system clipboard”
  2. 用户授权后,插件调用 pasteboard.getSystemPasteboard().getData() 读取剪贴板
  3. pasteData.hasType(MIMETYPE_PIXELMAP) 返回 true(浏览器复制图片为内嵌 PixelMap 形态)
  4. pasteData.getPrimaryPixelMap() 获取 PixelMap 对象
  5. imagePacker.packing(pixelMap, {format: 'image/jpeg', quality: 90}) 编码为 JPEG
  6. 返回 Uint8List,界面展示图片预览和尺寸信息(如 1080×1920 · 95832 字节

在这里插入图片描述

6.4 验证四:选择本地图片后读取

点击"选择本地图片"按钮,触发 clipboad_image_example/photo_picker 通道的 pickImageToClipboard 方法:

  1. PhotoViewPicker.select() 调起系统相册选择器(无需权限)
  2. 用户选择一张图片后,获取文件 URI
  3. fs.open(uri, READ_ONLY) 打开文件,image.createImageSource(fd) 创建图片源
  4. imageSource.createPixelMap() 解码为 PixelMap
  5. pasteboard.createData(MIMETYPE_PIXELMAP, pixelMap)PixelMap 写入系统剪贴板
  6. 写入成功后自动调用 ClipboadImage.getImage() 读取,形成完整闭环
  7. 读取时 hasType(MIMETYPE_PIXELMAP)true,走内嵌 PixelMap 分支

选图通道与插件通道的区别clipboad_image_example/photo_picker 是示例宿主工程提供的辅助通道,不属于 clipboad_image 插件本身。它的作用是"将本地图片写入剪贴板"以便验证"从剪贴板读取图片"的功能。实际业务中,用户通过浏览器或其他应用复制图片后直接调用 ClipboadImage.getImage() 即可,不需要这个辅助通道。

在这里插入图片描述

6.5 验证五:剪贴板无图片时返回 null

清空系统剪贴板(或复制纯文本到剪贴板),点击"从剪贴板读取"按钮:

  1. pasteData.hasType(MIMETYPE_PIXELMAP) 返回 false
  2. pasteData.hasType(MIMETYPE_TEXT_URI) 也返回 false(剪贴板为纯文本)
  3. 走 else 分支,返回 null
  4. 界面展示状态文本"剪贴板中没有图片(返回 null)"

在这里插入图片描述

不崩溃,不抛异常,与 Android/iOS 语义完全一致。

实测结论:

验证点结果
应用启动,Flutter 页面正常渲染通过
从浏览器复制图片后读取成功通过
首次读取自动弹出 READ_PASTEBOARD 权限弹窗通过
选择本地图片后自动写入剪贴板并读取成功通过
剪贴板无图片时返回 null,不崩溃通过
图片尺寸和字节数正确展示通过
插件注册成功(Adding plugin: ClipboadImagePlugin通过
全程 Dart 层零改动通过

以下是操作的视屏,可以参考一下:

Example 启动授权 Example 启动授权 Example 启动授权


七、工作原理

整个调用链路如下:

Dart: ClipboadImage.getImage()
  → ClipboadImagePlatform.instance.getImage()
    → MethodChannel('clipboad_image').invokeMethod('getImage')
      → ArkTS: ClipboadImagePlugin.onMethodCall('getImage', result)
        → getClipboardImage(result)
          ├─ ensurePasteboardPermission()  [AbilityAware]
          │  ├─ checkAccessToken → 已授权 → 继续
          │  └─ requestPermissionsFromUser → 弹窗 → 用户授权 → 继续
          → pasteboard.getSystemPasteboard().getData()
            ├─ hasType(MIMETYPE_PIXELMAP)?
            │  YES → getPrimaryPixelMap()
            │         → imagePacker.packing(pixelMap, {JPEG, 90})
            │           → result.success(Uint8Array)
            ├─ hasType(MIMETYPE_TEXT_URI)?
            │  YES → getPrimaryUri() → fs.open(uri)
            │         → image.createImageSource(fd)
            │           → imageSource.createPixelMap()
            │             → imagePacker.packing(pixelMap, {JPEG, 90})
            │               → result.success(Uint8Array)
            └─ else → result.success(null)
          finally: release ImagePacker / ImageSource / PixelMap / close File

HarmonyOS 技术点:AbilityAware 接口

AbilityAware 是 Flutter 鸿蒙适配层提供的接口,用于让原生插件感知宿主 UIAbility 的生命周期。它包含两个回调:onAttachedToAbility(binding) 在 Ability 挂载到引擎时调用,binding.getAbility() 返回 UIAbility 实例;onDetachedFromAbility() 在 Ability 卸载时调用。许多系统 API(如 requestPermissionsFromUser)需要 UIAbility 上下文才能调用,因此需要权限请求的插件必须实现 AbilityAwareClipboadImagePlugin 实现了该接口,在 onAttachedToAbility 中保存 UIAbility 引用,供 ensurePasteboardPermission() 使用。

7.1 Dart 层实现解析

库的 Dart 层包含三个文件,逐段解析如下。

clipboad_image.dart:对外入口

class ClipboadImage {
  static Future<Uint8List?> getImage() {
    return ClipboadImagePlatform.instance.getImage();
  }
}

ClipboadImage 类只有一个静态方法 getImage(),调用 ClipboadImagePlatform.instance.getImage() 将请求转发给平台接口层。这种设计通过平台接口抽象层实现了跨平台多态——每个平台提供自己的 ClipboadImagePlatform 实现,Dart 层代码无需关心当前运行在哪个平台。

clipboad_image_method_channel.dart:通道实现

class MethodChannelClipboadImage extends ClipboadImagePlatform {
  final methodChannel = const MethodChannel('clipboad_image');

  
  Future<Uint8List?> getImage() async {
    final version = await methodChannel.invokeMethod<Uint8List?>('getImage');
    return version;
  }
}

这段代码创建名为 'clipboad_image' 的 MethodChannel(与 Android/iOS 通道名一致),getImage() 通过 invokeMethod<Uint8List?>('getImage') 向原生侧发送方法调用。invokeMethod 的类型参数 Uint8List? 告诉 Flutter 框架期望的返回值类型——StandardMessageCodec 会自动将原生侧返回的 Uint8Array(ArkTS)转换为 Dart 的 Uint8Listnull 直接透传。

Dart → 原生方向的通信:与常见的"原生 → Dart"方向(如 UI 事件回调)不同,clipboad_image 的核心通信方向是 Dart → 原生。Dart 层主动发起方法调用,原生侧处理完毕后通过 result.success() 回传结果。这种"请求-响应"模式在 MethodChannel 中很常见——invokeMethod 返回 Future<T?>,原生侧调用 result.success(value) 时 Future 完成。

clipboad_image_platform_interface.dart:平台接口抽象

abstract class ClipboadImagePlatform extends PlatformInterface {
  ClipboadImagePlatform() : super(token: _token);
  static final Object _token = Object();
  static ClipboadImagePlatform _instance = MethodChannelClipboadImage();

  static ClipboadImagePlatform get instance => _instance;
  static set instance(ClipboadImagePlatform instance) {
    PlatformInterface.verifyToken(instance, _token);
    _instance = instance;
  }

  Future<Uint8List?> getImage() {
    throw UnimplementedError('platformVersion() has not been implemented.');
  }
}

这段代码使用 plugin_platform_interface 包的 PlatformInterface 基类,通过 token 机制防止第三方意外替换平台实现。默认实例为 MethodChannelClipboadImagegetImage() 抽象方法在基类中抛 UnimplementedError,由子类 MethodChannelClipboadImage 重写提供具体实现。

7.2 鸿蒙侧 ArkTS 插件实现逐段解析

鸿蒙侧插件只有一个文件 ClipboadImagePlugin.ets,但内容丰富,逐段解析如下。

第一段:导入与类声明

import {
  AbilityAware,
  AbilityPluginBinding,
  FlutterPlugin,
  FlutterPluginBinding,
  MethodCall,
  MethodCallHandler,
  MethodChannel,
  MethodResult,
} from '@ohos/flutter_ohos';
import pasteboard from '@ohos.pasteboard';
import image from '@ohos.multimedia.image';
import fs from '@ohos.file.fs';
import abilityAccessCtrl from '@ohos.abilityAccessCtrl';
import UIAbility from '@ohos.app.ability.UIAbility';
import { BusinessError } from '@kit.BasicServicesKit';

const READ_PASTEBOARD_PERMISSION = 'ohos.permission.READ_PASTEBOARD';

export default class ClipboadImagePlugin implements FlutterPlugin, MethodCallHandler, AbilityAware {
  private channel: MethodChannel | null = null;
  private ability: UIAbility | null = null;

这段代码导入了 Flutter 鸿蒙适配层的插件接口类型、剪贴板模块(@ohos.pasteboard)、图片处理模块(@ohos.multimedia.image)、文件系统模块(@ohos.file.fs)、权限管理模块(@ohos.abilityAccessCtrl)和错误类型(BusinessError)。ClipboadImagePlugin 类实现三个接口:FlutterPlugin(引擎生命周期管理)、MethodCallHandler(方法调用处理)、AbilityAware(UIAbility 上下文获取)——后者是权限请求所必需的。

HarmonyOS 技术点:@ohos.pasteboard 与剪贴板 MIME 类型

鸿蒙剪贴板模块通过 MIME 类型标识数据格式。MIMETYPE_PIXELMAP(值为 'pixelMap')表示内嵌像素图数据,MIMETYPE_TEXT_URI(值为 'text/uri')表示 URI 文本。PasteData.hasType(mimeType) 检查剪贴板中是否包含指定 MIME 类型的记录。getPrimaryPixelMap() 直接获取像素图,getPrimaryUri() 获取 URI 字符串。适配时先查 SDK 的 .d.ts 声明文件确认这些 API 的确切签名——不凭记忆写属性名,避免编译期才发现 API 不存在。

第二段:生命周期管理

getUniqueClassName(): string {
  return "ClipboadImagePlugin"
}

onAttachedToEngine(binding: FlutterPluginBinding): void {
  this.channel = new MethodChannel(binding.getBinaryMessenger(), "clipboad_image");
  this.channel.setMethodCallHandler(this)
}

onDetachedFromEngine(binding: FlutterPluginBinding): void {
  if (this.channel != null) {
    this.channel.setMethodCallHandler(null)
  }
}

onAttachedToAbility(binding: AbilityPluginBinding): void {
  this.ability = binding.getAbility();
}

onDetachedFromAbility(): void {
  this.ability = null;
}

getUniqueClassName() 返回 "ClipboadImagePlugin",需与 pubspec.yaml 中的 pluginClass 配置一致。onAttachedToEngine 创建名为 "clipboad_image" 的 MethodChannel 并设置方法调用处理器。onAttachedToAbility 保存 UIAbility 引用——这是 requestPermissionsFromUser 所需的上下文参数。onDetachedFromEngineonDetachedFromAbility 清理引用,防止内存泄漏。

引擎调用顺序:引擎先调用 onAttachedToEngine(创建通道),后调用 onAttachedToAbility(获取 UIAbility)。当 Dart 层调用 getImage() 时,两个回调都已执行完毕,this.ability 已经赋值,可以安全地用于权限请求。

第三段:方法分发

onMethodCall(call: MethodCall, result: MethodResult): void {
  if (call.method == "getImage") {
    this.getClipboardImage(result);
  } else {
    result.notImplemented()
  }
}

onMethodCall 是方法调用的入口。仅处理 "getImage" 方法,委托给 getClipboardImage(result) 异步处理。未知方法调用 result.notImplemented() 回复。

第四段:权限请求

private async ensurePasteboardPermission(): Promise<boolean> {
  if (this.ability == null) {
    return true;
  }
  const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  const grantStatus: abilityAccessCtrl.GrantStatus = await atManager.checkAccessToken(
    this.ability.context.applicationInfo.accessTokenId, READ_PASTEBOARD_PERMISSION);
  if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
    return true;
  }
  const requestResult = await atManager.requestPermissionsFromUser(
    this.ability.context, [READ_PASTEBOARD_PERMISSION]);
  return requestResult.authResults.length > 0 && requestResult.authResults[0] === 0;
}

这段代码实现了"先检查后请求"的权限链路。首先检查 this.ability 是否为 null(无 UIAbility 上下文则直接尝试读取)。然后通过 checkAccessToken(accessTokenId, permission) 异步检查权限状态。已授予直接返回 true,未授予则调用 requestPermissionsFromUser(context, permissions) 弹窗请求。authResults[0] === 0 表示用户授权(0 为 PERMISSION_GRANTED)。

HarmonyOS 技术点:accessTokenId 与权限校验

鸿蒙系统中的每个应用都有一个 accessTokenId(访问令牌 ID),它是应用在系统安全子系统中的唯一标识。applicationInfo.accessTokenId 获取当前应用的令牌 ID,将其传给 checkAccessToken() 即可查询权限状态。requestPermissionsFromUser() 返回的 authResults 数组与传入的 permissions 数组一一对应,0 表示已授权。

第五段:剪贴板图片读取(核心逻辑)

private async getClipboardImage(result: MethodResult): Promise<void> {
  let pixelMap: image.PixelMap | null = null;
  let imagePacker: image.ImagePacker | null = null;
  let imageSource: image.ImageSource | null = null;
  let file: fs.File | null = null;
  try {
    // 权限检查
    if (!(await this.ensurePasteboardPermission())) {
      result.success(null);
      return;
    }
    // 读取剪贴板
    const systemPasteboard: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
    const pasteData: pasteboard.PasteData = await systemPasteboard.getData();
    if (pasteData == null) {
      result.success(null);
      return;
    }
    if (pasteData.hasType(pasteboard.MIMETYPE_PIXELMAP)) {
      // 情况一:内嵌 PixelMap
      pixelMap = pasteData.getPrimaryPixelMap();
      if (pixelMap == null) {
        result.success(null);
        return;
      }
    } else if (pasteData.hasType(pasteboard.MIMETYPE_TEXT_URI)) {
      // 情况二:URI 记录
      const uri: string = pasteData.getPrimaryUri();
      if (uri == null || uri.length == 0) {
        result.success(null);
        return;
      }
      file = await fs.open(uri, fs.OpenMode.READ_ONLY);
      imageSource = image.createImageSource(file.fd);
      pixelMap = await imageSource.createPixelMap();
    } else {
      // 非图片内容
      result.success(null);
      return;
    }
    // 编码为 JPEG
    imagePacker = image.createImagePacker();
    const buffer: ArrayBuffer = await imagePacker.packing(pixelMap, {
      format: 'image/jpeg',
      quality: 90,
    });
    result.success(new Uint8Array(buffer));
  } catch (err) {
    const e = err as BusinessError;
    console.error(`ClipboadImagePlugin getImage failed: code=${e.code}, message=${e.message}`);
    result.success(null);
  } finally {
    if (imagePacker != null) { await imagePacker.release(); }
    if (imageSource != null) { await imageSource.release(); }
    if (pixelMap != null) { await pixelMap.release(); }
    if (file != null) { fs.closeSync(file); }
  }
}

这是插件的核心方法,逻辑分为五个步骤。第一步是权限检查,未授权返回 null。第二步是读取系统剪贴板,通过 getSystemPasteboard().getData() 异步获取 PasteData。第三步是判断图片形态:如果 hasType(MIMETYPE_PIXELMAP)true,走内嵌 PixelMap 分支,直接 getPrimaryPixelMap() 获取像素图;如果 hasType(MIMETYPE_TEXT_URI)true,走 URI 分支,通过 fs.open(uri) 打开文件、image.createImageSource(fd) 创建图片源、createPixelMap() 解码为 PixelMap;都不是则返回 null。第四步是编码:image.createImagePacker() 创建编码器,packing(pixelMap, {format: 'image/jpeg', quality: 90})PixelMap 编码为 JPEG 格式的 ArrayBuffer,包装为 Uint8Array 后通过 result.success() 回传。第五步是资源释放:finally 块中依次释放 ImagePackerImageSourcePixelMap 和关闭 File,避免原生资源泄漏。

为什么所有失败情况都返回 null 而非抛异常?

这是跨平台语义对齐的决策。Android 和 iOS 端在剪贴板无图、内容非图或读取失败时都返回 null(而非抛异常),Dart 层的调用方通过 if (data != null) 判断即可。如果鸿蒙端在某些失败场景抛异常而其他端返回 null,Dart 层就需要为不同平台编写不同的错误处理逻辑——这违背了"Dart 层零改动"的适配目标。因此插件将所有失败情况统一为返回 null,仅在 console.error 中记录错误日志。

7.3 示例宿主的选图闭环

示例工程的 EntryAbility 提供了一个辅助通道 clipboad_image_example/photo_picker,用于将本地图片写入系统剪贴板,形成验证闭环。逐段解析如下:

private async pickImageToClipboard(result: MethodResult): Promise<void> {
  let imageSource: image.ImageSource | null = null;
  let pixelMap: image.PixelMap | null = null;
  try {
    // 1. 调起系统相册选择一张图片(系统选择器无需申请权限)
    const photoPicker = new picker.PhotoViewPicker();
    const selectResult = await photoPicker.select({
      MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE,
      maxSelectNumber: 1,
    });
    if (selectResult.photoUris.length == 0) {
      result.success(false);
      return;
    }
    // 2. 打开文件并解码为 PixelMap
    const file = await fs.open(selectResult.photoUris[0], fs.OpenMode.READ_ONLY);
    try {
      imageSource = image.createImageSource(file.fd);
      pixelMap = await imageSource.createPixelMap();
    } finally {
      fs.closeSync(file);
    }
    // 3. 以 PixelMap 形式写入系统剪贴板(写剪贴板无需申请权限)
    const pasteData = pasteboard.createData(pasteboard.MIMETYPE_PIXELMAP, pixelMap);
    const systemPasteboard = pasteboard.getSystemPasteboard();
    await systemPasteboard.setData(pasteData);
    result.success(true);
  } catch (err) {
    result.success(false);
  } finally {
    if (imageSource != null) { await imageSource.release(); }
    if (pixelMap != null) { await pixelMap.release(); }
  }
}

这段代码实现了三步闭环:选图(PhotoViewPicker 系统选择器,无需权限)→ 解码(createImageSource + createPixelMap)→ 写剪贴板(createData(MIMETYPE_PIXELMAP, pixelMap) + setData)。写入剪贴板后 Dart 层自动调用 ClipboadImage.getImage() 读取,形成完整验证闭环。

HarmonyOS 技术点:PhotoViewPicker 与权限豁免

鸿蒙系统的 @ohos.file.picker 模块提供了系统级文件选择器。PhotoViewPicker.select() 调起系统相册选择器,用户选择图片后返回文件 URI。系统选择器的特殊之处在于:它运行在系统进程中,用户选择文件的行为本身就隐含了授权——应用获取到的 URI 只能在此后有限时间内访问,不需要申请 READ_IMAGEVIDEO 等权限。这与 Android 的 ACTION_OPEN_DOCUMENT 和 iOS 的 PHPicker 设计理念一致。

7.4 插件注册机制

鸿蒙侧的插件注册是自动完成的。Flutter 鸿蒙适配层在构建时扫描 pubspec.yaml 中的 ohos: pluginClass 配置,自动生成 GeneratedPluginRegistrant.ets

import { FlutterEngine, Log } from '@ohos/flutter_ohos';
import IntegrationTestPlugin from 'integration_test';
import ClipboadImagePlugin from 'clipboad_image';

export class GeneratedPluginRegistrant {
  static registerWith(flutterEngine: FlutterEngine) {
    try {
      flutterEngine.getPlugins()?.add(new IntegrationTestPlugin());
      flutterEngine.getPlugins()?.add(new ClipboadImagePlugin());
    } catch (e) {
      Log.e(TAG, "Tried to register plugins with FlutterEngine failed.");
    }
  }
}

整个注册链路为:

EntryAbility.configureFlutterEngine()
  → GeneratedPluginRegistrant.registerWith(flutterEngine)
    → flutterEngine.getPlugins().add(new ClipboadImagePlugin())
      → ClipboadImagePlugin.onAttachedToEngine(binding)
        → new MethodChannel(messenger, "clipboad_image")
          → setMethodCallHandler(this)
      → ClipboadImagePlugin.onAttachedToAbility(binding)
        → this.ability = binding.getAbility()  // 持有 UIAbility 引用

引擎先调用 onAttachedToEngine(创建通道),后调用 onAttachedToAbility(获取 UIAbility)。当 Dart 层调用 getImage() 时,两个回调都已执行完毕,this.ability 已经赋值,可以安全地用于权限请求。

三端通道契约对照

契约项Android (Kotlin)iOS (Swift)OHOS (ArkTS)
通道名clipboad_imageclipboad_imageclipboad_image
方法名getImagegetImagegetImage
参数
返回值ByteArray? (JPEG/90)Data? (JPEG/0.9)Uint8Array? (JPEG/90)
无图/失败nullnilnull
权限READ_PASTEBOARD(user_grant,自动请求)
接口AbilityAware-AbilityAware

三端通道契约完全一致,仅返回值的原生类型不同(ByteArray / Data / Uint8Array),Flutter 框架的 StandardMessageCodec 会自动将其统一转换为 Dart 的 Uint8List


八、常见问题

Q1:getImage() 返回 null 是怎么回事?

有多种可能。最常见的是剪贴板中确实没有图片——用户可能复制的是纯文本而非图片,或剪贴板为空。其次是权限未授予——插件在读取前会先检查 READ_PASTEBOARD 权限,未授权时自动弹窗请求,用户拒绝则返回 null。最后是读取失败——URI 形态的图片文件已被删除或无法访问,此时 catch 块捕获异常后返回 null。所有失败情况都不抛异常,Dart 层通过 if (data != null) 判断即可。

Q2:为什么首次读取时会弹出权限弹窗?

鸿蒙从 API 12 起读取系统剪贴板强制要求 READ_PASTEBOARD(user_grant)权限。插件实现 AbilityAware 接口,在 getImage() 被调用时先通过 checkAccessToken() 检查权限状态。已授权直接继续读取,未授权则通过 requestPermissionsFromUser() 弹出系统权限弹窗。用户授权后读取操作继续执行;用户拒绝则返回 null。后续调用不再弹窗(已授权)。

在这里插入图片描述

Q3:浏览器复制的图片和图库复制的图片有什么区别?

两种图片在剪贴板中的存储形态不同。浏览器复制图片时,图片像素数据直接嵌入剪贴板记录(MIMETYPE_PIXELMAP),插件通过 getPrimaryPixelMap() 直接获取。图库等应用复制图片时,剪贴板中只保存图片的文件 URI(MIMETYPE_TEXT_URI),插件需要通过 fs.open(uri) 打开文件、image.createImageSource(fd) 创建图片源、createPixelMap() 解码后才能获取像素数据。插件自动识别这两种形态并分别处理,业务层无需关心差异。

Q4:声明了 READ_PASTEBOARD 权限后编译报错怎么办?

READ_PASTEBOARD 是 user_grant 权限,在 module.json5 中声明时必须同时配置 reason(引用 string.json 中的字符串资源)和 usedScene(包含 abilities 列表和 when 时机)。缺少任何一个都会编译报错。同时,reason 引用的字符串资源必须在 string.json 中定义,否则也会报错。正确格式见"五、代码接入"中的 5.3 节。

在这里插入图片描述

Q5:返回的图片是什么格式?

返回的是 JPEG 格式、压缩质量 90 的 Uint8List。无论剪贴板中原始图片是 PNG、JPEG、WEBP 还是其他格式,插件都通过 imagePacker.packing(pixelMap, {format: 'image/jpeg', quality: 90}) 统一编码为 JPEG。这与 Android 端的 Bitmap.compress(JPEG, 90) 和 iOS 端的 jpegData(0.9) 语义完全一致。Dart 层可以直接用 Image.memory(data) 展示图片。

Q6:大图片会内存溢出吗?

getPrimaryPixelMap()createPixelMap() 解码时会将完整图片加载到内存中。如果剪贴板中的图片非常大(如 4K 照片,像素数据可能达到几十 MB),在内存受限的设备上可能存在 OOM 风险。ImagePacker.packing() 编码为 JPEG 后内存占用会大幅降低(JPEG 体积通常远小于原始像素数据),但解码和编码过程中的峰值内存仍需关注。建议在低内存设备上对返回的 Uint8List 做大小检查,超过阈值时提示用户或降级处理。


九、结语

回顾一下:在 pubspec.yaml 中以 git TAG 引入 clipboad_image,在 module.json5 中声明 READ_PASTEBOARD 权限(配置 reasonusedScene),在 Dart 层调用 ClipboadImage.getImage() 一个静态方法即可从鸿蒙系统剪贴板读取图片。插件自动处理权限请求、自动识别内嵌 PixelMap 和 URI 记录两种图片形态、统一编码为 JPEG 质量 90 字节返回。Dart 层零改动,通道契约与 Android/iOS 完全一致,已在 OpenHarmony 6.1.1.120 真机完整实测。

总结对比

维度AndroidiOSOpenHarmony / HarmonyOS
剪贴板来源ClipboardManager primaryClipUIPasteboard.generalpasteboard.getSystemPasteboard()
图片形态判断item.uri != nullimage != nilhasType(PIXELMAP)hasType(URI)
取图数据ContentResolver.openInputStreamBitmapFactory.imagegetPrimaryPixelMap()fs.opencreateImageSource
编码格式Bitmap.compress(JPEG, 90)jpegData(0.9)imagePacker.packing(JPEG, 90)
权限要求READ_PASTEBOARD(user_grant,自动请求)
无图/失败返回 null返回 nil返回 null
Dart 层改动
图片形态MIME 类型来源场景处理方式
内嵌 PixelMapMIMETYPE_PIXELMAP浏览器/网页复制图片getPrimaryPixelMap() 直接获取
URI 记录MIMETYPE_TEXT_URI图库/文件管理器复制图片fs.open(uri)createImageSourcecreatePixelMap
无图片非上述类型剪贴板为空或纯文本返回 null
权限状态getImage() 行为返回值业务建议
已授权读取剪贴板 → 编码 JPEG → 返回Uint8List?正常使用
未授权(首次)弹窗请求权限暂不返回等待用户授权
用户拒绝跳过读取null引导用户前往设置授权
无 UIAbility直接尝试读取Uint8List?null确保在前台 UIAbility 场景使用

核心要点:一个静态方法,自动权限请求,双形态兼容(PixelMap + URI),JPEG 质量 90 统一编码,Dart 层零改动

使用中发现任何问题,欢迎到配套仓库提 Issue,也欢迎发 PR 共建。

相关链接

欢迎加入 CPF-Flutter 鸿蒙社区,社区入口、环境搭建指南和本文相关链接统一放在这里:

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐