HarmonyOS鸿蒙App 增加从系统剪贴板读取图片的能力,用一个静态方法搞定网页图与本地图两种来源的读取,自动处理权限弹窗全程零配置 —— clipboad_image 鸿蒙使用实战指南
开发工具: 华为云码道
本文配套仓库: 上游 salman3xs/clipboard_image;OHOS 适配位于本地仓库提交
b6cb54f的ohos/、example/ohos/、README.OpenHarmony_CN.md、README.OpenHarmony.md、CHANGELOG.OpenHarmony.md与docs/。
鸿蒙适配后仓库: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 首次读取 | 弹出剪贴板权限授权框,允许后成功读取(本次使用允许 / 始终允许 / 不允许) |
以下是操作的视屏,可以参考一下:
图一:demo 应用在 OpenHarmony 真机启动,展示标题栏、图片预览区、状态行和操作按钮
图二:点击"从剪贴板读取"后系统弹出 READ_PASTEBOARD 权限请求弹窗
图三:授权后图片成功读取并展示在预览区,底部显示尺寸与字节数
检查要点:
- 剪贴板图片读取通过 MethodChannel
clipboad_image的getImage方法完成,由 ArkTS 插件ClipboadImagePlugin处理; - 鸿蒙系统的剪贴板图片存在两种形态——内嵌 PixelMap(
MIMETYPE_PIXELMAP)和 URI 记录(MIMETYPE_TEXT_URI),插件均做了处理; - 首次读取需要
READ_PASTEBOARD(user_grant)权限,插件实现AbilityAware接口自动请求; - 返回值为 JPEG 质量 90 编码的
Uint8List,无图片或读取失败时返回null,与 Android/iOS 语义完全一致; - 完整实测过程见"六、运行与验证"。
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}),三端语义完全对齐。
几个对使用者友好的特点:
- 极简 API:一个静态方法
ClipboadImage.getImage(),无需实例化、无需配置,一行代码完成剪贴板图片读取; - 自动权限请求:插件实现
AbilityAware接口,首次读取时自动弹出READ_PASTEBOARD权限弹窗,业务代码无需自行处理权限逻辑; - 双形态兼容:自动识别剪贴板中的内嵌 PixelMap 和 URI 记录两种图片形态,分别处理;
- 跨平台语义一致:无图返回
null、有图返回 JPEG 质量 90 字节,与 Android/iOS 完全对齐; - 资源安全:所有原生资源(
ImagePacker、ImageSource、PixelMap、File)在finally块中释放,避免内存泄漏。
接口说明:
| 名称 | 描述 | 类型 | 参数类型 | 返回值 | 必填 | 鸿蒙平台支持 |
|---|---|---|---|---|---|---|
ClipboadImage.getImage | 从系统剪贴板获取图片 | 静态方法 | 无 | Future<Uint8List?> | 是 | 是 |
HarmonyOS 技术点:
image.PixelMap与ImagePacker鸿蒙系统的图片处理由
@ohos.multimedia.image模块提供。PixelMap是像素图对象,包含图片的像素数据和尺寸信息。ImagePacker是图片编码器,packing(pixelMap, option)方法将PixelMap编码为指定格式(如 JPEG)的字节缓冲区(ArrayBuffer),PackingOption指定format和quality。对于 URI 形态的剪贴板图片,还需要ImageSource——通过image.createImageSource(fd)从文件描述符创建图片源,createPixelMap()解码为PixelMap。这些对象使用后都必须调用release()释放原生资源。
三、环境准备
本文所有实测均在以下环境完成:
| 项 | 版本 | 说明 |
|---|---|---|
| Flutter(ohos 版) | 3.44.9+ohos-0.0.1-canary1 | 主验证环境,真机实测 |
| DevEco Studio | 26.0.0.821 | 构建与签名 |
| 编译 SDK | 26.0.0(API 26) | 宿主工程 compatibleSdkVersion 同值 |
| 真机 | OpenHarmony 6.1.1.120 | API 24,arm64 |
两点提醒:
- 编译鸿蒙目标需要使用 ohos 版 Flutter SDK,普通 Flutter SDK 无法编译 ohos 产物;
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.44 | 0.0.1-ohos-1.0.0-beta.2 | main |
说明:该 TAG 已在 3.44.9+ohos-0.0.1-canary1 真机上实测通过。原库的 Dart 层 API 与上游完全一致,适配过程对 Dart 代码零改动。
pubspec.yaml 中的 ohos 平台声明
适配后的
pubspec.yaml在flutter.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?表示返回值可以是Uint8List或null。Flutter 的 MethodChannel 通过StandardMessageCodec编解码消息,原生侧返回的Uint8Array(ArkTS)会被自动转换为 Dart 的Uint8List,null直接透传。Dart 层无需手动做类型转换。
5.3 配置权限声明
在 module.json5 的 requestPermissions 中声明 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_GRANTED或PERMISSION_DENIED。requestPermissionsFromUser(context, permissions)异步弹出系统权限弹窗,返回requestPermissionsFromUserResult,其中authResults数组包含每个权限的授权结果(0 表示已授权)。clipboad_image的插件代码通过这两个方法实现了"先检查后请求"的权限链路。
5.4 跨平台行为
同一套 API 在各端的行为完全一致:
| 平台 | 剪贴板来源 | 判断图片 | 取图数据 | 编码格式 | 权限 |
|---|---|---|---|---|---|
| Android | ClipboardManager primaryClip | item.uri != null | ContentResolver.openInputStream → BitmapFactory | Bitmap.compress(JPEG, 90) | 无 |
| iOS | UIPasteboard.general | image != nil | UIPasteboard.general.image | jpegData(0.9) | 无 |
| OpenHarmony | pasteboard.getSystemPasteboard().getData() | hasType(PIXELMAP) 或 hasType(URI) | getPrimaryPixelMap() 或 fs.open(uri) → createImageSource | imagePacker.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提供),而非标准的UIAbility。FlutterAbility内部封装了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 应用点击"从剪贴板读取"按钮:
- 首次读取时,系统弹出
READ_PASTEBOARD权限弹窗,展示read_pasteboard_reason文案 “Used to read images from the system clipboard” - 用户授权后,插件调用
pasteboard.getSystemPasteboard().getData()读取剪贴板 pasteData.hasType(MIMETYPE_PIXELMAP)返回true(浏览器复制图片为内嵌 PixelMap 形态)pasteData.getPrimaryPixelMap()获取PixelMap对象imagePacker.packing(pixelMap, {format: 'image/jpeg', quality: 90})编码为 JPEG- 返回
Uint8List,界面展示图片预览和尺寸信息(如1080×1920 · 95832 字节)

