开发工具: 华为云码道

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

clipboad_image 把"从系统剪贴板读取图片"封装成一个静态方法:ClipboadImage.getImage() 返回 Future<Uint8List?>,即剪贴板图片的 JPEG 编码字节(质量 90);剪贴板为空或内容不是图片时返回 null。Dart 层只有通道调用,真正的剪贴板读取和图片编解码都在原生侧完成。本文以 clipboad_image 0.0.1+1 为例,介绍源码准备、OHOS 工程配置、ArkTS 实现和 example 构建。

插件目前支持 ohos 平台,上游源码位于 GitHub 仓库。文中的代码以上游 main 分支提交 51b73064ab97975d69bd284c95c9b5bce22c4b4f 为适配基线。

在这里插入图片描述

OHOS 改动记录在本地提交 a6bad9c2506737d80b3cfb1281cc6267a891d520,并按 0.0.1-ohos-1.0.0-beta.2 打 TAG 发布。

在这里插入图片描述


一、插件简介与适配目标

从剪贴板读取图片是内容类应用的常见需求:用户在浏览器长按保存、在图库中复制一张图,然后回到应用里直接"粘贴"成消息或素材。clipboad_image 把这一能力封装成一个静态方法 ClipboadImage.getImage(),返回 Future<Uint8List?>:剪贴板有图片时返回其 JPEG 编码字节(压缩质量 90),为空、内容不是图片或读取失败时返回 null

例如,用户在浏览器中复制一张图片后打开示例应用,点击"从剪贴板读取"按钮,页面直接通过 Image.memory 展示这张图片和它的尺寸、字节数。

正因如此,这个插件的 OHOS 适配工作集中在三点:在 pubspec.yaml 声明 ohos 平台并生成 HAR 模块;在 ArkTS 中实现剪贴板读取——处理 API 12 起的 READ_PASTEBOARD 运行时权限,并覆盖内嵌 PixelMap 与 URI 两种剪贴板图片形态;用 ImagePacker 按 JPEG 质量 90 编码,保持与 Android/iOS 完全一致的返回语义。


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

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

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

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


二、环境准备

环境搭建参考社区文档:Flutter OH 开发环境搭建,完成 Flutter OH SDK 安装、环境变量和 DevEco Studio 配置。

完成后,在宿主机终端执行以下命令,确认当前选中的是支持 OHOS 的 Flutter 工具链,并能发现目标设备:

flutter --version
flutter doctor -v
hdc list targets

在这里插入图片描述

在这里插入图片描述

编辑用户

工程使用的工具链和 SDK 配置如下:

项目版本或配置用途
Flutter OHOS SDK3.44.9+ohos-0.0.1-canary1Flutter 编译与 OHOS 平台工具链
Flutter 分支0.0.1-ohos-1.0.0-beta.2CPF-Flutter 对应开发分支
Dart SDK3.12.2Dart 语言与包管理环境
HarmonyOS 开发套件26.0.0(API 26)开发套件版本及对应的 API 级别
compatibleSdkVersion5.1.0(18)当前工程声明的最低兼容版本
插件版本0.0.1+1pubspec.yaml 中的包版本
OHOS 发布 TAG0.0.1-ohos-1.0.0-beta.1本次适配的发布标记
原生语言ArkTSHarmonyOS 插件实现
插件产物HAR被应用 entry 模块依赖

2.1 开发套件版本与工程中的 SDK 版本配置

26.0.0(API 26) 涉及 HarmonyOS 开发套件版本(API 版本)及其底座 OpenHarmony 的版本号体系。在本文工程中,相关版本的含义与配置方式如下:

  • 26.0.0(API 26) 表示本机 DevEco Studio 安装的开发套件为 26.0.0(README.OpenHarmony 记录的 DevEco 版本为 26.0.0.821),对应 API 26,Flutter 工具链构建时使用该 SDK;
  • 本工程的 example/ohos/build-profile.json5 没有显式声明 compileSdkVersiontargetSdkVersion,构建时按开发套件默认的 API 26 编译;
  • 5.1.0(18) 是本文工程中 compatibleSdkVersion 的属性值,声明最低兼容 API 18。

对应的 product 配置为:

{
  "name": "default",
  "signingConfig": "default",
  "compatibleSdkVersion": "5.1.0(18)",
  "runtimeOS": "HarmonyOS"
}

在这里插入图片描述

这组配置最低兼容 API 18。本插件用到的关键能力中,READ_PASTEBOARD 权限与剪贴板读取保护自 API 12 起生效,pasteboardmultimedia.imagefile.fs 均在更低版本即可用,因此 API 18 及以上设备均可运行。

三、从源码仓库开始准备适配工程

3.1 将上游源码同步到 AtomGit

适配已有第三方插件时,先从 Pub 包信息或项目 README 确认上游地址、包名、版本和许可证。同步仓库时保留源码、许可证和提交历史,方便后续跟踪上游更新。

在 AtomGit 网页的新建仓库流程中,找到从已有仓库导入的入口,填写上游 Git 地址,选择自己有写权限的组织或个人空间,设置目标仓库名称后执行导入。完成后检查目标分支、pubspec.yamlLICENSE 和提交历史是否完整,并记录适配起点的提交号。如果上游本身托管在 AtomGit,也可以通过 Fork 获得自己的工作仓库。

clipboad_image 的上游位于 GitHub,采用 MIT 许可证。本例的配套交付仓库为 AtomGit 的 fluttertpc_clipboad_image,适配完成后按第六节推送到该仓库并打 TAG。

3.2 将代码拉取到宿主机

