应用启动链路封面

HarmonyOS 应用的首屏稳定性,不只取决于某个页面能否成功构建。系统如何找到入口 Ability、运行时状态何时恢复、断点监听何时注册、窗口避让区何时可读、启动页如何进入主页,以及销毁时是否解除监听,共同组成一条完整启动链路。任意一步顺序混乱,都可能表现为首屏白屏、数据闪烁、底部导航被遮挡、窗口变化后布局不更新,或者返回键又退回启动页。

中国方言题库当前版本采用 Stage 模型的 UIAbilityEntryAbilityonCreate() 中初始化主题、用户数据、页面共享状态与断点系统,在 onWindowStageCreate() 中取得主窗口、监听避让区并加载 SplashPage,启动页完成渐入后使用 router.replaceUrl() 进入 Index。本文面向 HarmonyOS 5.0 及以上版本,逐段复核这条真实 ArkTS 链路,不把未实现的启动优化或监控能力写成现有功能。

本文唯一核验标记:启动状态先就绪,窗口创建后再装载首个页面

一、本文复核哪些真实文件

核心证据来自以下源码与配置:

entry/src/main/module.json5
entry/src/main/resources/base/profile/main_pages.json
entry/src/main/ets/entryability/EntryAbility.ets
entry/src/main/ets/pages/SplashPage.ets
entry/src/main/ets/pages/Index.ets
entry/src/main/ets/common/components/TopBar.ets
librarya/src/main/ets/utils/UserDataManager.ets
libraryb/src/main/ets/utils/BreakpointSystem.ets

这条链路覆盖“系统入口 -> Ability 生命周期 -> 窗口 -> 启动页 -> 主页”以及共享状态的准备与清理。源码没有接入远端启动配置、账号登录、热修复或启动性能上报,因此本文不会虚构这些能力。

二、module.json5 决定系统从哪里进入

模块配置明确声明:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "mainElement": "EntryAbility",
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "exported": true
      }
    ]
  }
}

mainElementabilities[].name 指向同一个 EntryAbilitysrcEntry 再把它绑定到 ArkTS 文件。系统不是从 Index.ets 直接启动,而是先创建 UIAbility,随后由 Ability 的窗口阶段加载首个页面。

三、桌面启动技能如何匹配 EntryAbility

配置中的技能声明包含:

"skills": [
  {
    "entities": ["entity.system.home"],
    "actions": ["ohos.want.action.home"]
  }
]

它表达应用可以作为桌面入口启动。exported: true 与入口技能共同构成外部启动边界。文章关注正常桌面启动流程,不推断未在代码中处理的深链、分享 Want 或多实例策略。

四、页面清单先注册,运行时才能按路径加载

main_pages.json 注册了启动页、主页和各业务页:

{
  "src": [
    "pages/SplashPage",
    "pages/Index",
    "pages/BankDetailPage",
    "pages/PracticePage",
    "pages/SearchPage",
    "pages/LearningStatsPage",
    "pages/SettingsPage"
  ]
}

windowStage.loadContent('pages/SplashPage') 和后续的 router.replaceUrl({ url: 'pages/Index' }) 都依赖这里的路径。文件存在但没有注册,或者注册路径与调用字符串不一致,都会破坏启动或导航链路。

五、onCreate 是运行时状态准备阶段

EntryAbilityonCreate() 依次执行四类工作:

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  // 1. 设置颜色模式
  // 2. 恢复用户数据
  // 3. 初始化页面共享状态
  // 4. 注册断点系统
}

此时 Ability 已创建,但窗口阶段尚未装载页面。把页面要读取的状态放在这里准备,能减少首个组件构建后再补数据造成的闪动。

六、颜色模式在页面加载前锁定为浅色

源码首先尝试:

this.context
  .getApplicationContext()
  .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT)

调用包裹在 try/catch 中,失败时通过 hilog.error() 记录,不阻断后续启动。当前产品策略是固定浅色,而不是跟随系统深浅色切换。主题常量也以浅色背景和深色文字为主。

这意味着测试时仍需在系统深色模式下启动应用,确认系统栏、输入控件和资源没有被意外反转;不能仅因为调用了 setColorMode() 就省略验证。

七、用户数据为什么必须先于首屏恢复

onCreate() 调用:

