异步钩子写了但没跑,Ability创建前的准备位不是插槽

一个应用,冷启动时首页偶尔把远端开关当成 false,展示默认文案,几百毫秒之后又突然刷新成正确内容。日志里 AbilityStage.onCreate 跑了,UIAbility.onCreate 也跑了,但你在 AbilityStage 上新写的那个异步准备方法,一行日志都没有。没有报错,没有警告,hilog 干干净净,应用照常启动、照常白屏一帧、照常进首页。要不是你提前在方法体里埋了打点,这件事大概会被当成"接口还没通"放过去。

HarmonyOS 7(API 26)在 AbilityStage 上新增了 onAboutToCreateAbilityAsync,返回 Promise<void>,官方描述它"允许在首次创建 UIAbility 之前执行异步操作"。这个钩子的位置很诱人:它卡在 Ability 创建之前,正好适合放那种"首页第一帧就需要,但又不能同步阻塞"的初始化,比如读本地缓存、预热数据库连接、拉远端开关。但它的触发条件不是"你写了它就执行",而是取决于你怎么声明 它,以及同步版钩子是否同时存在。写错了,它就是一个没有日志的空方法。

异步钩子不是"写了就触发",同步版的位置不能空着

AbilityStage 上有两个与 Ability 创建前准备相关的钩子:同步的 onAboutToCreateAbility(API 24 起)和异步的 onAboutToCreateAbilityAsync(API 26 起)。直觉上,有了异步版,只 override 异步版应该就够了,毕竟声明里写着"两者都 override 时只有异步版生效"。但实际行为不是这样。

一个经过验证的现场是:只 override 异步版,冷启动时 onAboutToCreateAbility 和 onAboutToCreateAbilityAsync 触发次数都是 0 。同步版没跑,异步版也没跑,整条时间线只有 AbilityStage.onCreate 和 UIAbility.onCreate。你注释掉的同步版和只写异步版,在运行结果上是完全一样的。

异步钩子的触发依赖 AbilityStage 入口的正确注册,以及同步钩子的存在作为挂载点。只写异步版,框架没有找到可以承载这个异步准备位的同步锚点,它不会替你补一个。正确的结构是两个都 override,同步版返回 undefined 或者做最小同步检查,异步版承载真正的准备工作。

typescript

复制代码
import { AbilityStage } from '@kit.AbilityKit';

export default class ProbeAbilityStage extends AbilityStage {
  // 同步版不能省,它是异步版的挂载点
  onAboutToCreateAbility(): void {
    // 只做同步的、极轻量的检查
  }

  // 异步准备位:读缓存、预热连接
  async onAboutToCreateAbilityAsync(): Promise<void> {
    await this.bootstrap.prepare();
  }

  private bootstrap: StageBootstrap = new StageBootstrap();
}

同步版的方法体可以几乎是空的,但它必须存在。如果你在 module.json5 里没有注册 srcEntry 指向这个 AbilityStage 子类,两个钩子同样都不会执行,AbilityStage.onCreate 也不会走你自定义的逻辑。配置和声明是两道门,缺一道,异步钩子就只是一个写在文件里的普通方法。

初始化 Promise 没有成为 Ability 创建前的门槛,首页就会读到空配置

异步钩子能跑了,下一个问题不是"它有没有执行",而是"它执行的结果有没有成为后续流程的等待条件"。

onAboutToCreateAbilityAsync 的语义是:框架拿到你返回的 Promise,等它兑现之后,再创建第一个 UIAbility。但如果你在这个钩子里启动了一个异步任务,却没有把它的 Promise 返回出去,或者返回了一个已经兑现的空 Promise,准备工作在跑,Ability 创建也在跑,两者并行。首页在 Ability 创建之后立刻读取配置,配置可能还没从缓存或远端回来,读到的是 undefined 或者默认值。几百毫秒后异步任务完成,数据写进去,页面才刷新成正确内容。

修正的核心是让共享的初始化 Promise 成为 Ability 创建前的明确门槛 。不要只写一个 async 方法然后 await 一个内部任务就结束,要确保这个 Promise 被返回、被框架消费,并且多个 Ability 入口共享同一个 Promise,避免深链启动第二个 Ability 时重复拉配置。

typescript

复制代码
class StageBootstrap {
  private task?: Promise<BootstrapResult>;

  prepare(): Promise<BootstrapResult> {
    if (this.task) return this.task;  // 多入口共享,只跑一次
    this.task = this.doPrepare();
    return this.task;
  }

  private async doPrepare(): Promise<BootstrapResult> {
    try {
      const remote = await this.fetchRemoteFlags(3000);
      return { source: 'remote', flags: remote };
    } catch (err) {
      // 超时或失败:降级到本地快照,不无限阻塞
      const cached = await this.readCachedFlags();
      return cached
        ? { source: 'cache', flags: cached }
        : { source: 'default', flags: DEFAULT_FLAGS };
    }
  }
}