在安装了 Flutter OH 和 DevEco Studio 的开发电脑上打开终端,进入准备存放项目的目录,执行:

git clone https://github.com/salman3xs/clipboard_image.git
cd clipboard_image
pwd
ls
git remote -v
git status --short --branch
git rev-parse HEAD

git clone 会创建 clipboard_image/ 目录;cd 后的位置就是下文所说的插件仓库根目录,这里应能看到 pubspec.yamllib/example/。注意一个容易混淆的细节:仓库名是 clipboard_image,而 Dart 包名是 clipboad_image——上游包名比仓库名少一个字母 r,这是上游的历史拼写,lib/ 源文件名、通道名和插件类名都沿用该拼写,适配时必须与上游保持一致,不要"顺手修正"。

需要使用与本文相同的代码版本时,切换到以下提交:

git switch --detach b6cb54f06599897c0baae556c74a822d6c4d8348

该提交即本次 OHOS 适配提交,其父提交为上游 main 分支的 51b7306。适配其他插件时,使用该插件对应版本的 tag 或 commit 作为分支起点。

请添加图片描述

图 1:在宿主机终端输入仓库拉取命令。

3.3 在仓库根目录确认分支与发布 TAG

clipboad_image 的鸿蒙改动直接基于 main 分支维护,并在发布时通过 TAG 标记 OHOS 版本。在仓库根目录执行:

git branch --show-current
git tag 0.0.1-ohos-1.0.0-beta.1
git tag -l

如果习惯使用适配分支,也可以先创建 feat/ohos_clipboad_image_0.0.1,完成后合并回 main 再打 TAG。本例按仓库现有工作方式直接以 main + TAG 发布:适配提交 b6cb54f 位于本地 main(领先上游一个提交),TAG 0.0.1-ohos-1.0.0-beta.1 指向该提交。

请添加图片描述

图 2:在 clipboard_image 仓库根目录确认分支与 TAG。

3.4 自动补全 OHOS 适配结构

分支确认后,仍在同一个插件根目录执行结构补全。以下命令适用于尚无 ohos/ 目录的既有 Flutter 平台插件

flutter create --template=plugin --platforms=ohos --project-name clipboad_image .
git status --short
git diff -- pubspec.yaml .metadata
  • --template=plugin 指定插件模板。
  • --platforms=ohos 指定需要补全的平台。
  • --project-name clipboad_image 使用 Dart 包名(注意是少一个 rclipboad_image),与 pubspec.yaml 中的 name 保持一致,而不是仓库目录名。
  • 最后的 . 表示在当前插件目录补全工程,不是另建一层目录。

在上游基线 51b7306 上执行后,git status --short 的输出为:

 M .metadata
 M pubspec.yaml
 M ohos/
 M example/ohos/

pubspec.yaml 新增了 ohos: pluginClass: ClipboadImagePlugin 两行;.metadata 记录了 ohos 平台的创建信息;ohos/example/ohos/ 是新生成的 HAR 脚手架和宿主工程。生成后通过 diff 检查变化,保留已有 API、其他平台注册项及依赖配置。不同 Flutter OH 版本生成的模板可能略有差异。

如果生成后 example/ohos/ 仍不存在,进入已有示例应用补全平台:

cd example
flutter create --platforms=ohos .
cd ..

注意:本机执行该命令时曾因 Xcode 13.2.1 过旧而崩溃,原因是工具探测 example/ios 时调用了旧版 xcodebuild 不支持的参数。规避方式见 9.11。

请添加图片描述

图 3:在插件根目录输入 OHOS 结构补全命令。

3.5 适配后的项目目录

适配后的关键目录如下:

clipboard_image/
├── lib/
│   ├── clipboad_image.dart
│   ├── clipboad_image_method_channel.dart
│   └── clipboad_image_platform_interface.dart
├── ohos/
│   ├── index.ets
│   ├── oh-package.json5
│   ├── build-profile.json5
│   ├── hvigorfile.ts
│   └── src/main/
│       ├── ets/components/plugin/ClipboadImagePlugin.ets
│       └── module.json5
├── example/
│   ├── lib/main.dart
│   ├── test/widget_test.dart
│   └── ohos/entry/
├── android/
├── ios/
├── assets/
│   ├── android_demo.gif
│   └── ios_demo.gif
├── docs/
│   └── ohos-adaptation-blog.md
└── pubspec.yaml

项目根目录如下,其中包含 ohos/example/ohos/、已补齐的 README.OpenHarmony_CN.mdREADME.OpenHarmony.mdCHANGELOG.OpenHarmony.md

请添加图片描述

图 4:适配后的 clipboard_image 项目根目录。

文件主要职责
lib/clipboad_image.dart对外入口,静态方法 getImage()
lib/clipboad_image_method_channel.dart默认 MethodChannel('clipboad_image') 实现
lib/clipboad_image_platform_interface.dart平台接口抽象与实例替换机制
ClipboadImagePlugin.ets读取剪贴板图片、权限申请、JPEG 编码并回传 Dart
插件 module.json5声明 HAR 模块
示例 entry module.json5声明宿主 Ability、设备类型和 INTERNET + READ_PASTEBOARD 权限
example/lib/main.dart完整 Demo:读取剪贴板、相册选图写入剪贴板、图片预览
docs/ohos-adaptation-blog.md适配过程的原始记录

四、Dart 接口与通道分析