UserDataManager.init(this.context)

UserDataManager 使用 Preferences 同步读取收藏、笔记、错题、题库进度、考试历史、章节进度和学习设置,再写入 AppStorage

AppStorage.setOrCreate<FavoriteRecord[]>('favoriteRecords', ...)
AppStorage.setOrCreate<BankProgress[]>('bankProgress', ...)
AppStorage.setOrCreate<ExamHistory[]>('examHistory', ...)

主页、题库卡片、收藏页和“我的”页面都通过 @StorageLink 消费这些状态。如果先加载页面、后恢复数据,用户可能先看到零进度,再看到真实值跳变。当前顺序把恢复动作放在页面装载之前。

八、Preferences 读取失败时如何保持可启动

初始化逻辑整体包在 try/catch 中。发生 Preferences 获取、读取或 JSON 解析异常时,会为各状态写入安全默认值:

AppStorage.setOrCreate<FavoriteRecord[]>('favoriteRecords', [])
AppStorage.setOrCreate<BankProgress[]>('bankProgress', [])
AppStorage.setOrCreate<number>('examDurationSec', 1800)
AppStorage.setOrCreate<boolean>('autoNextQuestion', false)

这保证数据损坏不会直接阻止首屏构建,但代价是当前实现对多个键采用同一个异常边界:任意一项解析失败,都可能让本次运行的全部状态回退默认值。本文只描述真实行为,不把它说成逐字段容错。

九、setOrCreate 避免重复覆盖已存在状态

Ability 使用 AppStorage.setOrCreate() 初始化共享键。该模式适合启动阶段:键不存在时创建,已经存在时按照 API 语义更新或复用对应状态入口,页面侧可统一使用 @StorageLink

当前显式准备的 UI 键包括:

AppStorage.setOrCreate<number>('currentTabIndex', 0)
AppStorage.setOrCreate<number>('favoriteTabIndex', 0)
AppStorage.setOrCreate<number>('topAvoidAreaHeightPx', 0)
AppStorage.setOrCreate<number>('navigationIndicatorHeightPx', 0)

Tab 索引和避让区先有默认值,即使窗口测量暂时失败,首屏也有可用状态。

十、断点系统也要在页面构建前注册

BreakpointSystem.register() 创建三组媒体查询:

mediaquery.matchMediaSync('(width<=600vp)')
mediaquery.matchMediaSync('(600vp<width<=840vp)')
mediaquery.matchMediaSync('(840vp<width)')

注册监听后,它立即做一次初始判断,并写入:

AppStorage.setOrCreate<string>('currentBreakpoint', bp)

因此 Index、首页、题库页和考试页首次构建时就能读取当前断点,而不是统一先按手机布局渲染,再等待一次窗口变化事件。

十一、onWindowStageCreate 才进入窗口阶段

窗口阶段创建时,源码先记录生命周期日志,然后通过:

this.mainWindow = windowStage.getMainWindowSync()

保存主窗口引用。避让区属于窗口能力,只有拿到窗口后才能查询和监听。把这些逻辑放在 onCreate() 会混淆 Ability 生命周期与窗口生命周期。

十二、首帧之前先计算系统避让区

拿到主窗口后立即执行:

this.updateNavigationIndicatorHeight()

方法读取两类区域:

const navigationArea =
  this.mainWindow.getWindowAvoidArea(
    window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR
  )
const systemArea =
  this.mainWindow.getWindowAvoidArea(
    window.AvoidAreaType.TYPE_SYSTEM
  )

顶部取系统区域的 topRect.height,底部取导航指示区域和系统区域高度的较大值。结果仍以像素保存到 AppStorage,页面使用时再通过 px2vp() 转换。

十三、为什么底部取两个高度的最大值

不同设备、导航方式与窗口状态下,可见的底部避让来源可能不同。源码计算:

Math.max(navigationHeight, systemHeight)

避免只读取一种区域时低估底部空间。Index 再把它与 Sizes.BOTTOM_NAV_MIN_PADDING 比较,给底部导航保留至少 28vp。

这种处理不是固定写死某款手机的导航栏高度,而是由窗口实际区域驱动。

十四、避让区变化监听如何保持窗口变化稳定

EntryAbility 注册:

this.avoidAreaCallback = (data: window.AvoidAreaOptions) => {
  if (
    data.type === window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR ||
    data.type === window.AvoidAreaType.TYPE_SYSTEM
  ) {
    this.updateNavigationIndicatorHeight()
  }
}

this.mainWindow.on('avoidAreaChange', this.avoidAreaCallback)

系统栏、导航方式或窗口形态变化时,相关状态会重新计算。页面通过 @StorageLink 读取这些值,因此无需每个页面分别订阅窗口事件。

从 Ability 创建到主页显示的启动流程

十五、窗口查询失败为何回退到零

获取主窗口、初次查询或监听注册处于 try/catch 中。异常时:

AppStorage.setOrCreate<number>('topAvoidAreaHeightPx', 0)
AppStorage.setOrCreate<number>('navigationIndicatorHeightPx', 0)

并写入 hilog.warn()。回退为零的目标是继续装载页面,而不是因避让区能力异常阻塞启动。页面自身还会叠加最小安全边距,降低内容直接贴边的风险。

十六、首个内容页为何是 SplashPage

窗口状态准备后,Ability 调用:

windowStage.loadContent('pages/SplashPage', (err) => {
  if (err.code) {
    hilog.error(
      DOMAIN,
      'testTag',
      'Failed to load the content. Cause: %{public}s',
      JSON.stringify(err)
    )
    return
  }
  hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.')
})

loadContent() 的回调明确区分失败和成功。失败只记录日志并返回,源码没有备用页面或自动重试,因此不能宣称已有启动故障恢复 UI。

十七、启动页动画不会承担数据初始化

SplashPage 的职责很窄:展示品牌、执行 600ms 渐入,并在 2 秒后跳转主页。

aboutToAppear(): void {
  animateTo({ duration: 600, curve: Curve.EaseOut }, () => {
    this.opacity_ = 1
    this.scale_ = 1
  })

  this.timerId = setTimeout(() => {
    router.replaceUrl({ url: 'pages/Index' })
  }, 2000)
}

用户数据和断点都已在 Ability 中准备,启动页不再发起存储读取或窗口监听。这避免动画组件同时承担业务初始化,职责更清楚。

十八、replaceUrl 比 pushUrl 更适合启动页

启动页进入主页使用 router.replaceUrl(),不是 pushUrl()。替换当前路由后,启动页不会作为普通历史页面留在栈中。

用户进入主页后按系统返回,不应再次看到两秒启动动画。这个选择直接关系到首屏后的返回行为,是启动链路稳定的一部分。

十九、定时器为何在离开页面时清理

启动页保存 timerId,并在:

aboutToDisappear(): void {
  if (this.timerId !== -1) {
    clearTimeout(this.timerId)
  }
}

中清理。若页面因外部导航或生命周期变化提前离开,残留定时器不会在之后再次触发主页替换。当前逻辑没有在 clearTimeout 后把 ID 重置为 -1,但页面已经离开,不影响本次职责。

二十、Index 首屏如何消费启动状态

Index 不是静态首页,而是五个 Tab 的产品容器。它读取:

@StorageLink('currentTabIndex') currentIndex: number = 0
@StorageLink('wrongRecords') wrongRecords: WrongRecord[] = []
@StorageLink('currentBreakpoint') currentBp: string = 'sm'
@StorageLink('topAvoidAreaHeightPx') topAvoidAreaHeightPx: number = 0
@StorageLink('navigationIndicatorHeightPx')
navigationIndicatorHeightPx: number = 0

这些键全部在 EntryAbility.onCreate() 或用户数据初始化中准备。主页第一次构建就能决定当前 Tab、错题徽标、导航形态与安全边距。

二十一、主页的布局选择与断点状态联动

Index 根据 currentBp 选择底部导航或侧边导航。当前源码把 smmdlg 都放入底部导航分支,只有其他值进入侧栏分支:

if (
  this.currentBp === 'sm' ||
  this.currentBp === 'md' ||
  this.currentBp === 'lg'
) {
  // 底部导航
} else {
  // 侧边导航
}

BreakpointSystem 的类型只产生 sm | md | lg,因此按当前真实代码,侧边导航分支不会由该断点系统触发。不能把注释中的“平板/折叠屏侧边栏”描述成当前可达行为;这是源码复核时必须指出的边界。

