【句匠|12】HarmonyOS ArkTS 启动与主页实战:把 Splash、Index 和主标签切换写稳
【句匠12】HarmonyOS ArkTS 启动与主页实战把 Splash、Index 和主标签切换写稳证据边界本文事实来自对 EntryAbility、SplashPage 与 Index 当前源码的静态核对。可以确认启动页动画和定时替换、五个主标签、AppStorage 共享索引、窗口安全区换算以及底部栏与侧边栏分支本轮未执行构建、真机启动、横竖屏、窗口缩放或进程恢复因此运行表现均列为待验证项。很多 HarmonyOS 应用的启动页和主页看起来只是两个页面一个展示 Logo一个展示 Tab。真正写到可上架、可维护的工程里问题会集中在更细的位置应用从UIAbility创建后先加载哪个页面启动动画是否会在页面销毁后继续触发跳转主页 Tab 的当前索引放在哪里底部安全区如何从窗口避让区同步到页面横竖屏或窗口宽度变化时断点状态如何进入 ArkUI 组件。本文基于句匠 HarmonyOS 源码中的三个文件展开entry/src/main/ets/entryability/EntryAbility.ets、entry/src/main/ets/pages/SplashPage.ets和entry/src/main/ets/pages/Index.ets。这三处代码构成了一个很典型的 Stage Model 启动链路EntryAbility负责应用级初始化和窗口装载SplashPage负责短暂的启动动画和无返回栈跳转Index负责主页内容区、主标签切换和系统安全区适配。本文只讨论源码中可复核的本地启动与主页能力不扩展到登录启动、远程配置、广告开屏、深链分发、云端初始化或推送唤起。本文唯一源码标识当前 Bundle Name已脱敏。一、为什么启动链路不能只看页面在 ArkUI 里写启动页很容易建一个SplashPage写 Logo、标题、动画再用router.replaceUrl进入Index。但如果只看页面层很容易漏掉三类工程问题。第一类是入口责任不清。应用真正进入页面前UIAbility已经完成了onCreate和onWindowStageCreate。如果用户数据、全局状态、断点监听、安全区监听散落在不同页面里后续主页、收藏页、我的页都会各自兜底最后形成重复初始化。句匠把这些入口动作集中在EntryAbility主页组件只读取已经同步到AppStorage的状态。第二类是启动页生命周期问题。启动页一般会有延迟跳转如果用户在延迟期间触发系统返回、应用被回收、窗口销毁定时器还继续执行就可能出现无效路由或重复跳转。句匠在SplashPage中保存timerId并在aboutToDisappear里清理这是比单纯setTimeout更稳的写法。第三类是主页不是孤立容器。主页 Tab 的选中态、红点、内容页、底部安全区、断点都要一致。Index.ets不是只写五个按钮它同时读取currentTabIndex、wrongRecords、currentBreakpoint、topAvoidAreaHeightPx和navigationIndicatorHeightPx把入口层和页面层连接起来。这也是本文的核心判断启动页和主页的稳定性不取决于动画是否好看而取决于入口初始化、页面生命周期、共享状态、系统避让区四条线是否写清楚。二、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.setOrCreatenumber(currentTabIndex, 0) AppStorage.setOrCreatenumber(favoriteTabIndex, 0) AppStorage.setOrCreatenumber(topAvoidAreaHeightPx, 0) AppStorage.setOrCreatenumber(navigationIndicatorHeightPx, 0) BreakpointSystem.register() }这段逻辑不是业务页面的一部分放在EntryAbility更合理。原因有三点。第一UserDataManager.init(this.context)依赖能力上下文。上下文属于运行时能力入口不应该从普通页面组件里随意传递。入口处初始化后页面只消费业务结果能减少页面和平台能力之间的耦合。第二AppStorage.setOrCreate明确了主页启动时的默认状态。currentTabIndex初始为0意味着启动后默认落到第一个主标签favoriteTabIndex初始为0给收藏相关页面准备默认索引topAvoidAreaHeightPx和navigationIndicatorHeightPx初始为0保证即使窗口避让区读取失败页面布局也有确定兜底值。第三BreakpointSystem.register()放在入口处说明断点监听不是某一个页面的私有逻辑而是应用级布局环境。Index.ets后续通过StorageLink(currentBreakpoint)读取断点值这就形成了入口注册、组件消费的单向流动。这里还有一个小但重要的细节设置浅色模式用try/catch包起来。源码中调用了setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT)如果环境或生命周期导致调用失败代码只记录 warn不让启动链路直接崩溃。对于面向上架审核的应用启动阶段的异常兜底非常重要因为审核里的兼容性和运行稳定性会直接关注安装后是否能正常启动。三、onWindowStageCreate先加载 SplashPage而不是直接进主页EntryAbility的onWindowStageCreate才是页面树真正装载的位置。句匠在这里做了两件事获取主窗口并同步避让区然后加载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即可。同时源码在onWindowStageDestroy和onDestroy中做释放动作窗口避让区回调会被注销断点系统会被BreakpointSystem.unregister()释放。这些生命周期闭环能避免应用多次创建窗口或销毁窗口后残留监听。四、避让区同步让主页不要压到底部系统区域主页底部 Tab 最容易出问题的位置不是图标而是底部安全区。不同设备、手势导航、横竖屏、窗口模式下底部系统导航指示条高度可能不同。句匠的入口层读取两类避让区TYPE_NAVIGATION_INDICATOR和TYPE_SYSTEM然后写入AppStorage。private updateNavigationIndicatorHeight(): void { if (!this.mainWindow) { AppStorage.setOrCreatenumber(topAvoidAreaHeightPx, 0) AppStorage.setOrCreatenumber(navigationIndicatorHeightPx, 0) return } try { const navArea this.mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) const systemArea this.mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM) AppStorage.setOrCreatenumber(topAvoidAreaHeightPx, systemArea.topRect.height) AppStorage.setOrCreatenumber(navigationIndicatorHeightPx, navArea.bottomRect.height) } catch (error) { AppStorage.setOrCreatenumber(topAvoidAreaHeightPx, 0) AppStorage.setOrCreatenumber(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。这点直接影响返回栈体验。启动页进入主页后用户按返回键不应该回到 SplashreplaceUrl会用主页替换当前路由让启动页不留在返回路径上。对于上架应用这是基础体验要求。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的核心不是某个业务模块而是主页容器。它导入了五个页面HomePage、BankListPage、ExamTab、FavoritePage、MinePage再根据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 的角标。源码中在索引为3且wrongRecords.length 0时展示红色徽标。这里不需要额外维护一个badgeCount状态直接从记录数组长度派生减少了手动同步错误。currentBreakpoint来自BreakpointSystem。实际断点系统定义了sm、md、lg三种值分别对应width600vp、600vpwidth840vp和840vpwidth。topAvoidAreaHeightPx和navigationIndicatorHeightPx来自入口层窗口避让区同步用来计算顶部和底部安全距离。内容区通过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((width600vp)) mediaquery.matchMediaSync((600vpwidth840vp)) mediaquery.matchMediaSync((840vpwidth))匹配结果更新到AppStorage的currentBreakpoint。这是一种适合 ArkUI 的做法监听逻辑在工具类里页面通过StorageLink获得响应式值。但阅读Index.ets时要注意一个边界。主页build中判断if (this.currentBp sm || this.currentBp md || this.currentBp lg) { // 底部导航 } else { // 侧边导航 }而当前BreakpointSystem只会产生sm、md、lg三种值。这意味着在现有断点系统下sm/md/lg都会进入底部导航分支侧边导航分支更多像是一个备用结构或未来扩展分支。文章不能声称“平板一定切到侧边导航”因为源码并不支持这个结论。这类边界在技术文章里必须写清楚。真实源码比“看起来应该如此”的推断更重要。能复核的结论是句匠已经有断点系统Index已读取currentBreakpoint并存在底部导航和侧边导航两套结构但以当前断点枚举和条件判断看正常sm/md/lg都走底部导航。九、主页布局内容区、底部栏和安全区分层底部导航模式下Index使用Stack放内容区再用底部Row承载五个BottomNavItem。内容区layoutWeight(1)底部栏高度通过bottomNavHeight()计算底部留白通过bottomSafePadding()追加。这种结构的好处是内容区和导航栏职责分离。内容区只负责当前页面底部栏只负责主导航安全区不需要每个业务页面单独处理。对于首页、题库、考试、收藏、我的这些一级页面统一由容器提供底部保护能避免某个页面忘记留出系统手势区。如果后续需要优化宽屏体验应该在现有结构上做两个明确动作一是让BreakpointSystem和Index的条件保持一致例如lg是否应该进入侧边导航二是验证侧边导航分支里的徽标、选中态、内容区宽度和安全区是否与底部导航一致。不能只改 UI 分支不改断点语义。十、这条链路适合怎样复用句匠这条启动与主页链路可以抽象成四步。第一步入口层初始化应用级依赖。包括颜色模式、用户数据管理、全局默认状态、断点监听和窗口避让区监听。这些逻辑放在EntryAbility避免散落到页面。第二步入口层加载启动页。windowStage.loadContent(pages/SplashPage)让启动体验独立于主页容器启动页只承担短展示和跳转不掺入主业务。第三步启动页用生命周期管理延迟任务。aboutToAppear中创建动画和定时器aboutToDisappear中清理定时器跳转使用router.replaceUrl避免返回栈里残留启动页。第四步主页容器统一承接主标签、内容区、红点、安全区和断点。currentTabIndex驱动内容区和导航项选中态wrongRecords.length驱动徽标窗口避让区驱动底部安全留白。这四步并不复杂但它们解决的是上架应用最常见的稳定性问题启动不崩、跳转不乱、返回栈正常、底部不遮挡、状态不分叉。十一、源码边界和审核视角从 AppGallery 审核角度看这篇源码对应的是兼容性和基础体验不是新业务能力。真实可复核的点包括EntryAbility使用 Stage Model 生命周期完成初始化和loadContent(pages/SplashPage)。SplashPage使用animateTo控制透明度和缩放2 秒后router.replaceUrl({ url: pages/Index })。SplashPage在aboutToDisappear清理setTimeout。Index使用StorageLink(currentTabIndex)控制主标签和内容页。Index使用StorageLink(navigationIndicatorHeightPx)计算底部安全留白。wrongRecords.length只在索引3的导航项上形成徽标。当前断点系统只定义sm、md、lg不能宣传源码已经在大屏下必然启用侧边导航。不属于本文结论的内容也要明确排除源码中没有基于这三个文件实现账号登录启动、云端配置下发、广告开屏、推送唤起、深链参数分发、远程 AB 实验或多设备流转。技术文章如果把这些能力写进去就是对源码能力的伪造。十二、落地建议如果你正在写一个 HarmonyOS 5.0 以上的 ArkTS 应用可以直接按这几个检查点审视启动和主页UIAbility.onCreate是否只做入口级初始化不把页面业务塞进去。onWindowStageCreate是否明确加载首屏页面并处理加载错误。启动页的延迟跳转是否在aboutToDisappear中清理。从启动页到主页是否用replaceUrl避免返回到启动页。主 Tab 的选中态、内容区和样式是否全部由同一个索引驱动。角标是否由真实数据派生而不是手写另一个容易不同步的计数。底部导航是否考虑系统导航指示条和最小安全留白。断点枚举和布局分支是否一致避免写了侧边栏但永远进不去。句匠这段源码的价值不在于炫技而在于边界清楚。入口层负责初始化和窗口信息启动页负责短生命周期动画和跳转主页负责主容器状态业务页面负责各自内容。只要这个分工稳定后续增加新 Tab、调整启动页视觉、优化大屏布局都不会牵一发而动全身。十六、主标签状态要有唯一真源Index 通过StorageLink(currentTabIndex)读取和修改当前标签底部导航与侧边导航都写入同一个索引。这使页面内容、选中图标和文字颜色能够同步变化也允许设置页等其他页面在返回主页前改变目标标签。验收时要确认所有入口都使用同一键避免组件各自维护一份局部索引。索引还需要边界保护。当前 PageContent 的最后一个 else 会落到“我的”页面如果外部写入负数或大于四的值界面可能显示“我的”而导航没有对应选中项。更稳妥的做法是在写入入口将索引限制为零到四并为恢复异常保留明确默认值。这是改进建议不代表当前源码已经完成校验。十七、徽标数据与标签语义必须一致当前源码在索引三的标签上显示wrongRecords.length而索引三标题是“收藏夹”索引二才是“错题本”。这是静态代码中可见的语义不一致文章不能把它自动解释为产品设计。修复前应先确认需求徽标究竟提示错题数量、收藏数量还是某种待处理数量再选择正确数据源与标签位置。验收应覆盖零条不显示、一到九十九正常显示、超过九十九显示 99同时检查底部栏和侧边栏两种形态。数据变化后徽标需要立即更新长数字不能压住图标或降低触控区域。十八、安全区不是固定底边距主页把底部导航高度拆成标签栏高度与动态安全区两者相加后再把安全区用于底部内边距。数据来自 EntryAbility 监听到的导航指示区和系统区高度并在页面侧从像素转换为 vp。这条链路比写死一个底边距更能适应手势导航和设备差异。但静态源码只能证明计算关系存在。真机还要验证三键导航、手势导航、横屏、分屏、折叠展开和窗口缩放确认最后一个控件始终可见且可点击。顶部同样应检查状态栏避让是否与各标签页自己的视觉留白重复叠加。十九、启动页计时器要验证重复与离场SplashPage 在 aboutToAppear 中启动两秒计时器在 aboutToDisappear 中清理并使用 replaceUrl 进入 Index。设计意图是让启动页退出历史栈用户从主页返回时不再看到启动动画。测试时应快速切后台、触发页面离场并重复进入确认旧计时器不会在新页面上再次执行跳转。动画时长六百毫秒、停留两秒是当前源码事实不等于所有性能环境都能在两秒内完成依赖初始化。若未来增加网络、账号或远程配置应重新设计启动任务和失败态不能简单继续往定时器前后叠加异步调用。二十、响应式导航需要成组验收当前 Index 将 sm、md、lg 都归入手机式底部导航其他断点使用八十 vp 侧边栏。需要结合 BreakpointSystem 的真实断点定义检查这些名称对应的宽度不能仅凭分支名称推断设备类型。每种布局都要验证五个标签、选中态、徽标、内容区尺寸和安全区。建议建立同一套状态用例默认首页、逐项切换、外部修改索引、错题数据变化、旋转和窗口缩放。底部栏切到侧边栏时当前标签应保持内容组件不应错位点击区域不能因动画或安全区覆盖而失效。二十一、结语启动和主页是一条连续状态链句匠当前源码把 EntryAbility、SplashPage 和 Index 串成清晰链路Ability 准备共享状态并装载启动页启动页在离场时清理计时器并替换到主页主页再用同一 AppStorage 索引驱动五个内容页面和两种导航形态。可靠性仍需靠边界校验和运行测试证明。索引越界、徽标语义、安全区变化、重复计时器与响应式切换都应有独立用例把这些证据补齐启动到主页的体验才真正可维护。AI 辅助声明本文在人工核对 EntryAbility、SplashPage 和 Index 当前源码后使用 AI 辅助整理结构、润色表达并生成三张示意图。未执行的构建、真机启动、旋转、窗口缩放与恢复测试均未描述为已完成。CSDN-SERIES:ALL-163175872