ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

【细胞工坊|14】HarmonyOS ArkTS 主导航实战:统一页面入口、返回路径和参数兜底

【细胞工坊|14】HarmonyOS ArkTS 主导航实战:统一页面入口、返回路径和参数兜底
  • entry/src/main/ets/pages/Index.ets
  • entry/src/main/ets/pages/HomePage.ets
  • entry/src/main/ets/pages/LearningPage.ets
  • entry/src/main/ets/pages/MinePage.ets
  • entry/src/main/ets/views/experiment/ExperimentSimPage.ets
  • entry/src/main/ets/views/experiment/ExperimentResultPage.ets
  • entry/src/main/ets/views/learning/KnowledgeListPage.ets
  • entry/src/main/resources/base/profile/main_pages.json

这篇文章只讨论源码已经实现的主导航链路:Index.ets组合四个一级 Tab,HomePage通过回调切换一级入口,首页、学习页、我的页通过router.pushUrl()进入二级页,详情页通过router.getParams()读取可选参数并使用默认状态兜底,二级页用router.back()返回上一层。当前源码没有统一 RouterService、集中参数校验器、导航拦截器、登录态守卫、深链恢复、路由栈可视化工具,也没有多窗口导航状态同步;这些能力不会被写成已实现功能。

1. 主导航先解决入口归属

教育工具类 HarmonyOS 应用常见的问题不是“页面不够多”,而是入口越来越散。首页卡片能进实验,实验室列表也能进实验,学习页能进知识点,我的页还能进收藏和记录。入口一多,如果根导航、二级路由和返回行为没有边界,后续每加一个页面都会带来回退路径混乱。

细胞工坊的源码把导航分成三层:

层级代表源码职责
一级根导航Index.ets四个 Tab、当前下标、底部栏
一级页入口HomePage、LearningPage、MinePage发起切 Tab 或 pushUrl
二级详情页实验页、记录页、知识页、设置页读取参数、展示内容、router.back

这套结构没有抽象成路由服务,但边界是清楚的:根页面管理一级导航,业务页面发起明确入口,详情页对参数缺失提供默认状态。

2. Index 是唯一的一级导航控制点

Index.ets是首屏根页面。它保存当前 Tab 下标,并持有TabsController:

@Entry @Component struct Index { @StorageProp('statusBarHeight') statusBarHeight: number = 36 @StorageProp('bottomBarHeight') bottomBarHeight: number = 0 @State currentIndex: number = 0 private tabController: TabsController = new TabsController() }

这段代码说明一级导航状态没有分散在四个页面里。currentIndex只属于根页面,底部安全区也由根页面统一应用。这样首页、实验室、学习、我的四个页面都能作为 TabContent 被组合,而不是互相 push。

根页面的 Tab 结构如下:

Tabs({ barPosition: BarPosition.End, index: this.currentIndex, controller: this.tabController }) { TabContent() { HomePage({ onSwitchTab: (index: number) => { this.tabController.changeIndex(index) } }) } TabContent() { LabPage() } TabContent() { LearningPage() } TabContent() { MinePage() } } .barHeight(56) .padding({ top: this.statusBarHeight, bottom: this.bottomBarHeight }) .onChange((index: number) => { this.currentIndex = index })

onChange负责把用户点击 Tab 后的下标写回状态。padding使用启动阶段写入的statusBarHeight和bottomBarHeight,让底部栏不压到系统手势区。

3. TabBarBuilder 统一底部入口样式

底部 Tab 的图标、文字、选中态都收在TabBarBuilder里:

@Builder TabBarBuilder(title: string, targetIndex: number, iconNormal: Resource, iconSelected: Resource) { Column() { Column() { Image(this.currentIndex === targetIndex ? iconSelected : iconNormal) .width(24) .height(24) .fillColor(this.currentIndex === targetIndex ? AppColors.TAB_SELECTED : AppColors.TAB_UNSELECTED) Text(title) .fontSize(10) .fontColor(this.currentIndex === targetIndex ? AppColors.TAB_SELECTED : AppColors.TAB_UNSELECTED) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .margin({ top: 2 }) } .width(64) .height(56) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } .width('100%') .height(56) }

