☰
【天体运行模拟|14】HarmonyOS ArkTS 主导航实战:统一页面入口、返回路径和参数校验
2026/10/24 16:17:53 网站建设 项目流程

主导航最容易出现的不是“点不动”,而是三套入口各自为政:首页按钮直接pushUrl,热门实验卡片又拼一遍参数,Tab 切换依赖裸数字,二级页面返回时只能假设栈顶正确。页面越多,路径拼写、参数缺失、默认场景误跳和返回层级异常就越难复核。

“天体运行模拟”的真实首屏由Index.ets组装:首页、关卡、知识、我的四个底部 Tab;HomePage.ets既能通过回调切换到关卡 Tab,也能直接打开稳定双体、自由宇宙和热门实验。本文基于这两份真实源码,面向 HarmonyOS 5.0 及以上版本,保留现有轻量结构,同时用类型化入口、统一路由契约和参数校验把主导航写稳。

本文会处理:

  • 四个 Tab 的索引如何从裸数字变成稳定契约;
  • Tab 内切换与压入二级页面为何要区分;
  • 实验页的expId、expName如何统一构造与校验;
  • 返回动作如何保持可预测,不制造重复首页;
  • phone、tablet、2in1 下底部导航如何稳定适配。

项目基线:应用版本1.0.0,targetSdkVersion 6.0.2(22),compatibleSdkVersion 6.0.1(21),设备类型为 phone、tablet 与 2in1。文中“统一导航”是工程演进方案,现有源码仍直接使用TabsController与router.pushUrl()。

一、真实主导航是四个 Tab

Index.ets维护当前索引和TabsController:

@State currentIndex: number = 0 private tabController: TabsController = new TabsController()

四个TabContent分别装载:

TabContent() { HomePage() } TabContent() { LabPage() } TabContent() { LearningPage() } TabContent() { MinePage() }

这是一种产品级主导航:Tab 负责一层功能域,二级详情页再使用 Router 压栈。不能把所有页面都塞成 Tab,也不能用 Router 重复创建首页来模拟 Tab 切换。

二、首页切换 Tab 的真实方式

HomePage接收一个回调:

export struct HomePage { onSwitchTab: (index: number) => void = () => {} }

Index注入:

HomePage({ onSwitchTab: (index: number) => { this.tabController.changeIndex(index) } })

首页点击“关卡模式 >”时调用onSwitchTab(1)。这条链路不会创建新页面栈,只改变当前 Tab,符合一级导航语义。

三、裸数字索引是第一个风险

0、1、2、3对编译器没有业务含义。Tab 顺序调整后,散落的onSwitchTab(1)可能跳错页面。

export enum MainTab { HOME = 0, LAB = 1, LEARNING = 2, MINE = 3 }

调用改为:

this.onSwitchTab(MainTab.LAB)

枚举不会增加运行复杂度,却让页面入口可以搜索、重构和测试。

四、主 Tab 与二级路由必须分流

首页包含两类动作:

// 一级导航 this.onSwitchTab(1) // 二级页面 router.pushUrl({ url: 'views/experiment/ExperimentSimPage', params: { expId: 'stable_orbit' } })

一级功能域切换使用TabsController;需要独立返回路径的详情、编辑器和模拟页使用 Router。两者混用会出现返回键回到重复首页、Tab 状态丢失等问题。

五、先建立页面路径常量

当前字符串路径在多个页面重复。建议集中定义:

export const AppRoutes = { EXPERIMENT_SIM: 'views/experiment/ExperimentSimPage', EXPERIMENT_RESULT: 'views/experiment/ExperimentResultPage', KNOWLEDGE_DETAIL: 'views/learning/KnowledgeDetailPage', FAVORITES: 'views/mine/FavoritesPage' } as const

路径常量仍需与main_pages.json一致。可以在构建脚本中验证每个常量都存在于清单,避免运行时才发现拼写错误。

六、实验入口需要统一参数模型

真实首页有三种实验入口:

  1. “开始模拟”传stable_orbit;
  2. “自由宇宙”传sandbox;
  3. 热门实验卡传exp.id和exp.name。

先定义参数:

export interface ExperimentRouteParams { expId: string expName?: string }

再由一个方法构造:

function openExperiment( params: ExperimentRouteParams ): void { router.pushUrl({ url: AppRoutes.EXPERIMENT_SIM, params }) }

入口不再自行拼 URL,后续增加来源、预设或埋点时只改一个边界。

七、expName 不是权威身份

实验 ID 是稳定身份,名称是展示字段。接收页应当用expId从实验目录解析当前名称,而不是无条件相信路由传来的expName:

const exp = getAllExperiments() .find(item => item.id === params.expId) if (!exp) { this.pageState = 'notFound' return } this.expId = exp.id this.title = exp.name