OHOS 适配前先理清通道契约:Dart 层只暴露一个拉取式方法 getImage(),剪贴板读取和图片编解码全部由原生侧承担。先阅读 lib/ 的三个文件和 Android/iOS 的原生实现,再在 ohos/src/main/ets/components/plugin/ 中实现对应的原生类。适配其他已有平台的插件时,也应保留其公开接口和其他平台实现。

本例的对应关系如下:

Dart 入口或模型实际依赖OHOS 实现应保持的行为
ClipboadImage.getImage()插件通道 clipboad_imageClipboadImagePlugin.ets 读取剪贴板并编码返回 JPEG(质量 90)字节或 null
无图 / 非图片内容 / 读取失败三端共同语义原生返回 nullDart 收到 null 而不是抛异常
首次读取剪贴板READ_PASTEBOARD 运行时权限AbilityAware + requestPermissionsFromUser用户拒绝授权时返回 null

通道名和方法名属于跨语言协议。任何一端拼写不一致,都会让 getImage 直接抛出 MissingPluginException

4.1 跨端架构与调用时序

Flutter 侧和 HarmonyOS 侧之间是一条单向拉取链路,没有事件订阅和平台视图:

  1. 业务数据流:Flutter 页面调用 ClipboadImage.getImage(),经平台接口层进入 MethodChannel('clipboad_image'),以 invokeMethod('getImage') 发起一次请求;
  2. 原生处理:ClipboadImagePlugin.ets 收到命令后申请权限、读取剪贴板、解码并编码为 JPEG,最后把 Uint8Array(或 null)作为应答返回 Dart。

Uint8Array / null

Uint8List / null

Flutter 页面

ClipboadImage

ClipboadImagePlatform

MethodChannel clipboad_image

ClipboadImagePlugin.ets

READ_PASTEBOARD 权限

pasteboard 剪贴板

multimedia.image 编解码

插件通道一次调用、一次应答,没有需要取消的订阅。

4.1.1 一次完整剪贴板图片读取的时序
ImagePacker SystemPasteboard ClipboadImagePlugin.ets ClipboadImage Flutter App ImagePacker SystemPasteboard ClipboadImagePlugin.ets ClipboadImage Flutter App 拒绝则 result.success(null) alt [未授权] alt [MIMETYPE_PIXELMAP] [MIMETYPE_TEXT_URI] [其他] getImage() invokeMethod('getImage') checkAccessToken(READ_PASTEBOARD) 系统授权弹窗 getData() PasteData getPrimaryPixelMap() fs.open(uri) + createImageSource null packing(pixelMap, jpeg, 90) ArrayBuffer result.success(Uint8Array) Future<Uint8List?>

4.2 对外 API 入口:lib/clipboad_image.dart

ClipboadImage 是无状态的静态入口,全部逻辑只有一次通道调用:

class ClipboadImage {
  static Future<Uint8List?> getImage() {
    return ClipboadImagePlatform.instance.getImage();
  }
}
成员签名行为
getImagestatic Future<Uint8List?> getImage()转发给平台接口实例,返回 JPEG 字节或 null

值得注意的是,lib/clipboad_image.dart 的首行注释仍是 Flutter 插件模板的"未指定 --platforms 生成"提示,属于上游遗留,不影响功能;ClipboadImage 没有任何实例状态,重复调用互不干扰,返回值只取决于调用那一刻的剪贴板内容。

4.3 公开 API 与平台接口

公开 API 只有一个静态方法,但库采用了标准的三层结构:

lib/
├── clipboad_image.dart                       # 对外入口
├── clipboad_image_platform_interface.dart    # 平台接口抽象
└── clipboad_image_method_channel.dart        # 默认 MethodChannel 实现
  • ClipboadImagePlatform 继承 PlatformInterface,持有 _token 校验,外部只能通过 set instance 替换实现,防止直接实现接口绕过校验;
  • 默认实例是 MethodChannelClipboadImage,其 getImage 只做一次 invokeMethod<Uint8List?>('getImage')
  • 调用时机完全由业务决定:用户点击"读取"按钮时调用一次,立即拿到当前剪贴板内容;
  • 没有 Stream、回调或需要取消的订阅。

pubspec.yaml 中的多端 pluginClass: ClipboadImagePlugin 用于原生插件注册,与这三层结构配合构成完整的调用链。

4.4 Dart 通道协议分析

4.4.1 通道名称必须两端完全一致

通道名称三端完全一致,都是 clipboad_image

// Android
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "clipboad_image")
// iOS
let channel = FlutterMethodChannel(name: "clipboad_image", binaryMessenger: registrar.messenger())
// OHOS
this.channel = new MethodChannel(binding.getBinaryMessenger(), "clipboad_image");

这是插件唯一的一条通道,OHOS 侧注册时必须与两端拼写一致(包括上游少一个 r 的历史拼写)。

4.4.2 命令处理与返回值模型

命令处理是"一问一答"模型:Dart 侧只调用 getImage,原生侧只实现 getImage

// Dart
final version = await methodChannel.invokeMethod<Uint8List?>('getImage');
// OHOS
onMethodCall(call: MethodCall, result: MethodResult): void {
  if (call.method == "getImage") {
    this.getClipboardImage(result);
  } else {
    result.notImplemented()
  }
}

返回值约定:原生侧把 JPEG 字节装进 Uint8Array 回传,Dart 侧自动映射为 Uint8List;剪贴板无图、内容不是图片、用户拒绝授权或读取失败时,一律 result.success(null),Dart 侧收到 null。三端的返回语义完全一致:

场景AndroidiOSOHOS
剪贴板有图片ByteArray(JPEG 90)Data(JPEG 0.9)Uint8Array(JPEG 90)
无图 / 非图片 / 失败nullnilnull

