HarmonyOS 7 AppLinking:冷暖启动链接归一与路由就绪屏障
一、审核机上的空白页只出现了 37 毫秒
LinkIngressLab 做的是资讯分享链接回流:用户点击 https://share.example.com/article/arkui-26?campaign=october,应用已安装时直接进入文章详情,未安装时留在网页。联调时冷启动、暖启动都能打开,送审后却收到一段很短的录屏:应用被拉起,首页闪过,详情没有出现。审核日志里没有崩溃,只有一条 ROUTER_NOT_READY,从链接到达到页面初始化完成相差 37 ms。
更麻烦的是复测结果不稳定。应用完全退出时,链接位于 UIAbility.onCreate(want);应用已在前台时,同一链接进入 onNewWant(want);某些浏览器在拉起后还会重发一次 Want。旧实现只在首页 aboutToAppear 读取启动参数,冷启动时路由栈尚未创建,暖启动时首页又不会重建,于是一个入口太早、另一个入口根本没接。为了“提高成功率”加的重试,反而会把同一详情压入两次。
这次我没有继续堆延时,而是把任务固定为 LINK-0012,把验收数据写成一条可观察流水线:输入 2 次,规范化后同一 dedupKey;待处理队列从 1 变 0;只提交 1 次 ArticleDetail;重复丢弃 1 次;最终参数为 articleId=arkui-26、campaign=october,域名校验为 true,入口回调显示 onNewWant,状态为 ROUTE_COMMITTED。

