启动与主页封面

很多 HarmonyOS 应用的启动页和主页看起来只是两个页面:一个展示 Logo,一个展示 Tab。真正写到可上架、可维护的工程里,问题会集中在更细的位置:应用从 UIAbility 创建后先加载哪个页面,启动动画是否会在页面销毁后继续触发跳转,主页 Tab 的当前索引放在哪里,底部安全区如何从窗口避让区同步到页面,横竖屏或窗口宽度变化时断点状态如何进入 ArkUI 组件。

本文基于句匠 HarmonyOS 源码中的三个文件展开:entry/src/main/ets/entryability/EntryAbility.etsentry/src/main/ets/pages/SplashPage.etsentry/src/main/ets/pages/Index.ets。这三处代码构成了一个很典型的 Stage Model 启动链路:EntryAbility 负责应用级初始化和窗口装载,SplashPage 负责短暂的启动动画和无返回栈跳转,Index 负责主页内容区、主标签切换和系统安全区适配。本文只讨论源码中可复核的本地启动与主页能力,不扩展到登录启动、远程配置、广告开屏、深链分发、云端初始化或推送唤起。

本文唯一源码标识:com.jiaweikang.one18

一、为什么启动链路不能只看页面

在 ArkUI 里写启动页很容易:建一个 SplashPage,写 Logo、标题、动画,再用 router.replaceUrl 进入 Index。但如果只看页面层,很容易漏掉三类工程问题。

第一类是入口责任不清。应用真正进入页面前,UIAbility 已经完成了 onCreateonWindowStageCreate。如果用户数据、全局状态、断点监听、安全区监听散落在不同页面里,后续主页、收藏页、我的页都会各自兜底,最后形成重复初始化。句匠把这些入口动作集中在 EntryAbility,主页组件只读取已经同步到 AppStorage 的状态。

第二类是启动页生命周期问题。启动页一般会有延迟跳转,如果用户在延迟期间触发系统返回、应用被回收、窗口销毁,定时器还继续执行,就可能出现无效路由或重复跳转。句匠在 SplashPage 中保存 timerId,并在 aboutToDisappear 里清理,这是比单纯 setTimeout 更稳的写法。

第三类是主页不是孤立容器。主页 Tab 的选中态、红点、内容页、底部安全区、断点都要一致。Index.ets 不是只写五个按钮,它同时读取 currentTabIndexwrongRecordscurrentBreakpointtopAvoidAreaHeightPxnavigationIndicatorHeightPx,把入口层和页面层连接起来。

这也是本文的核心判断:启动页和主页的稳定性,不取决于动画是否好看,而取决于入口初始化、页面生命周期、共享状态、系统避让区四条线是否写清楚。

启动流程图

二、EntryAbility:把应用级初始化放在入口边界

句匠的入口文件继承自 UIAbility,它的 onCreate 里先做应用级准备,再把状态写入 AppStorage。这部分代码有几个关键点。

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  try {
    this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT)
  } catch (error) {
    hilog.warn(0x0000, 'EntryAbility', 'setColorMode failed')
  }

  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()
}

这段逻辑不是业务页面的一部分,放在 EntryAbility 更合理。原因有三点。

第一,UserDataManager.init(this.context) 依赖能力上下文。上下文属于运行时能力入口,不应该从普通页面组件里随意传递。入口处初始化后,页面只消费业务结果,能减少页面和平台能力之间的耦合。

第二,AppStorage.setOrCreate 明确了主页启动时的默认状态。currentTabIndex 初始为 0,意味着启动后默认落到第一个主标签;favoriteTabIndex 初始为 0,给收藏相关页面准备默认索引;topAvoidAreaHeightPxnavigationIndicatorHeightPx 初始为 0,保证即使窗口避让区读取失败,页面布局也有确定兜底值。

第三,BreakpointSystem.register() 放在入口处,说明断点监听不是某一个页面的私有逻辑,而是应用级布局环境。Index.ets 后续通过 @StorageLink('currentBreakpoint') 读取断点值,这就形成了入口注册、组件消费的单向流动。

这里还有一个小但重要的细节:设置浅色模式用 try/catch 包起来。源码中调用了 setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT),如果环境或生命周期导致调用失败,代码只记录 warn,不让启动链路直接崩溃。对于面向上架审核的应用,启动阶段的异常兜底非常重要,因为审核里的兼容性和运行稳定性会直接关注安装后是否能正常启动。

三、onWindowStageCreate:先加载 SplashPage,而不是直接进主页

EntryAbilityonWindowStageCreate 才是页面树真正装载的位置。句匠在这里做了两件事:获取主窗口并同步避让区,然后加载 pages/SplashPage

