〖共创稿事节〗HarmonyOS 7 新特性实战(53):一条链接落在谁手里——App Linking 的三段路由、冷热启动与上架前卡住的域名校验
# 〖共创稿事节〗HarmonyOS 7 新特性实战(53):一条链接落在谁手里——App Linking 的三段路由、冷热启动与上架前卡住的域名校验
把一条链接复制到聊天窗口,点开,屏幕下方升起「选择打开方式」:应用图标排在第一,后面跟着两个浏览器。用户得自己判断该点哪个——点错就进了网页版。
这就是没有完成域名归属校验时,App Linking 的真实样子。代码里的路由全都写对了,/beast/beast_baize 也确实把白泽详情拉了起来,但系统仍然不肯替用户做决定。因为「这个域名属于这个应用」这句话,应用自己说一百遍都不算,得由平台侧的一条记录来背书。
这篇按两条线走。代码线:三段路径怎么接管、白名单为什么必须复用、冷启动与热启动怎样共用入口,以及在这条线上抓到的两个真缺陷。平台线:上架之前 AGC 那一整块灰色,以及被官方文档推翻的手工配置方案。

一、先定契约:三段路径,一个前缀
App Linking 的第一性问题不是「怎么跳」,而是「系统凭什么把这条 https 链接交给应用,而不是浏览器」。答案写在 entry 模块配置清单的 skills 里:声明一个可浏览实体,把动作限定成 viewData,把 scheme 限定为 https,再打开域名校验。
{
"entities": [
"entity.system.browsable"
],
"actions": [
"ohos.want.action.viewData"
],
"uris": [
{
"scheme": "https",
"host": "shanhai-api.wangji.bond"
}
],
"domainVerify": true
}
四个字段各有分工,少一个都会退化成「弹选择框」:
| 字段 | 作用 | 缺失后的表现 |
|---|---|---|
entity.system.browsable | 声明可被外部链接唤起 | 链接完全不进候选 |
ohos.want.action.viewData | 声明处理的是「查看数据」 | 点击无反应 |
scheme: https | 只接管 https | http 链接被漏掉或误接管 |
domainVerify: true | 请求系统校验域名归属 | 每次都弹「选择打开方式」 |
domainVerify 是那个真正决定体验的开关,但它的生效条件不在应用里——后面第六节单独说。
域名本身写了两处:模块清单的 host,以及模型层常量 SHANHAI_DEEPLINK_HOST。两处必须逐字相同,否则解析器认的前缀和系统匹配的域名会错位,链接能唤起应用却解析不出目标。静态门禁里专门有一条守这个一致性。
路由契约只开了三段,全部挂在同一个域名前缀下:
| 路径形态 | 解析出的动作 | 校验依据 | 非法输入落点 |
|---|---|---|---|
/beast/<id> | OPEN_BEAST | 异兽静态数据按 id 精确匹配 | OPEN_HOME + UNKNOWN_BEAST |
/region/<id> | OPEN_EXPLORE | 地区静态数据按 id 匹配 | OPEN_HOME + UNKNOWN_REGION |
/region/<id>/<hallId> | OPEN_HALL | 地区 + 展厅双向归属校验 | OPEN_HOME + UNKNOWN_HALL |
/article/<id> | OPEN_ARTICLE | 跨入口文章白名单 | OPEN_HOME + UNKNOWN_ARTICLE |
| 其他任意路径 | OPEN_HOME | — | OPEN_HOME + UNKNOWN_PATH |
只有四段能进业务,其余全部回首页。这条规则看着保守,但它把「链接里塞什么都能跳」的可能性一次性关掉了。
二、want.uri 优先:一个入口,两种载体
桌面卡片通过 want.parameters 递 5 个键(source、schemaVersion、action、beastId、regionId),深链通过 want.uri 递一整条 URL。两种载体不能各写一套解析,否则白名单会分叉——卡片那边校验过的 id,链接这边可能漏校验。
所以入口先看 uri,有就交给深链解析,没有才回到参数式解析:
createRequest(want: Want): ShanhaiLaunchRequest | undefined {
// 深链(App Linking):系统按 skills 匹配后把整条 URL 放进 want.uri。
// 只接管本应用关联域名的链接,其余 uri(浏览器里点的别人的链接)不产生启动请求。
const uri: string = want.uri ?? '';
if (uri.length > 0) {
return this.createRequestFromUri(uri);
}
const parameters: Record<string, Object> | undefined = want.parameters;
const source: string = this.stringValue(parameters?.[PARAM_SOURCE]);
if (source !== SHANHAI_LAUNCH_SOURCE_DESKTOP_FORM) {
return undefined;
}
// …参数式解析:schemaVersion / action / 三元组,走同一套白名单
}
这里有个容易被忽略的点:uri 存在时直接返回,不再看 parameters。系统把链接递进来时,parameters 里也可能残留别的东西,混着读会让「这条请求到底从哪来」变得不可判定。source 字段因此不是校验项,而是信封信息——由入口标注、由发布侧保留,校验过程不碰它。
| 入口 | 载体 | 标注的来源 | 校验路径 | 提示措辞 |
|---|---|---|---|---|
| 桌面卡片 | want.parameters(5 键) | DESKTOP_FORM | validateRequest | 已从桌面卡片… |
| App Linking | want.uri(整条 URL) | DEEPLINK | parseDeeplinkSegments → 白名单 | 已从链接… |
来源只影响两件事:日志里的 source=,以及回首页时那句提示。落到哪个页面、怎么落,两条入口走的是同一段代码。

