HarmonyOS 7 Share Kit + App Linking:碰一碰直达中的分享会话去重与冷启动路由恢复【鸿蒙心迹】
这次没有继续写“怎么把内容分享出去”,而是盯住碰一碰之后更容易被忽略的一段:目标应用被拉起以后,同一个分享会话如果连续进入两三次,页面到底应该开几次;应用如果本来没启动,路由又应该在什么时候执行。

一、问题不在“碰到”,而在“碰到以后重复进了两次”
最近我把一个内容卡片的跨设备直达 Demo 单独拆出来做了一个小工程,名字叫 KnockRouteLab。它模拟的业务很简单:设备完成碰一碰分享后,接收端拿到一个内容 ID,然后进入 /pages/NoteDetail 展示对应笔记。
最开始的实现非常直接:收到入口参数就解析,解析完就跳详情页。单次测试完全正常,所以我一度以为这条链路已经结束。真正把应用放到“冷启动 + 快速重复触发”场景后,问题才出现:同一个 shareId 可能连续走到 Ability 两次,一次发生在进程启动阶段,另一次发生在应用已经前台后收到新的 Want。页面表现就是详情页刚打开,又被同一个参数推了一遍。
这不是 Share Kit 本身“重复分享”这么简单。对应用来说,需要把系统入口、App Linking 入口、页面路由三个环节拆开看。官方资料里,Share Kit 负责跨应用/设备分享内容;App Linking 可以把链接导向应用内指定内容;分享详情页还可以通过 ShareExtensionAbility 接收分享数据。真正落到业务工程后,这些入口最后都可能汇合到“打开某个业务页面”这个动作上。问题也就从“有没有收到”变成了“同一份业务请求有没有被重复消费”。
这篇只讨论接收侧工程处理,不展开碰一碰硬件触发 API。我的目标是让入口来源变化时,业务层仍然只认一套会话模型。
二、先把入口参数压成一个可判断的 ShareRouteEnvelope
我给这次 Demo 固定了一组运行数据:
shareId = share_20261001_03contentId = photo_note_0821route = /pages/NoteDetailcoldStart = true- 最终状态:
RECEIVED → VALIDATING → ROUTING → OPENED - 重复请求丢弃数量:
2
我不希望页面层去判断 URI 参数、Want 参数和分享来源,所以第一步是把入口统一压成一个业务对象。这个对象不保存系统上下文,只保存后续做路由真正需要的数据。
这段代码解决的问题,是把不同入口带来的参数格式统一成一个可校验、可去重的业务信封:
export interface ShareRouteEnvelope {
shareId: string
contentId: string
route: string
receivedAt: number
coldStart: boolean
}
export function parseShareEnvelope(uri: string, coldStart: boolean): ShareRouteEnvelope | null {
if (!uri || uri.indexOf('?') < 0) {
return null
}
const query = uri.substring(uri.indexOf('?') + 1)
const pairs = new Map<string, string>()
query.split('&').forEach((item: string) => {
const [key, value] = item.split('=')
if (key && value) {
pairs.set(key, decodeURIComponent(value))
}
})
const shareId = pairs.get('shareId') ?? ''
const contentId = pairs.get('contentId') ?? ''
if (!shareId || !contentId) {
return null
}
return {
shareId,
contentId,
route: '/pages/NoteDetail',
receivedAt: Date.now(),
coldStart
}
}
这里我故意没有让外部链接直接决定最终页面路径。外部只提供 contentId 和 shareId,真正能跳到哪里由应用内部固定映射。这样做有两个好处:一是避免外部参数把应用带到没有准备好的页面;二是将来路由结构调整,分享协议不必跟着改。
正式项目还应该补充签名校验、内容 ID 合法性、来源域名校验和过期时间。Demo 只做“结构是否完整”的校验,因为这次重点不是安全协议,而是生命周期和重复消费。
三、去重不能只看 contentId,要看一次分享会话
我第一次做去重时用了 contentId。很快就发现不对:同一篇笔记可以被用户在不同时间分享多次,如果拿内容 ID 当唯一键,第二次正常分享也会被挡掉。
因此真正应该去重的是 shareId。它代表一次分享会话,而不是业务内容本身。
下面这段代码解决的是同一进程内短时间重复 Want 的幂等消费:
export class ShareRouteGate {
private consumed: Map<string, number> = new Map()
private readonly ttlMs: number = 30_000
isDuplicate(shareId: string): boolean {
const now = Date.now()
this.cleanup(now)
const hit = this.consumed.get(shareId)
if (hit !== undefined && now - hit < this.ttlMs) {
return true
}
this.consumed.set(shareId, now)
return false
}
private cleanup(now: number): void {
this.consumed.forEach((time: number, key: string) => {
if (now - time >= this.ttlMs) {
this.consumed.delete(key)
}
})
}
}
30 秒不是一个“标准答案”,只是这个 Demo 的观察窗口。它解决的是同一分享动作因为生命周期切换而短时间重复进入,而不是做永久幂等。正式业务如果涉及订单、支付、入群等强一致动作,去重键应该落到服务端或持久化存储,而不能依赖进程内 Map。
还有一个容易忽略的边界:Map 解决不了进程被杀后的重复进入。这个场景要不要持久化,取决于业务代价。像笔记详情这种只读路由,即使进程重启后再开一次问题不大;如果后续动作会产生写操作,就要把“消费过的 shareId”持久化并设置过期策略。
四、onCreate 和 onNewWant 必须走同一条处理链
真正让我觉得这篇值得写的,是 Ability 生命周期这里。
冷启动时,入口落在 onCreate;应用已经存在后,再次进入可能走 onNewWant。如果两个回调各写一套解析和跳转逻辑,很快就会出现一边加了校验、另一边忘了加的情况。
这段代码解决的是让冷启动和热态唤起共用同一个处理函数:
export default class EntryAbility extends UIAbility {
private routeGate: ShareRouteGate = new ShareRouteGate()
onCreate(want: Want): void {
this.handleIncomingWant(want, true)
}
onNewWant(want: Want): void {
this.handleIncomingWant(want, false)
}
private handleIncomingWant(want: Want, coldStart: boolean): void {
const envelope = parseShareEnvelope(want.uri ?? '', coldStart)
if (!envelope) {
hilog.warn(0x0000, 'KnockRouteLab', 'invalid share envelope')
return
}
if (this.routeGate.isDuplicate(envelope.shareId)) {
hilog.info(0x0000, 'KnockRouteLab',
`duplicate dropped: ${envelope.shareId}`)
return
}
AppStorage.setOrCreate('pendingShareRoute', JSON.stringify(envelope))
}
}
这里没有在 Ability 里直接调用页面路由,是我后来改掉的一个点。冷启动阶段 UI 还没有完全准备好,业务路由如果过早执行,很容易出现“参数已经到了,但页面容器还没建好”的时间差。Ability 更适合做入口解析和暂存,把真正的页面消费交给已经完成构建的 UI。

