entry/src/main/ets/pages/Index.etsentry/src/main/ets/pages/HomePage.etsentry/src/main/ets/pages/LearningPage.etsentry/src/main/ets/pages/MinePage.etsentry/src/main/ets/views/experiment/ExperimentSimPage.etsentry/src/main/ets/views/experiment/ExperimentResultPage.etsentry/src/main/ets/views/learning/KnowledgeListPage.etsentry/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 及以上的小型工具或教育应用,这种结构足够实用。先让入口归属清楚,再让参数缺失不破坏页面,最后让返回路径依赖标准路由栈。后续如果要扩展集中校验、深链或登录守卫,也应该在这个边界之上增加,而不是让每个页面各自发明一套路由规则。