HarmonyOS知识库——导航架构与路由传参的设计模式
知识库 App 有 6 个页面、4 个底部导航 tab、十几条路由跳转路径。页面之间的跳转不是随意的——每条路由都有明确的参数设计,参数的类型和内容决定了目标页面的行为。搞清楚"从哪来、传什么、怎么用",是理解整个 App 数据流的关键。
完整效果
一、路由参数的完整清单
| 源页面 | 目标页面 | 参数 | 参数类型 |
|---|---|---|---|
| Index(笔记本卡片) | NoteList | notebookId, notebookName | number, string |
| Index(底部导航"搜索") | NoteList | notebookId=-1, notebookName=‘全部笔记’ | number, string |
| Index("+"按钮) | NoteEditor | 无 | — |
| Index(笔记卡片) | NoteEditor | noteId | number |
| Index(标签点击) | TagFilterPage | tag | string |
| Index(底部导航"收藏") | FavoritesPage | 无 | — |
| Index(底部导航"我的") | ProfilePage | 无 | — |
| NoteList(笔记卡片) | NoteEditor | noteId | number |
| FavoritesPage(笔记卡片) | NoteEditor | noteId | number |
| ProfilePage(标签云点击) | TagFilterPage | tag | string |
| TagFilterPage(标签点击) | TagFilterPage | tag | string(同页面切换) |
11 条路由路径,4 种参数类型:
| 参数 | 类型 | 出现次数 | 用途 |
|---|---|---|---|
| notebookId | number | 2 次 | 按笔记本筛选笔记 |
| notebookName | string | 2 次 | NoteList 标题显示 |
| noteId | number | 3 次 | 编辑指定笔记 |
| tag | string | 3 次 | 按标签筛选笔记 |
二、三种参数传递模式
模式一:双参数关联传递
params:{'notebookId':nb.id,'notebookName':nb.name}asRecord<string,Object>同时传 id 和名称——id 用于查询,名称用于显示。NoteList 收到后用 notebookId 查询笔记列表,用 notebookName 设置导航栏标题。
为什么不只传 id?NoteList 需要显示笔记本名称作为标题。如果只传 id,NoteList 需要自己查 NOTEBOOKS 获取名称——多一次查询。双参数传递让目标页面直接拿到需要的数据。
踩坑记录:最初 Index 跳转 NoteList 只传了 notebookId,NoteList 标题显示为空。查了原因发现没传 notebookName。加了双参数后解决。
模式二:单参数定向传递
// 编辑笔记params:{'noteId':n.id}asRecord<string,Object>// 标签筛选params:{'tag':t.name}asRecord<string,Object>只传一个参数——目标页面用这个参数做查询或设置状态。NoteEditor 用 noteId 查询笔记详情,TagFilterPage 用 tag 筛选笔记。
为什么不需要传标题或名称?因为目标页面可以从参数查询到完整数据——NoteEditor 通过 noteId 查到笔记的所有字段,TagFilterPage 通过 tag 查到所有匹配笔记。
模式三:特殊值约定
// "搜索" tab 跳转 NoteListparams:{'notebookId':-1,'notebookName':'全部笔记'}asRecord<string,Object>notebookId=-1 是约定值——表示"不按笔记本筛选,显示全部笔记"。NoteList 收到 -1 后调用getNotes()而不是getNotesByNotebook(-1)。
特殊值约定的风险:如果未来 NOTEBOOKS 增加了 id=-1 的笔记本,逻辑就会出错。更安全的做法是不传 notebookId 参数,NoteList 判断参数不存在时显示全部笔记。但在原型阶段,约定值更简单。
三、NoteEditor 的双模式路由
// 新建模式params:{}asRecord<string,Object>// 编辑模式params:{'noteId':n.id}asRecord<string,Object>有没有 noteId 参数决定了新建还是编辑。NoteEditor 在 aboutToAppear 里判断:
if(p&&p['noteId']){this.note=getNoteById(p['noteId']asnumber)if(this.note){this.isNew=false;/* 预填数据 */}}有 noteId → 编辑模式 → 预填标题/内容/笔记本/标签
无 noteId → 新建模式 → 所有字段为空
两种模式共享同一个页面——减少代码重复。新建和编辑的 UI 结构一样(标题输入+内容输入+笔记本选择+标签管理),只是数据来源不同。用@State isNew控制差异部分。
新建和编辑的路由参数对比
| 维度 | 新建 | 编辑 |
|---|---|---|
| 参数 | 空对象{} | { noteId: number } |
| NoteEditor 判断 | if (p && p['noteId'])为 false | 为 true |
| isNew 值 | true | false |
| 导航栏标题 | “新建笔记” | “编辑笔记” |
| 收藏/删除按钮 | 不显示 | 显示 |
| 保存操作 | addNote() 新增 | 修改 note 属性 |
四、TagFilterPage 的入口差异
// 从首页标签点击params:{'tag':t.name}asRecord<string,Object>// 从 ProfilePage 标签云点击params:{'tag':t.name}asRecord<string,Object>// 直接打开(无参数)无参数三种入口传相同的参数格式——但初始状态不同。有 tag 参数 → 直接显示筛选结果;无参数 → 显示标签网格供选择。
TagFilterPage 的状态由 selectedTag 控制:
if(p&&p['tag']){this.selectedTag=p['tag']asstringthis.filterNotes()}// selectedTag 有值 → 显示笔记列表// selectedTag 为空 → 显示标签网格同一个页面两种视图——靠参数决定初始状态。这是"条件渲染"在路由层面的应用。
五、底部导航栏的路由设计
@BuilderNav(label:string,active:boolean){Column({space:2}){Text(label==='首页'?'🏠':(label==='搜索'?'🔍':(label==='收藏'?'❤️':'👤'))).fontSize(18)Text(label).fontSize(9).fontColor(active?A:'#C0BFC6')}.onClick(()=>{if(label==='搜索')router.pushUrl({url:'pages/NoteList',params:{'notebookId':-1,'notebookName':'全部笔记'}asRecord<string,Object>})elseif(label==='收藏')router.pushUrl({url:'pages/FavoritesPage'})elseif(label==='我的')router.pushUrl({url:'pages/ProfilePage'})})}四个 tab 的路由策略
| Tab | 行为 | 参数 | 原因 |
|---|---|---|---|
| 首页 | 不跳转 | — | 已经在首页 |
| 搜索 | 跳转 NoteList | notebookId=-1 | 搜索 = 查看全部笔记 |
| 收藏 | 跳转 FavoritesPage | 无 | 收藏页独立数据源 |
| 我的 | 跳转 ProfilePage | 无 | 个人中心独立页面 |
“搜索” tab 不是真正的搜索页——它跳转到 NoteList 并传入 notebookId=-1。这是一个设计选择:把"查看全部笔记"和"搜索"合并在同一个页面。NoteList 既有全部笔记列表,又有搜索框,一个页面承担两种功能。
“首页” tab 不跳转的处理
“首页” tab 没有 onClick 跳转逻辑——因为 Index 本身就是首页。如果写了router.pushUrl({ url: 'pages/Index' }),会导致页面重建,搜索框内容丢失。
踩坑记录:最初"首页" tab 也写了跳转,点击后页面闪烁,首页状态丢失。去掉跳转后解决。这是 @Entry 页面的特性——重复 push 同一个 @Entry 会重建实例。
六、返回导航的统一模式
所有非首页页面的返回按钮都是同一段代码:
Row(){SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1])}.width(34).height(34).borderRadius(17).backgroundColor('rgba(0,0,0,0.03)').justifyContent(FlexAlign.Center).onClick(()=>{router.back()})5 个非首页页面都用了这段代码。结构完全一致——34×34 点击区域、chevron_left 图标、3% 透明度背景、router.back()。
为什么不抽取 Builder?因为这段代码只有 3 行有效逻辑(SymbolGlyph + 样式 + onClick),抽取 Builder 的收益不大。在 5 个页面的规模下,重复 3 行代码比引入 Builder 更简洁。
router.back() 的行为
router.back()返回上一个页面——不需要指定目标。系统自动恢复上一个页面的状态(如果是 @State 页面,状态保留)。
和router.pushUrl的区别:
| 方法 | 行为 | 页面栈 |
|---|---|---|
| router.pushUrl | 新页面入栈 | 栈深度 +1 |
| router.back() | 当前页出栈 | 栈深度 -1 |
用户操作路径示例:
首页 → 点击笔记 → NoteEditor(栈:首页, NoteEditor) → 点击返回 → 首页(栈:首页)七、路由参数的类型安全
所有路由参数都用了as Record<string,Object>类型断言:
params:{'noteId':n.id}asRecord<string,Object>为什么需要类型断言?router.pushUrl 的 params 类型要求Record<string, Object>。直接传{ 'noteId': n.id }会报类型不匹配——因为 TypeScript 推断{ noteId: number }不完全等于Record<string, Object>。
接收参数时也需要断言:
constp=router.getParams()asRecord<string,Object>constid:number=p['noteId']asnumber类型断言是 ArkTS 路由传参的标准做法——虽然繁琐,但保证了类型安全。
八、路由架构的整体模式
页面的角色分类
| 角色 | 页面 | 特点 |
|---|---|---|
| 入口页 | Index | 底部导航栏,不接收路由参数 |
| 列表页 | NoteList, FavoritesPage, TagFilterPage | 接收参数,渲染列表 |
| 详情/编辑页 | NoteEditor | 接收 noteId,双模式 |
| 展示页 | ProfilePage | 不接收参数,纯展示 |
数据流向
Index(入口) ├→ NoteList(笔记本筛选/搜索) │ └→ NoteEditor(编辑) ├→ NoteEditor(新建) ├→ TagFilterPage(标签筛选) │ └→ TagFilterPage(同页面切换) ├→ FavoritesPage(收藏列表) │ └→ NoteEditor(编辑) └→ ProfilePage(个人中心) └→ TagFilterPage(标签筛选)所有页面都汇聚到 NoteEditor——它是唯一的"写入"页面。其他页面都是"读取"或"筛选",只有 NoteEditor 能新建和编辑笔记。这种"多入口单出口"的设计让数据写入集中管理,降低了数据不一致的风险。