未知命令一律 result.notImplemented(),与上游 Android/iOS 行为一致。

4.4.3 解绑与清理

插件没有需要取消的订阅。Engine 解绑时清理通道处理器,Ability 解绑时清理引用:

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

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

这一清理覆盖业务未主动释放的情况;通道上也没有需要持久保存的原生状态,单次 getImage 内部申请的 PixelMap、ImageSource 等资源在调用结束时即被释放(见 5.1.5)。

五、补全 OHOS 原生实现与工程配置

5.1 在 ClipboadImagePlugin.ets 中实现原生能力

业务调用 ClipboadImage.getImage() 后,全部工作落在原生侧:申请剪贴板读取权限、读取系统剪贴板、把两种形态的剪贴板图片统一为 PixelMap、再编码为 JPEG 质量 90 的字节回传。

ClipboadImagePlugin 实现 FlutterPluginMethodCallHandlerAbilityAware:前两者组成命令处理链路,AbilityAware 用于拿到 UIAbility,给运行时权限弹窗提供 context。没有平台视图,也没有事件订阅。

原生插件位于(本库只有一个原生文件):

ohos/src/main/ets/components/plugin/ClipboadImagePlugin.ets
5.1.1 引入 Flutter 和 HarmonyOS 能力
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';

其中:

  • FlutterPlugin 负责接入 Flutter Engine 生命周期;
  • MethodChannelMethodCallMethodCallHandlerMethodResult 组成命令处理的完整链路;
  • AbilityAware + UIAbility 用于运行时权限弹窗的 context;
  • pasteboard 读取系统剪贴板;
  • image 完成图片解码(URI 形态)与 JPEG 编码;
  • fs 以只读方式打开 URI 指向的文件;
  • abilityAccessCtrl 提供 checkAccessTokenrequestPermissionsFromUser
  • BusinessError 用于类型化异常信息。
5.1.2 连接 Flutter Engine
getUniqueClassName(): string {
  return "ClipboadImagePlugin"
}

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

Engine 启动时创建通道并注册处理器,通道名与 Android/iOS 一致。getUniqueClassName 返回类名,供引擎侧的插件管理使用。

5.1.3 读取剪贴板图片的两种形态

OHOS 的剪贴板图片存在两种形态,读取时都要处理:

const systemPasteboard: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const pasteData: pasteboard.PasteData = await systemPasteboard.getData();
if (pasteData.hasType(pasteboard.MIMETYPE_PIXELMAP)) {
  // 形态一:内嵌 PixelMap——网页/浏览器复制图片的常见形式
  pixelMap = pasteData.getPrimaryPixelMap();
} else if (pasteData.hasType(pasteboard.MIMETYPE_TEXT_URI)) {
  // 形态二:URI 记录——图库、文件管理等应用内复制图片的常见形式
  const uri: string = pasteData.getPrimaryUri();
  file = await fs.open(uri, fs.OpenMode.READ_ONLY);
  imageSource = image.createImageSource(file.fd);
  pixelMap = await imageSource.createPixelMap();
} else {
  // 剪贴板为空或内容不是图片:返回 null
  result.success(null);
  return;
}

拿到 PixelMap 后统一编码为 JPEG:

imagePacker = image.createImagePacker();
const buffer: ArrayBuffer = await imagePacker.packing(pixelMap, {
  format: 'image/jpeg',
  quality: 90,
});
result.success(new Uint8Array(buffer));

三端的剪贴板读取路径对照:

平台读取方式编码
AndroidClipboardManager 取 primaryClip 的 item URI → BitmapFactory.decodeStreamBitmap.compress(JPEG, 90)
iOSUIPasteboard.general.imagejpegData(0.9)
OHOSpasteboard.getData() → PixelMap 或 URI 解码ImagePacker.packing(JPEG, 90)

只有把两种形态都覆盖,才能在真机上同时支持"浏览器复制"和"图库复制"两类来源。

5.1.4 运行时申请 READ_PASTEBOARD 权限

API 12 起读取系统剪贴板需要 ohos.permission.READ_PASTEBOARD 权限(user_grant,需运行时申请)。插件在每次读取前检查并按需弹窗:

private async ensurePasteboardPermission(): Promise<boolean> {
  if (this.ability == null) {
    // 拿不到 UIAbility 时无法弹窗申请,直接尝试读取(部分场景前台可读)
    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;
}

要点:

  • 权限弹窗需要 UIAbility 的 context,这正是插件实现 AbilityAware 的原因:onAttachedToAbility 保存 binding.getAbility()
  • 用户拒绝授权时,getClipboardImage 直接 result.success(null)——与"剪贴板无图"语义一致,调用方无需区分原因;
  • checkAccessTokenapplicationInfo.accessTokenId 查询当前授权态,已授权时不再弹窗;
  • 宿主应用必须在 module.json5requestPermissions 中声明该权限,否则弹窗不会出现(见 5.2)。
5.1.5 处理命令与资源释放

getClipboardImage 的完整实现把所有资源操作包进 try/catch/finally

try {
  if (!(await this.ensurePasteboardPermission())) {
    result.success(null);
    return;
  }
  // ... 读取剪贴板、解码、编码(见 5.1.3)
  result.success(new Uint8Array(buffer));
} catch (err) {
  const e = err as BusinessError;
  console.error(`ClipboadImagePlugin getImage failed: code=${e.code}, message=${e.message}`);
  // 读取失败时同样返回 null,保持跨平台行为一致
  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,Dart 侧不会收到 PlatformException,与 Android/iOS 语义一致;
  • finally 确保无论成功还是失败,ImagePackerImageSourcePixelMap 和文件句柄都被释放,长列表页反复读取也不会累积原生内存。
5.1.6 Engine 解绑时释放资源
onDetachedFromEngine(binding: FlutterPluginBinding): void {
  if (this.channel != null) {
    this.channel.setMethodCallHandler(null)
  }
}

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

Flutter Engine 销毁时清理通道 Handler;Ability 解绑时清空引用。单次 getImage 内的资源已在 finally 中释放,解绑即完成全部清理。

5.2 声明插件和宿主权限

读取剪贴板图片属于敏感能力:API 12 起,READ_PASTEBOARDuser_grant 权限,宿主必须在 module.json5 中声明,并由插件在运行时申请。

5.2.1 插件 HAR 的权限

插件的 ohos/src/main/module.json5 只声明 HAR 模块信息,不带 requestPermissions

{
  "module": {
    "name": "clipboad_image",
    "type": "har",
    "deviceTypes": ["default", "tablet"]
  }
}

权限统一由宿主应用声明,HAR 保持无权限依赖,接入方可以按自己的场景决定是否声明。

5.2.2 应用 entry 的权限

最终安装的是宿主应用。本例在 example/ohos/entry/src/main/module.json5 中声明 INTERNETREAD_PASTEBOARD

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

user_grant 权限必须附带 reasonusedScene,否则构建报错。权限原因在 string.json 中提供多语言描述:

资源
base/element/string.jsonUsed to read images from the system clipboard
zh_CN/element/string.json用于读取系统剪贴板中的图片

漏声明该权限时,requestPermissionsFromUser 不会弹窗,checkAccessToken 恒为未授权,getImage 将始终返回 null(排查见 9.4)。

5.3 注册并导出插件

pubspec.yaml 通过以下配置声明 OHOS 插件类:

flutter:
  plugin:
    platforms:
      ohos:
        pluginClass: ClipboadImagePlugin

插件的 ohos/index.ets 需要导出实现:

import ClipboadImagePlugin from './src/main/ets/components/plugin/ClipboadImagePlugin';
export default ClipboadImagePlugin;

执行 flutter pub get 和构建后,Flutter 工具会为应用生成插件注册代码,example/ohos/entry/.../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.");
    }
  }
}

通常不应手工编辑该文件,因为下次构建可能覆盖它。这里同时注册了 IntegrationTestPlugin(来自 example 的 integration_test 依赖)和 ClipboadImagePlugin;缺少后者会导致 getImage() 抛出 MissingPluginException

注册异常的排查步骤见第九节 MissingPluginException

5.4 检查 example 的 OHOS 应用结构

本例的 example/ohos/build-profile.json5 应在 products 中设置版本。下面是需核对的配置片段,请合并到现有工程;其中 signingConfig: "default" 需要与本机配置的签名名称一致,签名材料保留在本地:

{
  "app": {
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        "compatibleSdkVersion": "5.1.0(18)",
        "runtimeOS": "HarmonyOS"
      }
    ]
  },
  "modules": [
    {
      "name": "entry",
      "srcPath": "./entry",
      "targets": [
        {
          "name": "default",
          "applyToProducts": ["default"]
        }
      ]
    }
  ]
}

配置后,在 DevEco Studio 中执行一次 Sync Project。如果 entry 模块正常识别,Project 视图中会出现 entry,并能打开 entry 模块的签名配置。

除标准结构外,本例的宿主 EntryAbility 还做了一件示例专属的事:注册第二条通道 clipboad_image_example/photo_picker,提供 pickImageToClipboard 方法——调起系统 PhotoViewPicker 选择一张本地图片,解码为 PixelMap 后写入系统剪贴板,供 getImage() 读取验证,形成"选图 → 写剪贴板 → 读取"的完整闭环。该方法只在 OHOS 宿主中实现,Android/iOS 上调用会抛 MissingPluginException,示例页已捕获并提示"当前平台暂不支持"。系统相册选择器本身不需要额外权限,写剪贴板也不需要权限,只有读取需要。

六、补全交付文件并提交适配分支

6.1 除代码外还要补全哪些文件

代码适配完成后,还需要整理安装说明、接口文档、版本记录和开源信息。接收仓库有专用模板时,按其格式填写:

文件应写清楚的内容
README.OpenSource上游名称、源码地址、适配版本或提交、版权及许可证信息;按仓库模板列出第三方依赖
README.md原项目说明、OHOS 支持入口、配套 Demo 和文档链接;保留上游信息
README.OpenHarmony_CN.md简介、安装方式、版本对应关系、环境约束、权限、接口表、示例、已验证范围和遗留问题
README.OpenHarmony.md与中文说明对应的英文文档
CHANGELOG.OpenHarmony.mdOHOS 新增能力、适配版本、兼容限制与测试范围
LICENSE / NOTICE保留上游许可证;NOTICE 按许可证和原项目要求保留或补充
example/README.md依赖方式、运行目录、签名、操作步骤与效果图;覆盖图片复制与读取操作
pubspec.yamlohos/oh-package.json5核对包名、版本、插件注册、仓库地址、许可证和依赖
.gitignore忽略构建缓存及本机签名材料,不漏提交必要源码和配置

