这次没有继续写“怎么把内容分享出去”而是盯住碰一碰之后更容易被忽略的一段目标应用被拉起以后同一个分享会话如果连续进入两三次页面到底应该开几次应用如果本来没启动路由又应该在什么时候执行。一、问题不在“碰到”而在“碰到以后重复进了两次”最近我把一个内容卡片的跨设备直达 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 Mapstring, 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: Mapstring, 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 shareIdshare_20261001_03, contentIdphoto_note_0821 duplicate dropped2, coldStarttrue 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从平时的 01 次突然变成几十次用户表面上可能仍然能顺利打开详情页但这已经说明上游入口、系统回调时序或者我们自己的注册逻辑发生了变化。如果没有计数问题会一直潜伏到出现明显页面抖动才被发现。因此正式项目里我会把一次分享会话至少拆成四个维度记录入口类型、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-phonesShareExtensionAbilityhttps://developer.huawei.com/consumer/cn/doc/doccenter-references/api/js-apis-app-ability-shareextensionabilityApp Linking Codelabhttps://developer.huawei.com/consumer/en/codelab/AppLinking-HarmonyOS/