windowStage.getMainWindow((err, data) => {
  if (err.code) {
    hilog.error(0x0000, 'EntryAbility', 'getMainWindow failed')
    return
  }

  this.mainWindow = data
  this.updateNavigationIndicatorHeight()
  this.avoidAreaCallback = () => {
    this.updateNavigationIndicatorHeight()
  }
  this.mainWindow.on('avoidAreaChange', this.avoidAreaCallback)
})

windowStage.loadContent('pages/SplashPage', (err) => {
  if (err.code) {
    hilog.error(0x0000, 'EntryAbility', 'Failed to load content')
  }
})

这段代码体现了两个边界。

一个边界是窗口信息归入口层。系统导航指示条、状态栏、避让区都来自窗口对象,页面只需要知道“当前顶部要避让多少”“底部导航栏要额外留多少”。所以入口层把像素值写到 AppStorage,页面通过 px2vp 转为布局单位。这比每个页面自己读取窗口安全区更容易维护。

另一个边界是启动页作为首屏。loadContent('pages/SplashPage') 表示应用先加载启动页,而不是直接加载 Index。这让启动动画和正式主页分离,避免把启动页布局塞进 Index 的条件分支里。后续需要调整启动文案、Logo、版本号或动画时,只改 SplashPage 即可。

同时,源码在 onWindowStageDestroyonDestroy 中做释放动作:窗口避让区回调会被注销,断点系统会被 BreakpointSystem.unregister() 释放。这些生命周期闭环能避免应用多次创建窗口或销毁窗口后残留监听。

四、避让区同步:让主页不要压到底部系统区域

主页底部 Tab 最容易出问题的位置不是图标,而是底部安全区。不同设备、手势导航、横竖屏、窗口模式下,底部系统导航指示条高度可能不同。句匠的入口层读取两类避让区:TYPE_NAVIGATION_INDICATORTYPE_SYSTEM,然后写入 AppStorage

private updateNavigationIndicatorHeight(): void {
  if (!this.mainWindow) {
    AppStorage.setOrCreate<number>('topAvoidAreaHeightPx', 0)
    AppStorage.setOrCreate<number>('navigationIndicatorHeightPx', 0)
    return
  }

  try {
    const navArea = this.mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR)
    const systemArea = this.mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM)
    AppStorage.setOrCreate<number>('topAvoidAreaHeightPx', systemArea.topRect.height)
    AppStorage.setOrCreate<number>('navigationIndicatorHeightPx', navArea.bottomRect.height)
  } catch (error) {
    AppStorage.setOrCreate<number>('topAvoidAreaHeightPx', 0)
    AppStorage.setOrCreate<number>('navigationIndicatorHeightPx', 0)
  }
}

这段代码有明确的失败兜底:如果没有窗口,或者读取避让区失败,两个高度都回到 0。页面层不会因为没有避让区数据而拿到 undefined。在 ArkTS 里,这种确定的初始值比后续到处判空更稳。

Index.ets 中再把像素高度转成 vp,并设置一个最小底部留白。

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

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

private bottomNavHeight(): number {
  return Sizes.TAB_BAR_HEIGHT + this.bottomSafePadding()
}

这个设计对应的是 AppGallery 布局审核中非常常见的问题:底部按钮、底部 Tab、列表末尾操作区不能被系统手势区域遮挡。Sizes.BOTTOM_NAV_MIN_PADDING 保证即使系统返回 0,底部也仍有项目约定的最小距离;navigationIndicatorHeightPx 则让真实设备上的导航指示条高度能进入布局计算。

五、SplashPage:动画、延迟跳转和返回栈控制

启动页源码的结构很直接:两个状态控制动画,一个定时器控制跳转。

@State private opacity_: number = 0
@State private 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)
}

这里有两个点值得保留。

第一,动画状态只属于启动页。opacity_scale_ 都是 @State,它们不进入 AppStorage,也不影响主页。启动页消失后,这些状态自然跟随组件生命周期结束。很多应用会把“是否展示过启动页”写成全局状态,但句匠这段代码没有这样做。源码表达的是每次冷启动进入 SplashPage,播放一次简单入场动画,再进入主页。

第二,跳转使用的是 router.replaceUrl,不是 router.pushUrl。这点直接影响返回栈体验。启动页进入主页后,用户按返回键不应该回到 Splash;replaceUrl 会用主页替换当前路由,让启动页不留在返回路径上。对于上架应用,这是基础体验要求。

aboutToDisappear 的清理也很关键。

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

