启动页最容易被误判成"放一张 Logo,等两秒,再跳首页"。真正进入工程阶段后,问题往往出在两个页面之间:系统启动窗口刚消失,ArkUI 页面却还没绘制,出现短暂白闪;用户把应用切到后台,定时器仍在推进路由;启动页被压入路由栈,进入首页后按返回键又看见一次品牌动画;本地数据尚未装入 AppStorage,首页先按空数组渲染,随后数字突然跳变。
口算王的实现把启动过程分成了三个连续阶段:系统启动窗口、EntryAbility 初始化、ArkUI SplashPage。其中数据读取和断点注册都发生在加载页面之前,Splash 只承担品牌过渡和固定展示时长,不把两秒定时器伪装成"等待数据加载"。这条边界对 HarmonyOS 5.0 及以上的 Stage 模型应用很重要。
本文基于口算王项目 D:\huawei\one16-11 的真实源码,复核 module.json5、main_pages.json、EntryAbility.ets、SplashPage.ets、Index.ets、UserDataManager.ets 与 BreakpointSystem.ets。包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。当前实现使用本地 Preferences、无网络请求、无登录、无运行时权限;启动页在 600ms 内完成渐入,并在出现约 2000ms 后通过 router.replaceUrl() 切换到训练首页。

本文集中解决六个问题:
- 系统启动窗口、Ability 初始化和 ArkUI Splash 分别负责什么;
- 为什么首页入口要由
EntryAbility明确加载,而不是依赖页面顺序猜测; - 固定两秒展示如何避免重复跳转、返回栈污染和生命周期泄漏;
replaceUrl()与pushUrl()在启动场景中的真实差别;- 数据、断点和安全区为何必须在首页组件消费之前准备;
- 如何验证冷启动、热启动、后台切换、平板窗口和异常路由。
一、先画清三段启动链路
口算王并不是从 SplashPage 开始执行。用户点击桌面图标后,系统先根据 Ability 配置显示启动窗口;随后 EntryAbility 创建应用级状态;最后 WindowStage 才加载 ArkUI 内容。把三段混成一个"启动页",排查白屏和延迟时就很难定位责任。