本仓库的适配提交中已经包含 README.OpenHarmony_CN.mdREADME.OpenHarmony.mdCHANGELOG.OpenHarmony.md(记录 0.0.1-ohos-1.0.0-beta.1)和 docs/ohos-adaptation-blog.md 适配记录,但提交前仍需核对两处:

  1. ohos/oh-package.json5license 字段仍是脚手架默认值 Apache-2.0version1.0.0,与上游 LICENSE(MIT)及包版本 0.0.1+1 不一致,应改为一致的声明;
  2. assets/ 下只有上游的 ios_demo.gifandroid_demo.gif,尚无 OHOS 真机运行截图,补齐后建议一并归档(见 8.6 的缺项说明)。

此外,适配提交之后工作区还有一组未提交的增量改动:运行时权限申请、宿主相册选图通道、示例页改版和本机调试签名配置。这些属于适配内容的完善,应整理后一并提交(注意先剥离签名材料中的本机绝对路径),再推送打 TAG。

6.2 提交前检查

提交前先完成第八节的插件、example 和真机测试,再从根目录检查改动:

git branch --show-current
git diff --check
git status --short
git diff --stat
git diff

检查 diff 中的接口、平台注册和依赖变化,移除本机路径及签名信息,并使文档中的版本和分支与提交内容一致。

6.3 提交并推送适配分支

文档和代码整理完成后,在根目录暂存并提交。文件名按项目实际情况调整:

git add ohos example/ohos pubspec.yaml .metadata
git add README.OpenHarmony.md README.OpenHarmony_CN.md CHANGELOG.OpenHarmony.md
git add docs
git diff --cached --check
git diff --cached --stat
git diff --cached
git commit -m "feat: add OHOS support for clipboad_image 0.0.1"
git tag 0.0.1-ohos-1.0.0-beta.1
git remote -v
git branch --show-current
git push -u origin main
git push origin 0.0.1-ohos-1.0.0-beta.1

本例的适配提交为 b6cb54f,提交信息说明通道契约(clipboad_image / getImage / JPEG 质量 90 或 null)与保持 Dart API 不变的适配范围。DevEco 可能向 example/ohos/build-profile.json5 写入本机签名配置(storeFile 等指向本机绝对路径),提交前需要从暂存内容中移除或还原为占位。推送时,origin 应指向自己有写权限的仓库;上游仓库在 GitHub,无权限直接推送时先推送到自己的镜像(本例为 AtomGit 的 fluttertpc_clipboad_image),再推送 TAG。

推送后在托管平台发起合并请求,说明上游来源和版本、OHOS 实现范围、依赖及权限、测试环境、操作结果、已知限制,并附 Demo 运行说明或运行图。目标分支和评审流程以接收仓库要求为准。

七、使用根目录 example 演示接入

仓库自带 example/,可以直接用来调试插件和体验剪贴板图片读取流程。

7.1 本地适配时使用路径依赖

当前 example/pubspec.yaml 的依赖是:

dependencies:
  flutter:
    sdk: flutter
  clipboad_image:
    path: ../

../ 相对于 example/pubspec.yaml 指向插件根目录,修改根目录插件后可直接联调。

7.2 通过 AtomGit 引入插件

业务应用通过 AtomGit 引入时,将 clipboad_imagepath 配置替换为下面的 Git 依赖。这里固定到本文使用的 TAG:

dependencies:
  flutter:
    sdk: flutter
  clipboad_image:
    git:
      url: https://atomgit.com/CPF-Flutter/fluttertpc_clipboad_image
      ref: 0.0.1-ohos-1.0.0-beta.1

使用自己的适配版本时,先推送 TAG,再将 url 改为对应仓库,ref 改为自己的 TAG。正式发布后可固定到 TAG 或 commit。注意上游 GitHub 仓库的 main 分支不包含 ohos/ 目录,直接引用上游会构建失败。

从插件根目录执行:

cd example
flutter pub get
flutter pub deps

检查 example/pubspec.lockclipboad_image 的来源为 git,并核对 urlrefresolved-ref。同时检查没有 dependency_overridespubspec_overrides.yaml 将其覆盖回本地依赖,确认应用使用的是 Git 依赖。

7.3 调用接口实现剪贴板图片读取

仓库中的 example/lib/main.dart 已经是一个完整的演示页 ClipboardImageExampleApp,包含图片预览卡、"从剪贴板读取"与"选择本地图片"两个按钮、状态栏和使用说明。最小接入页面如下:

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

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  Uint8List? _imageData;

  Future<void> getImage() async {
    Uint8List? imageData;
    try {
      imageData = await ClipboadImage.getImage();
    } on PlatformException {
      imageData = null;
    }
    if (!mounted) return;
    setState(() {
      _imageData = imageData;
    });
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('clipboad_image 示例')),
        body: Column(
          children: [
            Center(
              child: _imageData == null
                  ? const Text('剪贴板中没有图片')
                  : Image.memory(_imageData!),
            ),
            TextButton(
              onPressed: getImage,
              child: const Text('获取剪贴板图片'),
            ),
          ],
        ),
      ),
    );
  }
}

点击按钮即触发一次 getImage():OHOS 上首次会弹出剪贴板读取授权框,允许后返回图片字节并用 Image.memory 渲染。完整 Demo 在此基础上增加了尺寸/字节数展示、相册选图写剪贴板的闭环验证和多语言状态提示。

7.4 页面退出时的资源处理

ClipboadImage 是无状态静态入口,没有需要取消的订阅;页面退出时只需处理好自己的资源:

  • 示例页没有 TextEditingController 等需要 dispose 的成员,Image.memory 渲染的图片由框架随 Widget 树释放;
  • 异步回调中的 setState 前检查 mounted,避免页面已销毁后更新状态;
  • 原生侧单次 getImage 的 PixelMap、ImageSource 等资源在 finally 中即时释放(见 5.1.5),不需要 Dart 侧配合清理。