这段代码解决的是“页面已经离开,但延迟任务还在”的问题。即使启动页因为异常路径或生命周期变化提前消失,定时器也会被清掉,不会在不可见页面里继续触发路由。这是启动页可靠性的底线。

从布局看,SplashPage 根容器设置 width('100%')height('100%') 和背景色,内容居中,版本号为 v1.0.0。动画通过 .opacity(this.opacity_).scale({ x: this.scale_, y: this.scale_ }) 作用在根布局上。这个实现没有复杂条件分支,适合启动页这种生命周期短、展示目的明确的页面。

六、Index:主页的职责是容器,不是业务大杂烩

Index.ets 的核心不是某个业务模块,而是主页容器。它导入了五个页面:HomePageBankListPageExamTabFavoritePageMinePage,再根据 currentIndex 渲染对应内容。

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

这五个状态来源不同,但都和主页容器相关。

currentTabIndex 是主标签当前索引。它放在 AppStorage 中,使入口层可以给出默认值,也让主页内部多个构建函数共享选中态。

wrongRecords 用来控制收藏或错题相关 Tab 的角标。源码中在索引为 3wrongRecords.length > 0 时展示红色徽标。这里不需要额外维护一个 badgeCount 状态,直接从记录数组长度派生,减少了手动同步错误。

currentBreakpoint 来自 BreakpointSystem。实际断点系统定义了 smmdlg 三种值,分别对应 width<=600vp600vp<width<=840vp840vp<width

topAvoidAreaHeightPxnavigationIndicatorHeightPx 来自入口层窗口避让区同步,用来计算顶部和底部安全距离。

内容区通过 @Builder PageContent() 表达。

@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()
  }
}

这个写法的优点是清楚:主页负责“哪个 Tab 显示哪个页面”,具体页面逻辑仍在各自页面里。Index 没有把题库、考试、收藏、我的页面的业务规则混在一个文件里,这对后续维护很重要。

主页结构图

七、BottomNavItem:选中态和角标直接由状态派生

底部导航项由 BottomNavItem 构建函数生成。它读取 currentIndex 判断是否选中,然后决定图标、文字颜色、字重和点击行为。

@Builder
BottomNavItem(item: TabItem, index: number) {
  Stack({ alignContent: Alignment.TopEnd }) {
    Column() {
      Image(this.currentIndex === index ? item.selectedIcon : item.icon)
      Text(item.title)
        .fontColor(this.currentIndex === index ? Colors.PRIMARY : Colors.TEXT_SECONDARY)
        .fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Normal)
    }
    .onClick(() => {
      this.currentIndex = index
    })

    if (index === 3 && this.wrongRecords.length > 0) {
      Text(this.wrongRecords.length > 99 ? '99+' : this.wrongRecords.length.toString())
    }
  }
}

这里有一个实用原则:UI 不额外保存“是否选中”的状态。选中态只由 currentIndex === index 判断,角标只由 wrongRecords.length 判断。这样点击 Tab 时只改一个值,所有图标和文字样式会随状态重新计算。

对于主 Tab,最怕的是图标选中、文字选中、内容区显示三者不同步。句匠这段代码把三者都绑定到 currentIndex,同步边界很明确:点击某个 Tab,只修改 currentIndex;内容区和导航项都从这个状态读取。

角标处理也有上限表达:超过 99 时显示 99+。这避免了数字过长导致徽标撑开或覆盖图标。源码里只在第 4 个 Tab,也就是索引 3 的位置展示该角标,这一点要按真实代码说明,不能泛化为所有 Tab 都有消息红点。

八、断点系统:源码里有三档断点,也有一个需要注意的边界

BreakpointSystem.ets 使用 mediaquery.matchMediaSync 注册三档宽度:

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

匹配结果更新到 AppStoragecurrentBreakpoint。这是一种适合 ArkUI 的做法:监听逻辑在工具类里,页面通过 @StorageLink 获得响应式值。

但阅读 Index.ets 时要注意一个边界。主页 build 中判断:

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

而当前 BreakpointSystem 只会产生 smmdlg 三种值。这意味着在现有断点系统下,sm/md/lg 都会进入底部导航分支,侧边导航分支更多像是一个备用结构或未来扩展分支。文章不能声称“平板一定切到侧边导航”,因为源码并不支持这个结论。

这类边界在技术文章里必须写清楚。真实源码比“看起来应该如此”的推断更重要。能复核的结论是:句匠已经有断点系统,Index 已读取 currentBreakpoint,并存在底部导航和侧边导航两套结构;但以当前断点枚举和条件判断看,正常 sm/md/lg 都走底部导航。