二、链接配置通过不等于页面一定能收到
App Linking 的第一层是系统匹配。目标 UIAbility 的 skills 需要声明 ohos.want.action.viewData、entity.system.browsable,URI 使用 https、指定 host,并启用 domainVerify。路径字段不带开头或结尾斜线,这是一次很隐蔽的配置差异;另一个习惯是 scheme 和 host 都用小写,避免同一地址形成多份规则。
本 Demo 的 module.json5 只允许 share.example.com/article/...,不接管站点其他路径。下面这段代码解决的是“系统应该把什么交给应用”,它不能替代业务内的参数校验,也不会替开发者建立 Navigation 路由栈。
{
"module": {
"abilities": [{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"skills": [{
"actions": ["ohos.want.action.viewData"],
"entities": ["entity.system.browsable"],
"uris": [{
"scheme": "https",
"host": "share.example.com",
"pathStartWith": "article",
"domainVerify": true
}]
}]
}]
}
}
配置生效后,系统能把标准 URL 放进 Want 的 uri 字段。这里没有把 pathRegex 写得很复杂,因为产品只需要文章前缀,具体 articleId 由应用校验;如果把业务规则复制到系统配置,后续增加 /article/video-26 时很容易出现系统允许、业务拒绝或反过来的双份真相。
官方接口还提供 UIAbilityContext.openLink() 供应用主动打开链接,但本 Demo 关注的是应用作为链接接收方。测试页上的“模拟冷启动”和“模拟暖启动”按钮调用同一个调试入口,分别标记 onCreate 与 onNewWant 来源,生产包则只接受系统 Want。这个边界避免测试代码绕过域名校验后,让人误以为配置已经正确。
三、统一入口之前,先把 URI 变成可比较的数据
工程目录没有单独建“万能路由框架”,只放了这次问题需要的四个角色:
LinkIngressLab/
└── entry/src/main/
├── ets/entryability/EntryAbility.ets
├── ets/link/LinkNormalizer.ets
├── ets/link/LinkIngress.ets
├── ets/pages/Index.ets
└── ets/pages/LinkAuditPage.ets
LinkNormalizer 解决第二层问题:两个字符串形式不同的链接是否代表同一动作。它只接受 https、固定 host 和两段路径;查询参数只保留 allowlist 中的 campaign。host 大小写、末尾斜线、无关追踪参数都不能影响 dedupKey。解析失败时返回明确的拒绝原因,不能悄悄回首页,因为审核录屏里的“空白后回首页”正是最难定位的失败形态。
export interface LinkCommand {
normalizedUri: string;
articleId: string;
campaign: string;
dedupKey: string;
}
export class LinkNormalizer {
static parse(rawUri: string): LinkCommand {
const uri = new URL(rawUri);
if (uri.protocol.toLowerCase() !== 'https:' ||
uri.hostname.toLowerCase() !== 'share.example.com') {
throw new Error('LINK_HOST_REJECTED');
}
const parts = uri.pathname.split('/').filter((item: string) => item.length > 0);
if (parts.length !== 2 || parts[0] !== 'article' ||
!/^[a-z0-9-]{1,64}$/.test(parts[1])) {
throw new Error('LINK_PATH_REJECTED');
}
const campaign = uri.searchParams.get('campaign') ?? 'direct';
if (!/^[a-z0-9-]{1,32}$/.test(campaign)) {
throw new Error('LINK_CAMPAIGN_REJECTED');
}
const normalizedUri = `https://share.example.com/article/${parts[1]}?campaign=${campaign}`;
return {
normalizedUri,
articleId: parts[1],
campaign,
dedupKey: `${parts[1]}|${campaign}`
};
}
}
这里没有对整个 URL 做简单字符串去重,因为 HTTPS://SHARE.EXAMPLE.COM/article/arkui-26/ 和规范地址可能是同一意图;也没有把所有 query 排序后保留,因为广告平台追加的时间戳会让每次点击都变成“新链接”。campaign 进入路由参数,但不参与页面主键;dedupKey 保留它,是为了允许用户确实从另一个活动再次进入同一文章。
URL 解析仍有边界:URL 在当前工程目标版本可用;若项目受限于更早的 ArkTS 运行环境,应换成系统提供的 URI 解析能力,而不是自己用 split('?')。所有拒绝都必须在 HiLog 中打印任务 ID LINK-0012 和原因,但不要打印用户令牌或完整敏感 query。
四、路由就绪屏障只缓存最后一个有效命令
第三层才是这次故障的核心。onCreate 发生时,onWindowStageCreate 尚未完成 loadContent,页面里的 NavPathStack 当然不存在。固定等待 100 ms 只能让测试机“刚好成功”,并不能建立先后关系。
我做了一个很小的 LinkIngress。它有三个状态:BOOTING、READY、ROUTE_COMMITTED。入口先规范化并登记;路由未就绪时只保留最后一个有效命令,因为用户在启动过程中连续点两篇文章,最终意图应是最后一次点击,而不是启动后依次打开两层详情。去重窗口为 1500 ms,覆盖浏览器重发,但不会阻止用户稍后主动再次打开。
type LinkSource = 'onCreate' | 'onNewWant';
export class LinkIngress {
private ready: boolean = false;
private pending?: LinkCommand;
private seen: Map<string, number> = new Map();
private commit?: (command: LinkCommand, source: LinkSource) => void;
bind(commit: (command: LinkCommand, source: LinkSource) => void): void {
this.commit = commit;
}
markRouterReady(): void {
this.ready = true;
if (this.pending) {
const command = this.pending;
this.pending = undefined;
this.commit?.(command, 'onCreate');
console.info('LINK-0012 QUEUE pending=1->0 state=ROUTE_COMMITTED');
}
}
accept(rawUri: string, source: LinkSource, now: number = Date.now()): void {
const command = LinkNormalizer.parse(rawUri);
const last = this.seen.get(command.dedupKey) ?? 0;
if (now - last < 1500) {
console.info(`LINK-0012 DUPLICATE_DROPPED key=${command.dedupKey}`);
return;
}
this.seen.set(command.dedupKey, now);
this.trim(now);
if (!this.ready || !this.commit) {
this.pending = command;
console.info('LINK-0012 QUEUE pending=0->1 state=WAIT_ROUTER');
return;
}
this.commit(command, source);
}
private trim(now: number): void {
this.seen.forEach((time: number, key: string) => {
if (now - time > 10000) this.seen.delete(key);
});
}
}
这段实现故意没有启动定时器。过期 seen 项在下一次接收时顺手清理,避免页面销毁后还有一个 timer 持有单例。pending 只存一个结构化命令,不保存 Want 对象,减少生命周期对象被跨阶段持有的风险。当前 Demo 的冷启动来源在 flush 时固定记作 onCreate;若产品需要精确保留来源,可把 source 与 command 一起缓存。
重复调用 markRouterReady() 不会再次提交,因为 pending 在提交前已经清空。commit 抛错时应由页面层记录 ROUTE_FAILED 并允许再次处理;本 Demo 把路由栈操作包在同步回调中,真正涉及数据预取时不要让“接口成功”成为进入详情的前置条件,否则网络失败会再次伪装成 App Linking 失败。
五、UIAbility 只转发事件,不自己操作 NavPathStack
EntryAbility 能拿到 Want,却不应直接依赖页面对象。它负责把冷、暖启动汇入同一个单例,并在 loadContent 成功后打开屏障。页面创建 NavPathStack 后绑定 commit 回调,把 articleId 和 campaign 推入 ArticleDetail。这样 Ability 与 ArkUI 页面的生命周期通过明确的一次 bind 对接,而不是互相猜测。
const ingress = new LinkIngress();
export default class EntryAbility extends UIAbility {
onCreate(want: Want): void {
if (want.uri) ingress.accept(want.uri, 'onCreate');
}
onNewWant(want: Want): void {
if (want.uri) ingress.accept(want.uri, 'onNewWant');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (error: BusinessError) => {
if (error.code) {
console.error(`LINK-0012 LOAD_CONTENT_FAILED code=${error.code}`);
return;
}
ingress.markRouterReady();
});
}
}
@Entry
@Component
struct Index {
private stack: NavPathStack = new NavPathStack();
aboutToAppear(): void {
ingress.bind((command: LinkCommand, source: LinkSource) => {
this.stack.pushPathByName('ArticleDetail', {
articleId: command.articleId,
campaign: command.campaign
});
console.info(`LINK-0012 COMMIT route=ArticleDetail id=${command.articleId} source=${source}`);
});
}
build() {
Navigation(this.stack) { LinkAuditPage() }
.navDestination(this.PageMap)
}
}
这里要特别留意 bind 与 ready 的先后:如果 loadContent 回调先于页面 aboutToAppear,仅仅 ready=true 仍然没有 commit。工程版 markRouterReady 的含义实际上是“UI 内容加载完成”,而 bind 表示“路由对象可用”;只有两者同时满足才 flush。Demo 中 Index 会在 loadContent 完成路径内构建,截图日志也验证了 bind 先于提交。更严格的封装可拆成 contentReady 与 routerBound 两个布尔量,统一调用 tryFlush()。
页面退出时不释放全局 ingress,因为 UIAbility 仍可能收到 onNewWant;但要解绑旧页面 commit,避免窗口重建后闭包仍指向旧 NavPathStack。生产实现应在 aboutToDisappear 调用 unbind(ownerId),并让新页面以代次覆盖旧订阅。本 Demo 单窗口、单 Index,边界写进了验收说明,未假装它天然支持多窗口。