这段实现的工程价值在于统一约束:图标大小固定、文字单行省略、Tab 高度固定。对于主导航来说,样式一致性不是装饰问题,而是交互稳定性问题。标签文字如果在小屏上换行,底部导航高度就会不稳定,页面内容可能被挤压。

4. 首页用回调切换一级 Tab

首页并不直接持有TabsController。它只接收一个回调:

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

首页里的“全部实验”和“去学习”入口调用这个回调:

Text('全部实验 >') .onClick(() => { this.onSwitchTab(1) }) Button('去学习') .onClick(() => { this.onSwitchTab(2) })

这个设计比在首页里重复构建根 Tabs 更稳。首页只表达“我要切到某个一级入口”,真正的 Tab 切换仍由Index.ets执行:

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

这也是当前源码里最接近“统一入口”的地方:一级导航控制权在根页面,子页面通过函数请求切换。

5. 首页卡片用 pushUrl 进入实验页

首页还有直接进入实验的入口。Banner 按钮进入默认实验页:

Button('开始实验') .onClick(() => { router.pushUrl({ url: 'views/experiment/ExperimentSimPage' }) })

热门实验卡片会带上实验 ID 和名称:

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

这两种入口都存在,所以实验页必须能处理“有参数”和“无参数”两种情况。源码确实这样做了:无参数时使用默认实验,有参数时覆盖expId和标题。

6. 实验页用可选参数加默认值兜底

ExperimentSimPage定义了可选参数接口:

interface SimRouterParams { expId?: string expName?: string kpTitle?: string kpSummary?: string }

页面进入时读取参数:

aboutToAppear(): void { const params = router.getParams() as SimRouterParams | undefined if (params?.expId) { this.expId = params.expId } if (params?.expName) { this.title = params.expName } if (params?.kpTitle) { this.kpTitle = params.kpTitle } if (params?.kpSummary) { this.kpSummary = params.kpSummary } this.initExperiment() this.resetExperiment() this.loadFavoriteState() }

这里不是集中参数校验器,而是页面级参数兜底。默认状态在@State上已经给出:

@State title: string = '显微镜观察洋葱表皮' @State expId: string = 'microscope_observation'

如果首页直接进入实验页,没有传expId,页面仍能初始化默认实验。这个能力是真实源码支撑的,不能夸成“统一参数校验框架”。

7. currentExperiment 再做一次资源回退

实验页还用currentExperiment()做资源级回退:

private currentExperiment(): Experiment { const found = getAllExperiments().find((item: Experiment) => item.id === this.expId) return found ? found : getAllExperiments()[0] } private initExperiment(): void { const exp = this.currentExperiment() this.title = exp.name this.paramDefs = exp.params this.paramValues = this.paramDefs.map((p: ExperimentParam) => p.defaultValue) }

这段逻辑处理的是另一种风险:传了expId,但 ID 不在本地资源表里。页面不会直接崩溃,而是回到实验数组第一项。对于本地资源型应用,这种兜底非常实用,因为入口可能来自收藏、记录或旧版本数据。

8. 学习页把工具路由限制在映射表里

LearningPage中学习工具不是任意字符串直接跳转,而是通过映射表转换:

const routeMap: Record<string, string> = { 'formula': 'views/learning/FormulaPage', 'unit': 'views/learning/UnitConverterPage', 'constants': 'views/learning/ConstantsPage', 'method': 'views/learning/ExperimentMethodPage' } const url = routeMap[tool.route] ?? 'views/learning/FormulaPage' router.pushUrl({ url: url })

这段实现同样是参数兜底的一种:如果tool.route不在映射表里,就回到公式页。它不是严格报错,也不是弹窗提示,但可以避免点击工具卡片后跳到不存在路径。

学习分类入口则传入分类参数:

