【口算王|13】HarmonyOS ArkTS 应用启动链路实战:从 EntryAbility 到首屏加载保持窗口与路由稳定
公开边界:本文仅保留 HarmonyOS Stage 模型、UIAbility、WindowStage、AppStorage 与 ArkUI 页面装载的通用工程方法。所有代码均为重新编写的公开化示例,不包含本地项目路径、包名、签名、业务数据或私有源码。未执行的构建、真机与性能测试不会写成已通过。

应用启动故障很少只表现为"打不开"。更常见的是首屏能出来,但窗口状态不稳定:底部按钮第一次绘制时贴住手势区,几百毫秒后又向上跳;折叠屏改变窗口宽度后,页面仍沿用旧断点;窗口销毁了,避让区监听还持有旧对象;页面加载失败只剩一块系统背景,日志里却没有可定位的错误。
这些问题的共同点是责任落在 UIAbility、WindowStage、窗口对象和 ArkUI 页面之间。页面只看到一个安全区高度,却不知道这个值来自哪个窗口;数据服务只需要 Context,却不应该持有页面实例;断点系统要覆盖整个 Ability 生命周期,又不能在每个页面重复注册。启动链路要稳定,关键不是把所有初始化都塞进 onCreate(),而是让每种资源由正确的生命周期所有者管理。
本文基于口算类示例应用 示例工程 的公开化示例代码,复核 EntryAbility.ets、StartupDataService.ets、AdaptiveLayoutRuntime.ets、Index.ets、TopBar.ets、PracticePage.ets、module.json5 与应用配置。包名 com.example.mathapp 是本文草稿核验使用的唯一标记。项目采用 Stage 模型,SDK 版本应以实际工程与官方文档为准,设备范围包含 phone、tablet 和 2in1;本文讨论的 Ability、WindowStage、AppStorage 与避让区方法面向 HarmonyOS 5.0 及以上工程。
本文重点回答:
onCreate()、onWindowStageCreate()、onWindowStageDestroy()与onDestroy()分别拥有哪类资源;- 本地数据、Tab 状态、断点监听和窗口避让区为什么不能混在页面中初始化;
- 如何把窗口像素值转换为页面可消费的共享状态;
avoidAreaChange为什么既要监听,也要在两级销毁路径中解除;loadContent()的回调怎样成为首屏加载的第一条诊断证据;- 冷启动、窗口变化、前后台切换和销毁重建应该如何验证。
一、先建立 Ability 生命周期账本
Stage 模型里的 UIAbility 是一段业务窗口会话的运行时边界。口算王的 EntryAbility 同时拥有四类资源:应用 Context、本地共享状态、媒体查询监听器、主窗口与避让区回调。它们的创建时机和销毁时机并不相同。
可以先用资源账本明确边界:
| 资源 | 创建位置 | 使用范围 | 释放位置 |
|---|---|---|---|
| Preferences 访问入口 | onCreate() |
Ability 内业务数据 | 随进程与服务生命周期 |
| AppStorage 初始键 | onCreate() |
全部 ArkUI 页面 | 由运行时管理 |
| 断点媒体查询 | onCreate() |
phone/tablet/2in1 页面 | onDestroy() |
| 主窗口引用 | onWindowStageCreate() |
当前 WindowStage | onWindowStageDestroy() |
| 避让区监听回调 | onWindowStageCreate() |
当前主窗口 | WindowStage 与 Ability 销毁路径 |
| 首屏内容 | loadContent() |
当前窗口页面树 | WindowStage 销毁 |
这张表能避免两个常见错误。一是页面在 aboutToAppear() 中重复注册全局断点监听,导致每次进出页面都增加一组回调;二是 Ability 在 onCreate() 中读取尚未创建的主窗口,把窗口依赖提前到错误的阶段。
二、项目入口确实使用 Stage 模型
entry/build-profile.json5 明确声明:
"targets": [
]
模块配置把 EntryAbility 设置为入口:
"name": "entry",
"deviceTypes": [
"2in1"
"abilities": [
"srcEntry": "./ets/entryability/EntryAbility.ets",
]
这意味着启动诊断要沿 Stage 模型查找:系统创建 EntryAbility,Ability 创建 WindowStage,WindowStage 加载 ArkUI 页面。不能套用旧模型中 Page Ability 的生命周期假设,也不能把 EntryAbility.ets 当作普通工具类。
项目配置还表明兼容和目标版本都在 HarmonyOS 6.0 系列。文章中的 API 不是凭空拼接的兼容示例,而是来自该实际工程;如果迁移到 HarmonyOS 5.x 项目,应以本地 SDK 签名和官方文档为准复核具体重载与类型。
三、onCreate 只初始化不依赖窗口的能力
口算王在 onCreate() 中完成浅色模式、本地数据、共享索引和断点系统初始化:
launchParam: AbilityConstant.LaunchParam
this.context.getApplicationContext()
hilog.error(
'Failed to set colorMode. Cause: %{public}s',
}
AppStorage.setOrCreate ('currentTabIndex', 0)
AppStorage.setOrCreate ('navigationIndicatorHeightPx', 0)
这些动作有一个共同点:不需要 window.Window 实例。Context 在 Ability 创建后可用,Preferences 可以同步读取,AppStorage 可以先建立默认键,媒体查询也可以注册初始断点。
这种安排防止页面第一次绑定时遇到"键不存在"。例如 Index 声明:
@StorageLink('topAvoidAreaHeightPx') topAvoidAreaHeightPx: number = 0
即使窗口避让区尚未读取完成,页面也先拿到 0 作为可预测回退值。随后 Ability 写入真实高度,所有 @StorageLink 订阅者一起刷新,不需要页面各自调用窗口 API。
四、本地数据初始化为什么放在 Ability 层
StartupDataService.init() 使用 Ability Context 获取 Preferences,并把记录写入 AppStorage:
): void {
{ name: 'app_preferences' }
const favStr = StartupDataService.prefs
.getSync('wrongRecords', '\[\]') as string
JSON.parse(favStr) as FavoriteRecord\[\]
'wrongRecords',
}
如果每个页面自行读取 Preferences,会出现三类分歧:
- 首页计数、收藏列表和"我的"统计可能在不同时间拿到不同快照;
- 页面需要知道存储名称和字段名,UI 层被迫承担持久化协议;
- 页面销毁重建会重复解析同一份数据。
Ability 层只负责触发初始化,真正的存储细节仍封装在 StartupDataService。这符合"Context 由生命周期边界提供,业务页面只消费窄接口"的结构。页面不保存 Context,也不直接调用 preferences.getPreferencesSync()。
当前实现使用同步读取,所以 onCreate() 返回前数据已经写入 AppStorage。若未来换成 RDB 迁移或异步文件读取,应该把数据管理器改为显式启动任务,并增加 startupState,而不是在 Ability 内调用异步函数后立即假定成功。
五、断点系统属于 Ability,而不是单个页面
口算王的 AdaptiveLayoutRuntime.register() 创建三组媒体查询:
mediaquery.matchMediaSync('(width<=600vp)')
AdaptiveLayoutRuntime.lgListener =
AdaptiveLayoutRuntime.smListener.on('change', result => {
AdaptiveLayoutRuntime.mdListener.on('change', result => {
AdaptiveLayoutRuntime.lgListener.on('change', result => {
}
初始匹配完成后,系统把断点广播到:
AppStorage.setOrCreate ('currentBreakpoint', bp)
首页、题库页、挑战页、详情页和统计页都通过 @StorageLink 消费同一个断点。因此注册一次即可覆盖整棵页面树。把注册动作放在某个首页组件中,会让二级页面直接启动或窗口重建时缺少断点来源。
对应的清理在 onDestroy():
this.mainWindow.off(
)
}
unregister() 分别对三组监听执行 off('change')。Ability 销毁后不再接收窗口宽度变化,避免静态监听器持有旧运行环境。
六、窗口相关操作必须等到 WindowStage 创建
主窗口在 onWindowStageCreate() 中获取:
(data: window.AvoidAreaOptions) => void = undefined
this.mainWindow = windowStage.getMainWindowSync()
this.avoidAreaCallback =
data.type ===
) {
}
this.avoidAreaCallback
AppStorage.setOrCreate (
)
0
}
这里先主动读取一次,再注册变化监听。如果只监听 avoidAreaChange,首次页面构建可能一直拿默认 0,直到设备姿态或系统栏状态发生变化才更新;如果只读取一次,旋转、分屏、窗口缩放或手势导航变化后又会过期。
"先拉取,再订阅"是窗口状态同步的通用模式:
- 获取当前主窗口;
- 立即计算当前值;
- 保存回调引用;
- 监听后续变化;
- 销毁时用同一回调解除。
回调被保存成字段非常关键。解除监听通常需要事件名与原回调匹配;如果注册时直接写匿名函数,销毁阶段没有同一个函数引用可用。
七、避让区要同时看导航指示区和系统区域
updateNavigationIndicatorHeight() 读取两种避让区:
AppStorage.setOrCreate (
)
0
}
window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR
this.mainWindow.getWindowAvoidArea(
const topHeight =
navigationArea.visible
const systemHeight =
AppStorage.setOrCreate (
)
Math.max(navigationHeight, systemHeight)
顶部使用系统区域的 topRect.height,底部取导航指示区与系统区域底部高度的最大值。取最大值而不是相加,是因为两类区域可能描述重叠空间;直接相加会把页面内容推得过高。
这里保存的是像素值,因为窗口 API 返回 px。页面真正计算布局时再通过当前 UIContext 转成 vp:
Sizes.BOTTOM_NAV_MIN_PADDING,
)
这个转换位置是合理的:Ability 只广播窗口原始事实,组件根据自己的 UIContext 解释密度,并叠加业务最小间距。不要在 Ability 中硬编码一个假定密度,把所有设备都换算成同一个 vp。
八、一个安全区值如何驱动多个页面
navigationIndicatorHeightPx 不只服务首页。公开化示例代码中,以下页面都会消费它:
| 页面 | 使用方式 |
|---|---|
Index.ets |
增加底部 Tab 容器高度与 padding |
BankDetailPage.ets |
保证底部操作区离开系统手势区 |
CategoryPage.ets |
在列表末尾增加可滚动空白 |
ExamResultPage.ets |
提高底部按钮区域 |
PracticePage.ets |
调整答题操作栏与状态页面底部 |
SearchPage.ets |
为搜索结果尾部留出安全空间 |
SettingsPage.ets |
调整清理按钮和页面底部 padding |
这种共享方式比每页读取窗口更稳。窗口监听只有一份,转换和最小间距由组件按场景决定。页面不会互相覆盖窗口回调,也不会因为路由切换漏掉监听。
顶部值则由公共 TopBar.ets 和练习、搜索等页面订阅。当前 Index.topSafePadding() 返回 0,说明首页根布局并没有直接使用保存的顶部高度;顶部适配由具体页面或公共顶栏负责。文章只能描述真实消费路径,不能因为存在 topAvoidAreaHeightPx 就声称所有页面都已自动适配状态栏。
九、异常回退不能让首屏失去布局
窗口获取、监听注册和避让区读取都放在 try/catch 中。失败时写回 0:
'topAvoidAreaHeightPx',
AppStorage.setOrCreate (
)
'testTag',
)
回退为 0 不代表忽略安全区。页面的 Math.max(Sizes.BOTTOM_NAV_MIN_PADDING, ...) 仍保留至少 28vp 的底部距离。这样窗口 API 异常时,布局会退回保守间距,而不是直接把操作按钮压到最底边。
稳定启动需要区分"可降级"和"不可继续":
| 失败点 | 当前处理 | 是否可继续 |
|---|---|---|
| 设置浅色模式失败 | 记录 error | 可以,系统模式接管 |
| Preferences 解析失败 | 使用空记录与默认设置 | 可以 |
| 获取避让区失败 | 高度回退 0,页面保留最小间距 | 可以 |
| 注册窗口监听失败 | 记录 warn | 可以,但窗口变化不会动态更新 |
loadContent() 失败 |
记录 error 并返回 | 不可进入 ArkUI 首屏 |
最后一项属于硬失败,因为没有页面树可显示。它的日志必须比普通 warn 更醒目,也应在发布包冒烟测试中作为阻断项。
十、loadContent 是首屏加载的明确边界
窗口准备完成后,EntryAbility 加载 Splash:
(err) => {
DOMAIN,
JSON.stringify(err)
}
'testTag',
}
这个回调把"窗口已经创建"和"ArkUI 内容已经加载"分开。启动日志建议按以下顺序查看:
Succeeded in loading the content.
如果第二条都没有,问题在 Ability 或 WindowStage 创建前;如果第二条存在但第三条失败,重点检查页面注册、资源、构建产物和 loadContent 目标;如果第三条成功但品牌页不跳首页,才进入 Splash 和路由层排查。
不要用 Splash 的 aboutToAppear() 日志替代 loadContent 证据。前者只有在组件成功构建后才可能执行,无法解释页面树为什么没加载。
十一、窗口销毁与 Ability 销毁是两级清理
当前工程在 onWindowStageDestroy() 中解除窗口监听并清空字段:
this.mainWindow.off(
)
this.avoidAreaCallback = undefined
onDestroy() 也有一次防御性解除,并注销断点系统。这种两级结构对应两个不同事实:
- WindowStage 销毁后,旧窗口不能再被访问;
- Ability 销毁后,应用级媒体查询也必须停止。
重复执行 off() 是否安全要以实际 API 行为为准。当前代码通过 mainWindow && avoidAreaCallback 判断引用存在;WindowStage 销毁路径清空字段后,后续 onDestroy() 不会再次调用窗口解除,因此避免了重复操作。
更完整的封装可以把窗口清理提取为一个幂等方法:
this.mainWindow.off(
)
this.avoidAreaCallback = undefined
onWindowStageDestroy(): void {
onDestroy(): void {
}
这属于维护性增强,不是对示例实现已有方法的描述。它减少两处清理逻辑未来发生差异的风险。
十二、前后台回调当前只记录日志
口算王实现:
DOMAIN,
'Ability onForeground'
onBackground(): void {
'testTag',
)
当前应用是本地口算训练,没有后台网络、定位、音频常驻或定时同步,因此前后台切换不需要启动额外任务。只记录生命周期日志与实际能力一致。
如果以后增加语音朗读,页面或语音服务应在后台时停止正在播放的会话;如果增加在线同步,也要根据官方后台任务机制设计,不能把长时间请求直接塞进 onBackground()。本文不把当前空回调描述成已实现后台恢复能力。
十三、窗口监听中的数据类型边界
Ability 将安全区存成 number,但这个 number 代表 px;页面使用时转成 vp。单靠类型系统无法表达单位,因此命名承担了契约:
后缀 Px 很重要。若把字段命名成 bottomPadding,其他开发者可能直接当 vp 使用,在高密度设备上产生明显误差。
可以进一步用接口集中表示窗口事实:
bottom: number
function selectBottomInsetPx(
): number {
? navigation.bottomRect.height
system.visible
return Math.max(navigationHeight, systemHeight)
纯函数便于输入构造和边界测试,窗口 API 调用仍留在 Ability。这样能验证"不可见区域返回 0""两个区域取最大值""负值或异常值如何处理"等规则,而不必每次依赖真实设备姿态。
十四、多设备启动要同时验证断点与避让区
项目声明 phone、tablet 和 2in1,说明启动链路不能只在手机竖屏验一次。建议覆盖:
| 场景 | 断点预期 | 避让区预期 | 首屏检查 |
|---|---|---|---|
| 手机竖屏 | sm |
底部手势区有效 | Tab 不贴底 |
| 展开折叠屏 | md |
根据系统栏变化 | 内容重新排布 |
| 大平板 | lg |
可能无手机式手势区 | 不额外抬高过多 |
| 2in1 窗口缩小 | lg -> md -> sm |
随窗口变化 | 无跳变和裁切 |
| 横竖屏切换 | 按宽度重算 | 顶底区域更新 | 顶栏与按钮可达 |
还要注意当前 Index 判断:
this.currentBp === 'md' ||
// 底部导航
}
断点系统只产生这三个值,因此 else 侧栏分支不可达。Ability 的断点广播本身会更新,但 Index 的消费条件没有区分设备形态。测试时如果只看到 currentBreakpoint = lg 就认为平板适配完成,会漏掉这个真实逻辑问题。
十五、启动链路的诊断顺序
遇到白屏、布局跳动或监听异常时,可以按以下顺序排查:
module.json5是否指向正确 EntryAbility;onCreate()是否完成数据与 AppStorage 初始化;AdaptiveLayoutRuntime.register()是否写入初始断点;onWindowStageCreate()是否拿到主窗口;- 首次避让区读取是否成功;
avoidAreaChange是否只注册一次;loadContent()是否回调成功;- Splash 是否替换到 Index;
- 页面是否把 px 转成 vp;
- WindowStage 与 Ability 销毁时监听是否解除。
每一步都有独立证据。不要在看到底部按钮错位时立即给所有页面加 30vp 固定 padding,那会掩盖窗口监听或单位转换的真正问题。
十六、实机与发布包验证清单
生命周期
- 冷启动日志顺序正确;
- 进入后台和回到前台各触发一次对应回调;
- 旋转、分屏、窗口缩放不会重复注册监听;
- WindowStage 销毁后字段被清空;
- Ability 销毁后断点监听注销。
页面布局
- 首页底部 Tab 至少保留业务最小间距;
- 练习页操作栏不进入系统手势区;
- 搜索、分类、设置等长页面最后一个操作可滚动到安全区域;
- 顶部公共栏在状态栏下方可读;
- px 到 vp 的转换只在 UIContext 可用的组件侧执行。
异常路径
- 模拟窗口避让区读取失败,页面仍可显示;
- Preferences 内容损坏时回退为空记录和默认设置;
loadContent()失败能在 hilog 中定位;- 首屏资源丢失时构建或运行日志明确;
- 快速创建销毁窗口不会留下旧回调。
发布门槛
- 使用签名 release 包完成安装;
- 从桌面冷启动并走到首页;
- 完成一次题库选择与答题;
- 切换横竖屏或调整窗口;
- 回到桌面后重新进入;
- 正常卸载且无第三方安装依赖。
这些验证对应 AppGallery 对安装、启动、运行、稳定性和多设备布局的基本要求。只有编辑器预览正常,不足以证明 WindowStage 生命周期在发布包中可靠。
十七、常见问题与修复方向
| 现象 | 根因候选 | 优先修复 |
|---|---|---|
| 首屏底部栏先贴底后上跳 | 首次避让区读取晚于页面构建 | 获取窗口后先主动更新,再监听变化 |
| 横屏后仍用旧间距 | 只读取一次,没有监听变化 | 注册 avoidAreaChange |
| 多次进出后回调重复 | 页面或窗口重复注册 | Ability 单点注册并保存回调引用 |
| WindowStage 销毁后报窗口错误 | 仍持有旧 mainWindow |
销毁时解除监听并清空字段 |
| 平板断点为 lg 但仍是底部栏 | Index 条件覆盖全部断点 | 修正断点消费分支 |
| 某些设备间距过大 | 两类底部区域被相加 | 取最大值并按可见性判断 |
| 不同密度设备布局不一致 | 把 px 直接当 vp | 在页面 UIContext 中转换 |
| Splash 前出现白屏 | loadContent() 或资源失败 |
先读 WindowStage 加载回调 |
| 数据页首次显示空列表 | 数据初始化晚于页面 | 在 Ability 边界建立 ready 状态 |
十八、总结:让资源跟着生命周期走
口算王的 EntryAbility 把启动链路拆得比较清楚:onCreate() 处理不依赖窗口的应用状态,onWindowStageCreate() 获取窗口、读取并监听避让区,然后加载首屏;页面通过 AppStorage 消费断点和安全区;onWindowStageDestroy() 释放窗口引用,onDestroy() 注销应用级断点监听。
这套结构最值得复用的不是某个 API,而是资源所有权:
- Context 驱动的数据初始化归 Ability 触发;
- Preferences 细节归数据服务;
- 媒体查询归应用级断点系统;
- 主窗口和避让区回调归 WindowStage 生命周期;
- px 到 vp 的解释归页面 UIContext;
- 页面布局只消费共享事实,不重复监听窗口。
示例实现仍有可完善点:窗口清理可以抽成幂等方法,前后台回调暂时只有日志,Index 的断点消费让侧栏分支不可达,顶部安全区也不是所有根页面都直接使用。这些边界被明确记录后,后续优化才能基于证据推进,而不是把启动问题笼统归因于"设备兼容性"。
本文部分内容由 AI 辅助整理,所有实现边界、版本信息、代码片段与结论均依据上述本地源码复核。
当前启动链路:从桌面入口到业务首屏
源码显示,系统先依据模块清单创建 EntryAbility;onCreate 初始化用户数据、共享状态和断点系统;窗口阶段创建后注册避让区监听,并装载 SplashPage。SplashPage 到 Index 的实际跳转属于后续页面职责,不能仅凭 EntryAbility 推断为已经成功。

启动职责分层:配置、生命周期、状态与页面
启动稳定性不是一个回调的责任。配置层决定入口和页面注册,Ability 层管理上下文、窗口与监听,状态层准备用户数据和 AppStorage,页面层负责 SplashPage 与 Index 的可见切换。分层检查可以把"白屏"进一步定位为入口、窗口、数据或页面问题。

建议实现:把启动过程变成可观察状态机
示例实现已把日志、用户数据、共享状态、断点系统、避让区和页面装载放在正确的生命周期附近,但日志并不等于启动状态。更稳的做法是定义 idle、preparing、windowReady、contentReady、degraded 和 failed 六类状态,并记录每个状态的进入时间、错误类型和恢复动作。StartupDataService 初始化失败时,首屏可以使用明确默认值进入降级状态;SplashPage 跳转失败时,应保留可见页面并提供重试,而不是只留下后台日志。
状态机需要坚持单向推进和幂等初始化。onCreate 可能只执行一次,但页面恢复、窗口重建和测试桩会让初始化路径重复出现;StartupDataService、AdaptiveLayoutRuntime 和 avoidAreaChange 监听都应能安全地判断"已初始化"或"已注册"。释放路径同样要与注册路径成对,避免窗口销毁后继续持有回调。这里给出的是建议设计,不能据此宣称当前工程已经实现。
启动回归不能只验证"看见首页"
验收时至少覆盖首次安装冷启动、已有用户数据冷启动、后台恢复、进程被系统回收后的重建、窗口尺寸变化和底部导航区域变化。每个场景分别观察启动窗口连续性、SplashPage 是否可交互、Index 是否只进入一次、五个 Tab 状态是否稳定、避让区是否刷新,以及退出后监听和断点系统是否完成释放。
性能数据必须来自真实测量。可以记录 onCreate 起点、StartupDataService 完成、onWindowStageCreate、loadContent 回调、SplashPage 首帧和 Index 首次可交互的时间戳,但在没有真机采样之前,不应写出冷启动毫秒数、通过率或设备覆盖结论。本文没有执行这些测试,因此只提供验证方法,不报告未发生的结果。
排查表:按启动阶段寻找第一处真实失败
| 现象 | 首查位置 | 可验证证据 | 不应直接推断 |
|---|---|---|---|
| 点击图标后无页面 | module.json5、EntryAbility 日志 | mainElement、srcEntry、onCreate 是否到达 | 不能直接归因于 Index |
| 启动页出现后停住 | SplashPage 跳转逻辑 | 路由 Promise、错误分支、目标页注册 | 不能仅凭截图断言数据服务失败 |
| 首屏内容为空 | StartupDataService、AppStorage | 初始化结果、默认值、页面读取时机 | 空数据不等于读取异常 |
| 底部内容被遮挡 | avoidAreaChange、px2vp 转换 | 避让区类型、可见性、高度更新 | 不应固定写死某个设备高度 |
| 返回前台状态错乱 | onForeground、页面状态恢复 | 生命周期顺序和状态快照 | 日志出现不代表恢复完成 |
排查时先找到第一个没有满足契约的阶段,再向下游追踪。这样可以避免把所有启动问题都归结为"路由不稳定",也能防止为了修复一个页面问题而修改入口清单、权限或签名配置。
可迁移示例:用公开接口组织启动链路
下面代码用于说明职责分配,名称和数据均为通用示例,不是任何本地项目源码。
{
"module": {
"name": "entry",
"type": "entry",
"mainElement": "EntryAbility",
"pages": "$profile:main_pages"
}
}
模块清单只负责声明入口和页面清单,不应承载数据初始化逻辑。
#enum StartupPhase {
Idle = 'idle',
Preparing = 'preparing',
WindowReady = 'windowReady',
ContentReady = 'contentReady',
Degraded = 'degraded',
Failed = 'failed'
}enum StartupPhase {
#enum StartupPhase {
Idle = 'idle',
Preparing = 'preparing',
WindowReady = 'windowReady',
ContentReady = 'contentReady',
Degraded = 'degraded',
Failed = 'failed'
} Idle = 'idle',
#enum StartupPhase {
Idle = 'idle',
Preparing = 'preparing',
WindowReady = 'windowReady',
ContentReady = 'contentReady',
Degraded = 'degraded',
Failed = 'failed'
} Preparing = 'preparing',
#enum StartupPhase {
Idle = 'idle',
Preparing = 'preparing',
WindowReady = 'windowReady',
ContentReady = 'contentReady',
Degraded = 'degraded',
Failed = 'failed'
} WindowReady = 'windowReady',
#enum StartupPhase {
Idle = 'idle',
Preparing = 'preparing',
WindowReady = 'windowReady',
ContentReady = 'contentReady',
Degraded = 'degraded',
Failed = 'failed'
} ContentReady = 'contentReady',
#enum StartupPhase {
Idle = 'idle',
Preparing = 'preparing',
WindowReady = 'windowReady',
ContentReady = 'contentReady',
Degraded = 'degraded',
Failed = 'failed'
} Degraded = 'degraded',
#enum StartupPhase {
Idle = 'idle',
Preparing = 'preparing',
WindowReady = 'windowReady',
ContentReady = 'contentReady',
Degraded = 'degraded',
Failed = 'failed'
} Failed = 'failed'
#enum StartupPhase {
Idle = 'idle',
Preparing = 'preparing',
WindowReady = 'windowReady',
ContentReady = 'contentReady',
Degraded = 'degraded',
Failed = 'failed'
}}
显式阶段比单个布尔值更容易定位卡在数据、窗口还是页面装载阶段。
#export default class EntryAbility extends UIAbility {
onCreate(): void {
AppStorage.setOrCreate<StartupPhase>('startupPhase', StartupPhase.Preparing)
AppStorage.setOrCreate<number>('bottomAvoidHeightPx', 0)
StartupDataService.initialize(this.context)
}
}export default class EntryAbility extends UIAbility {
#export default class EntryAbility extends UIAbility {
onCreate(): void {
AppStorage.setOrCreate<StartupPhase>('startupPhase', StartupPhase.Preparing)
AppStorage.setOrCreate<number>('bottomAvoidHeightPx', 0)
StartupDataService.initialize(this.context)
}
} onCreate(): void {
#export default class EntryAbility extends UIAbility {
onCreate(): void {
AppStorage.setOrCreate<StartupPhase>('startupPhase', StartupPhase.Preparing)
AppStorage.setOrCreate<number>('bottomAvoidHeightPx', 0)
StartupDataService.initialize(this.context)
}
} AppStorage.setOrCreate<StartupPhase>('startupPhase', StartupPhase.Preparing)
#export default class EntryAbility extends UIAbility {
onCreate(): void {
AppStorage.setOrCreate<StartupPhase>('startupPhase', StartupPhase.Preparing)
AppStorage.setOrCreate<number>('bottomAvoidHeightPx', 0)
StartupDataService.initialize(this.context)
}
} AppStorage.setOrCreate<number>('bottomAvoidHeightPx', 0)
#export default class EntryAbility extends UIAbility {
onCreate(): void {
AppStorage.setOrCreate<StartupPhase>('startupPhase', StartupPhase.Preparing)
AppStorage.setOrCreate<number>('bottomAvoidHeightPx', 0)
StartupDataService.initialize(this.context)
}
} StartupDataService.initialize(this.context)
#export default class EntryAbility extends UIAbility {
onCreate(): void {
AppStorage.setOrCreate<StartupPhase>('startupPhase', StartupPhase.Preparing)
AppStorage.setOrCreate<number>('bottomAvoidHeightPx', 0)
StartupDataService.initialize(this.context)
}
} }
#export default class EntryAbility extends UIAbility {
onCreate(): void {
AppStorage.setOrCreate<StartupPhase>('startupPhase', StartupPhase.Preparing)
AppStorage.setOrCreate<number>('bottomAvoidHeightPx', 0)
StartupDataService.initialize(this.context)
}
}}
onCreate 只触发不依赖窗口的初始化,并先建立页面可消费的默认状态。
#onWindowStageCreate(stage: window.WindowStage): void {
stage.loadContent('pages/SplashPage', (error) => {
AppStorage.set('startupPhase',
error.code === 0 ? StartupPhase.ContentReady : StartupPhase.Failed)
})
}onWindowStageCreate(stage: window.WindowStage): void {
#onWindowStageCreate(stage: window.WindowStage): void {
stage.loadContent('pages/SplashPage', (error) => {
AppStorage.set('startupPhase',
error.code === 0 ? StartupPhase.ContentReady : StartupPhase.Failed)
})
} stage.loadContent('pages/SplashPage', (error) => {
#onWindowStageCreate(stage: window.WindowStage): void {
stage.loadContent('pages/SplashPage', (error) => {
AppStorage.set('startupPhase',
error.code === 0 ? StartupPhase.ContentReady : StartupPhase.Failed)
})
} AppStorage.set('startupPhase',
#onWindowStageCreate(stage: window.WindowStage): void {
stage.loadContent('pages/SplashPage', (error) => {
AppStorage.set('startupPhase',
error.code === 0 ? StartupPhase.ContentReady : StartupPhase.Failed)
})
} error.code === 0 ? StartupPhase.ContentReady : StartupPhase.Failed)
#onWindowStageCreate(stage: window.WindowStage): void {
stage.loadContent('pages/SplashPage', (error) => {
AppStorage.set('startupPhase',
error.code === 0 ? StartupPhase.ContentReady : StartupPhase.Failed)
})
} })
#onWindowStageCreate(stage: window.WindowStage): void {
stage.loadContent('pages/SplashPage', (error) => {
AppStorage.set('startupPhase',
error.code === 0 ? StartupPhase.ContentReady : StartupPhase.Failed)
})
}}
loadContent 的回调必须进入状态模型,失败时才能显示可恢复界面。
#function normalizeAvoidHeight(px: number, ui: UIContext): number {
if (!Number.isFinite(px) || px <= 0) return 0
return Math.max(0, ui.px2vp(px))
}function normalizeAvoidHeight(px: number, ui: UIContext): number {
#function normalizeAvoidHeight(px: number, ui: UIContext): number {
if (!Number.isFinite(px) || px <= 0) return 0
return Math.max(0, ui.px2vp(px))
} if (!Number.isFinite(px) || px <= 0) return 0
#function normalizeAvoidHeight(px: number, ui: UIContext): number {
if (!Number.isFinite(px) || px <= 0) return 0
return Math.max(0, ui.px2vp(px))
} return Math.max(0, ui.px2vp(px))
#function normalizeAvoidHeight(px: number, ui: UIContext): number {
if (!Number.isFinite(px) || px <= 0) return 0
return Math.max(0, ui.px2vp(px))
}}
窗口 API 返回像素值,页面布局使用前要校验并转换为 vp。
#function openHomeAfterReady(): void {
const phase = AppStorage.get<StartupPhase>('startupPhase')
if (phase === StartupPhase.ContentReady || phase === StartupPhase.Degraded) {
router.replaceUrl({ url: 'pages/Index' })
}
}function openHomeAfterReady(): void {
#function openHomeAfterReady(): void {
const phase = AppStorage.get<StartupPhase>('startupPhase')
if (phase === StartupPhase.ContentReady || phase === StartupPhase.Degraded) {
router.replaceUrl({ url: 'pages/Index' })
}
} const phase = AppStorage.get<StartupPhase>('startupPhase')
#function openHomeAfterReady(): void {
const phase = AppStorage.get<StartupPhase>('startupPhase')
if (phase === StartupPhase.ContentReady || phase === StartupPhase.Degraded) {
router.replaceUrl({ url: 'pages/Index' })
}
} if (phase === StartupPhase.ContentReady || phase === StartupPhase.Degraded) {
#function openHomeAfterReady(): void {
const phase = AppStorage.get<StartupPhase>('startupPhase')
if (phase === StartupPhase.ContentReady || phase === StartupPhase.Degraded) {
router.replaceUrl({ url: 'pages/Index' })
}
} router.replaceUrl({ url: 'pages/Index' })
#function openHomeAfterReady(): void {
const phase = AppStorage.get<StartupPhase>('startupPhase')
if (phase === StartupPhase.ContentReady || phase === StartupPhase.Degraded) {
router.replaceUrl({ url: 'pages/Index' })
}
} }
#function openHomeAfterReady(): void {
const phase = AppStorage.get<StartupPhase>('startupPhase')
if (phase === StartupPhase.ContentReady || phase === StartupPhase.Degraded) {
router.replaceUrl({ url: 'pages/Index' })
}
}}
启动页只在内容就绪或明确降级后替换路由,避免重复压栈。
AI 辅助声明
本文在人工复核公开接口与通用启动职责后/,使用 AI 辅助整理结构、润色表达并生成配图;未执行的构建、真机、性能与异常恢复测试均未写成已通过。