这样应用升级后实验改名,旧入口仍显示最新标题;恶意或错误参数也无法伪造场景名称。

八、参数校验不能依赖类型断言

router.getParams() as ExperimentRouteParams不会校验运行时数据。更稳的解析器:

function parseExperimentParams( raw: object | undefined ): ExperimentRouteParams | undefined { if (!raw) return undefined const value = raw as Record<string, unknown> if (typeof value.expId !== 'string' || value.expId.trim().length === 0) { return undefined } return { expId: value.expId, expName: typeof value.expName === 'string' ? value.expName : undefined } }

解析失败应进入错误或缺省状态。只有入口产品明确允许默认场景时,才能回退到stable_orbit。

九、默认场景必须是显式产品决策

当前模拟页缺少expId时会使用稳定双体默认值。这对首页“开始模拟”合理,但对“查看相关实验”或收藏详情可能掩盖参数丢失。

可以增加来源:

export type ExperimentEntrySource = | 'home_primary' | 'home_hot' | 'lab' | 'favorite' | 'knowledge'

只有home_primary允许缺省稳定双体;其他来源缺参就显示不可用。这样按钮文案和实际目标保持一致。

十、返回路径用 router.back,不重复 push 首页

二级页面的返回动作应优先:

router.back()

不要用:

router.pushUrl({ url: 'pages/Index' })

后者会在栈上再创建一个首页,连续操作后返回路径越来越长。主 Tab 页面本身通常由系统返回行为退出应用或回到上一个任务,不需要自建返回按钮。

十一、首页热门卡片的真实入口

首页从实验目录截取前 4 或 6 个:

private hotExperiments(): Experiment[] { return this.experiments.slice( 0, this.screenWidth > 600 ? 6 : 4 ) }

点击卡片传真实模型:

router.pushUrl({ url: 'views/experiment/ExperimentSimPage', params: { expId: exp.id, expName: exp.name } })

这里不是推荐算法,而是目录顺序截取。文章和产品文案不应宣称“智能推荐”。

十二、统一导航服务应保持窄职责

不需要构造庞大的全局路由框架。一个窄服务足够:

export class AppNavigator { static openExperiment( expId: string, source: ExperimentEntrySource ): void { const exp = getAllExperiments() .find(item => item.id === expId) if (!exp) return router.pushUrl({ url: AppRoutes.EXPERIMENT_SIM, params: { expId: exp.id, expName: exp.name, source } }) } }

它负责路径、参数和目录验证,不负责页面业务状态,也不持有组件实例。

十三、Tab 状态如何保持

currentIndex是Index的@State:

.onChange((index: number) => { this.currentIndex = index })

只要Index没被重复创建,从二级页面返回后 Tab 仍然存在。若需要进程重启后恢复最后 Tab,可以保存一个轻量整数,但必须校验范围:

function normalizeTab(index: number): MainTab { if (index < MainTab.HOME || index > MainTab.MINE) { return MainTab.HOME } return index as MainTab }

是否恢复最后 Tab 是产品决策。面向快速开始的工具,冷启动回首页也很合理。

十四、重复点击要防止重复压栈

按钮快速连点可能连续执行pushUrl()。可以增加短暂导航锁:

private navigating: boolean = false private async openOnce( action: () => Promise<void> ): Promise<void> { if (this.navigating) return this.navigating = true try { await action() } finally { setTimeout(() => { this.navigating = false }, 300) } }

更重要的是在真机上验证 Router Promise 和页面动画行为,不要用过长锁让正常返回后无法再次进入。

十五、导航失败要有页面反馈

入口路径错误、参数无效或目标不存在时,不能只写日志。首页可以展示轻量提示;详情页可以显示notFound:

type RouteState = | 'ready' | 'navigating' | 'invalid' | 'failed' @State routeState: RouteState = 'ready'

导航失败后恢复按钮可点击状态,并给用户明确文案,例如“该实验暂不可用”,而不是静默无响应。

十六、底部 Tab 的视觉状态

真实TabBarBuilder根据当前索引切换图标与文字颜色:

Image( this.currentIndex === targetIndex ? iconSelected : iconNormal ) .fillColor( this.currentIndex === targetIndex ? AppColors.TAB_SELECTED : AppColors.TAB_UNSELECTED )

项目目前传入的 normal 与 selected 图标资源相同,主要依靠填充色区分。发布前应确认图标支持着色,并验证选中/未选中对比度不只靠细微色差。

十七、Tab 尺寸与系统避让

源码设置:

.barHeight(56) .padding({ top: this.statusBarHeight, bottom: this.bottomBarHeight })