从 DevEco 的日志可以看到,本次 share_20261001_03 最终只消费一次,另外两次重复触发被 Gate 丢弃。状态链记录为:
State: RECEIVED -> VALIDATING -> ROUTING -> OPENED
shareId=share_20261001_03, contentId=photo_note_0821
duplicate dropped=2, coldStart=true
route=/pages/NoteDetail
这套日志我认为比“分享成功”四个字有用得多。只看结果时,你不知道重复事件有没有发生;把状态和去重数打出来,才知道链路到底经历了什么。
五、页面只消费一次 pendingShareRoute
页面起来以后,我让首页消费 pendingShareRoute,把它转成页面状态,再立刻清空暂存值。
这段代码解决的是避免 UI 重建时再次消费同一条待处理路由:
@Entry
@Component
struct KnockRoutePage {
@StorageLink('pendingShareRoute') pendingShareRoute: string = ''
@State state: string = 'IDLE'
@State duplicateDropped: number = 2
aboutToAppear(): void {
if (!this.pendingShareRoute) {
return
}
const envelope = JSON.parse(this.pendingShareRoute) as ShareRouteEnvelope
this.state = 'ROUTING'
// Demo 中用页面状态模拟 Navigation 跳转完成
AppStorage.setOrCreate('currentContentId', envelope.contentId)
this.state = 'OPENED'
// 消费后立刻清空,避免页面重建再次处理
this.pendingShareRoute = ''
}
}
正式项目里,我更建议把这一层放进统一的 Navigation 路由协调器,而不是让页面自己拼路由。尤其应用同时存在通知点击、App Linking、分享、扫码等多个入口时,一个 RouteCoordinator 能明显降低重复代码。
资源释放方面,这个 Demo 没有长连接和媒体资源,但生命周期仍然有两件事要注意。第一,注册在页面或 Ability 上的监听器要成对取消;第二,不要把 Context、Window 对象塞进全局单例里长期持有。分享路由模型最好保持“纯数据”,需要上下文时再从当前生命周期拿。
六、冷启动恢复最怕“路由快于页面”
我实际跑下来,最明显的感受不是碰一碰速度,而是冷启动阶段的时序。
如果应用已经在前台,onNewWant → 解析 → 页面消费 很顺。但冷启动时,Ability、WindowStage、页面树是依次起来的。入口参数并不会因为 UI 还没完成就自动等你。这个时候如果把“收到参数”和“执行页面动作”写在同一个函数里,代码看起来短,调试起来反而麻烦。
所以现在我把状态拆成四段:
RECEIVED 表示系统入口已经到达;VALIDATING 只做结构和业务校验;ROUTING 表示 UI 已经准备消费;OPENED 才表示目标内容真正打开。
这四个状态也直接出现在 Demo 页面里。最终运行结果如下:

同一份数据在页面和日志里保持一致:share_20261001_03,内容 photo_note_0821,目标 /pages/NoteDetail,冷启动为 true,重复事件丢弃 2 次。这样截图不仅是“页面长什么样”,还能直接证明本次链路最后停在什么状态。
七、这类问题真正要验收的是幂等,不是动画
碰一碰是一个很有感知的交互,但工程验收不能只盯“碰完有没有跳页面”。我最后给这个 Demo 定了四个验收动作:
第一,应用未启动时触发一次,目标内容能在 UI 准备完成后打开;第二,应用前台时触发新的 shareId,能够正常消费;第三,同一个 shareId 在 30 秒内连续进入三次,只打开一次;第四,页面重建后不重新消费已经清空的 pendingShareRoute。
如果后续要继续扩展,我会再补两个方向:一个是跨进程/进程重启后的持久化幂等,另一个是把通知、扫码、App Linking、碰一碰的入口全部收敛到同一个 RouteCoordinator。到那时这篇里的 ShareRouteEnvelope 就不再只是分享模型,而会变成应用统一“外部意图”的一部分。
从使用体验看,用户只会觉得“碰一下就到了”。但从代码看,真正保证这件事稳定的,往往是后面这些没有视觉效果的状态判断、生命周期隔离和重复消费防护。
八、把“重复进入”当成可观测指标以后,调试思路会完全不一样
这个 Demo 写到最后,我又补了一层自己平时很容易忽略的东西:不要只在发生错误时记日志,正常被去重的事件同样值得统计。
一开始我只关心有没有异常,所以重复 shareId 被丢弃时只打一行 info。后来发现这不够。假设某个版本发布后,duplicateDropped 从平时的 0~1 次突然变成几十次,用户表面上可能仍然能顺利打开详情页,但这已经说明上游入口、系统回调时序或者我们自己的注册逻辑发生了变化。如果没有计数,问题会一直潜伏到出现明显页面抖动才被发现。
因此正式项目里,我会把一次分享会话至少拆成四个维度记录:入口类型、shareId、是否冷启动、最终消费结果。对于重复事件,只记录“被丢弃”而不记录完整内容,避免日志里重复堆业务数据。需要上报分析时也只上报枚举和计数,不把用户分享的正文、图片路径直接带出去。
另外我专门测试了“用户很快返回首页,再次碰入同一个会话”的场景。此时页面树已经换了,但进程还在,Map 去重仍然有效;如果我把去重集合错误地放进详情页组件,那么页面销毁后去重状态也会跟着消失,同一个 shareId 就会重新被消费。这也是为什么 Gate 放在 Ability 生命周期附近,而不是跟着某个业务页面走。
最后一轮我模拟了系统回收进程后再从同一链接进入。这个时候内存 Map 肯定清空,Demo 会再次打开详情页。我没有把它判成 bug,因为当前业务是只读内容,重复打开没有副作用。这个判断很关键:幂等不是“所有场景都必须永远只执行一次”,而是根据业务副作用决定去重边界。只读页面、收藏动作、创建订单,这三类操作显然不能用同一套策略。
把这些边界测试加进去以后,OPENED 就不再只是一个 UI 标签,而是能回答三个问题:这次请求从哪里来、之前是否消费过、最终有没有真正到达目标内容。对我来说,这比单纯测“碰一下有没有动画”更接近工程验收。
九、资料核对
本文接收侧设计参考了华为开发者文档中 Share Kit、ShareExtensionAbility 与 App Linking 的公开能力说明。官方文档显示,Share Kit 用于跨应用分享文本、图片、视频等内容;ShareExtensionAbility 可用于目标应用处理分享内容;App Linking 可将链接导向 HarmonyOS 应用内指定内容。具体接口、设备范围和版本要求应以当前 HarmonyOS 7 / API 26 官方文档为准。
- Share Kit / 碰一碰分享:https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/knock-share-pc-phones
- ShareExtensionAbility:https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/js-apis-app-ability-shareextensionability
- App Linking Codelab:https://developer.huawei.com/consumer/en/codelab/AppLinking-HarmonyOS/
更多推荐





所有评论(0)