二十二、顶部避让区如何传递到页面

Index 使用:

private topSafePadding(): number {
  return Math.max(
    Sizes.PADDING_SMALL,
    this.getUIContext().px2vp(this.topAvoidAreaHeightPx)
  )
}

顶层容器把该值作为顶部 padding。二级页面的 TopBar 也读取同一个 topAvoidAreaHeightPx,将像素转为 vp 后增加导航条高度与顶部 padding。

窗口事件只在 Ability 里维护一次,页面只消费结果,减少每个页面重复访问 window API。

二十三、底部导航如何避免进入手势区域

Index 的底部安全边距为:

private bottomSafePadding(): number {
  return Math.max(
    Sizes.BOTTOM_NAV_MIN_PADDING,
    this.getUIContext().px2vp(
      this.navigationIndicatorHeightPx
    )
  )
}

底部导航总高度等于 Tab 栏高度加安全边距。即使窗口返回的避让区为零,仍保留最小 28vp;若设备实际区域更高,则采用真实值。

二十四、启动链路的五层职责

应用启动链路的职责边界

可以把当前实现拆成五层:

Manifest   -> 声明 EntryAbility 与页面清单
Ability    -> 初始化运行时状态与生命周期资源
Window     -> 查询并监听系统避让区
Splash     -> 品牌动画与一次性路由替换
Index      -> 消费状态并渲染产品首屏

这五层没有互相替代:页面配置不能恢复用户数据,启动页不该注册全局媒体查询,业务页面也不应各自维护窗口监听。

二十五、销毁时为什么必须解除避让区监听

onWindowStageDestroy() 中执行:

if (this.mainWindow && this.avoidAreaCallback) {
  this.mainWindow.off('avoidAreaChange', this.avoidAreaCallback)
}
this.mainWindow = undefined
this.avoidAreaCallback = undefined

窗口阶段销毁后,旧窗口对象不应继续回调 Ability。清除引用也避免后续误用已经失效的窗口。

二十六、onDestroy 还负责断点系统注销

Ability 最终销毁时:

BreakpointSystem.unregister()

内部对三组 MediaQueryListener 调用 off('change')。这与 register() 形成对称生命周期,避免 Ability 重建后累积重复监听。

onDestroy() 也再次检查并解除避让区监听。正常情况下窗口阶段销毁已经清空引用,这个判断不会重复操作;它为不同销毁顺序提供了额外保护。

二十七、日志覆盖了哪些关键节点

当前 hilog 记录:

Ability onCreate
Ability onWindowStageCreate
Succeeded / Failed in loading the content
Ability onForeground
Ability onBackground
Ability onWindowStageDestroy
Ability onDestroy

还有颜色模式与避让区异常日志。它们足以判断生命周期走到哪一步,但没有记录启动耗时、首帧时间、Preferences 数据量或页面替换耗时,因此本文不会给出虚构性能指标。

二十八、前后台切换当前只记录日志

onForeground()onBackground() 只有 hilog.info(),没有暂停定时任务、刷新数据或重新注册窗口监听。

这是符合当前离线题库场景的简化实现:共享数据主要由页面操作更新,断点与窗口监听在生命周期内保持。若未来加入音频播放、联网同步或后台任务,才需要扩展前后台策略。

二十九、启动过程没有网络依赖

module.json5 声明了 ohos.permission.INTERNET,但本文复核的启动链路中没有 HTTP 请求、登录校验或远端配置。UserDataManager 从本地 Preferences 同步恢复数据,SplashPage 只执行动画与路由替换。

因此可以准确说“当前启动链路不等待网络”,但不能进一步推断整个应用完全不使用网络;权限与其他源码仍需单独审计。

三十、当前启动页不是系统起始窗口

模块 Ability 配置还声明:

"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background"

这是系统在应用内容可用前展示的起始窗口材料;随后 loadContent() 才装载 ArkUI 的 SplashPage。两者不是同一个阶段。若图标、背景色和 SplashPage 视觉差异太大,用户会感到跳变,所以发布前应同时检查系统起始窗口和应用启动页。

三十一、loadContent 失败时当前有什么结果

回调检测 err.code 后记录错误并 return。源码没有再次调用 loadContent(),也没有加载本地错误页。换言之,当前策略是“保留日志证据,不进行自动恢复”。

