Flutter 三方库适配 OpenHarmony:基于 drag_and_drop 与 UDMF 实现文本、文件和目录拖放

前言

Flutter 的 drag_and_drop_flutter 已经抽象了文本、文件和目录拖放模型,但原仓库没有 OpenHarmony 平台实现。本次适配基于 OpenHarmony Flutter 引擎内置的 drag_and_drop 框架,结合 UDMF 完成跨应用拖放数据接收。

本文记录完整适配过程,包括权限判断、联邦插件结构、ArkTS 原生实现、Flutter 通道协议、文件 URI 处理、目录拖放、安全校验和验证方式。效果如下图所示。

在这里插入图片描述

说明:本文代码以当前仓库的实现为准。不同 OpenHarmony SDK 对 UDMF 记录字段的具体类型可能存在差异,最终应以目标 DevEco 工程的 SDK 定义为准。

一、适配目标

1.1 原插件能力

原插件通过平台接口暴露 DragDropArea,业务层可以注册以下回调:

DragDropArea(
  onDragEnter: (items) {},
  onDragExit: () {},
  onDrop: (data) {},
  child: const Text('Drop here'),
)

平台接口中的 DataTransferItem 支持两类数据:

  • DataTransferItem.data:字符串数据,例如 text/plain
  • DataTransferItem.file:文件或目录引用。

1.2 OpenHarmony 目标

本次适配实现:

  1. 使用 Flutter OpenHarmony 引擎的 drag_and_drop 回调体系;
  2. 使用 UDMF 解析拖放记录;
  3. 支持文本、文件和目录 URI;
  4. 通过 EventChannel 将拖放事件发送到 Dart;
  5. 保持原有 DragDropArea API 不变;
  6. 提供完整的 OpenHarmony HAR 工程和示例宿主工程。

二、三方应用权限确认

2.1 是否需要显示模式权限

普通三方应用接收 UDMF 拖放,不需要申请“设置显示模式”权限。

显示模式、窗口模式和拖放能力属于不同系统能力:

能力用途是否是拖放前置权限
窗口显示模式全屏、分屏、悬浮窗
drag_and_drop接收拖入和释放事件是实现能力,不是显示权限
UDMF描述和传递数据
URI 访问授权读取外部文件或目录文件场景需要关注

因此,本插件的 module.json5 不增加显示模式权限。示例应用只保留 Flutter 应用运行所需的网络权限:

{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" }
    ]
  }
}

2.2 文件和目录的授权

文本拖放一般可以直接读取。文件和目录拖放通常携带 URI,目标应用是否能读取内容取决于源端是否授予访问能力。

推荐的数据链路如下:

拖放事件
  ↓
UDMF 记录
  ↓
文件/目录 URI
  ↓
检查 URI 访问能力
  ↓
按需读取或复制到应用沙箱
  ↓
Flutter 业务层处理

不要把 URI 直接替换为绝对路径,也不要假设外部 URI 一定属于当前应用沙箱。

三、仓库结构

3.1 联邦插件结构

本仓库新增 drag_and_drop_flutter_ohos

packages/
├── drag_and_drop_flutter/
├── drag_and_drop_flutter_platform_interface/
├── drag_and_drop_flutter_web/
└── drag_and_drop_flutter_ohos/
    ├── lib/
    │   └── drag_and_drop_flutter_ohos.dart
    ├── ohos/
    │   ├── AppScope/
    │   ├── src/main/module.json5
    │   ├── src/main/ets/components/plugin/
    │   │   └── DragAndDropFlutterOhosPlugin.ets
    │   ├── build-profile.json5
    │   ├── hvigorfile.ts
    │   ├── oh-package.json5
    │   └── index.ets
    ├── pubspec.yaml
    └── README.md

3.2 示例应用结构

Flutter 示例也生成了完整 OpenHarmony 宿主:

packages/drag_and_drop_flutter/example/ohos/
├── AppScope/
├── entry/
│   ├── src/main/ets/entryability/EntryAbility.ets
│   ├── src/main/ets/pages/Index.ets
│   ├── src/main/ets/plugins/GeneratedPluginRegistrant.ets
│   └── src/main/module.json5
├── build-profile.json5
├── hvigorfile.ts
└── oh-package.json5

四、使用 drag_and_drop 框架

4.1 Flutter 引擎回调

OpenHarmony Flutter 引擎的 FlutterPage 已经通过 ArkUI 的拖放修饰器监听:

.onDragEnter((event: DragEvent, extraParams: string) => {
  FlutterManager.getInstance().getDragEnterCbs().forEach(callback => {
    callback.do(event, extraParams);
  });
})
.onDragMove((event: DragEvent, extraParams: string) => {
  FlutterManager.getInstance().getDragMoveCbs().forEach(callback => {
    callback.do(event, extraParams);
  });
})
.onDragLeave((event: DragEvent, extraParams: string) => {
  FlutterManager.getInstance().getDragLeaveCbs().forEach(callback => {
    callback.do(event, extraParams);
  });
})
.onDrop((event: DragEvent, extraParams: string) => {
  FlutterManager.getInstance().getDropCbs().forEach(callback => {
    callback.do(event, extraParams);
  });
})

插件不再创建自定义悬浮层,也不需要额外的原生 PlatformView。插件只需向 FlutterManager 注册回调。

4.2 回调注册

const manager = FlutterManager.getInstance();

const enterId = manager.addDragEnterCb({
  do: (_event: DragEvent, extra: string): void => {
    emitDrop('enter', recordsFrom(extra));
  }
});

const dropId = manager.addDropCb({
  do: (event: DragEvent, extra: string): void => {
    emitDrop('drop', recordsFromEvent(event, extra));
  }
});

组件销毁或停止监听时,需要移除回调:

manager.removeDragEnterCb(enterId);
manager.removeDropCb(dropId);

五、UDMF 数据归一化

5.1 统一数据模型

原插件的数据模型不要求 Flutter 了解 UDMF 具体类型,因此在 ArkTS 层做一次归一化:

type DropItem = Record<string, Object>;

export class UdmfDropNormalizer {
  static normalize(records: Array<any>): DropItem[] {
    return records.map((record: any): DropItem => {
      const type = String(
        record.dataType ?? record.mimeType ?? record.type ?? ''
      );
      const uri = String(record.uri ?? record.fileUri ?? '');

      if (type === 'text/plain' || type === 'text') {
        return {
          type: 'text/plain',
          data: String(record.data ?? record.text ?? ''),
          isFile: false,
        };
      }

      if (uri.length > 0) {
        return {
          type: record.isDirectory === true
            ? 'inode/directory'
            : type || 'application/octet-stream',
          uri,
          name: String(record.name ?? ''),
          isFile: true,
        };
      }

      return {
        type: type || 'application/octet-stream',
        data: '',
        isFile: false,
      };
    });
  }
}

5.2 文本记录

文本记录转换为 DataTransferItem.data

{
  "type": "text/plain",
  "data": "来自其他应用的文本",
  "isFile": false
}

5.3 文件记录

文件记录转换为文件引用:

{
  "type": "application/pdf",
  "uri": "file://example/report.pdf",
  "name": "report.pdf",
  "isFile": true
}

5.4 目录记录

目录仍然使用文件引用接口承载,但类型设为 inode/directory

{
  "type": "inode/directory",
  "uri": "file://example/project",
  "name": "project",
  "isFile": true
}

Flutter 层可以根据类型决定是读取单文件,还是递归枚举目录。

六、ArkTS 插件实现

6.1 插件声明

export default class DragAndDropFlutterOhosPlugin
  implements FlutterPlugin, MethodCallHandler, StreamHandler {
  private methodChannel: MethodChannel | null = null;
  private eventChannel: EventChannel | null = null;
  private sink: EventSink | null = null;
  private callbackIds: number[] = [];
}

6.2 通道注册

onAttachedToEngine(binding: FlutterPluginBinding): void {
  this.methodChannel = new MethodChannel(
    binding.getBinaryMessenger(),
    'drag_and_drop_flutter/ohos'
  );
  this.methodChannel.setMethodCallHandler(this);

  this.eventChannel = new EventChannel(
    binding.getBinaryMessenger(),
    'drag_and_drop_flutter/ohos/events'
  );
  this.eventChannel.setStreamHandler(this);
}

6.3 生命周期释放

onDetachedFromEngine(_binding: FlutterPluginBinding): void {
  this.unregisterDragAndDropCallbacks();
  this.methodChannel?.setMethodCallHandler(null);
  this.eventChannel?.setStreamHandler(null);
  this.methodChannel = null;
  this.eventChannel = null;
  this.sink = null;
}

6.4 事件发送

emitDrop(state: string, records: Array<any>): void {
  this.sink?.success({
    state,
    items: UdmfDropNormalizer.normalize(records),
  });
}