九、主页布局:内容区、底部栏和安全区分层

底部导航模式下,Index 使用 Stack 放内容区,再用底部 Row 承载五个 BottomNavItem。内容区 layoutWeight(1),底部栏高度通过 bottomNavHeight() 计算,底部留白通过 bottomSafePadding() 追加。

这种结构的好处是内容区和导航栏职责分离。内容区只负责当前页面,底部栏只负责主导航;安全区不需要每个业务页面单独处理。对于首页、题库、考试、收藏、我的这些一级页面,统一由容器提供底部保护,能避免某个页面忘记留出系统手势区。

如果后续需要优化宽屏体验,应该在现有结构上做两个明确动作:一是让 BreakpointSystemIndex 的条件保持一致,例如 lg 是否应该进入侧边导航;二是验证侧边导航分支里的徽标、选中态、内容区宽度和安全区是否与底部导航一致。不能只改 UI 分支,不改断点语义。

十、这条链路适合怎样复用

句匠这条启动与主页链路可以抽象成四步。

第一步,入口层初始化应用级依赖。包括颜色模式、用户数据管理、全局默认状态、断点监听和窗口避让区监听。这些逻辑放在 EntryAbility,避免散落到页面。

第二步,入口层加载启动页。windowStage.loadContent('pages/SplashPage') 让启动体验独立于主页容器,启动页只承担短展示和跳转,不掺入主业务。

第三步,启动页用生命周期管理延迟任务。aboutToAppear 中创建动画和定时器,aboutToDisappear 中清理定时器,跳转使用 router.replaceUrl,避免返回栈里残留启动页。

第四步,主页容器统一承接主标签、内容区、红点、安全区和断点。currentTabIndex 驱动内容区和导航项选中态,wrongRecords.length 驱动徽标,窗口避让区驱动底部安全留白。

这四步并不复杂,但它们解决的是上架应用最常见的稳定性问题:启动不崩、跳转不乱、返回栈正常、底部不遮挡、状态不分叉。

十一、源码边界和审核视角

从 AppGallery 审核角度看,这篇源码对应的是兼容性和基础体验,不是新业务能力。真实可复核的点包括:

  1. EntryAbility 使用 Stage Model 生命周期完成初始化和 loadContent('pages/SplashPage')
  2. SplashPage 使用 animateTo 控制透明度和缩放,2 秒后 router.replaceUrl({ url: 'pages/Index' })
  3. SplashPageaboutToDisappear 清理 setTimeout
  4. Index 使用 @StorageLink('currentTabIndex') 控制主标签和内容页。
  5. Index 使用 @StorageLink('navigationIndicatorHeightPx') 计算底部安全留白。
  6. wrongRecords.length 只在索引 3 的导航项上形成徽标。
  7. 当前断点系统只定义 smmdlg,不能宣传源码已经在大屏下必然启用侧边导航。

不属于本文结论的内容也要明确排除:源码中没有基于这三个文件实现账号登录启动、云端配置下发、广告开屏、推送唤起、深链参数分发、远程 AB 实验或多设备流转。技术文章如果把这些能力写进去,就是对源码能力的伪造。

十二、落地建议

如果你正在写一个 HarmonyOS 5.0 以上的 ArkTS 应用,可以直接按这几个检查点审视启动和主页:

  1. UIAbility.onCreate 是否只做入口级初始化,不把页面业务塞进去。
  2. onWindowStageCreate 是否明确加载首屏页面,并处理加载错误。
  3. 启动页的延迟跳转是否在 aboutToDisappear 中清理。
  4. 从启动页到主页是否用 replaceUrl,避免返回到启动页。
  5. 主 Tab 的选中态、内容区和样式是否全部由同一个索引驱动。
  6. 角标是否由真实数据派生,而不是手写另一个容易不同步的计数。
  7. 底部导航是否考虑系统导航指示条和最小安全留白。
  8. 断点枚举和布局分支是否一致,避免写了侧边栏但永远进不去。

句匠这段源码的价值不在于炫技,而在于边界清楚。入口层负责初始化和窗口信息,启动页负责短生命周期动画和跳转,主页负责主容器状态,业务页面负责各自内容。只要这个分工稳定,后续增加新 Tab、调整启动页视觉、优化大屏布局,都不会牵一发而动全身。

AI 辅助声明

本文由 AI 辅助生成和整理,内容基于 com.jiaweikang.one18 项目中 EntryAbility.etsSplashPage.etsIndex.ets 及断点工具源码进行人工复核;文章中的能力描述仅覆盖上述源码可验证范围。

Logo

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

更多推荐