工程上首先应保证 main_pages.json 注册正确、页面能编译、资源完整,再用真实设备检查启动。盲目重试无法修复路径或构建问题,还可能让错误更难定位。

三十二、启动状态初始化的顺序为何重要

当前顺序可以概括为:

锁定颜色模式
  -> 恢复持久化数据
  -> 创建 UI 默认状态
  -> 注册并计算断点
  -> 获取主窗口
  -> 计算和监听避让区
  -> loadContent(SplashPage)
  -> replaceUrl(Index)

如果把断点注册放到主页之后,首屏可能先按默认 sm 构建;如果把数据恢复放到启动页定时器之后,主页可能短暂显示空收藏与零进度;如果先加载内容再计算避让区,顶部和底部布局可能出现一次位移。

三十三、异常边界应当“降级但不伪装成功”

颜色模式和避让区异常会记录日志并继续启动,数据恢复异常会使用默认状态,loadContent 失败则记录错误并停止后续成功日志。不同失败采用不同策略,是因为影响范围不同:

主题设置失败      -> 页面仍可能可用
避让区查询失败    -> 使用默认边距继续
本地数据解析失败  -> 使用空状态继续
首内容加载失败    -> 无可用页面,记录硬失败

这种区分比所有异常都吞掉更容易排查,也比所有异常都终止更符合用户可用性。

三十四、真实测试应覆盖冷启动与窗口变化

建议至少执行以下验证:

1. 清数据冷启动:SplashPage 出现,2 秒后进入 Index
2. 有历史数据冷启动:主页与题库进度首次显示即正确
3. 启动后返回:不回到 SplashPage
4. 系统深色模式启动:应用仍保持设计的浅色可读性
5. 旋转或调整窗口:currentBreakpoint 随宽度更新
6. 导航方式变化:底部安全边距重新计算
7. 前后台切换:页面状态不丢失、无重复监听表现
8. 快速离开 SplashPage:定时器不会再次替换路由
9. 页面清单故意错误的开发环境验证:日志能定位 loadContent 失败
10. Ability 销毁重建:媒体查询与窗口监听不重复累积

发布验证还应覆盖安装、启动、核心流程和卸载,而不是只在预览器中观察动画。

三十五、当前实现仍有三个明确边界

第一,启动页跳转固定等待 2 秒,不根据初始化耗时动态结束;不过初始化本身在前面同步完成,因此定时器主要承担品牌展示。

第二,loadContent 失败没有用户可见兜底页。当前通过静态页面清单和构建验证降低风险。

第三,BreakpointSystem 只产生 smmdlg,而 Index 把三者全部映射到底部导航,注释中的侧栏分支当前不可达。要实现侧栏,需要统一断点契约与容器判断,而不是只改注释。

三十六、可演进但不能冒进的优化

在真实监控或设备证据支持下,可以考虑:

  1. 给启动阶段增加分段耗时记录,但避免输出隐私数据。
  2. 将 Preferences 每个键的解析隔离,减少单项损坏影响。
  3. loadContent 失败设计本地错误页,而不是无限重试。
  4. 统一 BreakpointTypeIndex 的导航形态映射。
  5. 校验系统起始窗口与 SplashPage 的背景、图标和主题一致。
  6. 若将来有异步初始化,再以明确的 ready 状态替代固定等待。

这些是基于现有结构的演进方向,不代表当前版本已完成。

三十七、结语

中国方言题库的启动链路没有把全部逻辑塞进首个页面,而是遵循清晰顺序:模块配置声明入口,EntryAbility.onCreate() 准备共享状态,窗口阶段维护避让区,SplashPage 只负责品牌展示和一次性路由替换,Index 消费已经就绪的数据、断点与安全边距。

这条链路真正保障的是可预测性:页面加载前状态已准备,窗口能力只在窗口存在时访问,监听在销毁时解除,启动页不会残留在返回栈。与此同时,源码没有启动耗时监控、可见错误页、网络启动依赖或可达侧边导航,技术文章必须保留这些边界,才能让结论经得起复核。

AI 辅助声明:本文由 AI 辅助整理与润色,生命周期顺序、页面路径、状态键、窗口避让区算法和实现边界均依据项目真实源码复核。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