
本文基于「笔下生辉」HarmonyOS 5.0+ ArkTS 工程源码复盘,主要复核 entry/src/main/ets/pages/Index.ets、entry/src/main/ets/views/HomePage.ets、entry/src/main/resources/base/profile/main_pages.json,并结合 BankDetailPage.ets、PracticePage.ets、SearchPage.ets 中的参数读取方式说明导航边界。唯一复核标记:com.jiaweikang.one17。本文只讨论源码中真实存在的主导航、Tab 状态、二级页面路由、返回路径和参数兜底,不扩展成账号体系、云端路由、远程配置或权限分流。
1. 主导航真正要解决的问题
写作学习类应用的首页通常不只有一个入口。用户会从首页进入题库,从题库进入练习,从收藏进入错题,从我的页面进入设置或学习统计。如果这些入口都直接散落在各个组件里,短期能跑,长期会出现三个问题:一级页面和二级页面边界不清,返回行为不可预测,路由参数缺失时页面容易空白。
「笔下生辉」的主导航方案没有引入复杂导航框架,而是用一个 Index 页面承接五个一级 Tab:HomePage、BankListPage、ExamTab、FavoritePage、MinePage。一级区域通过 @StorageLink('currentTabIndex') 切换,不把每个 Tab 都压入路由栈;搜索、分类、题库详情、练习、设置、学习统计等二级页面再用 router.pushUrl 进入。启动页此前使用 router.replaceUrl({ url: 'pages/Index' }),因此用户进入首页后返回不会回到启动页。
这套设计的价值在于简单可控。一级导航是应用内部状态,二级页面是系统路由栈。Tab 切换不会污染返回栈,详情页和设置页保留正常返回路径,参数页在 aboutToAppear 中读取 router.getParams() 并设置默认值或回退数据。对 HarmonyOS 5.0+ ArkTS 项目来说,这比“所有跳转都 push”更适合有固定底部导航的应用。
本文会围绕四个源码事实展开:main_pages.json 只声明可被系统加载的页面;Index.ets 统一组合五个一级 Tab;HomePage.ets 既能改 currentTabIndex,也能 pushUrl 到二级页;详情、练习、搜索页读取 params 时都存在条件判断或兜底逻辑。这里不声称源码已经封装了独立 NavigationService,也不把普通路由写成不存在的跨设备协同能力。
2. main_pages.json 是路由清单,不是业务导航
main_pages.json 中声明了应用可加载页面:
{
"src": [
"pages/SplashPage",
"pages/Index",
"pages/BankDetailPage",
"pages/SichuanBankPage",
"pages/YueBankPage",
"pages/NortheastBankPage",
"pages/ShanghaiBankPage",
"pages/MinnanBankPage",
"pages/HakkaBankPage",
"pages/PracticePage",
"pages/ExamResultPage",
"pages/SearchPage",
"pages/CategoryPage",
"pages/LearningStatsPage",
"pages/SettingsPage"
]
}
这份清单解决的是页面可被框架识别和加载的问题,不负责决定用户从哪里进入哪里。真正的导航决策仍在 Index、HomePage、各详情页和操作组件里完成。把这两层分清很重要:main_pages.json 缺页会导致 router.pushUrl 或 loadContent 失败;但页面在清单里,并不代表它就应该出现在底部 Tab 里。
从清单可以看出,源码把 SplashPage、Index、题库详情页、地区题库页、练习页、结果页、搜索页、分类页、统计页、设置页都注册为可路由页面。Index 是一级入口,其他多数页面是二级或专题入口。文章讨论“统一页面入口”,不是说所有页面都只从 Index 直接加载,而是说用户进入主应用后,一级结构由 Index 承担,二级页面按需从各功能入口 push 出去。
如果一个页面出现在 main_pages.json,但没有明确入口,测试时就要确认它是否仍被某个业务分支使用。例如地区题库页可能作为固定题库入口存在;如果将来删除入口,就需要同步清理路由清单。反过来,如果代码里新增 router.pushUrl({ url: 'pages/NewPage' }),但忘记把页面加入清单,问题通常会暴露在页面跳转时,而不是编译阶段。
3. Index 用 StorageLink 统一五个一级 Tab

