从 EntryAbility 到首屏:应用启动与 Runtime 装配顺序

发布时间:2026/7/24 3:04:44
从 EntryAbility 到首屏:应用启动与 Runtime 装配顺序 上一篇讲了 Debug/Release 的 composition seam 怎么切。seam 切好之后下一个问题是应用冷启动后从EntryAbility.onCreate到首屏渲染中间这一串事情按什么顺序发生这个顺序不是细节是架构。排错了你会遇到首屏渲染时词库还没加载完导致分析不可用、凭据没恢复导致 AI 功能冷启动后神秘失效、退后台回来麦克风还在偷偷采集。这篇按真实代码把启动链路拆开讲。1. 启动链路全景先看整条链路的时序onCreate └─ 设置 ColorMode 跟随系统setColorMode onWindowStageCreate ├─ enableImmersiveWindow(windowStage) # 沉浸式窗口、系统栏颜色 └─ loadSpeakLabContentAfterCompositionSettles( ensureSpeakLabShellCompositionReady( # ① 依赖就绪异步 resourceManager, context), loadInitialContent, # ② 就绪后 loadContent(pages/AppShell) readinessFailureAction) # ③ 失败只记日志壳照常渲染 AppShellEntry ├─ 字段初始化createSpeakLabShellRuntime() # ④ 组装运行时同步 ├─ aboutToAppear: runtime.attach() # ⑤ 挂到 lifecycle hub └─ aboutToDisappear: runtime.dispose() # ⑥ 摘钩、释放几个值得单独讲的决策逐个展开。2. 决策一首屏等内容就绪但内容失败不挡壳onWindowStageCreate里没有直接loadContent而是先等 composition 就绪。原因很实际我们的首屏要展示基于本地词库的分析能力词库是 rawfile 里的三个 JSON 文件加载是异步的。如果壳先渲染、词库后到用户点下开始分析的那一刻服务还没 ready就是一个时序 bug。但我们抽出来的启动协调器长这样export async function loadSpeakLabContentAfterCompositionSettles( ensureAction: () Promisevoid, loadAction: () void, readinessFailureAction: () void ): Promisevoid { try { await ensureAction(); } catch (_error) { readinessFailureAction(); // 只记日志 } loadAction(); // 无论成败壳都要渲染 }注意这个看似矛盾的设计就绪要等但失败了壳照样渲染。这是 fail-closed 的另一半——词库加载失败时底层 ServiceBundle 本身就是拒绝服务的分析调用会返回明确的不可用错误UI 走对应提示所以壳不需要替底层挡驾反过来如果因为词库失败连壳都不渲染用户面对的就是一个白屏闪退的 App那才是真正的灾难。让每一层在自己该有的位置失败而不是在最外层一刀切。这个协调器被故意写成纯函数式的三参数形式不碰任何窗口对象——这样 Hypium 测试可以注入假的 ensure/load/failure 动作把先等就绪、失败也渲染这条时序不变式变成一条断言而不是靠真机手点。3. 决策二就绪序列内部顺序也是设计出来的ensureSpeakLabShellCompositionReadyRelease seam 侧内部不是一把梭的并发加载而是有明确顺序const pending releaseCatalogLoader.load(manager) // ① 词库 catalog .then(async (catalog) { releaseLexiconService.installCatalog(catalog); // ② 装进词库服务 await hydrateSpeakLabReleaseSettings(...); // ③ 设置Preferences wireSpeakLabReleaseCredentialStore(...); // ④ 凭据双通道绑定 context await hydrateSpeakLabReleaseCredential(); // ⑤ Key 从 Asset 恢复进内存 });为什么这个顺序因为后两步依赖前一步的产物设置在词库之后个性化设置里有用户自定义填充词要调用replaceFillerOverlay覆盖到词库服务上——词库没装好overlay 无处附着。凭据在 context 之后Key 的安全存储Asset Store 沙箱 vault 双通道需要 Ability context 才能绑定应用私有目录所以wireSpeakLabReleaseCredentialStore必须在拿到 context 之后、hydrate 之前。另外这个就绪 promise 是进程级去重的releaseReadinessPromise存在时直接返回同一个 promise并发调用不会触发重复加载失败时清空重来可以重试。冷启动、页面重建、Ability 重建等多入口场景都汇聚到这一个就绪闸门。4. 决策三Ability 很薄生命周期事件只进 hub看EntryAbility的四个生命周期回调onForeground(): void { // Recheck capability only via hub — never ASR start/resume. getSpeakLabRuntimeLifecycleHub().notifyForeground(Date.now()); } onBackground(): void { // Invalidate-first release via hub (release, not pause/stop). getSpeakLabRuntimeLifecycleHub().notifyBackground(Date.now()); } onDestroy(): void { getSpeakLabRuntimeLifecycleHub().notifyAbilityDestroy(Date.now()); }注意注释里的两个 never回前台绝不在这里启动/恢复 ASR退后台绝不在这里直接操作 Store。Ability 不认识任何业务能力只做一件事——把事件连同时间戳转给 lifecycle hub。hub 是一个单活动目标注册表带 generation tokenexport class SpeakLabRuntimeLifecycleHub { private target: SpeakLabRuntimeLifecycleTarget | null; private attachGeneration: number; attach(target): number { this.attachGeneration 1; this.target target; return this.attachGeneration; // 返回 token } detach(token: number): void { if (token this.attachGeneration) { this.target null; // token 不匹配说明已被替换忽略 } } notifyBackground(atMs: number): void { const t this.target; if (t null) return; t.handleLifecycleBackground(atMs); } }generation token 解决的是一个隐蔽的竞态场景切换Debug 下换夹具或运行时重建时旧运行时已经 dispose但系统层面一个迟到的onBackground通知才姗姗来迟。如果没有 token旧对象可能错误地响应新周期的事件。有了 tokendetach 之后的一切迟到通知都被天然忽略。运行时的挂接发生在SpeakLabAppRuntime.attach()attach(): void { this.flow.attachAsrObserver(); this.lifecycleToken getSpeakLabRuntimeLifecycleHub().attach(this.flow); }于是整条线是Ability 只发通知 → hub 只路由通知 → 当前活动的 TrainingFlow 决定怎么响应退后台走 release、Ability destroy 清内存但保留 Asset……。每一层职责单一每一层都可单独测试。5. 决策四同步路径也要有——IDE 重启与字段初始化前面都是异步就绪但有一个现实问题AppShell 的运行时是字段初始化器里同步创建的Entry Component struct AppShell { private runtime: SpeakLabAppRuntime createSpeakLabShellRuntime(); }字段初始化没有 await 的机会。而且热重载/IDE 重启路径下createSpeakLabShellRuntime可能跑在ensure()完成之前。所以 Release seam 额外提供了一条同步凭据恢复路径export function hydrateSpeakLabReleaseCredentialSync(): void { if (releaseCredential.hasCredential()) return; if (releaseCredentialStore instanceof SpeakLabDualCredentialStorePort) { const secret releaseCredentialStore.loadSync(); // 仅双通道存储暴露 loadSync if (secret ! null secret.trim().length 0) { releaseCredential.apply(secret); } } }设计约束藏在类型里只有生产双通道存储暴露loadSync测试用的内存 port 没有同步 API——同步路径天然只能在生产实现上工作测试无法误用。这是用接口形状表达规则的一个小例子。6. 决策五窗口配置也守规矩——颜色不出现 HEXonWindowStageCreate里还有一块容易被当脏活随手写掉的代码沉浸式窗口和系统栏颜色。两个细节值得抄系统栏颜色从 color.json 资源解析ArkTS 里一个 HEX 字面量都没有。setWindowSystemBarProperties只接受字符串颜色所以运行时按资源名取色再格式化成#AARRGGBB解析失败回退到全透明系统栏永远不会被刷成一个错误的实色。深浅色跟随系统配置变化onConfigurationUpdate里重刷系统栏图标颜色深色模式用亮色图标状态栏高度写进AppStorage供页面避让安全区。private resolveColorHexByName(resourceName: string): string { try { const value this.context.resourceManager.getColorByNameSync(resourceName); // …格式化为 #AARRGGBB } catch (_err) { return # 00000000; // 失败全透明绝不刷错颜色 } }这些属于不写也能跑的代码但上架应用的质感恰恰在这些地方深浅色切换时状态栏图标不消失、刘海区域不错位。7. 小结启动链路的五条纪律首屏等内容就绪但底层失败不挡壳渲染——每层在自己的位置失败启动协调器抽成可测的纯逻辑。就绪序列有顺序词库 → 设置 overlay → 凭据绑定 → Key 恢复后一步依赖前一步的产物。Ability 只做窗口和转发生命周期事件全部进 hub业务响应由当前活动运行时决定generation token 防迟到通知。异步就绪之外为字段初始化/热重启保留同步恢复路径并用接口形状限制同步路径只服务生产实现。窗口配置守同一套资源纪律颜色来自 color.json失败回退透明深浅色跟系统。