6.4 验证四:选择本地图片后读取
点击"选择本地图片"按钮,触发 clipboad_image_example/photo_picker 通道的 pickImageToClipboard 方法:
PhotoViewPicker.select()调起系统相册选择器(无需权限)- 用户选择一张图片后,获取文件 URI
fs.open(uri, READ_ONLY)打开文件,image.createImageSource(fd)创建图片源imageSource.createPixelMap()解码为PixelMappasteboard.createData(MIMETYPE_PIXELMAP, pixelMap)将PixelMap写入系统剪贴板- 写入成功后自动调用
ClipboadImage.getImage()读取,形成完整闭环 - 读取时
hasType(MIMETYPE_PIXELMAP)为true,走内嵌 PixelMap 分支
选图通道与插件通道的区别:
clipboad_image_example/photo_picker是示例宿主工程提供的辅助通道,不属于clipboad_image插件本身。它的作用是"将本地图片写入剪贴板"以便验证"从剪贴板读取图片"的功能。实际业务中,用户通过浏览器或其他应用复制图片后直接调用ClipboadImage.getImage()即可,不需要这个辅助通道。

6.5 验证五:剪贴板无图片时返回 null
清空系统剪贴板(或复制纯文本到剪贴板),点击"从剪贴板读取"按钮:
pasteData.hasType(MIMETYPE_PIXELMAP)返回falsepasteData.hasType(MIMETYPE_TEXT_URI)也返回false(剪贴板为纯文本)- 走 else 分支,返回
null - 界面展示状态文本"剪贴板中没有图片(返回 null)"