if (this.task) return this.task 这一行解决的是"第二个 Ability 启动时重复请求"的问题。深链拉起另一个 UIAbility 时,AbilityStage 的异步准备位会再次被调用,如果每次都新建 Promise,远端配置会被拉两遍甚至多遍,两个 Ability 还可能拿到不同版本的结果。

没有超时和降级的异步准备,会把白屏换成一个更长的白屏

把初始化 Promise 返回给框架,意味着 Ability 创建会等它。如果这个 Promise 背后是一个没有超时的网络请求,弱网下 Promise 悬住,Ability 创建也跟着悬住,用户看到的白屏比同步初始化时更长,而且这次连 UIAbility.onCreate 的日志都不会出现。

异步准备位不是"异步了就可以不做超时",恰恰因为它的结果直接阻塞 Ability 创建,超时和降级比同步场景更关键。Promise.race 或者 AbortController 让准备任务在一个可接受的窗口内结束,超时之后使用本地快照或默认值,把远端结果标记来源,等首屏渲染完成之后在后台更新,而不是让首屏去等一个不确定的远端。

typescript

复制代码
private async doPrepare(): Promise<BootstrapResult> {
  const timeout = new Promise<BootstrapResult>((resolve) => {
    setTimeout(() => resolve({ source: 'default', flags: DEFAULT_FLAGS }), 800);
  });
  const remote = this.fetchRemoteFlags().then(
    (flags): BootstrapResult => ({ source: 'remote', flags }),
    () => ({ source: 'default', flags: DEFAULT_FLAGS })
  );
  return Promise.race([remote, timeout]);
}

800 毫秒不是精确值,但它是一个比"无限等"诚实的窗口。首屏用降级值先把结构渲染出来,远端结果回来之后,如果关键结构没有变化,只更新非结构性字段;如果关键结构变了,再走一次显式刷新,而不是让首页的开关状态在用户眼皮底下悄悄翻转。

准备位里只放"创建 Ability 之前必须完成的事"

onAboutToCreateAbilityAsync 的位置很靠前,靠前的位置最容易被当成"什么都能放"。网络 SDK 初始化、数据库建表、埋点启动、推送注册、日志上传,全部塞进去,Promise 的兑现时间被拉长,Ability 创建被推迟,首帧跟着推迟。

这个钩子应该只放那些第一个 Ability 创建之前必须就绪、且首屏结构依赖 的最小集合。本地缓存的读取、首屏开关的恢复、数据库连接的最小可用打开,属于这个集合。远端配置的完整拉取、耗时迁移、非首屏模块预热、埋点上报,不属于。它们可以放在 AppStartup 的异步任务里,或者等首屏渲染完成之后再触发。

判断标准不是"这个任务重不重",而是"如果它没有完成,第一个 Ability 还能不能创建、首屏还能不能渲染出一个正确的骨架"。能,就移出去;不能,才留下。留下的部分必须有超时和降级,移出去的部分必须有明确的触发时机和失败兜底。

排查顺序:先看钩子跑没跑,再看 Promise 有没有被门槛消费

下次异步准备位"写了没反应"或者"首页读到空配置"的时候,按这个顺序走,比反复改 doPrepare 里的逻辑快。

第一步,在 onAboutToCreateAbility 和 onAboutToCreateAbilityAsync 的方法体第一行各打一条带相对时间的日志,冷启动一次,看两条日志有没有出现。如果两条都没有,检查 module.json5 的 srcEntry 是否指向了正确的 AbilityStage 子类,以及同步版钩子是否被 override 了。只写异步版,两条都不会跑。

第二步,如果两条日志都出现了,但在 UIAbility.onCreate 里读取配置时拿到的还是空值,检查异步版的 Promise 有没有被 return 出去,StageBootstrap.prepare() 返回的是不是同一个共享 Promise,以及有没有在 prepare 内部提前 resolve 了一个空结果。

第三步,如果白屏时间比同步初始化还长,检查准备任务有没有超时和降级。Promise 悬住的时候,Ability 创建也在悬住,用户看到的是一个没有任何日志的白屏。

第四步,如果深链启动第二个 Ability 时配置被重复拉取,检查 StageBootstrap 的 task 字段是不是实例级共享的,AbilityStage 在同一个进程内应该只有一个实例,prepare 的 Promise 应该被复用而不是重新创建。

onAboutToCreateAbilityAsync 不是"异步版的 onCreate",它是一个需要被框架消费的准备门槛 。门槛要立起来,同步钩子得在,module.json5 得指对,返回的 Promise 得被框架等,超时之后得有降级。下一次首页把远端开关当成 false 又自己刷新的时候,先别怀疑远端接口不稳------打开 AbilityStage 看一眼,那个异步方法,到底有没有人真的在等它。