〖共创稿事节〗HarmonyOS 7 新特性实战(48):碰一碰分享接进来,我却先被一个回调骗了一整天
# 〖共创稿事节〗HarmonyOS 7 新特性实战(48):碰一碰分享接进来,我却先被一个回调骗了一整天
详情页要加个「碰一碰分享」,把当前这只异兽的卡片投到旁边的设备上。接口不复杂:SharedData 装载荷,ShareController 拿出来,show() 一调,系统面板起来。我半天就写完了,编译一次过。
然后我在 shareCompleted 回调里写了一行:
controller.on('shareCompleted', (result: systemShare.ShareOperationResult) => {
const target = result.targetAbilityInfo === undefined ? 'unknown' : result.targetAbilityInfo.name;
hilog.info(DOMAIN, TAG, 'share completed beast=%{public}s target=%{public}s', beast.id, target);
ShanhaiBeastShare.detach();
});
这是改完之后的。改之前那版里,我写了 result.resultCode 和 result.abilityName——编译能过,运行不报错,日志打出来是 undefined。我以为是自己日志拼错了,查了半天拼写,又怀疑是不是回调没触发。
问题出在这里:ShareOperationResult 根本没有 resultCode。 ArkTS 里访问一个不存在的字段,只要对象类型是 any 兜着的、或者中间过了一道 JSON,运行时不一定炸,它只是给你 undefined。而我脑子里那个 resultCode 是从哪来的?是从同一个 .d.ts 文件里另一个枚举——ShareAbilityResultCode ——串过来的。它确实存在,ERROR=-1、BACK=0、CLOSE=1,看着特别像一个分享结果码。
它不是。它是给 UIExtensionAbility 的返回值用的,控制分享面板的行为;shareCompleted 的回调参数里没有它。这两个东西名字长得像、值也像,但它们属于两个完全不同的调用方向。
官方文档:systemShare(分享)— ArkTS API(核对日期 2026-10-30)
一、这个回调只回答一个问题
把 ShareOperationResult 的定义整段贴出来,只有三行有用的:
interface ShareOperationResult {
targetAbilityInfo: ShareAbilityInfo;
}
interface ShareAbilityInfo {
name: string;
}
一个字段,字段里一个 string。文档对它的描述是「用于数据统计」——它只告诉你用户把内容投给了哪个通道,不告诉你成没成。
| 你可能想从回调里拿到的 | 实际有没有 | 说明 |
|---|---|---|
| 成功/失败码 | 没有 | 能走到这个回调就说明内容已经交出去了 |
| 目标应用名 | 有,但只是一串 name | 可能是系统操作名,也可能是 bundle#module#ability |
| 用户取消了 | 不走这里 | 取消走 dismiss |
| 错误详情 | 没有 | 真正的错误在 show() 的 catch 里 |
那 name 到底是什么形态?文档说两种。一种是固定值,来自 ShareAbilityName 枚举:
| 枚举成员 | 实际字符串 |
|---|---|
COPY_TO_PASTEBOARD | SystemShare_CopyToPasteboard |
SAVE_TO_MEDIA_ASSET | SystemShare_SaveToMediaAsset |
SAVE_AS_FILE | SystemShare_SaveAsFile |
PRINT | SystemShare_Print |
SAVE_TO_SUPERHUB | SystemShare_Superhub |
COLLECTION | SystemShare_Collection |
HARMONYSHARE | SystemShare_HarmonyShare |
ENCRYPT | SystemShare_Encrypt |
另一种是非系统通道,格式是 [bundleName]#[moduleName]#[abilityName]。这两种值混在同一个 string 里,所以拿到之后要么先比枚举,要么直接按字符串前缀判——SystemShare_ 开头的是系统通道,剩下的是三方应用。

