一、审核机上的空白页只出现了 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 才从一次碰运气的跳转,变成可以在审核机、真机和自动化测试里重复证明的工程能力。

Logo

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

更多推荐