三、白名单是唯一防线:不认识就回首页
深链解析最短的写法是 segments[0] 当动作、segments[1] 当 id,直接拼进路由。问题在于 id 来自外部输入,用户、脚本、聊天工具都能改。所以第二段必须落回静态数据做精确匹配。
private parseDeeplinkSegments(segments: string[]): ShanhaiLaunchRequest {
if (segments.length === 0) {
return this.homeRequest(true, '');
}
const segment: string = segments[0];
const targetId: string = decodeURIComponent(segments[1] ?? '');
const hallId: string = segments.length > 2 ? decodeURIComponent(segments[2]) : '';
if (segment === SEGMENT_BEAST) {
return this.validateRequest(SHANHAI_LAUNCH_SCHEMA_VERSION, 'OPEN_BEAST', targetId, '', hallId, true);
}
if (segment === SEGMENT_REGION) {
const action: string = hallId.length > 0 ? 'OPEN_HALL' : 'OPEN_EXPLORE';
return this.validateRequest(SHANHAI_LAUNCH_SCHEMA_VERSION, action, '', targetId, hallId, true);
}
if (segment === SEGMENT_ARTICLE) {
return this.validateArticle(targetId, true);
}
return this.homeRequest(true, 'UNKNOWN_PATH');
}
文章这一段是新长出来的,它没有复用参数式启动的 action 表,而是单独查跨入口文章白名单:
private validateArticle(articleId: string, generateRequestId: boolean): ShanhaiLaunchRequest {
const base: ShanhaiLaunchRequest = this.homeRequest(generateRequestId, '');
const article: ShanhaiCrossEntryArticle | undefined = SHANHAI_CROSS_ENTRY_ARTICLES.find(
(item: ShanhaiCrossEntryArticle): boolean => item.id === articleId);
if (article === undefined) {
base.errorCode = 'UNKNOWN_ARTICLE';
return base;
}
base.action = 'OPEN_ARTICLE';
base.articleId = article.id;
base.regionId = article.regionId;
base.hallId = article.hallId;
return base;
}
「未知 id 不静默」是这一节真正想立的规矩。解析失败时不返回 undefined,而是返回一条 OPEN_HOME 请求并带上 errorCode。消费方据此把用户送回首页并给一句提示,而不是点完链接什么都没发生——后者最让人以为是应用卡了。
| 输入 | 解析结果 | 页面行为 | 取证 |
|---|---|---|---|
/beast/beast_unknown | OPEN_HOME + UNKNOWN_BEAST | 回首页,提示「链接无效」 | 截图 04 |
/foo/bar | OPEN_HOME + UNKNOWN_PATH | 同上 | 截图 05 |
/article/article_region_kunlun | OPEN_ARTICLE | 直达昆仑地域专卷 | 日志 15:33:33 |
根路径 / | OPEN_HOME,errorCode 为空 | 回首页,不提示 | 日志 15:36:44 |
| 他域链接 | 不产生任何请求 | 系统弹框,候选里只有浏览器 | 截图 06 |
截图 04 与截图 05 逐字节相同。这不算失误,反而是个证据:两种非法输入走到同一个首页态,没有任何视觉差异,说明回退路径是收敛的,而不是每个错误各长一副样子。
日志两段是刻意成对的,第一段在 Ability,第二段在页面:
I A02026/ShanhaiSpirits: launch request parsed source=DEEPLINK action=OPEN_HOME uri=https://shanhai.wangji93.com/beast/beast_unknown error=UNKNOWN_BEAST
I A02026/ShanhaiIndex: launch request consumed source=DEEPLINK action=OPEN_HOME target=
顺带说一句域名。日志里是 shanhai.wangji93.com,代码里现在是 shanhai-api.wangji.bond。取证那天用的是临时域名,之后统一到生产 API 域名——静态层能直接放配置文件,不依赖后端服务是否在跑。两处同改,门禁第 5 条守的就是这个一致性,当前仍是 PASS。旧日志保留原样,不做替换。
四、冷启动与热启动:同一入口,两个真缺陷
系统只在两个回调里把 Want 递进来:冷启动走 onCreate,热启动走 onNewWant。两个回调都只做一件事——把解析结果序列化后写进 AppStorage:
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
AppStorage.setOrCreate<string>(SHANHAI_LAUNCH_REQUEST_STORAGE_KEY, '');
this.publishLaunchRequest(want);
// …其余初始化
}
onNewWant(want: Want, _launchParam: AbilityConstant.LaunchParam): void {
this.publishLaunchRequest(want);
}
private publishLaunchRequest(want: Want): void {
const request: ShanhaiLaunchRequest | undefined = this.launchIntentService.createRequest(want);
if (request === undefined) {
return;
}
hilog.info(DOMAIN, TAG, 'launch request parsed source=%{public}s action=%{public}s uri=%{public}s error=%{public}s',
request.source, request.action, want.uri ?? '', request.errorCode);
AppStorage.setOrCreate<string>(SHANHAI_LAUNCH_REQUEST_STORAGE_KEY, JSON.stringify(request));
}
页面侧只订阅这一个键:
private onLaunchRequestChanged(): void {
this.consumeLaunchRequest();
}
private consumeLaunchRequest(): void {
// 前置守卫:内容进入 CONTENT 阶段、存储键非空才继续;读到即清空,避免重复消费。
// 下文从 decodeRequest 之后开始——request 已经过白名单,解码失败会先回首页并提示。
const fromDeeplink: boolean = request.source === SHANHAI_LAUNCH_SOURCE_DEEPLINK;
const sourceLabel: string = fromDeeplink ? '链接' : '桌面卡片';
if (request.action === 'OPEN_ARTICLE') {
this.switchTab(1);
this.openArticle(request.articleId);
this.notice = '已从' + sourceLabel + '打开文章。';
return;
}
// …OPEN_BEAST / OPEN_ARCHIVE / OPEN_HOME
}
第一处缺陷就藏在这段分支表里。ShanhaiLaunchAction 加了 OPEN_ARTICLE,解析层也已经产出这个动作,但消费侧当时没有对应分支,于是文章深链一路掉进最后的探索分支——用户点的是文章,落到的却是地区页。静态门禁后来补了两条断言专盯这里:Index 必须存在 OPEN_ARTICLE 分支,且必须调用既有的 openArticle,而不是另写一套打开逻辑。
第二处缺陷更隐蔽,出现在探索分支上。原始写法是直接赋值 this.activeTab = 2,看起来和切 tab 一模一样。但真正的问题是栈:热启动时页面栈里可能还压着之前读过的文章或异兽详情,只改 tab 索引不会让它们出栈,链接到达后用户被挡在那篇文章后面,以为链接没生效。
private switchTab(index: number): void {
this.selectedBeastId = '';
this.showHallExplorer = false;
this.activeTab = index;
// 切 tab = 回 tab 根页:阅读流入栈页面全部出栈(M1)。
// 改造前只关专题阅读浮层,章节/目录浮层不关 —— 深链切 tab 时章节页会压在请求到的
// 内容上面(用户「已从桌面卡片打开神兽图鉴」却什么也看不见)。统一出栈后深链才可见。
this.clearReaderPages();
}
探索分支改成 this.switchTab(2) 之后,清栈和切 tab 变成一次原子操作。这不是为深链新写的函数——桌面卡片入口一直在用它,只是深链这条路径当时绕过去了。
| 对比项 | 冷启动 | 热启动 |
|---|---|---|
| 系统回调 | onCreate(want) | onNewWant(want) |
| 发布动作 | publishLaunchRequest | 同一个方法 |
| 页面消费时机 | 内容加载完成后的 CONTENT 阶段 | AppStorage 变化触发 |
| 栈状态 | 空栈 | 可能压着详情页 / 文章页 |
| 典型风险 | 内容未就绪时消费被丢弃 | 不清栈就被栈顶页面挡住 |