上面这张图是我把 show() 的三种出口画在一起:show() 正常返回只代表面板起来了,shareCompleted 只代表投出去了,dismiss 只代表面板关了。三条路互不覆盖,也都不是"成功/失败"。
二、两个 off,签名不一样
第二个坑在摘监听。
面板关掉之后不摘监听,下次分享同一只异兽会重复回调——这个道理我懂,所以写了个 detach()。第一版长这样:
private static detach(): void {
const controller = ShanhaiBeastShare.controller;
if (controller === undefined) {
return;
}
controller.off('shareCompleted');
controller.off('dismiss');
}
编译直接报:
10505001 Argument of type '"dismiss"' is not assignable to
parameter of type '() => void'.
翻了 .d.ts 才发现两个 off 的签名是故意不一样的:
off(type: 'shareCompleted', callback?: Callback<ShareOperationResult>): void;
off(event: 'dismiss', callback: () => void): void;
第一个带 ?,第二个不带。文档写得很清楚:shareCompleted 不传 callback 就摘掉全部监听;dismiss 必须传回 on() 时用的那个函数引用。
这意味着 dismiss 的回调不能写成内联箭头函数再丢掉——你丢了引用就摘不掉它了。必须存下来:
private static dismissHandler: (() => void) | undefined = undefined;
const dismissHandler = (): void => {
hilog.info(DOMAIN, TAG, 'share dismissed beast=%{public}s', beast.id);
ShanhaiBeastShare.detach();
};
ShanhaiBeastShare.dismissHandler = dismissHandler;
controller.on('dismiss', dismissHandler);
摘的时候原样传回去:
controller.off('shareCompleted');
const handler = ShanhaiBeastShare.dismissHandler;
if (handler !== undefined) {
controller.off('dismiss', handler);
}
这个不对称背后大概是历史原因:dismiss 是 since 4.1.0(11) 就有的老接口,shareCompleted 到 5.1.0(18) 才加。老接口按"精确摘一个"设计,新接口学乖了给了省略形式。写的时候记一条就行:看到 off 就去看它带不带 ?。
三、日志里能看到的调用顺序
两次 on 到底有没有叠加,光看代码看不出来。我把真机日志按时间抠出来,发现系统在 NAPI 层把回调计数打出来了:
09:33:08.791 ConstructorCallback# ConstructorCallback
09:33:08.792 OnCallback# SubEvent op=on kit=ShareKit event=shareCompleted.
09:33:08.793 OnCallback# callbacks: 1
09:33:08.793 OnCallback# SubEvent op=on kit=ShareKit event=dismiss.
09:33:08.793 OnCallback# callbacks: 1
两次注册,两次计数都是 1。说明 shareCompleted 和 dismiss 各有一个独立回调列表,不是共用一个池子——否则第二次会打成 2。
面板关掉之后:
09:34:08.862 OffCallback# SubEvent op=off_all kit=ShareKit event=shareCompleted.
09:34:08.862 OffCallback# callbacks: 0
09:34:08.862 OffCallback# SubEvent op=off kit=ShareKit event=dismiss.
09:34:08.862 OffCallback# callbacks: 0
注意 op= 的值不一样:off_all 和 off。这就是 .d.ts 里那个 ? 在底层的样子——我省略 callback,系统走 off_all;我传了引用,系统走 off。两个都清到 0。
日志里的 op | 对应我的写法 | 语义 |
|---|---|---|
op=on | on('shareCompleted', cb) / on('dismiss', cb) | 注册一个 |
op=off_all | off('shareCompleted') | 摘全部 |
op=off | off('dismiss', handler) | 精确摘一个 |
日志这一层是白送的。我本来是拿它验"有没有漏摘"的,结果反过来把接口设计的意图证实了一遍。
四、载荷怎么组才不白投
SharedRecord 十个字段,其中真正决定"投出去能看懂"的就四个:
const line = `${beast.no} ${beast.name}·${beast.alias}`;
const summaryLine = beast.curatorRoute === undefined ? beast.summary : `${beast.summary}(${beast.curatorRoute})`;
const record: systemShare.SharedRecord = {
utd: utd.UniformDataType.PLAIN_TEXT,
title: line,
content: summaryLine,
extraData: {
shanhaiSchemaVersion: SHARE_SCHEMA_VERSION,
shanhaiBeastId: beast.id,
shanhaiBeastNo: beast.no
}
};
record.description = summaryLine;
record 先在字面量里建好,最后一行再补 description——不写在字面量里,纯粹是行文折中,效果完全一样。content 和这个字段共用 summaryLine 也不是笔误,理由在下面那张表之后。逐条说:
| 字段 | 我填的 | 为什么 |
|---|---|---|
utd | PLAIN_TEXT | 必填。这个值决定接收端拿什么应用匹配,填得越准匹配越准 |
content / uri | 只填前者 | 二选一,至少一个。异兽没有文件实体,只有文本 |
title | No.001 白泽·通晓万物之灵 | 不建议省。省了系统在文本场景会拿正文顶上,标题就变成摘要第一行 |
description | 与 content 相同 | 默认空串。面板上的副标题读这个 |
thumbnail | 没填 | 上限 32 KB,超了会让 want 数据超限、Share Kit 起不来 |
content 和 description 填一样的内容,看着冗余,其实不是。content 是给接收端的数据,description 是给分享面板的显示文本。这次两者一致,是因为这句摘要只打算给人看;以后真要带结构化信息,content 会先被改掉,而面板上该显示的那句话不该跟着变。
至于 label——只在单选模式下有效,值是 HYPERLINK/HTML/TEXT/VIDEO/AUDIO/IMAGE/FILE 之一。既然 utd 已经说清了,就没重复填。
thumbnail 那个 32 KB 是有代价的决定。异兽插画是工程里最好看的东西,分享卡上带一张缩略图显然更吸引人;但 32 KB 对一张有色阶的插画太紧了,压进去基本就是一块色块。与其放一块糊掉的色块,不如不放——系统会给一个跟内容类型匹配的默认图标。这是主动放弃,不是漏了。
extraData 是钩子,不是凑字段
三个键里,shanhaiSchemaVersion 最容易被当成摆设删掉。它的用途是给以后的自己留退路:今天接收端什么都不读,明天要加"碰一下回应用"的时候,接收端拿到的第一件事应该是"这份数据是第几版的字段布局",而不是先猜。版本号放进载荷里,比放进代码注释里管用。
shanhaiBeastId 才是真正的功能钩子——没有它,接收端只能回到应用首页,用户还得自己再找一遍那只异兽。shanhaiBeastNo 是给人类看的冗余,方便日志里一眼认出来。
类型上有个小限制:extraData 的值只能是 string | number | boolean | Array<string|number|boolean>。嵌套对象放不进去,所以以后要传结构化的东西,得自己序列化成字符串再塞。
五、单选还是批量:这里没有第二种答案
SelectionMode 只有两个值:SINGLE = 0、BATCH = 1,默认单选。我显式传了 SINGLE:
await controller.show(context, { selectionMode: systemShare.SelectionMode.SINGLE });
理由不在 API 上,在入口上。这个按钮长在详情页,语义是"当前这一只"。一次只有一笔记录,BATCH 和 SINGLE 的行为完全一样——但语义不一样。将来如果详情页加了"多选对比",那会是另一个入口,那时候传 BATCH 才对。
而且 BATCH 不是想用就能用:文档写着 show() 会抛 1003702001,注释是「批量与多选模式只支持 UDMF.File 类型的记录」。纯文本载荷在多选模式下会被直接拒掉。我这场景连试都不该试。