七、Flutter 侧实现

7.1 Harmony 平台实现

class DragAndDropFlutterOhosPlatform
    extends DragAndDropFlutterPlatform {
  static const MethodChannel _methods =
      MethodChannel('drag_and_drop_flutter/ohos');

  static const EventChannel _events =
      EventChannel('drag_and_drop_flutter/ohos/events');

  static void registerWith() {
    DragAndDropFlutterPlatform.instance =
        DragAndDropFlutterOhosPlatform();
  }
}

7.2 DropArea 构建


Widget buildDropArea({
  DragData? dragData,
  DataTransferTypeFilter? canDrop,
  DragEnterCallback? onDragEnter,
  DragExitCallback? onDragExit,
  DropCallback? onDrop,
  required Widget child,
}) {
  return _OhosDropArea(
    dragData: dragData,
    canDrop: canDrop,
    onDragEnter: onDragEnter,
    onDragExit: onDragExit,
    onDrop: onDrop,
    child: child,
  );
}

7.3 事件转换

void _handleEvent(Object? event) {
  final payload = Map<dynamic, dynamic>.from(event as Map);
  final state = payload['state'];

  if (state == 'enter') {
    widget.onDragEnter?.call(_metadata(payload['items']));
  } else if (state == 'exit') {
    widget.onDragExit?.call();
  } else if (state == 'drop') {
    widget.onDrop?.call(
      DragData(
        readonly: true,
        items: _items(payload['items']),
      ),
    );
  }
}

7.4 文件入口

class _OhosFileEntry implements FilesystemEntry {
  const _OhosFileEntry(this.path, this.name);

  
  final String path;

  
  final String name;
}

当前平台接口的 FilesystemEntry 只要求 pathname。如果业务需要读取内容,可以在后续版本增加 OpenHarmony 专用的 URI 导入方法。

八、pubspec 配置

8.1 主包配置

flutter:
  plugin:
    platforms:
      android:
        dartPluginClass: NullDragAndDropPlatform
      ios:
        dartPluginClass: NullDragAndDropPlatform
      web:
        default_package: drag_and_drop_flutter_web
      ohos:
        default_package: drag_and_drop_flutter_ohos

8.2 鸿蒙联邦包配置

name: drag_and_drop_flutter_ohos
version: 0.1.0

flutter:
  plugin:
    implements: drag_and_drop_flutter
    platforms:
      ohos:
        pluginClass: DragAndDropFlutterOhosPlugin
        fileName: drag_and_drop_flutter_ohos.dart

8.3 本地开发覆盖

发布包不能依赖本地路径,因此仓库开发阶段将路径覆盖放在 dependency_overrides

dependency_overrides:
  drag_and_drop_flutter_ohos:
    path: ../drag_and_drop_flutter_ohos

九、文件和目录处理

9.1 文件导入流程

获取 URI
  ↓
检查读取授权
  ↓
读取元数据
  ↓
复制到应用沙箱
  ↓
将沙箱路径交给业务层

9.2 不要直接替换 URI

错误写法:

const path = uri.replace('file://', '');

这种方式会破坏编码,也无法处理非本地 URI。应该通过 OpenHarmony SDK 提供的 URI 文件访问接口完成读取。

9.3 目录限制

目录递归需要限制深度和数量:

interface DirectoryScanOptions {
  maxDepth: number;
  maxEntries: number;
  maxTotalBytes: number;
}

建议默认限制:

项目建议值
最大递归深度20
最大文件数10000
最大总大小10 GB
单次拖放记录数100

十、安全处理

10.1 外部数据校验

function validateDropItem(item: Record<string, Object>): boolean {
  const type = String(item.type ?? '');

  if (!['text/plain', 'file', 'inode/directory'].includes(type)) {
    return false;
  }

  if (type !== 'text/plain' && !item.uri) {
    return false;
  }

  return true;
}

10.2 文件名清理