五、把契约钉在门禁里
路由和清栈这类问题,靠人眼复查不可靠:写的时候都对,改别的地方就容易破。所以把契约写成 25 条静态断言,改代码就跑一遍。
PASS skills 声明 entity.system.browsable
PASS uris 限定 https scheme
PASS domainVerify 开启(免歧义直达的前提)
PASS host 与 SHANHAI_DEEPLINK_HOST 两处一致(shanhai-api.wangji.bond)
PASS createRequest 先判 want.uri 再走参数白名单
PASS region 三段走 OPEN_HALL、两段走 OPEN_EXPLORE
PASS 未知路径/未知文章带 errorCode 回首页,不静默
PASS Index 增加 OPEN_ARTICLE 消费分支
PASS 深链无效回首页有专属提示
深链契约门禁全部通过
25 条按职责分五组,每组守一类退化:
| 组 | 条数 | 守什么 |
|---|---|---|
| 模块声明 | 5 | 四个 skills 字段 + 两处域名一致 |
| 请求模型 | 3 | OPEN_ARTICLE 动作、DEEPLINK 来源、articleId 字段 |
| 深链解析 | 10 | uri 优先、域前缀、三段常量、解码、hall 归属、两类 errorCode |
| 来源标注 | 2 | 两个入口各标各的,解码时保留发布侧标注 |
| 页面消费 | 5 | 文章分支、复用 openArticle、来源文案、无效提示 |
只跑正向还不够。断言写得太松时,删掉功能它也会绿。所以做了三轮反向验证:删 OPEN_ARTICLE 分支、把 switchTab(2) 改回直写 activeTab、去掉 DEEPLINK 来源标注,每次只红对应的那几条,其余保持绿。反向验证 3/3 通过,说明这 25 条确实咬在行为上,而不是咬在字符串上。
六、上架之前:AGC 那扇灰色的门
代码侧到此闭环。平台侧则完全卡住,而且卡得毫无悬念:AGC 控制台里「增长 > App Linking」整块是灰的,鼠标放上去没有任何可点的入口。原因是应用还没上架——这个模块对未上架应用不开放。
第一反应是绕过去:既然校验的是「域名归属」,那就自己在服务器根目录放一份 assetlinks.json,把证书指纹写进去。文件确实生成过,长这样:
[{
"relations": ["delegate_permission/common.use_all_web_url"],
"target": {
"namespace": "harmonyos_app",
"bundleName": "com.shanhai.spirits",
"sha256CertFingerprints": ["DD:3A:…:7F"]
}
}]
这份文件没有解决问题。查官方文档(2026-10-21 版)才确认两件事:设备侧的域名校验依赖 AGC 里「域名发布」这条记录,而不是服务器上有没有文件;服务器配置文件的当前名称是 applinking.json,标识字段填的是 AGC 应用 ID,不再是证书指纹。也就是说 assetlinks.json 属于另一套旧机制,照着它做,文件放对了也不会被认。
代码注释里现在还留着一句「Nginx 静态层部署 assetlinks.json」。这是那段探索期的化石,功能上无害,但会误导下一个读代码的人——真正的文件名是 applinking.json。
| 尝试过的动作 | 预期 | 实际 |
|---|---|---|
| 打开 AGC「增长 > App Linking」 | 创建关联域名 | 整块灰色,未上架不可用 |
手工部署 assetlinks.json | 设备校验通过 | 旧机制;当前配置名是 applinking.json,标识是 AGC 应用 ID |
| 照搬参考项目的托管域名路径 | 免校验直达 | 那是元服务 + 已上架的组合,普通应用不可复制 |
所以此刻真实状态是:契约已通、路由已通、域名归属未验证。系统仍然弹「选择打开方式」,用户仍然要自己挑。这不是实现没写完,而是链路上有一段必须等上架才能点亮。



