# 〖共创稿事节〗HarmonyOS 7 新特性实战(53):一条链接落在谁手里——App Linking 的三段路由、冷热启动与上架前卡住的域名校验

把一条链接复制到聊天窗口,点开,屏幕下方升起「选择打开方式」:应用图标排在第一,后面跟着两个浏览器。用户得自己判断该点哪个——点错就进了网页版。

这就是没有完成域名归属校验时,App Linking 的真实样子。代码里的路由全都写对了,/beast/beast_baize 也确实把白泽详情拉了起来,但系统仍然不肯替用户做决定。因为「这个域名属于这个应用」这句话,应用自己说一百遍都不算,得由平台侧的一条记录来背书。

这篇按两条线走。代码线:三段路径怎么接管、白名单为什么必须复用、冷启动与热启动怎样共用入口,以及在这条线上抓到的两个真缺陷。平台线:上架之前 AGC 那一整块灰色,以及被官方文档推翻的手工配置方案。

冷启动:系统把 /beast/beast_baize 交给应用,直接落在白泽详情

一、先定契约:三段路径,一个前缀

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只接管 httpshttp 链接被漏掉或误接管
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_FORMvalidateRequest已从桌面卡片…
App Linkingwant.uri(整条 URL)DEEPLINKparseDeeplinkSegments → 白名单已从链接…

来源只影响两件事:日志里的 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_unknownOPEN_HOME + UNKNOWN_BEAST回首页,提示「链接无效」截图 04
/foo/barOPEN_HOME + UNKNOWN_PATH同上截图 05
/article/article_region_kunlunOPEN_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 字段 + 两处域名一致
请求模型3OPEN_ARTICLE 动作、DEEPLINK 来源、articleId 字段
深链解析10uri 优先、域前缀、三段常量、解码、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 矩阵在模拟器上跑过一遍。域名校验那一半,标成待上架,连同五步清单一起留着。

Logo

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

更多推荐