社区引导:框架源码与版本发布见 https://atomgit.com/CPF-Flutter/flutter_flutter/tree/oh-3.41.9-release
CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter
实测环境版本(写清版本是排查问题的第一步,本文所有结论均基于下表):
| 项目 | 版本 |
|---|---|
| 开发工具 | DevEco Studio 26.0(内置 SDK:DevEco Studio/sdk) |
| 系统 API | HarmonyOS 7.0.0 / API 26(compatibleSdkVersion: 26.0.0) |
| Flutter | 3.41.9 |
| 实测设备 | 华为(nova 14) |
一、真实痛点:Flutter 开发者为什么拿不到"碰一碰"
先说一个业务场景。做连锁门店系统的团队一定遇到过这类需求:店员用手机给顾客"碰一碰"发优惠券;仓库管理员拿设备碰一下货架标签,直接把盘点文件接力传到工作手机;展会现场碰一碰交换电子名片。原生鸿蒙开发者对这套能力并不陌生——NFC 前台分发感知"碰一碰"、Share Kit 的系统分享面板承接"华为分享接力",两段系统级管线拼起来就是全场景分享。
但对 Flutter 开发者来说,这条路在鸿蒙上是断的,断得很具体:
第一,pub.dev 上没有可用的鸿蒙碰一碰插件。 Flutter 官方不提供鸿蒙平台支持,鸿蒙化依赖社区维护的 flutter_flutter 分支。整个鸿蒙 Flutter 插件生态还处于"从零开始补库"的阶段,NFC、Share Kit、分布式这些系统能力的插件覆盖率几乎为零。想在 Flutter 业务里加一个"碰一碰发券"功能,没有轮子,只能自己造。
第二,造轮子的第一道坎是嵌入层 API 和文档对不上。 flutter_ohos 嵌入层(flutter.har)的公开文档相对滞后,比如 EventChannel 的 StreamHandler / EventSink 在当前版本并没有从主入口导出,照着官方 EventChannel 的写法 import 会直接编译失败;拿 UIAbility 上下文的 FlutterPluginBinding.getAbility() 也不存在,正确入口是 FlutterManager.getInstance().getUIAbility()。这些坑文档不会告诉你,只能解包 flutter.har 看 Index.ets 才能确认。
第三,系统分享的正确入口发生了代际切换。 网上能搜到的鸿蒙分享代码大多是 FA 模型时代的 ohos.want.action.sendData Want 写法,在 Stage 模型的真机上只会弹"暂无可用打开方式",分享面板根本拉不起来。API 12 之后的正规入口是 Share Kit 的 systemShare,两者的差异没有任何迁移提示。
第四,权限体系有一处和直觉相反的枚举定义。 abilityAccessCtrl 的 GrantStatus 是 0 = PERMISSION_GRANTED、-1 = PERMISSION_DENIED,不是 Android 开发者习惯的"1 表示已授予"。照习惯写出来的权限判断在模拟器上"看起来正常",真机一跑权限页全乱。
这四个断点叠加起来的结果就是:Flutter 业务方想上碰一碰,要自己踩完嵌入层、Share Kit、权限、分布式四个坑,成本极高。所以我们的做法不是在业务里写一次性代码,而是把这些系统能力封装成一个标准 Flutter 三方库 tap_share_ohos——业务侧只 import 一个门面类,调用 shareFiles / shareCard 两个方法就能跑通真机碰一碰,所有系统细节收敛在库的 ArkTS 实现里。
选题上刻意收窄:本文只覆盖"碰一碰分享"这一个能力点(传文件 + 传卡片),不写框架横评,不做 API 盘点。跨端调起(分布式 Want)涉及完全另一套 API 与坑位,值得单独成文。
二、先看效果:
按"先给效果、再讲原理"的顺序,先看真机上实际跑出来的样子,后面每一段原理都对应画面里的一个环节。
2.1 入口与能力探测
打开 example,主页能力面板由 getCapabilities() 真实上报设备能力,不是写死的标签:

这份能力面板的意义在于让业务 UI 可以"按能力路由":真机上显示真实链路入口,模拟器上显示模拟通道入口,桌面上直接隐藏不可用功能。能力探测的实现后面第三节会拆开讲。
2.2 碰一碰传文件:从沙箱文件到系统分享面板
进入"碰一碰传文件"页,点"生成演示文件"在应用沙箱里生成一个 116890 字节的文本文件,再点"碰一碰发送":

点击发送后,系统分享面板从屏幕底部弹出,文件以卡片形式呈现,面板顶部提示"可碰一碰分享至 HarmonyOS 5 及以上版本手机"——这正是华为分享碰一碰接力的正式入口;面板里已经自动列出了附近的可信设备(鸿蒙 PC)和"华为分享":

这一步的动效(点击发送 → 面板滑入 → 关闭回落)看下面的 GIF,约 8 秒:

2.3 碰一碰传卡片:结构化消息编码为 URI
传卡片页可以编辑名片(姓名/标题 + 描述),点"碰一碰发送"后,卡片被编码成 tapshare://contact?id=...&title=... 形式的 URI 文本,以文本记录进入同一个系统分享面板:


为什么卡片要走 URI 编码而不是直接塞 JSON?
2.4 权限页:两级权限、两种授予方式
权限页展示碰一碰分享涉及的两个权限及其真实授权状态,NFC_TAG 是 normal 级(安装即授予),DISTRIBUTED_DATASYNC 是 user_grant 级(运行时弹窗申请):

2.5 关键日志
在真机上触发一次完整的"生成文件 → 碰一碰发送",用 hdc shell hilog | grep TapShareOhos 过滤出的插件专属日志如下(domain 0x0042 / tag TapShareOhos,插件内置打点):
11-10 18:06:40.143 I A00042/.flutter_ohos_ai_agent_example/TapShareOhos:
shareFiles: 1 个文件已转 file:// URI(transferId=tf-1794305200141), 拉起系统分享面板
11-10 18:06:40.310 I A00042/.flutter_ohos_ai_agent_example/TapShareOhos:
shareFiles: 用户完成分享操作(transferId=tf-1794305200141)
两行日志把"沙箱路径转 URI → 面板拉起 → 用户完成"三个节点串了起来。transferId 从 Dart 侧发起调用时就生成并贯穿始终,用于把后续 transferProgress 事件关联回这次调用。如果分享面板被用户取消,日志会打出"分享面板被取消/失败,回落模拟通道",并在 Dart 侧通过事件流收到模拟进度——这是刻意设计的降级路径,保证业务逻辑在任何环境都有确定性行为。
效果层面看完,值得强调一点:以上所有画面都没有写一行原生页面代码。分享面板是系统组件,Flutter 侧只是通过插件触发它;UI 上可见的"待发送文件 / 传输进度 / 收到的文件"三段式反馈,全部由事件流驱动。
三、核心实现:把系统管线封装成 Flutter 三方库
3.1 总体架构:一个门面、两个通道、一条降级链