Index.ets 是主导航的核心。源码里定义了一个 TabItem 接口,包含标题、普通图标、选中图标。组件内部通过 @StorageLink('currentTabIndex') currentIndex 持有当前一级 Tab:
@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 控制当前显示哪个 Tab。第二类是业务提示状态:wrongRecords 用于在错题 Tab 上显示数量徽标。第三类是设备环境状态:currentBreakpoint、顶部避让区、底部导航指示器高度用于布局适配和安全区避让。
页面内容通过 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 不是用 router.pushUrl 切换,而是通过状态驱动条件渲染。这样用户从首页切到题库、挑战、收藏、我的,不会把每次 Tab 切换都放进系统返回栈。对底部导航应用来说,这是更符合预期的行为:返回键通常应该退出当前二级页或应用,而不是依次回放用户点过的 Tab。
BottomNavItem(index) 和 SideNavItem(index) 都通过点击修改 currentIndex:
.onClick(() => { this.currentIndex = index })
同一个状态同时服务手机底部 Tab 和宽屏侧边栏。这比维护两套导航状态更稳。源码中用 currentBreakpoint 判断布局形态:小屏和常规断点使用底部导航,其他情况使用侧边导航加内容区。导航入口没有因为设备形态变化而变成两套业务逻辑,只是呈现方式不同。
4. 手机底部导航与宽屏侧边导航共享同一套状态
Index 的 build() 里,源码根据 currentBp 分支渲染。小屏模式下是 Column:上方 Stack 放当前页面内容,下方 Row 放五个底部导航项。宽屏模式下是 Row:左侧 Column 放 Logo 和侧边导航项,右侧 Stack 放当前内容。
这一点对多设备开发很关键。很多应用在适配平板或 2in1 时,会复制一套页面入口,结果底部 Tab 和侧边栏分别维护状态,跳转和徽标逻辑很容易不一致。当前源码使用同一个 currentIndex、同一个 tabs 数组、同一个 PageContent(),只是 BottomNavItem 和 SideNavItem 的 UI 形态不同。
底部安全区也在主导航里统一处理:
private bottomSafePadding(): number {
return Math.max(Sizes.BOTTOM_NAV_MIN_PADDING, this.getUIContext().px2vp(this.navigationIndicatorHeightPx))
}
private topSafePadding(): number {
return Math.max(Sizes.PADDING_SMALL, this.getUIContext().px2vp(this.topAvoidAreaHeightPx))
}
private bottomNavHeight(): number {
return Sizes.TAB_BAR_HEIGHT + this.bottomSafePadding()
}
这三段方法把窗口避让区从像素转为 vp,并用最小 padding 做兜底。它们和上一篇启动链路中的 EntryAbility.updateNavigationIndicatorHeight() 对应:Ability 负责从窗口读取 px,Index 负责在 UI 层转换和使用。这样可以避免页面里硬编码固定底部留白,也避免底部导航压到系统手势区。
错题数量徽标同样在主导航项里处理。当 index === 3 && this.wrongRecords.length > 0 时,底部 Tab 和侧边 Tab 都显示数量,超过 99 显示 99+。这属于导航层可以承担的提示信息,因为它直接服务“收藏/错题入口是否有待处理内容”。但真正的错题列表、错题练习逻辑仍然在 FavoritePage 和 PracticePage 中,不应该塞进 Index。
5. HomePage 同时处理 Tab 切换和二级路由
HomePage.ets 是主导航下的首页内容。它没有直接替代 Index,而是在首页内部提供快捷入口。源码里既有状态式 Tab 切换,也有 router.pushUrl 二级跳转。
例如推荐题库区域的“更多”动作:
SectionHeader({
title: '推荐题库',
actionText: '更多 >',
onAction: () => { this.currentTabIndex = 1 }
})
这里选择修改 currentTabIndex,因为“更多题库”本质上是进入一级题库 Tab,不需要进入新页面,也不需要返回栈。用户从首页点“更多”切到题库 Tab 后,返回键不应回到首页 Tab。
分类区域的“全部”动作则不同:
SectionHeader({
title: '题型分类',
actionText: '全部 >',
onAction: () => { router.pushUrl({ url: 'pages/CategoryPage' }) }
})
分类页是一个二级页面。它应该有独立返回路径,因此使用 pushUrl。同样,搜索按钮使用 router.pushUrl({ url: 'pages/SearchPage' }),分类项点击使用:
router.pushUrl({
url: 'pages/SearchPage',
params: { categoryType: cat.type, categoryName: cat.name }
})
这说明源码对“一级入口”和“二级页面”做了区分。一级入口改 Tab 状态,二级页面进入路由栈。这个边界比统一写 router.pushUrl 更重要,因为它直接决定返回行为是否符合用户预期。
快速操作区也体现了这个原则:
this.ActionCard('限时挑战', '60 秒快速纠错', () => { this.currentTabIndex = 2 })
this.ActionCard('错题复习', `${this.wrongRecords.length} 道错题`, () => {
this.favoriteTabIndex = 2
this.currentTabIndex = 3
})
“限时挑战”进入挑战 Tab,“错题复习”进入收藏/错题相关 Tab,并同步设置 favoriteTabIndex。这里没有 push 到新页面,因为目标是一级区域内部状态切换。用户看到的是主页面切换,而不是新页面压栈。
6. 二级页面通过 params 进入,并在 aboutToAppear 读取

