Want 隐式跳转是鸿蒙开放体系里最容易被低估的机制------很多团队第一次接触它是在"应用拉起应用"的需求评审上,自己页面的跳转还停在 router.pushName。健康菜谱助手做过三个真场景:菜谱详情一键唤起系统分享、从冷启动 deeplink 直接进菜谱详情、接入了美食类意图的标准拉起。三个场景走完,才理解隐式跳转的核心不是"跳",是"声明与匹配":调用方描述意图,系统在所有 ability 的 skill 配置里找最合适的承接方,两边的契约写清楚,跳转才稳。这篇记录 Want 隐式跳转的匹配规则、配置写法、参数传递与生产验证。

一、显式与隐式:两种跳转的真实分野
显式跳转指名道姓------Want 里直接写 bundleName 和 abilityName,系统原样拉起。隐式跳转只说意图------action 是什么、要匹配哪些 entities、带什么 uri,系统按规则在全部已安装应用里筛出承接方。什么时候用哪种,我的判断标准:应用内部跳转用显式(路由表或 Want 均可,值在组件树里传更省事);跨应用跳转用隐式,因为目标应用的 bundleName 不可控,写死了就是脆弱契约。菜谱分享的场景里,承接方可能是系统分享服务、可能是第三方社交应用、可能是用户装的任意浏览器------这种"目标不定"的跳转只有隐式能做。
| 维度 | 显式跳转 | 隐式跳转 |
|---|---|---|
| 目标指定 | bundleName+abilityName | action/entities/uri |
| 目标范围 | 唯一确定 | 系统匹配,可能多个 |
| 契约维护 | 写死,目标改名即断 | 按标准声明,目标可替换 |
| 典型场景 | 应用内页面跳转 | 分享/支付/查看/意图拉起 |
| 匹配失败 | 几乎不失败 | 无承接方抛异常 |
二、action 与 entities:匹配的两个轴
隐式匹配走两个轴:action 描述"做什么",entities 描述"目标的类别"。系统内置的 action 常量里最常用的是 ACTION_SEND_DATA(分享数据)、ACTION_VIEW_DATA(查看数据)、ACTION_SELECT(选择)。entities 里 ENTITY_CARD 是卡片、ENTITY_DATA 是数据类目标。匹配规则是"且"关系:Want 里声明的每个 action 和 entity 都必须被目标 skill 配置覆盖才算命中。一个实际教训:最初分享菜谱图片时 Want 里写了 ACTION_SEND_DATA 加两个 entities,结果系统分享面板只弹出一个应用------排查半天发现第二个 entity 太具体,把大部分应用的 skill 配置筛掉了。砍掉非必要 entity 后,面板恢复正常。结论:entities 按"必须满足的最小类别"声明,多一个都是筛选损失。
ts
// 调用方:构造隐式 Want 唤起系统分享
import { common, wantConstant } from '@kit.AbilityKit'
const want: common.Want = {
action: wantConstant.Action.ACTION_SEND_DATA,
entities: [wantConstant.Entity.ENTITY_DATA],
type: 'text/plain',
parameters: {
title: recipe.name,
'ability.params.stream': 'internal://share/recipe-' + recipe.id
}
}
context.startAbility(want).catch(err => showShareFailToast(err))
三、被拉起方:module.json5 里的 skill 声明
承接方的能力通过 module.json5 的 skills 声明,actions、entities、uris、type 四个字段对应匹配的四个维度。写 skill 的原则:每个 skill 只做一件事------一个 ability 可以有多个 skill,但每个 skill 的 action 加 entity 组合应该唯一,不要把不相关的匹配塞进同一个 skill。 uris 里的 scheme 是自有协议(如 recipeapp://),host 是资源域名,path 是路径模板。声明过宽(只写 action 不写 type)会把不该接的意图也接进来,用户在分享面板里看到你的应用选了却没反应;声明过窄则接不到。健康菜谱助手的承接 skill 只声明两个:查看菜谱(scheme 协议)和接收文本分享,每个都有明确的 type 或 uri 收口。
json5
// module.json5:承接方 skill 声明
"abilities": [{
"name": "RecipeEntryAbility",
"skills": [{
"actions": ["ohos.want.action.viewData"],
"uris": [{ "scheme": "recipeapp", "host": "detail" }]
}, {
"actions": ["ohos.want.action.sendData"],
"type": "text/plain"
}]
}]
四、uri 匹配:scheme、host、path 的逐级规则
uri 匹配是逐级收紧的:scheme 匹配再看 host,host 匹配再看 path,path 支持通配(/.* 匹配任意子路径)。deeplink 场景"recipeapp://detail/123"要落到菜谱 123 的详情页,承接方 uris 声明 scheme=recipeapp、host=detail,path 不写(全收),启动后在 onNewWant 里解析 want.uri 提取 id。这里踩过两个坑:一是调用方 uri 里带 query(recipeapp://detail/123?from=share)而承接方声明了具体 path,通配没配好导致匹配失败,改成 path 不声明加手动解析才稳;二是 scheme 全小写的问题------有个版本的 uri 拼接里混进了大写,真机上匹配静默失败,日志里只有一行 want not found。scheme 与 host 一律小写、拼完统一 toLowerCase,是那之后的硬规矩。
ts
// 承接方:解析 deeplink 参数
onNewWant(want: common.Want, launchParam: common.LaunchParam) {
const uri = want.uri ?? ''
if (uri.startsWith('recipeapp://detail/')) {
const id = uri.split('/').pop()?.split('?')[0] ?? ''
this.jumpToDetail(id, uri.includes('from=share') ? 'share' : 'direct')
}
}
五、parameters 传参:序列化与体量的边界
Want 的 parameters 是一个键值字典,基础类型直接放,对象走 JSON 序列化。它的体量边界比组件参数宽得多,但也不是无底洞------超大数据(图片二进制、整本菜谱库)不适合塞进 parameters,正确做法是传引用(internal:// 协议的临时文件路径或 URI),让承接方自己读。分享场景的实际做法:菜谱全文 20KB 以内直接 JSON 塞 parameters 的 detail 字段;带图分享则把图先存到临时目录,parameters 里只传文件路径。另一个注意点:parameters 的键名避免与应用内组件参数混淆,统一加前缀(share.title、share.recipeId),防止承接方解析时和系统保留键撞名。
| 数据类型 | 传递方式 | 上限经验值 |
|---|---|---|
| 基础类型/短文本 | parameters 直传 | 几十 KB 内 |
| 结构化对象 | JSON.stringify 后直传 | 50KB 内 |
| 图片/文件 | 临时目录+路径引用 | 受沙箱限制 |
| 超大内容 | 只传 id,对端拉取 | 不限 |
六、真实排查:want not found 的三层定位
隐式跳转的失败形态只有一个:startAbility 抛 16000001(want not found),但原因有三层。第一层:目标应用未安装------跨应用分享菜单要按"已安装可承接"过滤,未安装就静默不出面板,这不算异常;第二层:skill 声明与 Want 不匹配------action、entity、type、uri 任一维度没对上;第三层:flags 问题------wantConstant.Flags.FLAG_AUTH_READ_URI_PERMISSION 之类的权限 flag 在特定场景必须带,漏带时部分承接方拒绝响应。排查按"先自测契约、再系统验证、最后真机日志"三步:先用 abilityManager 查询匹配结果,再在 hdc shell 里用 aa start 手动发起同样意图,最后看 hilog 里 AbilityManagerService 的匹配日志。那次大写 scheme 的问题就是 aa start 直接把错误打在脸上才定位的。
ts
// 自测:查询当前隐式 Want 能匹配到谁
import { abilityManager } from '@kit.AbilityKit'
const queryInfo = {
action: 'ohos.want.action.sendData',
entities: ['entity.system.home'],
type: 'text/plain'
}
abilityManager.queryAbilities(queryInfo).then(list => {
console.info(`match count: ${list.length}`)
})

七、onNewWant 与冷启动:两种进入路径都要接
承接 deeplink 的 ability 有两种进入状态:冷启动走 onCreate,热启动走 onNewWant------两条路径都要写解析逻辑,漏一条就是"有时候能进详情有时候进不了"。健康菜谱助手的做法是把 Want 解析收敛成一个纯函数 parseRecipeWant(want),onCreate 和 onNewWant 都调它,返回统一的路由指令对象,再由入口页执行跳转。这样两处逻辑不会漂移,单测也只需要覆盖这一个函数。另一个细节:onNewWant 触发时页面栈可能深在编辑页,直接跳详情要把栈管理想清楚------我们的策略是清到首页再 push 详情,保证返回链路符合直觉。
ts
// 统一解析:onCreate 与 onNewWant 共用
function parseRecipeWant(want: common.Want): RouteCmd {
const uri = want.uri ?? ''
if (uri.startsWith('recipeapp://detail/')) {
return { page: 'detail', id: uri.split('/').pop()?.split('?')[0] ?? '' }
}
if (want.parameters?.['share.recipeId']) {
return { page: 'detail', id: String(want.parameters['share.recipeId']) }
}
return { page: 'home' }
}
八、安全边界:隐式跳转的两条防线
隐式跳转的开放性带来两个安全问题。防线一是输入校验:承接方收到的 want.uri 和 parameters 是外部输入,等同 Web 里的 query string------解析出的 id 必须校验格式(纯数字)再进数据库查询,防止注入式的构造串。防线二是权限最小化:给外部 URI 授权只开读(FLAG_AUTH_READ_URI_PERMISSION),分享出去的临时文件用完即删;承接敏感数据(用户菜谱草稿)的 skill 不对外开放,用显式跳转在应用内消化。另外分享内容的脱敏在构造 Want 之前做------parameters 里一旦塞进去,对接了无数未知承接方,再想收回来就不可能了。

九、生产验证:三场景回归清单
隐式跳转的回归测试固定三个场景:一、系统分享面板------从菜谱详情发起分享,验证面板出现、文本带出、选每个候选应用后对方正常收到;二、deeplink 冷启------杀进程后 adb 唤起 recipeapp://detail/123,验证落详情页、返回栈正确;三、deeplink 热启------应用在编辑页时唤起,验证清栈跳详情、编辑内容不丢(丢不丢是产品决策,但行为要确定)。三个场景各配一条 hilog 断言(parseRecipeWant 的解析结果),CI 跑不起真机就人工跑,但清单不能少------Want 相关代码一年改不了三次,可每次改完漏测都是用户先发现。
十、总结:契约思维的四个落点
收束成四点:一、跨应用跳转必隐式,契约写在 skill 里,声明按"最小必要"收口;二、uri 匹配逐级收紧,scheme 与 host 全小写,path 谨慎声明;三、parameters 传小不传大,大内容走引用,键名加前缀防撞;四、onCreate 与 onNewWant 双路径统一解析,生产回归三场景固定跑。隐式跳转的本质是解耦------调用方不认识承接方,双方只认系统撮合的标准契约。契约思维一旦建立,后面接意图框架、接服务卡片交互,走的都是同一条路。
参考资料:
- 华为开发者:Want 隐式 Want 匹配规则
- 华为开发者:module.json5 skills 标签说明
- 华为开发者:startAbility 与 URI 权限
#鸿蒙 #HarmonyOS 7(API 26)