router.pushUrl({ url: 'views/learning/KnowledgeListPage', params: { category: cat.name } })

对应的KnowledgeListPage会读取可选分类参数:

const params = router.getParams() as KnowledgeListParams | undefined if (params?.category) { this.category = params.category }

这说明当前项目的路由参数策略是一致的:发送方只传业务字段,接收方判断是否存在,不存在就使用页面默认状态。

9. 我的页用 route 名称做菜单分发

MinePage的菜单项不是直接保存完整 URL,而是保存业务 route 名称:

interface MenuItem { icon: Resource title: string route: string } @State menuItems: MenuItem[] = [ { icon: $r('app.media.ic_tab_lab'), title: '我的实验记录', route: 'records' }, { icon: $r('app.media.ic_tab_home'), title: '我的收藏', route: 'favorites' }, { icon: $r('app.media.ic_tab_learn'), title: '我的笔记', route: 'notes' }, { icon: $r('app.media.ic_tab_lab'), title: '保存的数据', route: 'saved_data' }, { icon: $r('app.media.ic_tab_mine'), title: '设置', route: 'settings' } ]

点击时由openRoute()分发:

private openRoute(routeName: string): void { try { if (routeName === 'records' || routeName === 'saved_data') { router.pushUrl({ url: 'views/mine/ExperimentRecordsPage' }) } else if (routeName === 'favorites') { router.pushUrl({ url: 'views/mine/FavoritesPage' }) } else if (routeName === 'notes') { router.pushUrl({ url: 'views/mine/NotesPage' }) } else if (routeName === 'settings') { router.pushUrl({ url: 'views/mine/SettingsPage' }) } } catch (_) { } }

这不是集中式路由表,但已经把“我的页菜单项”和“真实页面路径”隔开。后续如果要调整记录页路径,只需要改openRoute(),不用改每一行 UI。

10. 二级页返回路径保持简单

二级页普遍使用router.back()。例如记录页:

Text('←') .fontSize(22) .fontColor(AppColors.TEXT_PRIMARY) .width(40) .height(40) .onClick(() => { router.back() })

实验页、知识页、设置页、关于页、隐私协议页也都有类似返回按钮。源码没有实现自定义返回栈,也没有根据来源页面定制返回目标。当前能力是标准路由栈返回。

这种做法适合当前页面结构:一级 Tab 是根,二级页从一级页 push 出去,返回时回到上一页。只要入口都使用pushUrl,router.back()就能保持可预测。

11. main_pages 是路由可用性的底线

所有pushUrl的目标都必须出现在main_pages.json中。当前页面表包含:

{ "src": [ "pages/Index", "views/experiment/ExperimentSimPage", "views/experiment/ExperimentResultPage", "views/experiment/SceneSelectorPage", "views/mine/ExperimentRecordsPage", "views/learning/KnowledgeListPage", "views/learning/FormulaPage", "views/learning/UnitConverterPage", "views/learning/ConstantsPage", "views/learning/ExperimentMethodPage", "views/mine/FavoritesPage", "views/mine/SettingsPage", "views/mine/NotesPage" ] }

导航排查时,这个文件是底线。按钮代码写对了,但目标页面没注册,仍然会跳转失败。当前源码没有自动扫描页面并生成路由表,所以新增页面时要同步维护main_pages.json。

12. 结果页参数按类型读取

实验完成后会进入结果页,结果页读取参数时区分字符串和数字:

const params = router.getParams() as ResultRouterParams | undefined if (params?.expId) this.expId = params.expId if (params?.expName) this.expName = params.expName if (params?.category) this.category = params.category if (typeof params?.progress === 'number') this.progress = params.progress if (typeof params?.successRate === 'number') this.successRate = params.successRate if (typeof params?.activity === 'number') this.activity = params.activity if (typeof params?.contamination === 'number') this.contamination = params.contamination if (typeof params?.temperature === 'number') this.temperature = params.temperature if (params?.stage) this.stage = params.stage if (params?.paramSummary) this.paramSummary = params.paramSummary