真实执行顺序可以概括为:
| 阶段 | 当前代码入口 | 主要职责 | 不应该承担的职责 |
|---|---|---|---|
| 系统启动窗口 | module.json5 |
首帧背景与图标 | 业务初始化、路由判断 |
| Ability 初始化 | EntryAbility.onCreate() |
本地数据、Tab、断点系统 | 绘制品牌动画 |
| 窗口内容加载 | onWindowStageCreate() |
加载 pages/SplashPage |
用固定时间模拟数据成功 |
| ArkUI Splash | SplashPage.aboutToAppear() |
动画、短暂停留、替换路由 | 长任务、网络请求 |
| 训练首页 | pages/Index |
组合首页与导航 | 再次执行启动初始化 |
这张表的核心是"一个阶段只拥有一种决定"。系统启动窗口追求尽快可见,Ability 负责运行时环境,Splash 负责视觉过渡,首页负责业务交互。只要某个职责跨了两层,就容易出现重复初始化或闪屏。
二、系统启动窗口是首帧,不是 Splash 页面
module.json5 中的 startWindowIcon 和 startWindowBackground 在 ArkUI 页面创建前生效。口算王把入口 Ability、页面清单与启动资源关联在一起:
json5
{
"module": {
"mainElement": "EntryAbility",
"pages": "$profile:main_pages",
"requestPermissions": [],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"icon": "$media:app_icon",
"startWindowIcon": "$media:app_icon",
"startWindowBackground": "$color:start_window_background"
}
]
}
}
这里有四个工程含义。
- 系统知道首个生命周期对象是
EntryAbility。 - ArkUI 路由只允许访问
main_pages.json注册过的页面。 - 启动窗口复用正式应用图标,减少"桌面图标与启动图标不一致"的审核风险。
- 当前
requestPermissions为空,因此启动链路不会被权限弹窗打断。
基础颜色资源把启动窗口背景配置为白色:
json
{
"color": [
{
"name": "start_window_background",
"value": "#FFFFFF"
}
]
}
这不是装饰细节。系统窗口与 Splash 根容器的背景差异越大,交接瞬间越容易被肉眼识别成闪烁。当前应用在 EntryAbility.onCreate() 中锁定浅色模式,Splash 又使用主题中的浅色背景,因此白色启动窗口与页面整体方向一致。
如果未来改成跟随系统深色模式,不能只修改 Splash 的文字颜色。start_window_background、应用图标边缘、Splash 根背景和状态栏图标都要一起复核,否则深色系统下会先闪出一块白屏。
三、页面清单决定路由是否真实存在
口算王的 main_pages.json 同时注册 Splash 与首页:
json
{
"src": [
"pages/SplashPage",
"pages/Index",
"pages/BankDetailPage",
"pages/PracticePage",
"pages/ExamResultPage",
"pages/SearchPage",
"pages/CategoryPage",
"pages/LearningStatsPage",
"pages/SettingsPage"
]
}
启动页执行:
typescript
router.replaceUrl({ url: 'pages/Index' })
这里的字符串必须与页面清单完全对应。目录名、大小写、后缀和注册路径任何一个不一致,都可能让定时器按时触发,但页面没有切换。由于错误发生在两秒后,开发者很容易误以为是定时器失效。
排查这种问题时,顺序应该是:
- 检查
main_pages.json是否含有pages/Index; - 检查目标文件是否真是
entry/src/main/ets/pages/Index.ets; - 检查当前模块加载的是否是同一份 profile;
- 最后再看
replaceUrl()的 Promise 是否被拒绝。
先查路由契约,比先改等待时间更有效。把两秒改成三秒不会修复路径拼写错误。
四、初始化顺序比启动动画更重要
EntryAbility.onCreate() 在页面加载之前完成应用级准备:
typescript
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.context.getApplicationContext()
.setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT)
UserDataManager.init(this.context)
AppStorage.setOrCreate<number>('currentTabIndex', 0)
AppStorage.setOrCreate<number>('favoriteTabIndex', 0)
AppStorage.setOrCreate<number>('topAvoidAreaHeightPx', 0)
AppStorage.setOrCreate<number>('navigationIndicatorHeightPx', 0)
BreakpointSystem.register()
}
这些动作与 Splash 的两秒等待没有因果关系。UserDataManager.init() 使用同步 Preferences API 读取收藏、笔记、错题、学习进度和设置,然后写入 AppStorage;BreakpointSystem.register() 同步创建媒体查询监听器并写入初始断点。也就是说,当前项目的首页数据依赖在 loadContent() 之前已经准备完毕。