八、验证、构建与鸿蒙设备运行效果

8.1 分别验证插件与 example

从插件仓库根目录执行:

flutter pub get
flutter analyze
flutter test
cd example
flutter pub get
flutter analyze

本仓库当前的 flutter analyze 未发现问题(No issues found!)。

flutter test 共 3 个用例,1 个失败:test/clipboad_image_method_channel_test.dartgetPlatformVersion 用例。失败原因是模板遗留——mock 处理器返回字符串 '42',而 invokeMethod<Uint8List?>('getImage') 期望字节类型,抛出 type 'String' is not a subtype of type 'Uint8List?' in type cast。这不是 OHOS 适配引入的缺陷:把 mock 的返回值改为 Uint8List(42) 即可通过。test/clipboad_image_test.dart 的两个用例(默认实例检查、mock 平台接口返回 Uint8List(42))均通过。

example/test/widget_test.dart 已从模板的"Platform version"断言改写为示例页渲染断言(标题、两个按钮、空预览占位、初始状态文案),与当前示例页保持一致。

Dart 测试只能覆盖通道封装的分支逻辑,剪贴板读取、权限弹窗和图片编解码的真实行为还需要在鸿蒙设备上验证。

8.2 确认设备连接

hdc list targets
flutter devices

设备首次连接电脑时,需要在手机端确认调试授权。列表为空时,检查 USB 连接、调试模式和电脑授权。

8.3 配置签名

真机安装的 HAP 通常需要有效签名。推荐使用 DevEco Studio 为 entry 模块配置自动签名:

  1. 用 DevEco Studio 打开 example/ohos,不是仓库根目录;
  2. 等待工程 Sync 成功,确认 Project 视图中存在 entry 模块;
  3. 打开 File > Project Structure > Signing Configs
  4. default product 选择或生成签名;
  5. 确认设备、应用包名、证书和 Profile 匹配;
  6. 再回到终端执行 Flutter 构建或运行。

签名材料保存在本机,公开仓库中只保留构建所需的通用配置。

8.4 运行示例

以下命令在 example/ 目录执行,将 <device-id> 替换为设备列表中的实际 ID:

flutter run -d <device-id>

也可以先构建 HAP:

flutter build hap --debug

典型产物位于:

example/ohos/entry/build/default/outputs/default/

目录中通常包含已签名和未签名 HAP(本例构建产物为 entry-default-signed.hap)。真机安装应选择与当前设备匹配的已签名产物。构建失败时按第九节的检查项排查签名与 SDK 配置后重试。

8.5 在设备上测试剪贴板图片读取

  1. 安装并启动应用,确认页面显示 clipboad_image 标题、图片预览卡("暂无图片预览"占位)、"从剪贴板读取"和"选择本地图片"两个按钮;
  2. 在浏览器或图库中复制一张图片,回到应用点击 从剪贴板读取,首次会弹出剪贴板读取授权框,选择允许;
  3. 确认预览卡显示该图片,状态栏显示"✅ 读取成功"和尺寸、字节数信息;
  4. 点击 选择本地图片,在系统相册中选择一张图,确认选图后自动完成"复制到剪贴板 → 读取"闭环并显示图片;
  5. 复制一段纯文本(或清空剪贴板)后再点击 从剪贴板读取,确认状态提示"剪贴板中没有图片(返回 null)",页面不崩溃;
  6. 在系统设置中关闭应用的剪贴板读取权限后再次读取,确认同样返回 null 而不是抛异常。

8.6 鸿蒙设备运行效果

完成适配后,Flutter 应用可以在 OHOS 页面中读取系统剪贴板图片:点击按钮完成 权限申请剪贴板读取JPEG 编码回传Image.memory 直接渲染返回的字节。


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

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

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

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


剪贴板读取依赖设备的剪贴板服务与权限框架。即使系统版本满足要求,不同型号对 PixelMap 与 URI 两种剪贴板形态的支持也可能存在差异,需要在目标设备上分别用"浏览器复制"和"图库复制"两条路径测试。

九、FAQ:适配过程与使用问题

9.1 Missing SDK components

典型错误如下:

Missing SDK components. SDK path: ...,
missing components: toolchains,ets,js,native,previewer.

这个错误发生在 Hvigor 同步阶段。通常需要检查构建工具使用的 SDK 路径、组件是否完整,以及 Hvigor 与 SDK 的版本是否匹配。

处理顺序:

  1. 在 DevEco Studio SDK Manager 中确认 API 26 组件已经下载完整;
  2. 检查 Flutter 和 DevEco Studio 使用的 SDK 路径是否一致;
  3. 避免误用 /Applications/DevEco-Studio.app/Contents/sdk 之类的不完整目录;
  4. 确认 SDK 根目录下存在 toolchainsetsjsnativepreviewer
  5. 执行 flutter config --ohos-sdk <正确路径>
  6. 重新执行 flutter doctor -v 和 DevEco Studio Sync。
为什么连接 API 24 手机仍然会报这个错误?

因为 Sync 和 Compile 首先读取 Mac 本地 SDK。手机 API 版本只在部署、安装和运行时参与兼容判断。即使完全不连接手机,本地 SDK 不完整时也会得到相同错误。

当前工程的 compatibleSdkVersion 是 API 18,因此 API 24 在安装版本门槛上是满足的;但设备还必须支持剪贴板读取的权限授权流程,并满足签名要求。

9.2 DevEco Studio 中看不到 entry 模块

插件的 ohos/ 目录是 HAR 模块,可安装应用的 entry 模块位于 example/ohos/entry

