# 〖共创稿事节〗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_PASTEBOARDSystemShare_CopyToPasteboard
SAVE_TO_MEDIA_ASSETSystemShare_SaveToMediaAsset
SAVE_AS_FILESystemShare_SaveAsFile
PRINTSystemShare_Print
SAVE_TO_SUPERHUBSystemShare_Superhub
COLLECTIONSystemShare_Collection
HARMONYSHARESystemShare_HarmonyShare
ENCRYPTSystemShare_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=onon('shareCompleted', cb) / on('dismiss', cb)注册一个
op=off_alloff('shareCompleted')摘全部
op=offoff('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 也不是笔误,理由在下面那张表之后。逐条说:

字段我填的为什么
utdPLAIN_TEXT必填。这个值决定接收端拿什么应用匹配,填得越准匹配越准
content / uri只填前者二选一,至少一个。异兽没有文件实体,只有文本
titleNo.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.064code 101面板启动成功,我的 show() 在这之后才 resolve
09:33:09.720code 100面板进入就绪状态
09:34:08.847code -1面板被关掉

code 101 那一行后面紧跟 HandleStartupSucc,再后面才是我的 share sheet shown。顺序很重要:show() 的 resolve 时机在系统确认面板起来之后,不是在这之前。这解释了为什么 show() 抛异常是个可信的信号——它抛了就是真没起来。

面板上显示成什么样,是可以直接看到的:

分享面板:标题、摘要、碰一碰提示

标题是 No.001 白泽·通晓万物之灵,摘要被系统截断成 《云笈七签》所收黄帝传说中的能言…。截断是面板宽度决定的,不是我的字段没填对——这也正好印证了第一节里那个「不要把全文塞进载荷」的判断:接收窗口就那么宽,塞进去也是被截。

面板中间那行小字「可碰一碰分享至 HarmonyOS 5 及以上版本手机」值得单独看一下。它不是我在代码里写的任何东西,是系统识别出载荷类型之后自己加的场景提示。这句话出现,等于系统把这次分享归到了碰一碰通道——我什么都没配,它自己判的。

七、anchor 这笔债

ShareControllerOptions 里我没传的字段,有一个迟早要还:

字段默认行为我传了吗
selectionMode单选显式传 SINGLE
anchor面板居中没传
previewModeDEFAULT(缩略图卡片)没传
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 我先留着没填。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