七、上架之后要做的五步
把这扇门后面的动作先写清楚,上架当天照着做就行,不靠回忆:
| 步骤 | 位置 / 命令 | 通过标志 |
|---|---|---|
| 1 部署配置文件 | https://shanhai-api.wangji.bond/.well-known/applinking.json | 公网访问返回 200,内容含 AGC 应用 ID |
| 2 在 AGC 发布域名 | 增长 > App Linking > 新建关联域名 | 域名状态变为已发布 |
| 3 覆盖安装并等待 | 重装后静置约 20 秒 | 系统完成一次域名校验 |
| 4 设备侧自检 | hdc shell hidumper -s AppDomainVerifyManager | 输出 success |
| 5 复测同一矩阵 | T1–T7 原样再跑 | 不再出现「选择打开方式」 |
第 5 步必须原样复测,不能只看首页能不能打开。真正要确认的是冷启动直达、热启动清栈、非法 id 回退这三类行为在校验通过后没有变化——域名校验只改变「谁来处理链接」,不该改变「链接落到哪」。
官方对这套机制的说明见 App Linking 开发指导。
八、这一篇写到这里为止
App Linking 看上去是个跳转功能,做下来发现是两件事:一件是应用内的路由契约,一件是应用外的归属证明。前者能自己写完、自己测完;后者只能等商店那扇门打开。
这两件事混在一起写,最容易出的错是把「能跳」当成「已打通」。模拟器上七条用例确实都能跳,但其中一条的表现形式是弹框——如果只看「目标页出来了」就收工,那条弹框会被记成通过。
所以这篇停在能确认的地方:路由契约 25 条断言全绿、反向验证 3/3、冷热启动两个真缺陷已修、T1–T7 矩阵在模拟器上跑过一遍。域名校验那一半,标成待上架,连同五步清单一起留着。
更多推荐
所有评论(0)