这比简单地全量赋值稳一些。数字字段必须是number才写入状态,字符串字段必须存在才覆盖默认值。它仍然不是完整 schema 校验,但对当前页面已经能避免一部分异常参数污染。

13. 可迁移的轻量导航约定

如果迁移细胞工坊这种做法,可以先建立轻量约定,而不是一开始写复杂路由框架:

type MainTabIndex = 0 | 1 | 2 | 3 interface ExperimentNavParams { expId?: string expName?: string } function openExperiment(exp?: ExperimentNavParams): void { router.pushUrl({ url: 'views/experiment/ExperimentSimPage', params: exp }) }

这段示例比源码多了一层函数,但意图一致:入口只传稳定字段,详情页自己做默认值兜底。

一级 Tab 切换也可以保持回调方式:

@Component struct HomeEntry { onSwitchTab: (index: MainTabIndex) => void = () => {} private openLearning(): void { this.onSwitchTab(2) } }

这样子页面不需要知道根页面控制器细节,也不会直接修改根状态。

14. 验证主导航时按路径跑

主导航验证不能只点底部 Tab。建议按入口覆盖:

路径操作预期
一级 Tab首页、实验室、学习、我的逐个点击currentIndex更新,选中态正确
首页切实验室点击“全部实验”切到实验室 Tab
首页切学习点击“去学习”切到学习 Tab
首页进默认实验点击“开始实验”无参数进入默认实验
热门实验进详情点击热门实验卡片带expId和expName进入
学习工具点击公式、单位、常量、方法进入映射表目标页
我的菜单点击记录、收藏、笔记、设置进入对应二级页
二级返回点击返回箭头回到来源页或根 Tab

如果某条路径失败,优先查三处:目标是否注册在main_pages.json、pushUrl的字符串是否一致、目标页是否对缺失参数做了默认值。

15. 常见问题和修复方向

问题常见原因修复方向
首页按钮切 Tab 无效子页面没有拿到回调或下标写错由Index传入onSwitchTab,统一下标含义
二级页白屏目标页面未注册补充main_pages.json
实验页标题不对只传了expId没传expName详情页用currentExperiment()从资源表恢复名称
工具卡片跳错页route 字段不在映射表给映射表增加项或保留默认页
返回路径混乱一级页之间用 pushUrl 互跳一级入口用 Tabs 切换,二级入口才 push
参数异常污染页面直接全量赋值 router 参数字符串判断存在,数字判断类型

这些修复方向都来自当前源码的真实模式:根导航收口、入口明确、详情页兜底。

16. 当前源码的边界

需要明确,细胞工坊当前没有实现以下导航能力:

未实现能力当前源码的真实做法
统一RouterService页面直接调用router.pushUrl()和router.back()
集中路由参数 schema 校验目标页用可选参数和类型判断兜底
导航前置守卫没有拦截器,点击后直接进入目标页
登录态拦截应用没有账号体系和登录态
深链解析只使用应用内页面路径
路由栈恢复依赖系统路由栈和router.back()
多窗口导航同步没有跨窗口状态同步逻辑
统一错误页目标页用默认值回退,没有错误页跳转

因此本文不把它写成“完整导航框架”。它更适合被理解为一个轻量但清晰的主导航实践。

17. 小结:让根导航、入口和详情页各做各的事

细胞工坊的主导航实现没有复杂抽象,但它把最关键的边界放对了。Index.ets管四个一级 Tab 和当前下标,HomePage通过回调请求切 Tab,首页和列表页通过router.pushUrl()进入二级页,详情页读取可选参数并在缺失时回到默认状态,二级页用router.back()回到上一层。

对于 HarmonyOS 5.0 及以上的小型工具或教育应用,这种结构足够实用。先让入口归属清楚,再让参数缺失不破坏页面,最后让返回路径依赖标准路由栈。后续如果要扩展集中校验、深链或登录守卫,也应该在这个边界之上增加,而不是让每个页面各自发明一套路由规则。

返回列表