【口算王|12】HarmonyOS ArkTS 启动页实战:处理 Splash 到训练首页的稳定切换

启动页最容易被误判成"放一张 Logo,等两秒,再跳首页"。真正进入工程阶段后,问题往往出在两个页面之间:系统启动窗口刚消失,ArkUI 页面却还没绘制,出现短暂白闪;用户把应用切到后台,定时器仍在推进路由;启动页被压入路由栈,进入首页后按返回键又看见一次品牌动画;本地数据尚未装入 AppStorage,首页先按空数组渲染,随后数字突然跳变。

口算王的实现把启动过程分成了三个连续阶段:系统启动窗口、EntryAbility 初始化、ArkUI SplashPage。其中数据读取和断点注册都发生在加载页面之前,Splash 只承担品牌过渡和固定展示时长,不把两秒定时器伪装成"等待数据加载"。这条边界对 HarmonyOS 5.0 及以上的 Stage 模型应用很重要。

本文基于口算王项目 D:\huawei\one16-11 的真实源码,复核 module.json5main_pages.jsonEntryAbility.etsSplashPage.etsIndex.etsUserDataManager.etsBreakpointSystem.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 中的 startWindowIconstartWindowBackground 在 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"
      }
    ]
  }
}

这里有四个工程含义。

  1. 系统知道首个生命周期对象是 EntryAbility
  2. ArkUI 路由只允许访问 main_pages.json 注册过的页面。
  3. 启动窗口复用正式应用图标,减少"桌面图标与启动图标不一致"的审核风险。
  4. 当前 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' })

这里的字符串必须与页面清单完全对应。目录名、大小写、后缀和注册路径任何一个不一致,都可能让定时器按时触发,但页面没有切换。由于错误发生在两秒后,开发者很容易误以为是定时器失效。

排查这种问题时,顺序应该是:

  1. 检查 main_pages.json 是否含有 pages/Index
  2. 检查目标文件是否真是 entry/src/main/ets/pages/Index.ets
  3. 检查当前模块加载的是否是同一份 profile;
  4. 最后再看 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 读取收藏、笔记、错题、学习进度和设置,然后写入 AppStorageBreakpointSystem.register() 同步创建媒体查询监听器并写入初始断点。也就是说,当前项目的首页数据依赖在 loadContent() 之前已经准备完毕。

这种顺序有三个直接收益:

  • Index 第一次构建时就能读取 currentTabIndexcurrentBreakpoint
  • 首页、收藏页和"我的"页面拿到的是同一组 AppStorage 数据;
  • Splash 不需要知道收藏记录、错题记录或屏幕宽度的内部细节。

如果初始化未来改成异步数据库迁移或在线配置,不能继续靠固定两秒"赌它完成"。更稳的做法是引入显式启动状态,例如 initializingreadyfailed,由 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 只会产生 smmdlg 三个值,因此上述条件对所有已知断点都为真,侧边导航分支不会进入。这不阻断 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 背景、图标和首页第一帧形成连续画面。

十二、验证清单:不只测一次冷启动

本地构建通过只能证明代码可编译,启动链路还要覆盖时序和设备状态。建议按下面顺序验证。

冷启动

  1. 结束应用进程后从桌面启动;
  2. 检查系统启动窗口无黑闪、白块或错误图标;
  3. 检查 Splash 动画约 600ms 完成;
  4. 检查约 2 秒后只进入一次首页;
  5. 首页收藏、错题、进度等数据与上次退出前一致;
  6. 首页按返回键不会回到 Splash。

前后台切换

  1. Splash 出现后立刻切到后台;
  2. 等待超过两秒再回到前台;
  3. 观察是否发生不可见页面跳转或重复跳转;
  4. 再次进入时确认 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 辅助整理,所有实现边界、代码片段与结论均依据上述本地源码复核。

相关推荐
万物智能信息科技2 小时前
RK3568 的多路显示移植—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
linux·开发语言·华为·开源·harmonyos
万物智能信息科技3 小时前
MIPI DSI屏幕输出—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
嵌入式硬件·华为·开源·harmonyos·鸿蒙
贾伟康4 小时前
【口算王|13】HarmonyOS ArkTS 应用启动链路实战:从 EntryAbility 到首屏加载保持窗口与路由稳定
harmonyos·arkts·arkui·应用启动·entryability
万物智能信息科技6 小时前
LVDS屏幕输出桌面—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
人工智能·华为·开源·harmonyos·鸿蒙
知潮网7 小时前
HarmonyOS 7正式发布:华为分享远程直传无距离限制,还能和iPhone、Apple Watch互联
华为·iphone·harmonyos
OH_TPC9 小时前
HarmonyOS APP开发---“好物优选“电商导购App,需要用到这个库
华为·harmonyos·鸿蒙
lqj_本人20 小时前
Flutter 三方库「flutter_ble_peripheral」的鸿蒙化适配指南
flutter·华为·harmonyos
lqj_本人21 小时前
Flutter 三方库「flutter-dualscreen」的鸿蒙化适配指南
flutter·华为·harmonyos
熊猫钓鱼>_>1 天前
【SenseNova U1.5 Lite实战】鸿蒙校园工具开发者适配原生统一多模态大模型全记录
人工智能·华为·ai·harmonyos·媒体·sensenova