56vp 是稳定的主导航高度。若改为沉浸式布局,底部避让必须计算系统导航区,不能只依赖固定 0。按钮命中区域应覆盖整格,而不是只有 24vp 图标。

十八、多设备宽度下首页入口变化

HomePage用onAreaChange更新宽度:

.onAreaChange((_o, n) => { this.screenWidth = n.width as number })

宽度大于 600 时热门实验从 4 个增到 6 个,网格变成两列或三列。需要验证窗口缩放时卡片数量变化不会让滚动位置突跳,入口顺序也保持一致。

private gridColumns(): string { if (this.screenWidth > 840) { return '1fr 1fr 1fr' } return '1fr 1fr' }

phone、tablet、2in1 共享入口模型,只改变布局,不改变导航语义。

十九、深浅色与可访问性

项目启动链路锁定浅色模式,但导航令牌仍要集中维护。Tab 文本当前为 10vp,需要结合 UX 标准和真实设备检查可读性;普通手机文字通常应尽量保持 12vp 及以上。

同时验证:

  • 选中与未选中状态有足够对比;
  • 图标资源在浅色背景下清晰;
  • 长 Tab 文案不会挤压;
  • 2in1 键盘焦点可见;
  • 返回按钮拥有足够触控区域。

二十、导航清单的自动一致性检查

可以读取main_pages.json,扫描AppRoutes中的路径:

interface RouteAuditItem { route: string declared: boolean sourceExists: boolean }

构建检查至少验证:

  1. 常量路径已声明;
  2. 对应.ets文件存在;
  3. 首屏pages/Index位于清单;
  4. 不存在大小写不一致;
  5. 已删除页面没有遗留入口。

这类检查比运行时点击所有链接更早发现机械错误。

二十一、测试主 Tab 与路由契约

主 Tab 测试:

const tabCases = [ { action: 'home', expected: MainTab.HOME }, { action: 'lab', expected: MainTab.LAB }, { action: 'learning', expected: MainTab.LEARNING }, { action: 'mine', expected: MainTab.MINE } ]

实验路由测试:

  • stable_orbit解析为稳定双体;
  • sandbox解析为自由宇宙;
  • 热门卡片传真实实验 ID;
  • 空 ID 被拒绝;
  • 未知 ID 进入 notFound;
  • 返回后二级页面出栈,原 Tab 保持;
  • 快速双击只压入一次。

二十二、常见问题与修复顺序

现象优先检查修复方向
首页“关卡模式”跳错 Tab裸数字索引使用MainTab
返回出现多个首页是否 push 了 Index二级页使用 back
相关实验进入默认场景expId是否缺失来源相关入口禁止默认
页面标题与场景不符是否信任expName由目录按 ID 解析
点击无响应路径、清单与 Promise 错误返回可见失败状态
双击进入两层页面是否缺少导航锁阻止重复压栈
Tab 状态丢失Index 是否被重建区分 Tab 切换和 Router
平板入口布局跳动宽度阈值与卡片数量验证 resize 行为

定位时先看入口类型,再看路径清单,然后看参数解析与返回栈。不要把所有问题都归因于 Router。

二十三、发布前验证清单

  • [ ] 四个主 Tab 使用业务枚举;
  • [ ] Tab 切换不创建新首页;
  • [ ] 二级页面统一使用路径常量;
  • [ ] 实验入口都携带稳定expId;
  • [ ] 接收页校验运行时参数;
  • [ ] 标题由实验目录解析;
  • [ ] 默认场景只用于明确入口;
  • [ ] 返回不会重复压入 Index;
  • [ ] 快速点击不产生重复页面;
  • [ ] 导航失败有可见反馈;
  • [ ] 页面路径与main_pages.json一致;
  • [ ] phone、tablet、2in1 导航均可达;
  • [ ] release 包完成启动、切 Tab、进详情、返回和退出冒烟。

总结

“天体运行模拟”的主导航已经形成清晰基础:Index管理四个 Tab,HomePage通过回调切换关卡域,通过 Router 打开实验二级页。真正需要补强的是契约,而不是推翻现有结构。

用MainTab替代裸数字,用AppRoutes集中路径,用稳定 ID 构造参数,用目录解析当前模型,再把返回和重复点击纳入测试,主导航就能从“能跳转”升级为“入口统一、返回可预测、参数可验证”。这套方法同样适用于知识页、收藏页和实验结果页。

NAV-ONE13-TAB-ROUTE-PARAM-20260726:主 Tab 使用业务枚举,二级页面使用统一路径和稳定 ID,接收页校验参数并由目录解析,返回只操作既有页面栈。

本文部分内容由 AI 辅助整理,源码事实、工程边界与验证结论均依据文中所列项目文件复核。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询