这种顺序有三个直接收益:
Index第一次构建时就能读取currentTabIndex和currentBreakpoint;- 首页、收藏页和"我的"页面拿到的是同一组
AppStorage数据; - Splash 不需要知道收藏记录、错题记录或屏幕宽度的内部细节。
如果初始化未来改成异步数据库迁移或在线配置,不能继续靠固定两秒"赌它完成"。更稳的做法是引入显式启动状态,例如 initializing、ready、failed,由 Ability 或启动协调器推进;Splash 只订阅状态并展示反馈。
五、WindowStage 明确加载 Splash
口算王没有把页面数组第一项当作隐式入口,而是在窗口创建完成后明确加载:
typescript
onWindowStageCreate(windowStage: window.WindowStage): void {
this.mainWindow = windowStage.getMainWindowSync()
this.updateNavigationIndicatorHeight()
windowStage.loadContent('pages/SplashPage', (err) => {
if (err.code) {
hilog.error(
0x0000,
'testTag',
'Failed to load the content. Cause: %{public}s',
JSON.stringify(err)
)
return
}
hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.')
})
}
loadContent() 的回调是启动白屏排查的重要证据。如果 Splash 文件本身编译通过,但资源、页面注册或运行时加载失败,这里会得到错误码。保留这条日志比在 Splash 内部打"页面已出现"更早、更接近根因。
窗口创建阶段还读取导航指示区域和系统避让区,并把像素高度写入 AppStorage。首页的底部导航随后按:
typescript
private bottomSafePadding(): number {
return Math.max(
Sizes.BOTTOM_NAV_MIN_PADDING,
this.getUIContext().px2vp(this.navigationIndicatorHeightPx)
)
}
计算底部安全间距。这样从 Splash 切到首页时,导航栏不会先贴底,再因避让区回调到达而突然上移。即使系统区域读取失败,代码也回退为 0,并由最小间距兜底。
六、Splash 的动画与路由必须相互独立
当前 SplashPage 的核心逻辑很短:
typescript
@State opacity_: number = 0
@State scale_: number = 0.85
private timerId: number = -1
aboutToAppear(): void {
animateTo({ duration: 600, curve: Curve.EaseOut }, () => {
this.opacity_ = 1
this.scale_ = 1
})
this.timerId = setTimeout(() => {
router.replaceUrl({ url: 'pages/Index' })
}, 2000)
}
aboutToDisappear(): void {
if (this.timerId !== -1) clearTimeout(this.timerId)
}
600ms 渐入和 2000ms 页面停留是两条并行时间线。动画完成不代表初始化完成,定时器触发也不依赖动画回调。当前初始化是同步的,因此这种分离成立;如果把数据读取塞进 animateTo() 回调,就会把视觉时长与业务时长绑定。
timerId 作为字段保存,而不是声明在 aboutToAppear() 的局部作用域,是为了让 aboutToDisappear() 能够清理它。用户可能通过系统行为离开页面,测试工具也可能快速重建组件。没有清理时,已经不可见的页面仍可能在两秒后修改路由。
还可以补一个小的状态保护,避免同一组件实例重复调度:
typescript
@State private leaving: boolean = false
private timerId: number = -1
private scheduleHome(): void {
if (this.timerId !== -1 || this.leaving) {
return
}
this.timerId = setTimeout(() => {
if (this.leaving) {
return
}
this.leaving = true
router.replaceUrl({ url: 'pages/Index' })
.catch(() => {
this.leaving = false
})
}, 2000)
}
这段是针对当前代码边界的增强方案,不是对现有行为的伪造描述。它为路由失败留下恢复机会,也能避免未来在 onPageShow()、按钮点击或动画回调中重复调用跳转。
七、为什么这里必须使用 replaceUrl
启动页不是业务历史的一部分。用户进入首页后按系统返回键,合理结果通常是退出应用或回到系统,而不是再次看到 Splash。
两种路由方式的行为差异如下:
| 路由方法 | 结果 | 是否适合 Splash |
|---|---|---|
router.pushUrl() |
Splash 留在栈中,Index 压到上层 | 不适合 |
router.replaceUrl() |
当前 Splash 被 Index 替换 | 适合 |
router.back() |
依赖已有上一页 | 不适合首次启动 |
| 直接在 Splash 内渲染首页 | 页面职责混合,状态难拆 | 不建议 |
使用 replaceUrl() 后,首页成为当前路由栈的根页面。这个选择同时解决了返回路径与重复动画问题,不需要额外监听返回键去"拦截"用户。
验证时不要只看"跳转成功"。还要在首页按一次系统返回:如果又回到 Splash,说明真实代码可能用了 pushUrl(),或者其他导航层又把 Splash 保存进了栈。
八、Index 如何确定训练首页
Index 使用 currentTabIndex 作为五个主页面的唯一选择状态:
typescript
@StorageLink('currentTabIndex') currentIndex: number = 0
@Builder
PageContent() {
if (this.currentIndex === 0) {
HomePage()
} else if (this.currentIndex === 1) {
BankListPage()
} else if (this.currentIndex === 2) {
ExamTab()
} else if (this.currentIndex === 3) {
FavoritePage()
} else {
MinePage()
}
}
EntryAbility 每次进程创建时调用 setOrCreate('currentTabIndex', 0)。在没有既存键时,它把首页设为 0;因此 Splash 替换到 pages/Index 后,首次内容是 HomePage。主页面之间的切换不再创建新路由,而是修改共享 Tab 索引。
这里需要注意 setOrCreate() 的语义:键已存在时不会无条件覆盖。若应用进程没有销毁,只是从后台回到前台,原 Tab 状态可能继续保留。冷启动与热恢复是否都强制回首页,应当由产品规则决定,不能仅凭字段默认值推断。
当前 Index 还有一个可复核的适配问题:
typescript
if (this.currentBp === 'sm' ||
this.currentBp === 'md' ||
this.currentBp === 'lg') {
// 手机模式:底部导航
} else {
// 平板/折叠模式:侧边导航
}
BreakpointSystem 只会产生 sm、md、lg 三个值,因此上述条件对所有已知断点都为真,侧边导航分支不会进入。这不阻断 Splash 到首页的路由,但会影响平板和 2in1 首屏布局。修复时应按真实设计拆分,例如只让 sm 使用底部导航,md/lg 使用侧栏,随后重新做多设备启动截图验证。
九、固定两秒何时合理,何时应该删除
口算王当前没有网络登录、远程配置、数据库迁移和权限请求。Preferences 读取使用同步 API,断点注册也在页面加载前完成。在这个前提下,两秒是品牌展示策略,不是技术等待条件。
可以用一张决策表判断启动时长:
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 纯本地、初始化同步且很快 | 短暂展示或直接进首页 | 不需要假等待 |
| 首次数据迁移 | 显式进度状态 | 时长不可预测 |
| 必须登录 | 路由到登录或会话恢复页 | Splash 不处理账号交互 |
| 在线配置可降级 | 设超时并使用本地默认值 | 避免无限等待 |
| 初始化失败可重试 | 展示错误与重试按钮 | 不能自动跳到不可用首页 |
固定时长最大的问题不是"慢",而是它无法证明任何依赖已经就绪。如果异步任务 500ms 完成,用户仍等两秒;如果任务需要三秒,两秒后首页仍拿不到数据。启动协调器应该等待状态,品牌动画只负责视觉。
对当前项目而言,优化方向可以是把最短展示时长缩短到 600~1000ms,或仅在首次安装展示完整品牌页;但这属于产品调整,不应在没有需求和实机首帧数据时随意修改。
十、生命周期与异常路径怎么收口
启动链路至少要处理四类异常:
1. Splash 加载失败
证据位于 windowStage.loadContent() 回调。检查错误码、页面注册、资源引用与构建产物,不要先怀疑定时器。
2. 目标路由失败
当前代码没有消费 replaceUrl() 的失败结果。增强时应捕获 Promise 拒绝,记录目标路由,并保留重试或直接显示错误页的能力。
3. 页面提前消失
aboutToDisappear() 已清理定时器。更完整的实现还应把 timerId 复位为 -1,让状态可读:
typescript
private clearHomeTimer(): void {
if (this.timerId === -1) {
return
}
clearTimeout(this.timerId)
this.timerId = -1
}
aboutToDisappear(): void {
this.clearHomeTimer()
}
4. Ability 销毁
EntryAbility.onDestroy() 注销断点监听;窗口销毁时也移除避让区监听。启动页面虽然短暂,但应用级监听器不能依赖页面销毁自动释放。
typescript
onDestroy(): void {
if (this.mainWindow && this.avoidAreaCallback) {
this.mainWindow.off('avoidAreaChange', this.avoidAreaCallback)
}
BreakpointSystem.unregister()
}
这条链路说明生命周期清理要回到资源所有者:Splash 清自己的定时器,Ability 清窗口和媒体查询监听。页面不应该越权注销应用级断点系统。
十一、首屏视觉如何减少跳变
Splash 的根容器同时绑定透明度和缩放:
typescript
Column({ space: 18 }) {
// Logo、应用名、副标题、版本号
}
.width('100%')
.height('100%')
.backgroundColor(Colors.BACKGROUND)
.opacity(this.opacity_)
.scale({ x: this.scale_, y: this.scale_ })
初始 opacity_ = 0 意味着 ArkUI 页面刚加载时内容完全透明,只显示背景;随后 600ms 渐入。这个设计能让系统启动窗口与品牌内容平滑衔接,但也要求背景颜色稳定。如果根背景透明或深浅色不一致,就会暴露窗口底色。
版本号放在底部并设置 32vp 内边距,当前尚未直接叠加系统导航指示区。因为整个页面以 Blank().layoutWeight(1) 分配上下空间,常见手机上不会贴边;但在横屏、小窗和底部导航区域较高的设备上,仍应实测版本号是否进入避让区。
启动图标本身还要满足发布素材一致性:
- 包内
app_icon与 AGC 应用图标保持同源; - PNG 使用明确的不透明背景,避免深色桌面出现透明边缘;
- 四个数学符号在小尺寸下仍可辨认;
- 安装、桌面、系统启动窗口与应用内 Logo 不产生品牌错位。
视觉稳定不是给动画加更多效果,而是让系统窗口、ArkUI 背景、图标和首页第一帧形成连续画面。
十二、验证清单:不只测一次冷启动
本地构建通过只能证明代码可编译,启动链路还要覆盖时序和设备状态。建议按下面顺序验证。
冷启动
- 结束应用进程后从桌面启动;
- 检查系统启动窗口无黑闪、白块或错误图标;
- 检查 Splash 动画约 600ms 完成;
- 检查约 2 秒后只进入一次首页;
- 首页收藏、错题、进度等数据与上次退出前一致;
- 首页按返回键不会回到 Splash。
前后台切换
- Splash 出现后立刻切到后台;
- 等待超过两秒再回到前台;
- 观察是否发生不可见页面跳转或重复跳转;
- 再次进入时确认 Tab 状态符合产品预期。
多设备与窗口
| 设备/窗口 | 必查内容 |
|---|---|
| 手机竖屏 | Logo 居中、版本号不压导航区域 |
| 手机横屏 | 上下 Blank 不导致内容溢出 |
| 平板 | Splash 居中,首页导航模式符合断点设计 |
| 2in1 小窗 | 窗口缩放后无裁切,断点及时刷新 |
| 深色系统 | 当前锁定浅色后仍保持文字与系统栏可读 |
日志证据
建议按顺序确认:
text
Ability onCreate
Ability onWindowStageCreate
Succeeded in loading the content.
若第三行缺失,问题发生在窗口内容加载;若三行齐全但两秒后不跳转,再检查 Splash 生命周期和路由 Promise。
十三、常见问题与修复方向
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 点击图标后先黑一下 | 启动窗口背景、图标透明通道 | 统一不透明背景和页面底色 |
| Splash 永远不消失 | main_pages.json、目标 URL |
回读路由错误,不延长定时器 |
| 首页返回又见 Splash | 是否使用 pushUrl() |
改用 replaceUrl() |
| 进入首页后数字跳变 | 初始化是否异步、是否晚于加载页面 | 使用显式 ready 状态或提前初始化 |
| 快速前后台切换后重复跳转 | 定时器是否清理、是否重复调度 | 保存 timerId,增加一次性保护 |
| 平板仍显示手机底部栏 | 断点条件是否覆盖全部值 | 修正 sm/md/lg 分支判断 |
| 底部版本号被遮挡 | 导航指示区与小窗高度 | 使用避让区或动态底部间距 |
| 只有发布包白屏 | 资源路径、混淆、签名包日志 | 用 release 包做安装启动冒烟测试 |
这些问题可以按"系统窗口 -> Ability -> Splash -> Router -> Index"逐层缩小范围。不要把所有启动故障都塞进 Splash 页面解决。
十四、把启动页写成可维护的边界
口算王当前启动链路的优点是简单且可复核:EntryAbility 同步装入本地状态并注册运行时监听,WindowStage 明确加载 Splash,Splash 保存并清理定时器,最后用 replaceUrl() 让首页取代品牌页。对于无网络、无登录、无权限请求的本地训练应用,这套结构足够轻。
需要持续关注的地方也很明确:
- 两秒只是展示时长,不能替代异步就绪信号;
replaceUrl()应补充失败处理和一次性跳转保护;- 系统启动窗口与 Splash 背景要在深浅色和发布图标上保持一致;
Index的当前断点条件覆盖sm/md/lg全部值,侧栏分支不可达,需要在多设备版本中修正;- 发布前必须用正式包执行安装、冷启动、首页核心流程和卸载冒烟测试。
启动页真正的工程价值,不是多停留两秒,而是把系统首帧、运行时初始化、视觉过渡和业务首页接成一条没有重复职责的链路。只要每一层都能独立验证,白屏、回栈、数据跳变和生命周期泄漏就不再是只能靠运气复现的问题。
本文部分内容由 AI 辅助整理,所有实现边界、代码片段与结论均依据上述本地源码复核。