库分三层:
- Dart 门面层(
lib/tap_share_ohos.dart):业务唯一入口,暴露startTapSession、shareFiles、shareCard、onEvent、ensurePermissions等方法; - 平台通道层:
MethodChannel(tap_share_ohos/methods)承载方法调用,BasicMessageChannel(tap_share_ohos/events)承载事件推送,编解码统一用JSONMessageCodec/StandardMessageCodec; - ArkTS 原生层(
ohos/src/main/ets/components/plugin/TapShareOhosPlugin.ets):NFC 探测、权限三件套、Share Kit 分享面板、模拟通道四块实现,全部带异常处理和 hilog 打点。
业务侧的使用代码可以短到只有一个方法调用。下面是 example 里传文件页的核心逻辑,完整可运行(演示文件生成 + 调用门面 + 事件驱动 UI):
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tap_share_ohos/tap_share_ohos.dart';
import 'package:tap_share_ohos/src/models.dart';
/// 碰一碰传文件页:生成演示文件 → 碰一碰发送 → 事件流驱动进度与接收反馈
class FileSharePage extends StatefulWidget {
const FileSharePage({super.key});
State<FileSharePage> createState() => _FileSharePageState();
}
class _FileSharePageState extends State<FileSharePage> {
TapShareFile? _file; // 待发送文件
double _progress = 0; // 传输进度 0~1
String? _receivedName; // 收到的文件名
StreamSubscription<TapEvent>? _sub;
void initState() {
super.initState();
// 订阅碰一碰事件流:进度事件与接收事件都从这里来
_sub = TapShare.onEvent.listen(_onEvent);
}
void dispose() {
_sub?.cancel(); // 页面销毁必须取消订阅,防止内存泄漏
super.dispose();
}
/// 事件分发:transferProgress 更新进度条,fileReceived 展示接收结果
void _onEvent(TapEvent event) {
if (!mounted) return;
if (event.type == TapEvent.transferProgress && event.progress != null) {
setState(() => _progress = event.progress!.progress);
} else if (event.type == TapEvent.fileReceived && event.file != null) {
setState(() => _receivedName = event.file!.name);
}
}
/// 在应用沙箱 cache 目录生成一个约 114KB 的演示文本文件
Future<void> _generateFile() async {
final rnd = Random();
final sb = StringBuffer();
for (int i = 0; i < 12000; i++) {
sb.write('碰一碰演示数据 ${rnd.nextInt(999999)}\n');
}
final dir = await Directory.systemTemp.createTemp('demo');
final f = File('${dir.path}/demo-${rnd.nextInt(9999)}.txt');
await f.writeAsString(sb.toString());
setState(() {
_file = TapShareFile(path: f.path, name: f.uri.pathSegments.last,
mimeType: 'text/plain');
});
}
/// 发起碰一碰传文件:真机弹出系统分享面板(华为分享接力),失败回落模拟通道
Future<void> _send() async {
if (_file == null) return;
try {
final transferId = await TapShare.shareFiles([_file!], title: '碰一碰传文件');
debugPrint('transferId=$transferId'); // 用于关联回调事件
} on TapShareException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('发送失败: ${e.code} ${e.message ?? ''}')));
}
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('碰一碰传文件')),
body: ListView(padding: const EdgeInsets.all(16), children: [
Row(children: [
TextButton(onPressed: _generateFile, child: const Text('生成演示文件')),
const SizedBox(width: 12),
FilledButton(onPressed: _file == null ? null : _send,
child: const Text('碰一碰发送')),
]),
if (_file != null) Text('待发送: ${_file!.name}'),
LinearProgressIndicator(value: _progress),
Text(_receivedName == null
? '等待对端碰一碰…' : '收到文件: $_receivedName'),
]),
);
}
}
这段业务代码里没有任何平台判断——TapShare.shareFiles 在真机上走系统分享面板。
3.2 事件通道:为什么用 BasicMessageChannel 而不是 EventChannel
碰一碰的所有异步反馈(会话开启、卡片到达、传输进度)都要从 ArkTS 侧推回 Dart。按 Flutter 官方习惯,事件流应该用 EventChannel + StreamHandler。但在 flutter_ohos 当前版本(3.7.12-ohos 分支对应的 flutter.har)里,StreamHandler / EventSink 没有从 @ohos/flutter_ohos 主入口导出,import 直接编译失败。我们解包 flutter.har 确认 Index.ets 的导出清单后,改用已导出的 BasicMessageChannel:
ArkTS 侧的发送实现(完整方法,含空通道保护):
/** 信息级日志(%{public} 保证明文输出,hilog 默认对参数脱敏) */
private logInfo(msg: string): void {
hilog.info(LOG_DOMAIN, LOG_TAG, '%{public}s', msg);
}
/** 异常级日志 */
private logError(msg: string): void {
hilog.error(LOG_DOMAIN, LOG_TAG, '%{public}s', msg);
}
/**
* 统一事件出口:所有原生事件都从这里推给 Dart 侧。
* data 的键值会被平铺进事件 payload,Dart 侧按 TapEvent.fromMap 解析。
*/
private emit(name: string, data: Record<string, Object>): void {
const channel: BasicMessageChannel<Object> | null = this.eventChannel;
if (channel == null) {
this.logError(`emit 失败: 事件通道未初始化(event=${name})`);
return;
}
const event: Record<string, Object> = { 'event': name };
const keys: Array<string> = Object.keys(data);
keys.forEach((k: string) => {
event[k] = data[k];
});
channel.send(event);
}
Dart 侧把 BasicMessageChannel 的消息接成广播流(完整实现):
Stream<TapEvent> get onEvent {
_eventController ??= () {
final controller = StreamController<TapEvent>.broadcast();
controller.onListen = () {
_events.setMessageHandler((msg) async {
if (msg != null) {
try {
controller
.add(TapEvent.fromMap((msg as Map).cast<dynamic, dynamic>()));
} catch (_) {}
}
return null;
});
};
controller.onCancel = () {
_events.setMessageHandler(null);
};
return controller;
}();
return _eventController!.stream;
}
两个实现细节值得展开。其一,用 broadcast StreamController:进度事件可能同时被进度条 UI 和日志模块订阅,单订阅流会让第二个订阅者抛异常。其二,onCancel 里把 handler 置空:所有订阅者取消后不再处理消息,避免页面销毁后事件继续派发到已 dispose 的回调。TapEvent.fromMap 内部做了两种 payload 的兼容——原生侧进度事件是平铺字段(bytesSent / bytesTotal / state 直接在顶层),而部分演示事件把进度嵌在 progress 子对象里,fromMap 里两个分支都处理了,这是踩过"嵌套 Map 解码"的坑之后补的防御。
通道编解码的选型也有讲究。BasicMessageChannel 配 JSONMessageCodec,意味着两侧数据都走 JSON 可序列化结构——这是刻意的:JSON 结构在 ArkTS 侧天然是 Record,避开了 StandardMessageCodec 把嵌套 Dart Map 解码成 ArkTS Map 类型的那套差异(坑 5 的根源),收发两侧的心智模型一致,调试时 emit 的 payload 打出来就是合法 JSON。代价是数据量比二进制编解码略大,但碰一碰事件都是轻量结构(状态、进度、卡片 URI),最大的单条 payload 也就几百字节,性能不构成约束。方法调用通道(MethodChannel + StandardMessageCodec)则保留默认编解码,参数表浅层平铺,两侧约定好键名即可——两条通道一个偏稳、一个偏简,各取所长。
3.3 传文件:沙箱路径 → file:// URI → SharedData → 系统分享面板
这是整条链路的核心。Dart 侧传来的文件路径是应用沙箱路径(形如 /data/storage/el2/base/haps/entry/cache/demo-8586.txt),而 Share Kit 的 SharedRecord 要求 file:// 协议的 URI,必须用 fileUri.getUriFromPath 转换。ArkTS 侧完整实现如下:
private shareFiles(call: MethodCall, result: MethodResult): void {
const files: Array<ESObject> = call.argument('files') as Array<ESObject> ?? [];
if (files.length === 0) {
this.logError('shareFiles: files 列表为空,拒绝发起');
result.error('invalidArgs', 'files 不能为空', null);
return;
}
const transferId: string = `tf-${Date.now()}`;
const fileCount: number = files.length;
const title: string = call.argument('title') as string ?? '碰一碰传文件';
const names: Array<string> = [];
files.forEach((f: ESObject) => {
names.push(this.fieldValue(f, 'name') as string ?? 'file');
});
const context: common.UIAbilityContext | null = this.getAbilityContext();
if (this.nfcSupported && context != null) {
// 真实设备:走 ShareKit systemShare 拉起系统分享面板(华为分享支持碰一碰接力)
try {
const uris: Array<string> = [];
files.forEach((f: ESObject) => {
const path: string = this.fieldValue(f, 'path') as string ?? '';
if (path.length > 0) {
uris.push(fileUri.getUriFromPath(path));
}
});
if (uris.length > 0) {
this.logInfo(`shareFiles: ${fileCount} 个文件已转 file:// URI(transferId=${transferId}), 拉起系统分享面板`);
const first: systemShare.SharedRecord = {
utd: 'general.file-object',
uri: uris[0],
title: title,
};
const data: systemShare.SharedData = new systemShare.SharedData(first);
for (let i = 1; i < uris.length; i++) {
const rec: systemShare.SharedRecord = {
utd: 'general.file-object',
uri: uris[i],
title: names[i],
};
data.addRecord(rec);
}
const controller: systemShare.ShareController = new systemShare.ShareController(data);
controller.show(context, {
selectionMode: uris.length > 1
? systemShare.SelectionMode.BATCH : systemShare.SelectionMode.SINGLE,
}).then(() => {
this.logInfo(`shareFiles: 用户完成分享操作(transferId=${transferId})`);
const out: Record<string, Object> = { 'transferId': transferId, 'simulated': false };
result.success(out);
}).catch(() => {
this.logError(`shareFiles: 分享面板被取消/失败,回落模拟通道(transferId=${transferId})`);
this.simulateFileTransfer(transferId, fileCount, result);
});
return;
}
} catch (err) {
this.logError(`shareFiles: systemShare 异常,落入模拟通道: ${err}`);
// 落入模拟通道
}
}
this.simulateFileTransfer(transferId, fileCount, result);
}
拆开讲几个关键决策:
整条链路一张图看懂。 下图把从 Flutter 调用到对端接收的完整路径串了起来,包括踩坑后修正过的部分(FA 时代 Want 写法的废弃路径)和事件回流通道:

图中有一条容易被忽略的虚线:事件回流。分享面板弹出期间,Dart 侧 UI 并没有被挂起,进度条、状态文案都在实时刷新,靠的就是 transferProgress 事件沿 BasicMessageChannel 回流。实线是"调下去",虚线是"推上来",两个方向的通道各自独立,这也是 3.2 节用两条通道而不是一条的架构原因。
UTD 类型决定面板行为。 utd: 'general.file-object' 是统一类型描述符(Uniform Type Descriptor),告诉分享面板这是一条文件记录。面板据此渲染文件卡片、匹配接收方。传卡片用的是 general.plain-text,面板渲染成文本卡片。UTD 写错不会报错,但面板会显示"?"图标且无法接力——图 3 文件卡片左下角的问号小图标就是我们调试 UTD 时留下的真实痕迹。
多文件走 BATCH、单文件走 SINGLE。 SelectionMode 影响面板的预览布局,与文件数量一一对应,写错会导致面板预览异常。
controller.show 的 Promise 语义是"面板关闭"而非"传输完成"。 用户点分享面板右上角关闭,Promise 也会 resolve;真正把文件接力出去是系统行为(华为分享),插件无法感知传输结果。所以 result.success 返回的 transferId 只是任务标识,业务侧不应把它当成"传输完成"的信号——需要传输结果感知时订阅 transferProgress 事件(模拟通道)/ 系统通知(真实接力由华为分享负责,见第四节边界说明)。
异常路径有四层兜底:参数为空 → result.error;路径转换失败或 URI 为空 → 落入模拟通道;systemShare 抛异常 → 落入模拟通道;用户取消面板 → 回落模拟通道。任何一条路径 Dart 侧都能拿到确定结果或确定事件,不会悬挂。
3.4 传卡片:为什么编码成 tapshare:// URI
卡片(名片、优惠券)和文件的本质区别是卡片是结构化消息,需要在接收端还原成对象。我们把卡片编码为:
private shareCard(call: MethodCall, result: MethodResult): void {
const id: string = call.argument('id') as string ?? `card-${Date.now()}`;
const cardTitle: string = call.argument('title') as string ?? '碰一碰卡片';
const cardType: string = call.argument('cardType') as string ?? 'generic';
const transferId: string = `tc-${Date.now()}`;
// NFC 真实链路:卡片编码为 tapshare:// URI(NDEF 自定义记录承载)
const uri: string = `tapshare://${cardType}?id=${encodeURIComponent(id)}&title=${encodeURIComponent(cardTitle)}`;
this.logInfo(`shareCard: 卡片编码完成 ${uri}(transferId=${transferId})`);
const context: common.UIAbilityContext | null = this.getAbilityContext();
if (this.nfcSupported && context != null) {
// 真实设备:卡片以 tapshare:// URI 文本形式进入系统分享面板
try {
const record: systemShare.SharedRecord = {
utd: 'general.plain-text',
content: uri,
title: cardTitle,
description: '碰一碰卡片',
};
const data: systemShare.SharedData = new systemShare.SharedData(record);
const controller: systemShare.ShareController = new systemShare.ShareController(data);
controller.show(context, {
selectionMode: systemShare.SelectionMode.SINGLE,
previewMode: systemShare.SharePreviewMode.DEFAULT,
}).then(() => {
const out: Record<string, Object> = { 'transferId': transferId, 'simulated': false };
result.success(out);
}).catch(() => {
const out: Record<string, Object> = { 'transferId': transferId, 'simulated': true };
result.success(out);
});
return;
} catch (err) {
this.logError(`shareCard: systemShare 异常,落入模拟通道: ${err}`);
// 落入模拟通道
}
}
const out: Record<string, Object> = { 'transferId': transferId, 'simulated': true };
result.success(out);
const data: Record<string, Object> = {
'transferId': transferId,
'bytesSent': 1,
'bytesTotal': 1,
'state': 'done',
};
this.emit(TapShareEvent.transferProgress, data);
}
三个设计点:
URI 格式自定义 scheme + query 编码。 tapshare://<cardType>?id=xx&title=xx。自定义 scheme 的好处是接收端一眼可辨"这是碰一碰卡片协议"(对端应用解析文本时先匹配 scheme),query 里只放标识与标题这类轻量字段并用 encodeURIComponent 转义,避免特殊字符破坏 URI 结构。大块业务数据(payload)不应该进 URI——URI 会随分享面板预览明文展示,敏感信息放进去既不安全也会撑爆面板预览;正确姿势是 URI 只当"取件凭证",payload 走自家业务后端。
卡片和文件共用同一条系统管线。 卡片就是一条 general.plain-text 记录,接收端(华为分享接力到 PC、或另一台手机)拿到的是文本,对端插件解析文本时匹配 tapshare:// scheme 还原卡片对象并发 cardReceived 事件。这样一条 NFC + 分享管线同时服务两种内容,库的实现面收敛了一半。
previewMode 显式给默认值。 SharePreviewMode 不传时部分版本面板表现不一致,显式声明 DEFAULT 后真机表现稳定。
3.5 权限三件套:查询、申请、以及那个要命的枚举
权限层提供三个方法:checkPermissions(查状态)、requestPermissions(弹窗申请)、ensurePermissions(一步到位)。核心是状态查询的枚举映射(完整方法):
private mapPermissionStatus(state: number): string {
// abilityAccessCtrl.GrantStatus: 0 = PERMISSION_GRANTED, -1 = PERMISSION_DENIED
if (state === 0) {
return 'granted';
} else if (state === -1) {
return 'denied';
}
return 'notDetermined';
}
private checkPermissions(): Record<string, string> {
const out: Record<string, string> = {};
const tokenId: number = this.getTokenId();
try {
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
PERMISSION_MAP.forEach((perm: string, key: string) => {
try {
const state: number = atManager.verifyAccessTokenSync(tokenId, perm as ESObject as Permissions);
out[key] = this.mapPermissionStatus(state);
} catch (err) {
out[key] = 'unavailable';
}
});
} catch (err) {
PERMISSION_MAP.forEach((perm: string, key: string) => {
out[key] = 'unavailable';
});
}
return out;
}
注意两个细节。** tokenId 的获取走同步接口** getBundleInfoForSelfSync(GET_BUNDLE_INFO_WITH_APPLICATION),取 appInfo.accessTokenId;逐条权限单独 try-catch——某个权限在低版本系统上不存在时(比如设备无 NFC 芯片,NFC_TAG 接口异常),只降级该条为 unavailable,不让整页权限查询崩掉。requestPermissions 内部调用 requestPermissionsFromUser 弹系统授权窗,无论用户点允许还是拒绝,都回读 checkPermissions 返回真实状态,Dart 侧不需要自己区分"弹窗被拒"和"查询失败"。
权限声明是库的 module.json5 完成的事,example 里声明如下(业务接入方同样需要在自己的 module.json5 里声明这两条):
"requestPermissions": [
{ "name": "ohos.permission.NFC_TAG" },
{
"name": "ohos.permission.DISTRIBUTED_DATASYNC",
"reason": "$string:datasync_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
]
NFC_TAG 是 normal 级,声明即授予;DISTRIBUTED_DATASYNC 是 user_grant 级,必须带 reason 字符串资源,且 usedScene.when 要写 inuse,缺了这两项打包直接报错。
3.6 模拟通道:没有 NFC 的环境怎么联调
模拟器没有 NFC 硬件,nfcController.isNfcAvailable() 返回 false。库的策略是能力探测 + 自动降级:startTapSession 里检测 NFC 可用性,不可用则把 simulated: true 返回给 Dart,同时事件流里发一条"已进入模拟碰一碰模式"的 sessionStarted;业务调用 simulateTapReceive 可以主动注入一次模拟碰一碰(模拟对端发来卡片/文件),接收链路的解析逻辑与真实链路完全共用。
四、踩坑与边界条件
坑 1:GrantStatus 枚举映射反了,权限页"看起来全被拒了"
现象:真机权限页 NFC_TAG 显示"已拒绝",但它是 normal 级权限,安装即授予。
定位:hdc shell "atm dump -t -b com.example.flutter_ohos_ai_agent_example" 查真实授权状态,输出里 grantStatus: 0。
根因:SDK 的 GrantStatus 是 PERMISSION_DENIED = -1、PERMISSION_GRANTED = 0,不是 Android 习惯的"1 = granted"。照习惯写 state == 1 ? granted : denied,在模拟器上所有权限都返回 denied,“看起来只是权限没申请”,实际上是映射反了。修复见 3.5 节 mapPermissionStatus。经验:任何跨平台权限代码迁移到鸿蒙,先用 atm dump 对一次真实值。
坑 2:FA 时代的 sendData Want 拉不起分享面板
现象:点"碰一碰发送"后真机弹系统提示"暂无可用打开方式",分享面板不出现。
根因:早期资料里鸿蒙分享的写法是构造 ohos.want.action.sendData 的 Want 去隐式拉起接收方。这套写法是 FA 模型时代的,Stage 模型下参数结构变了(要传 ability.picker.fileNames 等键),且不带真实文件 URI,系统匹配不到任何接收方。
修复:换 Share Kit 的 systemShare(3.3 节完整代码),面板正常弹出并出现华为分享碰一碰入口。经验:Stage 模型下做"分享",systemShare 是唯一正规入口,不要再从 FA 时代博客里抄 Want 拼接。
坑 3:沙箱路径不转 URI,面板卡片是"?"
现象:直接把沙箱路径塞进 SharedRecord.uri,面板显示一个带问号的灰色卡片,无法接力。
根因:SharedRecord.uri 要求 file:// 协议 URI,沙箱绝对路径不是合法 URI。
修复:fileUri.getUriFromPath(path)(@kit.CoreFileKit)逐文件转换后再进 SharedData。
坑 4:EventChannel 的 StreamHandler 没导出,编译失败
现象:import { EventSink, StreamHandler } from '@ohos/flutter_ohos' 报找不到导出。
根因:flutter_ohos 当前版本的 flutter.har 主入口未导出事件流相关类型(解包看 Index.ets 确认)。
修复:改用 BasicMessageChannel + JSONMessageCodec(3.2 节),Dart 侧 receiveBroadcastStream 等价能力由 setMessageHandler 实现。顺带拿到 FlutterManager.getInstance().getUIAbility() 这个正确取上下文的姿势——文档里的 getAbility() 在 FlutterPluginBinding 上并不存在。
坑 5:嵌套 Map 在 ArkTS 侧用下标访问拿到 undefined
现象:模拟碰一碰的 payload 里嵌套的 card 对象在 ArkTS 侧读出来是 undefined,卡片接收页显示空白。
根因:Dart 的嵌套 Map 经 StandardMessageCodec 解码到 ArkTS 是 Map 类型而不是 Record/普通对象,obj['card'] 下标访问对 Map 无效,必须 map.get('card')。平铺字段不受影响。
修复:插件里统一走 fieldValue 兼容方法(下面是完整实现),对 Map 用 .get()、对普通对象用下标:
/** 兼容读取 StandardMessageCodec 解码出的 Map / 普通对象字段 */
private fieldValue(obj: ESObject, key: string): ESObject {
if (obj == null) {
return null;
}
if (obj instanceof Map) {
return (obj as Map<string, Object>).get(key);
}
return obj[key];
}
总结:
本文基于 Flutter OHOS 3.41.9、鸿蒙 API 26,落地
tap_share_ohos三方插件,打通了鸿蒙真机碰一碰分享链路,支持碰一碰传文件与结构化卡片。
Flutter 鸿蒙生态缺少现成碰一碰插件,同时存在多处适配卡点:嵌入层文档滞后、EventChannel 相关类型未导出、Stage 模型下旧的 FA 分享写法失效、权限枚举定义和跨平台习惯相反,这些问题叠加导致业务直接接入成本很高。这套方案的核心思路是封装一层 ArkTS 桥接层,对外收敛成简洁统一的 Dart API,业务只需要调用 shareFiles、shareCard,不用关心底层 NFC、Share Kit、权限、沙箱路径转换等系统细节。
插件采用 MethodChannel 承载方法调用,受导出限制改用 BasicMessageChannel 做事件推送,使用 JSONMessageCodec 简化两侧数据编解码;沙箱文件路径通过 fileUri.getUriFromPath 转为 file:// URI,构造 SharedData 调用 Share Kit 的 systemShare 拉起系统分享面板,复用华为分享的碰一碰接力通道;结构化卡片则编码为自定义 tapshare:// URI,和文件复用同一套分享管线,接收端解析 Scheme 还原卡片信息。
权限模块单独封装查询、申请逻辑,重点处理了 GrantStatus 0代表授权、-1代表拒绝这个特殊枚举;同时内置完整的能力探测和模拟通道,模拟器无NFC硬件时自动降级,保证开发联调的可用性。整套链路附带完善的异常兜底、传输事件回流、日志打点,通过 transferId 关联单次分享任务。
参考资料:
- Share Kit(systemShare 官方文档):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/share-systemshare
- 访问控制(权限开发指南):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/access-token-overview
16



被折叠的 条评论
为什么被折叠?