上图是这笔载荷从应用到接收端窗口的完整路径。中间那个跨进程的节点是下面要说的重点。
六、载荷真的到了系统那边
show() 返回 Promise<void> 成功,只说明面板起来了,不能证明载荷被解析了。这两件事在代码上是分开的。
日志里这一段是我最想留的证据:
47830 09:33:09.020 [NapiWantParser.cpp] GetSharedData# enter.
47830 09:33:09.022 [SharedData.cpp] ParseFromParams# Records total count is 1
47830 09:33:09.022 [NapiSharedData.cpp] CreateFromWant# finish: 0
PID 47830,进程名 tshare:SystemShareModalAbility——不是我的应用进程。我的进程是 5802。也就是说这几行是分享面板那个独立进程在解析我递过去的 want,而且它数出来了 Records total count is 1。
这一条比截图有力得多。截图能证明面板显示了标题和摘要,但显示可能是兜底渲染;ParseFromParams 数出 1,证明我那个 SharedRecord 被系统当成一条合法记录读进去了。
同一条时间轴上还有三个会话码:
| 时间 | 会话码 | 含义 |
|---|---|---|
09:33:09.064 | code 101 | 面板启动成功,我的 show() 在这之后才 resolve |
09:33:09.720 | code 100 | 面板进入就绪状态 |
09:34:08.847 | code -1 | 面板被关掉 |
code 101 那一行后面紧跟 HandleStartupSucc,再后面才是我的 share sheet shown。顺序很重要:show() 的 resolve 时机在系统确认面板起来之后,不是在这之前。这解释了为什么 show() 抛异常是个可信的信号——它抛了就是真没起来。
面板上显示成什么样,是可以直接看到的:

标题是 No.001 白泽·通晓万物之灵,摘要被系统截断成 《云笈七签》所收黄帝传说中的能言…。截断是面板宽度决定的,不是我的字段没填对——这也正好印证了第一节里那个「不要把全文塞进载荷」的判断:接收窗口就那么宽,塞进去也是被截。
面板中间那行小字「可碰一碰分享至 HarmonyOS 5 及以上版本手机」值得单独看一下。它不是我在代码里写的任何东西,是系统识别出载荷类型之后自己加的场景提示。这句话出现,等于系统把这次分享归到了碰一碰通道——我什么都没配,它自己判的。
七、anchor 这笔债
ShareControllerOptions 里我没传的字段,有一个迟早要还:
| 字段 | 默认行为 | 我传了吗 |
|---|---|---|
selectionMode | 单选 | 显式传 SINGLE |
anchor | 面板居中 | 没传 |
previewMode | DEFAULT(缩略图卡片) | 没传 |
excludedAbilities | 不排除 | 没传 |
appLaunchTrustInfo | 不限制 | 不能传 |
anchor 那一行的默认值是重点。文档写着:2in1 设备和平板上,分享面板是弹出模式,必须传 anchor 信息。手机上是模态(横屏或展开时变对话框),居中没问题。
这个工程 deviceTypes 是 ["phone", "tablet", "2in1"]。也就是说,在平板上点这个按钮,面板会弹在屏幕正中,而不是贴着按钮。功能不坏,位置不对。
要修的话,anchor 两种形态:一种是字符串,传控件的 id;一种是 ShareControllerAnchor,传坐标:
const anchor: systemShare.ShareControllerAnchor = {
windowOffset: { x: 120, y: 640 }, // 相对锚点的窗口偏移
size: { width: 254, height: 59 } // 锚点矩形尺寸,省略时退化为一个点
};
await controller.show(context, { selectionMode: systemShare.SelectionMode.SINGLE, anchor: anchor });
字段名是 windowOffset 不是 offset。我第一版就写成了 offset——又是同一个毛病:凭印象补一个听起来对的字段名。windowOffset 是必填,size 可选。
我这里没有写,因为没有在平板上验过。数字是从手机截图的按钮 bounds 抄的,直接搬到平板上大概率是错的。宁可留着一个"位置不完美但功能对"的版本,也不留一个"看起来写了坐标、实际全错"的版本。
至于 appLaunchTrustInfo——它带 ohos.permission.SET_SYSTEMSHARE_APPLAUNCHTRUSTLIST,是受限权限,只给企业应用用来把分享目标限制在可信应用白名单内。个人应用碰不到,也不该碰。
八、801 单独判
show() 的 catch 里我只对 801 做了特殊处理:
} catch (error) {
const failure = error as BusinessError;
if (failure.code === 801) {
hilog.warn(DOMAIN, TAG, 'share unsupported on this device beast=%{public}s', beast.id);
ShanhaiBeastShare.detach();
return 'unsupported';
}
hilog.error(DOMAIN, TAG, 'share show failed beast=%{public}s code=%{public}d message=%{public}s',
beast.id, failure.code, failure.message);
ShanhaiBeastShare.detach();
return 'failed';
}
801 是"设备不支持该能力"。systemShare 整个命名空间都挂在 SystemCapability.Collaboration.SystemShare 下,老设备上根本没有这个能力。
把 unsupported 和 failed 分开,对外是一个返回值,对内是两件事:
| 返回 | 含义 | 该不该报错 |
|---|---|---|
shown | 面板起来了 | 否 |
unsupported | 这台设备没有这个能力 | 否,是环境限制 |
dismissed | 用户自己关了 | 否 |
failed | 真出错了 | 是,但只写日志 |
show() 另外两个已知的抛出码是 1003702001(记录类型不支持)和 1003702002(IPC 数据超限)。后者跟第四节那张 thumbnail 32 KB 的表是同一件事的两头——放大了图,就在这儿挨打。
调用侧我全程不弹提示:
ShanhaiBeastShare.show(context, this.beast).then((outcome: ShanhaiShareOutcome) => {
hilog.info(0x2026, 'ShanhaiBeastDetail', 'share outcome=%{public}s beast=%{public}s',
outcome, this.beast.id);
}).catch((error: Error) => {
hilog.error(0x2026, 'ShanhaiBeastDetail', 'share rejected: %{public}s', error.message);
});
分享是附加动作。用户点了按钮没反应,他自己会再点一次;弹一个"分享失败"的 toast 反而像在怪他。这类"辅助功能失败"的处理原则是:日志里必须有,界面上一律不出现。
九、跨设备投送:中间隔着一个签名坑
上面第一到八节的结论,都来自同一台手机:面板起来了、字段映射对了、系统识别成碰一碰场景、回调与监听清理正常。但「碰一下,另一台真的收到」这一条,一开始没跑成——不是代码问题,是签名。
第二台设备 MIS-AL00 装包时报:
code:9568423 the device is unauthorized,
make sure the UDID of your device is configured in the signing profile
原因是调试 profile 的 device-ids 里只有第一台设备的 UDID。把第二台的 UDID 加进 AGC 设备列表之后,我以为重新下载 profile 就完事了,结果错误码变了:
code:9568322 signature verification failed due to not trusted app source
这个错误码的转变本身就是信息:设备白名单那关过了,卡在证书上。用签名工具把 HAP 内嵌的 profile 解出来对比,发现 profile 里登记的开发证书和 HAP 实际签名用的证书是两张——profile 绑的是 AGC 上 7 月 1 日签发的那张调试证书,而本机 .p12 对应的是 9 月 24 日那张。AGC 上同时存在多张调试证书,手动下载 profile 时默认绑的不是本机签名材料对应的那张。
最后用 DevEco 的自动签名重新生成整套材料(.p12、.cer、profile 一起重出),装包一次通过。
真碰一碰:60 秒变成 23 秒
签名通了之后,跨设备投送在 HBN-AL80(发送端)→ MIS-AL00(接收端)之间实测成功。发送端日志(完整版在验收目录,这里取两行关键):
10:14:15.712 share sheet shown beast=beast_baize
10:14:38.863 share completed beast=beast_baize target=SystemShare_HarmonyShare
两行之间,系统侧还有一对 SessionContext 日志:投递状态先报进行中(-1),两秒后报完成(0)。share completed 那行是这篇最想要的结论——shareCompleted 在碰一碰路径上会触发,name 就是 SystemShare_HarmonyShare。第一节里我只能从枚举定义推测它"可能是 SystemShare_HarmonyShare 也可能是 bundle#module#ability",现在有日志了:碰一碰走系统通道,返回固定枚举值。接收端收到的标题与摘要和发送端一致,两端截图都在验收目录里。
待验清单全部落定:
| 待验项 | 结果 |
|---|---|
| 接收端能装上并启动 | 通过(新签名包一次装上) |
| 碰一碰后接收端收到卡片 | 通过 |
| 接收端标题摘要与发送端一致 | 通过,两端截图对照 |
发送端日志出现 target=<通道名> | 通过,SystemShare_HarmonyShare |
| 双机取证 | 两张端别截图 + 发送端 hilog |