六、用四条路径代替一次“点链接成功”
最终验收不是只点一次链接。我保留了四条路径:完全结束应用后点击规范链接,验证 onCreate → WAIT_ROUTER → ROUTE_COMMITTED;前台点击同一链接,验证进入 onNewWant;在 1500 ms 内由浏览器重发,验证 DUPLICATE_DROPPED=1;把 host 改成 evil.example.com,验证在规范化阶段得到 LINK_HOST_REJECTED,路由栈不变化。
任务 LINK-0012 的成功样本里,输入计数为 2,提交计数为 1,pending 从 1 降到 0,目标 ArticleDetail,文章 arkui-26,活动 october,域名校验 true,入口回调 onNewWant,延迟 37 ms。页面把这些数值直接列出来,而不是用一个绿色勾号掩盖中间过程。

对应 HiLog 为:
LINK-0012 ACCEPT source=onCreate uri=/article/arkui-26 campaign=october
LINK-0012 QUEUE pending=0->1 state=WAIT_ROUTER
LINK-0012 QUEUE pending=1->0 state=ROUTE_COMMITTED latency=37ms
LINK-0012 COMMIT route=ArticleDetail id=arkui-26 campaign=october
LINK-0012 DUPLICATE_DROPPED key=arkui-26|october count=1
七、链接问题最后都应该落到状态,而不是概率
这次问题的修复没有增加重试次数,反而删掉了所有延时重试。系统配置回答“哪个链接可以拉起应用”,LinkNormalizer 回答“它是不是合法业务命令”,LinkIngress 回答“路由未就绪时如何保存、重复到达时如何收敛”,页面只负责提交 Navigation。冷启动和暖启动因此不再是两套实现。
仍需承认几个产品边界:域名校验依赖站点侧关联文件与发布配置,测试按钮不能替代真实外链验收;1500 ms 是浏览器重发窗口,不是永久幂等;多窗口场景需要按 windowId 保存 ready、pending 和 commit;如果链接携带登录后才能访问的页面,应缓存结构化命令并在鉴权完成后恢复,而不是缓存整个 Want。
最重要的变化是故障已经可解释。37 ms 不再是“偶发空白”,而是 onCreate 早于路由就绪;第二次拉起不再是“浏览器怪异”,而是同一 dedupKey 在窗口内重复;页面没有跳转也不再回退成首页,而会留下 WAIT_ROUTER、LINK_HOST_REJECTED 或 ROUTE_FAILED。当链路每一步都有状态,App Linking 才从一次碰运气的跳转,变成可以在审核机、真机和自动化测试里重复证明的工程能力。
更多推荐



所有评论(0)