请直接使用 DevEco Studio 打开:

clipboard_image/example/ohos

如果仍看不到 entry,先解决 SDK Sync 错误,再检查 example/ohos/build-profile.json5modules 是否包含 ./entry。同步失败时,Project Structure 无法正确解析模块,签名界面也可能不显示 entry。

9.3 无法手动签名

签名配置依附于可构建的应用模块和 product。只有 HAR 插件模块、工程 Sync 失败,或者打开了错误目录时,DevEco Studio 都可能无法提供 entry 签名入口。

建议先确认:

  • 打开的是 example/ohos
  • SDK 组件完整并且 Sync 成功;
  • entry 的模块类型为 entry
  • default product 和 target 已正确关联;
  • 当前账号、证书和调试设备状态有效。

在这里插入图片描述

9.4 能安装但读取不到图片

getImage() 返回 null 时按以下顺序检查:

  1. 首次读取是否拒绝了权限弹窗——拒绝授权与"无图"同语义返回 null,到系统设置中开启剪贴板读取权限后再试;
  2. 宿主 entry/src/main/module.json5 是否声明了 READ_PASTEBOARD(含 reasonusedScene)——HAR 不带权限,漏声明时弹窗不会出现;
  3. 剪贴板内容形态——网页复制多为内嵌 PixelMap,图库/文件管理复制多为 URI 记录,两种形态插件均已处理;但纯文本、空剪贴板返回 null 属正常语义;
  4. 是否在其他平台上误判——iOS 的 UIPasteboard.general.image 对部分格式返回 nil,与 OHOS 行为一致;
  5. 查看原生日志——读取失败时会输出 ClipboadImagePlugin getImage failed: code=..., message=...,据此定位权限或文件句柄问题。

在这里插入图片描述

9.5 MissingPluginException

这通常表示 Dart 通道找不到已注册的原生插件。新增原生插件后需要重新构建应用。从仓库根目录执行:

cd example
flutter clean
flutter pub get
flutter run -d <device-id>

如果仍然出现,检查自动生成的插件注册文件中是否包含 ClipboadImagePlugin,同时核对 pubspec.yamlohos/index.etsoh-package.json5

9.6 透明背景的 PNG 读取后背景变黑

现象:透明背景的 PNG 复制后读取,透明区域显示为黑色。

原因:三端实现都把剪贴板图片重新编码为 JPEG——OHOS 侧 ImagePacker.packing 使用 format: 'image/jpeg', quality: 90,Android 侧 Bitmap.compress(JPEG, 90),iOS 侧 jpegData(0.9)。JPEG 格式没有 Alpha 通道,透明像素按黑色参与编码,这是三端共同的既有语义,不是 OHOS 适配引入的缺陷。

处理方式:业务上接受该语义(与 Android/iOS 一致);或修改插件把编码格式改为 image/png(同时建议与上游沟通保持三端一致),返回字节仍通过 Uint8List 传递,Dart 层无需改动。

9.7 编译成功但安装失败

常见原因包括:

  • HAP 未签名或使用了错误的 Profile;
  • 设备未加入调试设备列表;
  • 包名与签名 Profile 不匹配;
  • 安装包的 compatibleSdkVersion 高于设备 API;
  • 手机上已经安装了使用不同证书签名的同包名应用。

根据安装错误码区分签名、版本和包名冲突,再处理对应配置。

9.8 flutter create 不认识 ohos,或包名不合法

先执行 flutter --version,确认使用的是 OHOS 版工具链,环境配置回到第二节的社区链接核对。包名报错时,确认当前目录包含目标插件的 pubspec.yaml,并显式传入 --project-name clipboad_image(注意是上游少一个 r 的包名拼写,不是仓库名)。生成后检查 diff,再核对 ArkTS 实现与两端契约一致。

9.9 AtomGit 依赖提示找不到分支或无权限

先检查 url 是否指向已包含 OHOS 适配的仓库:上游 GitHub 仓库的 main 分支不包含 ohos/ 目录,直接引用会构建失败,应使用 AtomGit 配套仓库。再确认 ref 写的是已推送的 TAG(如 0.0.1-ohos-1.0.0-beta.1),TAG 未推送时 Git 依赖会解析失败。私有仓库还需在本机配置 Git 认证。

9.10 改了本地 ArkTS,Demo 为什么没变化

先检查 example/pubspec.yaml:Git 依赖读取远程提交,不会自动读取本地插件改动。本地联调切回 path: ../;测试远程版本则先提交推送,再更新依赖并核对 pubspec.lockresolved-ref。原生代码变动后停止应用并重新构建运行,不能只做 Dart 热重载。

9.11 flutter create 在本机崩溃(xcodebuild 退出码 64)

现象:在插件根目录执行 flutter create --template=plugin --platforms=ohos . 直接崩溃,日志中出现 xcodebuild -list -skipPackageUpdates ... 且退出码 64。

原因:本机 Xcode 13.2.1 过旧,不认识 -skipPackageUpdates 参数;而工程中存在 example/ios 时,Flutter 工具生成前会探测既有 iOS 工程,触发该调用。该问题记录于本仓库 docs/ohos-adaptation-blog.md 的适配过程。

处理方式任选其一:

  1. 临时把 ios/example/ios/ 移出仓库,生成 ohos 平台后再恢复;
  2. 升级 Xcode 到支持该参数的版本;
  3. 跳过结构补全,直接使用配套仓库中已生成好的 ohos/example/ohos/ 工程。

相关链接

Logo

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

更多推荐