主导航之外,二级页面需要参数。BankDetailPage.ets 定义了:
interface BankDetailParams {
bankId: string
}
在 aboutToAppear() 中,它先判断 fixedBankId,再读取 router.getParams():
aboutToAppear(): void {
if (this.fixedBankId.length > 0) {
this.bank = getBankById(this.fixedBankId)
return
}
const params = router.getParams() as BankDetailParams | undefined
if (params && params.bankId) {
this.bank = getBankById(params.bankId)
}
}
这段逻辑说明题库详情页支持两种入口:固定题库页传入 fixedBankId,普通详情页通过路由参数传入 bankId。参数存在时再查题库,避免无参数时直接访问未定义字段。页面标题也基于 this.bank ? this.bank.name : '题库详情' 做兜底。
PracticePage.ets 的参数更复杂:
interface PracticeParams {
bankId: string
chapterId?: string
mode: string
records?: string
questionType?: string
startQuestionId?: string
}
练习页在 aboutToAppear() 中读取 params 后,根据 mode 和 chapterId 决定加载错题、章节题、随机题或挑战题。它还处理了空题目兜底:
if (this.questions.length === 0 && this.mode !== 'wrongAnalysis') {
this.questions = getQuestions(params.bankId)
}
这不是完整的参数校验框架,但已经体现了关键原则:路由参数进入页面后,页面不应无条件相信所有字段都可用;至少要判断 params 是否存在、关键字段是否存在、查询结果是否为空,并给出可继续运行的默认路径。
SearchPage.ets 也采用类似方式:
const params = router.getParams() as SearchParams | undefined
if (params && params.categoryType) {
this.categoryType = params.categoryType
this.categoryName = params.categoryName || params.categoryType
this.keyword = this.categoryName
this.doSearch()
}
搜索页允许无参数进入,也允许带分类参数进入。无参数时展示普通搜索状态;带参数时直接把分类名放进关键词并执行搜索。categoryName || categoryType 是一个小兜底,避免只传类型不传显示名时页面没有可读标题。
7. 返回路径由二级页面自己收口
源码中多个页面使用 router.back()。公共组件 TopBar.ets 里有返回按钮,点击时调用 router.back();SearchPage 顶部返回、PracticePage 的退出和部分操作、SettingsPage 的快捷跳回主 Tab 后返回,都能看到这一模式。
这说明主导航不是接管所有返回逻辑,而是把返回路径留给二级页面。一级 Tab 通过状态切换,二级页面通过路由返回。这样页面行为更容易解释:
| 首页切到题库 Tab | 修改 currentTabIndex = 1 | 不产生新返回层级 |
| 首页进入搜索页 | router.pushUrl({ url: 'pages/SearchPage' }) | 返回回到 Index 当前状态 |
| 分类进入搜索页 | pushUrl 并带 categoryType | 返回回到分类入口 |
| 设置页跳收藏 Tab | 改 favoriteTabIndex/currentTabIndex 后 router.back() | 回到主页面指定 Tab |
| 启动页进入首页 | router.replaceUrl({ url: 'pages/Index' }) | 返回不回启动页 |
这个表能看出源码的边界:Tab 切换是应用内状态,二级页面是路由栈,启动页是一次性入口。只要这个边界不乱,返回路径就不会变得难以预测。
8. 当前源码的参数校验边界
从严谨角度看,当前源码做了基础参数兜底,但还没有抽象成统一的路由参数校验模块。比如 BankDetailPage 判断了 params && params.bankId,但如果传入不存在的 bankId,getBankById 返回 undefined,页面会显示默认标题和后续空状态。PracticePage 会在题目为空时回退到 getQuestions(params.bankId),但如果 bankId 本身无效,仍需要页面后续状态处理兜底。
这类边界在技术文章里必须说清。可复核源码支持的结论是:“页面入口读取参数时有存在性判断和部分空数据回退”;不能写成“实现了完整类型安全路由协议”。如果后续要加强,可以新增一个轻量方法统一判断:
private hasUsableBankId(value: string): boolean {
return value.trim().length > 0 && getBankById(value) !== undefined
}
这段示例说明增强方向:校验不只检查字符串是否存在,还检查业务对象是否能查到。对于练习页,还可以把 mode 限制在 'chapter' | 'random' | 'exam' | 'wrong' | 'wrongAnalysis' 范围内,避免未知 mode 进入不明确分支。当前源码没有完整实现这个封装,因此本文只把它作为可演进建议。
9. 验证主导航是否稳定
验证主导航不能只看首页能否显示,还要按入口路径逐条走。
第一,启动路径:启动页 2 秒后应进入 pages/Index,返回不应回到 SplashPage。这是 replaceUrl 的验证点。
第二,一级 Tab:点击底部或侧边五个导航项,currentTabIndex 应切换到 0 到 4,对应显示首页、题库、挑战、收藏、我的。错题数量存在时,收藏 Tab 应显示徽标。
第三,二级页面:从首页搜索按钮进入 SearchPage,从分类卡片带参数进入 SearchPage,从题库卡片进入详情页,从详情页进入 PracticePage。这些路径应能返回,不应把 Tab 状态错乱。
第四,参数兜底:直接打开搜索页不带参数时应保持可搜索状态;带 categoryType 时应自动搜索;题库详情缺少 bankId 时不应崩溃;练习页题目为空时应执行已有回退逻辑。
第五,多设备布局:小屏底部导航应避让底部手势区,宽屏侧边导航应共享同一套 currentIndex。切换断点后,当前 Tab 不应被重置成其他页面。
10. 常见问题与排查
| 页面 push 后打不开 | main_pages.json | 页面路径未注册或路径拼写错误 | 补齐 src,保持 pages/xxx 一致 |
| 返回键回到启动页 | SplashPage | 使用了 pushUrl 而不是 replaceUrl | 启动页进入首页用 replaceUrl |
| Tab 切换后返回路径混乱 | Index/入口代码 | 一级 Tab 被当成二级页面 push | 一级 Tab 改 currentTabIndex |
| 搜索页参数丢失 | HomePage 分类入口 | params 字段名和页面接口不一致 | 对齐 categoryType/categoryName |
| 练习页空白 | PracticePage.aboutToAppear | bankId 无效或题目查询为空 | 增加 bankId 和题目数组兜底 |
| 底部导航被遮挡 | Index.bottomSafePadding | 安全区高度未写入或未转换 vp | 检查 EntryAbility 避让区更新 |
这张表对应的都是源码中能看到的导航边界。排查时不要先假设是框架问题,先确认页面是否在清单里、入口是状态切换还是路由压栈、参数字段是否和目标页接口一致、返回是否由二级页自己收口。
11. 小结
「笔下生辉」的主导航实现选择了一个务实结构:Index 作为五个一级 Tab 的统一入口,手机底部导航和宽屏侧边导航共享 currentTabIndex;HomePage 根据目标层级选择修改 Tab 状态或 router.pushUrl;二级页面在 aboutToAppear 中读取 router.getParams(),并对缺省参数和空数据做基础兜底;返回路径由二级页面通过 router.back() 收口。
这套方案的关键不是代码量,而是边界一致:一级入口不进路由栈,二级页面才进路由栈,启动页不留返回栈,参数页不无条件相信外部输入。对 HarmonyOS 5.0+ ArkTS 应用来说,这些规则能减少首页、题库、练习、搜索、设置之间的导航混乱,也为后续多设备布局和上架稳定性检查提供更清晰的测试路径。
本文的文章封面使用本篇独立 media/cover.png,应用合集封面使用「笔下生辉」应用级 media/collection-cover.png,两者职责不同:合集封面保持一个软件一张固定图,文章封面逐篇区分。
—
部分内容由AI辅助生成,已基于真实源码进行人工核验与边界修正。
网硕互联帮助中心








评论前必须登录!
注册