function safeFileName(name: string): string {
  return name
    .replace(/[\\/:*?"<>|]/g, '_')
    .replace(/\.\./g, '_')
    .slice(0, 255);
}

10.3 日志脱敏

function maskUri(uri: string): string {
  if (uri.length < 12) {
    return '***';
  }

  return `${uri.slice(0, 8)}***${uri.slice(-4)}`;
}

跨应用拖放数据应当视为不可信输入。不要因为事件来自系统框架,就跳过长度、类型、URI 和路径校验。

十一、示例用法

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

  
  State<DropPage> createState() => _DropPageState();
}

class _DropPageState extends State<DropPage> {
  String message = '将文本、文件或目录拖到这里';

  
  Widget build(BuildContext context) {
    return DragDropArea(
      canDrop: (items) => items.every((item) {
        return item.type == 'text/plain' || item.isFile;
      }),
      onDragEnter: (_) {
        setState(() => message = '可以释放');
      },
      onDragExit: () {
        setState(() => message = '将文本、文件或目录拖到这里');
      },
      onDrop: (data) {
        setState(() => message = '收到 ${data.items.length} 条数据');
      },
      child: Center(child: Text(message)),
    );
  }
}

十二、测试方案

12.1 文本测试

  1. 从文本编辑器拖入纯文本;
  2. 从浏览器拖入选中文本;
  3. 测试空文本和超长文本;
  4. 测试换行符和特殊字符;
  5. 检查 Dart 回调是否收到 text/plain

12.2 文件测试

  • 单个文本文件;
  • 多个文件;
  • 图片和 PDF;
  • 中文文件名;
  • 重名文件;
  • 无授权 URI;
  • 大文件异步导入。

12.3 目录测试

  • 空目录;
  • 多级目录;
  • 目录中包含不可读文件;
  • 目录递归超限;
  • 目录总大小超限。

12.4 三方应用矩阵

源应用文本文件目录
文件管理器
文档应用视授权而定视授权而定
浏览器视页面而定
另一个 Flutter 应用视源端实现
自研 ArkTS 应用

十三、构建与验证

13.1 Flutter 依赖

cd packages/drag_and_drop_flutter
flutter pub get

13.2 Dart 静态检查

flutter analyze

当前仓库已验证:

drag_and_drop_flutter          No issues found
drag_and_drop_flutter_ohos     No issues found
example                        No issues found

13.3 DevEco 构建

在 DevEco Studio 中打开:

packages/drag_and_drop_flutter/example/ohos

然后执行依赖同步和默认构建任务。HAR 插件本身依赖本机 Flutter OpenHarmony 引擎产物,路径需要根据本地 Flutter SDK 位置调整。

十四、常见问题

14.1 为什么不直接返回绝对路径?

因为跨应用拖放通常返回 URI。绝对路径可能不属于当前应用沙箱,直接拼接还会引入路径穿越风险。

14.2 为什么需要 EventChannel?

拖放事件由 ArkUI 和 Flutter 引擎异步产生,EventChannel 更适合推送进入、移动、离开和释放事件。

14.3 为什么 canDrop 在 Dart 层判断?

这样可以保持和原插件一致,让业务层根据 MIME 类型和文件标志决定是否接受数据。

14.4 为什么还要兼容 extraParams

不同引擎或 SDK 版本可能把拖放附加数据放在事件对象或额外参数中。适配层同时尝试事件对象和 JSON 字符串,能提高跨版本兼容性。

14.5 是否需要显示模式权限?

不需要。显示模式与 drag_and_drop、UDMF、URI 访问是不同能力。

十五、后续完善方向

  1. 增加 OpenHarmony URI 到沙箱文件的异步复制 API;
  2. 增加文件复制进度和取消接口;
  3. 增加目录递归枚举接口;
  4. 增加图片和自定义 UDMF 类型;
  5. 针对不同 SDK 建立兼容测试矩阵;
  6. 在真实设备上补充跨应用测试截图和性能数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图片说明:发布文章时建议替换为实际 DevEco 工程截图、拖放时序图或设备运行截图。当前图片路径是占位位置,不应伪装成已经生成的实机截图。

总结

本次适配以原插件平台接口为边界,在 OpenHarmony 侧使用 Flutter 引擎内置的 drag_and_drop 框架,并通过 FlutterManager 注册拖放回调;数据层使用 UDMF 统一处理文本、文件和目录记录;跨层使用 EventChannel 传递结构化结果。

最终得到的工程具备以下特点:

  • 不修改原有 DragDropArea 公共 API;
  • 不需要申请显示模式权限;
  • 支持文本、文件和目录拖放;
  • 文件和目录使用 URI 表达;
  • 原生层和 Dart 层职责清晰;
  • 联邦插件和示例 OpenHarmony 工程结构完整;
  • 可以在 DevEco 工程中继续绑定具体 SDK 的 URI 读取实现。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

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

更多推荐