一个经验放这里:手动在 AGC 下载 profile 时,先确认它绑的调试证书就是本机 .p12 对应的那张——两张证书长得几乎一样(同样的 DN 前缀、同样的 CA 链),只有指纹和签发日期不同,而且设备上的报错信息完全不会告诉你是证书绑错了。
十、这套做法没覆盖的部分
previewMode没动过。默认是DEFAULT,缩略图卡片,另一个选项没用过。异兽有插画,按理说详情预览更适合,但那要验,没验就不写。revisitShareRecordData完全没碰。它是since 6.1.0(23)的东西,用于生成"重现资源",跟"碰一下把卡片投过去"是两个方向。我的extraData三键算是手工版的前身,但不等价。SharedData一次只塞了一条记录。500 条上限、200 KB IPC 上限这些约束我压根没碰到,因为场景里就只有一只异兽。- 只在 API 26 的鸿蒙 7 设备上跑过。
compatibleSdkVersion是6.0.0(20),理论上 20 以上都能装,但801降级分支我没在真老设备上触发过——模拟器上也没跑。 thumbnail是主动放弃的,不是忘了。如果以后能压到 32 KB 以内还看得清,那是另一个决定。
十一、收个尾
这次真正花时间的地方不是接口调用,是信了一个长得像的回调字段。ShareAbilityResultCode 和 ShareOperationResult 在同一个文件里、隔了一百多行、名字都带 Result,一个有三个值另一个只有一个 name——它俩唯一的共同点是都不回答"成没成"。
判断一个回调能给你什么,唯一可靠的办法是去 .d.ts 把 interface 整段读完。读完之后我这行日志改成了只打 name,undefined 的兜底留着——不是怕它空,是怕以后有人又往里加不存在的字段。
碰一碰本身接得很轻:一段文本、一个标题、一次 show。真正需要想清楚的是那些默认值——selectionMode 默认单选正好对,anchor 默认居中在平板上不对,previewMode 默认卡片可能是错的。默认值不报错,但它会一直错下去。
下一篇往回接一条更基础的线:同一个详情页在多端上怎么把布局撑住,以及为什么那个 anchor 我先留着没填。
更多推荐
所有评论(0)