不崩溃,不抛异常,与 Android/iOS 语义完全一致。
实测结论:
| 验证点 | 结果 |
|---|---|
| 应用启动,Flutter 页面正常渲染 | 通过 |
| 从浏览器复制图片后读取成功 | 通过 |
首次读取自动弹出 READ_PASTEBOARD 权限弹窗 | 通过 |
| 选择本地图片后自动写入剪贴板并读取成功 | 通过 |
剪贴板无图片时返回 null,不崩溃 | 通过 |
| 图片尺寸和字节数正确展示 | 通过 |
插件注册成功(Adding plugin: ClipboadImagePlugin) | 通过 |
| 全程 Dart 层零改动 | 通过 |
以下是操作的视屏,可以参考一下:
七、工作原理
整个调用链路如下:
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上下文才能调用,因此需要权限请求的插件必须实现AbilityAware。ClipboadImagePlugin实现了该接口,在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 的 Uint8List,null 直接透传。
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 机制防止第三方意外替换平台实现。默认实例为 MethodChannelClipboadImage。getImage() 抽象方法在基类中抛 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 所需的上下文参数。onDetachedFromEngine 和 onDetachedFromAbility 清理引用,防止内存泄漏。
引擎调用顺序:引擎先调用
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 块中依次释放 ImagePacker、ImageSource、PixelMap 和关闭 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 权限(配置 reason 和 usedScene),在 Dart 层调用 ClipboadImage.getImage() 一个静态方法即可从鸿蒙系统剪贴板读取图片。插件自动处理权限请求、自动识别内嵌 PixelMap 和 URI 记录两种图片形态、统一编码为 JPEG 质量 90 字节返回。Dart 层零改动,通道契约与 Android/iOS 完全一致,已在 OpenHarmony 6.1.1.120 真机完整实测。
总结对比
维度 Android iOS OpenHarmony / HarmonyOS 剪贴板来源 ClipboardManagerprimaryClipUIPasteboard.generalpasteboard.getSystemPasteboard()图片形态判断 item.uri != nullimage != nilhasType(PIXELMAP)或hasType(URI)取图数据 ContentResolver.openInputStream→BitmapFactory.imagegetPrimaryPixelMap()或fs.open→createImageSource编码格式 Bitmap.compress(JPEG, 90)jpegData(0.9)imagePacker.packing(JPEG, 90)权限要求 无 无 READ_PASTEBOARD(user_grant,自动请求)无图/失败 返回 null返回 nil返回 nullDart 层改动 无 无 无
图片形态 MIME 类型 来源场景 处理方式 内嵌 PixelMap MIMETYPE_PIXELMAP浏览器/网页复制图片 getPrimaryPixelMap()直接获取URI 记录 MIMETYPE_TEXT_URI图库/文件管理器复制图片 fs.open(uri)→createImageSource→createPixelMap无图片 非上述类型 剪贴板为空或纯文本 返回 null
权限状态 getImage()行为返回值 业务建议 已授权 读取剪贴板 → 编码 JPEG → 返回 Uint8List?正常使用 未授权(首次) 弹窗请求权限 暂不返回 等待用户授权 用户拒绝 跳过读取 null引导用户前往设置授权 无 UIAbility 直接尝试读取 Uint8List?或null确保在前台 UIAbility 场景使用 核心要点:一个静态方法,自动权限请求,双形态兼容(PixelMap + URI),JPEG 质量 90 统一编码,Dart 层零改动。
使用中发现任何问题,欢迎到配套仓库提 Issue,也欢迎发 PR 共建。
相关链接
欢迎加入 CPF-Flutter 鸿蒙社区,社区入口、环境搭建指南和本文相关链接统一放在这里:
更多推荐



所有评论(0)