HarmonyOS Navigation + NavPathStack 页面导航实战:为小说查询应用添加详情页
效果

前言
在 HarmonyOS 应用开发中,页面导航 是连接用户体验的核心环节。本文基于一个已有的小说文件查询应用,详细讲解如何使用 Navigation + NavPathStack 组件为应用添加详情页跳转功能。
通过本文,你将掌握:
Navigation+NavPathStack的核心概念与使用方式NavDestination详情页的构建方法- 路由参数的定义、传递与接收
@Builder路由映射的实现模式- 在详情页中通过 TaskPool 加载全文内容
一、需求分析
1.1 功能目标
在已有的小说查询列表页基础上,新增以下交互流程:
首页列表 → 点击小说卡片 → 跳转详情页 → 点击左上角返回 → 回到首页
1.2 详情页功能点
| 功能 | 说明 |
|---|---|
| 封面信息区 | 展示分类、标题、作者、文件大小、修改日期 |
| 内容简介 | 从全文中提取【内容简介】段落 |
| 章节目录 | 自动解析以"第X章"开头的标题行 |
| 全文内容 | 通过 TaskPool 在子线程中读取文件全文 |
| 返回按钮 | 左上角系统返回按钮,点击返回首页 |
1.3 技术方案选型
HarmonyOS 提供了多种页面导航方案:
| 方案 | 适用场景 | 本文采用 |
|---|---|---|
router 模块 |
简单页面跳转,支持参数传递 | ❌ |
Navigation + NavPathStack |
多层级页面导航,支持栈管理 | ✅ |
Tabs |
平级页面切换 | ❌ |
选择 Navigation 的理由:
- 原生支持栈式导航(push/pop),自带返回按钮
- 支持路由参数传递,无需全局状态
- 与
@ComponentV2完美配合,代码结构清晰
二、Navigation 核心概念
2.1 三大核心组件
┌─────────────────────────────────────────────────┐
│ Navigation (导航容器) │
│ ├── 管理页面栈 │
│ ├── 通过 NavPathStack 控制导航 │
│ └── .navDestination() 注册目标页面 │
│ │
│ NavPathStack (导航栈) │
│ ├── pushPathByName() → 压入新页面 │
│ ├── pop() → 弹出当前页面 │
│ ├── getAllPathName() → 获取栈中所有页面名 │
│ └── getParamByName() → 获取页面参数 │
│ │
│ NavDestination (目标页面) │
│ ├── 作为 Navigation 的子页面 │
│ ├── 自带标题栏和返回按钮 │
│ └── .title() / .hideTitleBar() 配置标题栏 │
└─────────────────────────────────────────────────┘
2.2 导航流程图
首页 (Index) 详情页 (NovelDetailPage)
┌──────────────┐ pushPathByName ┌──────────────────────┐
│ Navigation │ ──────────────────→ │ NavDestination │
│ pathStack │ │ @Param param │
│ │ │ 加载全文内容 │
│ 小说列表 │ ←────────────────── │ 左上角返回按钮 │
└──────────────┘ 系统自动 pop() └──────────────────────┘
2.3 路由参数传递机制
┌─────────────────┐ ┌───────────────────┐
│ 首页 │ │ 详情页 │
│ │ 可序列化 interface │ │
│ NovelFileInfo │ ──序列化──→ │ NovelDetailParam │
│ (@ObservedV2) │ │ (interface) │
│ 不可直接传递 │ │ @Param 接收 │
└─────────────────┘ └───────────────────┘
关键原则 :@ObservedV2 类实例不可序列化,必须转换为纯 interface 后再传递。
三、实现步骤
步骤 1:定义路由参数接口
在详情页文件中定义可序列化的参数接口,作为跨页面传递数据的载体:
typescript
// NovelDetailPage.ets
/**
* 详情页参数接口(可序列化,用于路由传参)
*/
export interface NovelDetailParam {
title: string
author: string
category: string
fileName: string
filePath: string
fileSize: number
lastModified: number
preview: string
matchScore: number
}
设计要点:
- 只包含基本类型(
string、number),确保可序列化 - 与
NovelFileRawData结构类似,但面向详情页的展示需求 - 不包含
@ObservedV2类实例(不可序列化)
步骤 2:创建详情页组件
使用 @ComponentV2 + NavDestination 构建详情页:
typescript
@ComponentV2
struct NovelDetailPage {
// 通过 @Param 接收路由参数
@Param param: NovelDetailParam = {
title: '', author: '', category: '', fileName: '',
filePath: '', fileSize: 0, lastModified: 0, preview: '', matchScore: 0
}
@Local fullContent: string = ''
@Local isLoading: boolean = true
@Local chapterList: Array<string> = []
aboutToAppear(): void {
this.loadContent()
}
// 通过 TaskPool 在子线程中读取全文
async loadContent(): Promise<void> {
this.isLoading = true
try {
let context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext
let helper: NovelQueryHelper = new NovelQueryHelper(context.filesDir)
let content: string = await helper.readContent(this.param.filePath)
this.fullContent = content
this.parseChapters(content)
} catch (e) {
this.fullContent = '内容加载失败'
}
this.isLoading = false
}
build() {
NavDestination() {
Scroll() {
Column() {
// 封面信息区、内容简介、章节目录、全文内容...
}
}
}
.title(this.param.title) // 标题栏显示小说名称
.hideTitleBar(false) // 显示系统标题栏(含返回按钮)
.hideToolBar(true) // 隐藏底部工具栏
}
}
export { NovelDetailPage }
关键说明:
| 装饰器/组件 | 作用 |
|---|---|
@ComponentV2 |
声明为 V2 组件,支持 @Param、@Local |
@Param |
接收父组件/路由传递的参数(只读) |
@Local |
页面内部状态 |
NavDestination |
作为 Navigation 的目标页面容器 |
.title() |
设置标题栏文字 |
.hideTitleBar(false) |
显示标题栏,自动包含返回按钮 |
步骤 3:添加全文读取服务
在 NovelQueryService.ets 中新增 @Concurrent 函数和 Helper 方法:
typescript
// NovelQueryService.ets
/**
* @Concurrent 并发函数:读取小说文件全文内容
*/
@Concurrent
function readNovelContent(filePath: string): string {
let content: string = ''
try {
let fd: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY)
let stat: fileIo.Stat = fileIo.statSync(fd.fd)
let buf: ArrayBuffer = new ArrayBuffer(stat.size)
fileIo.readSync(fd.fd, buf)
fileIo.closeSync(fd.fd)
let decoder: util.TextDecoder = util.TextDecoder.create('utf-8')
content = decoder.decodeWithStream(new Uint8Array(buf))
} catch (e) {
content = ''
}
return content
}
// 在 NovelQueryHelper 类中新增方法:
export class NovelQueryHelper {
// ...已有方法...
/**
* 读取小说文件全文内容
*/
async readContent(filePath: string): Promise<string> {
let task: taskpool.Task = new taskpool.Task(readNovelContent, filePath)
let result: Object = await taskpool.execute(task, taskpool.Priority.HIGH)
return result as string
}
}
为什么用 TaskPool? 全文文件读取是 IO 操作,放在子线程执行不会阻塞主线程 UI,尤其在文件较大时效果明显。
步骤 4:改造首页为 Navigation 容器
将原有的 Index.ets 从纯 Column 布局改为 Navigation 容器:
改造前:
typescript
build() {
Column() {
// 搜索栏、分类过滤、小说列表...
}
.width('100%')
.height('100%')
}
改造后:
typescript
// 新增 NavPathStack 成员
private pathStack: NavPathStack = new NavPathStack()
// 新增 import
import { NovelDetailPage, NovelDetailParam } from './NovelDetailPage'
build() {
Navigation(this.pathStack) {
Column() {
// 搜索栏、分类过滤、小说列表...(内容不变)
}
.width('100%')
.height('100%')
.backgroundColor('#FAFAFA')
}
.navDestination(this.pageMap) // 注册目标页面
.hideTitleBar(true) // 首页隐藏标题栏
.hideToolBar(true) // 首页隐藏工具栏
}
改造要点:
Navigation(this.pathStack)将 NavPathStack 绑定到导航容器- 原有的
Column内容作为Navigation的子内容(首页) .navDestination(this.pageMap)注册路由映射.hideTitleBar(true)首页不需要系统标题栏(已有自定义标题栏)
步骤 5:实现路由映射
在 Index 组件中添加 @Builder 方法,将路由名称映射到目标组件:
typescript
/**
* 页面路由映射
*/
@Builder
pageMap(name: string, param: Object) {
if (name === 'NovelDetail') {
NovelDetailPage({ param: param as NovelDetailParam })
}
}
工作原理:
- 当
pathStack.pushPathByName('NovelDetail', data)被调用时 Navigation在栈顶创建新的NavDestination页面navDestination回调被触发,name = 'NovelDetail',param = data@Builder返回NovelDetailPage组件实例,并传入参数
步骤 6:添加卡片点击跳转
为小说列表卡片添加点击事件,调用 navigateToDetail 方法:
typescript
// 小说列表中添加 .onClick
List({ space: 12 }) {
ForEach(this.viewModel.filteredList, (novel: NovelFileInfo) => {
ListItem() {
this.NovelCard(novel)
}
.onClick(() => {
this.navigateToDetail(novel)
})
}, (novel: NovelFileInfo) => novel.filePath)
}
typescript
/**
* 跳转到小说详情页
*/
navigateToDetail(novel: NovelFileInfo): void {
let detailParam: NovelDetailParam = {
title: novel.title,
author: novel.author,
category: novel.category,
fileName: novel.fileName,
filePath: novel.filePath,
fileSize: novel.fileSize,
lastModified: novel.lastModified,
preview: novel.preview,
matchScore: novel.matchScore
}
this.pathStack.pushPathByName('NovelDetail', detailParam)
}
关键操作:
- 将
NovelFileInfo(@ObservedV2实例)的属性提取为纯NovelDetailParam对象 - 调用
pathStack.pushPathByName('NovelDetail', param)压入新页面 - 系统自动执行页面转场动画(从右滑入)
四、详情页 UI 布局详解
4.1 封面信息区
typescript
Column() {
// 分类标签 + 匹配度
Row() {
Text(this.param.category)
.fontSize(12)
.fontColor('#1890FF')
.backgroundColor('#E6F7FF')
.borderRadius(6)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
Blank()
Text(`匹配度 ${this.param.matchScore}%`)
.fontSize(12)
.fontColor(this.param.matchScore >= 80 ? '#52C41A' : '#FAAD14')
}
// 标题
Text(this.param.title)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.margin({ top: 16 })
// 作者
Text(`作者:${this.param.author}`)
.fontSize(15)
.fontColor('#666666')
.margin({ top: 8 })
// 文件信息行(大小、日期、文件名)
Row({ space: 20 }) {
Text(this.getFormattedSize())
Text(this.getFormattedDate())
Text(this.param.fileName)
}
}
.padding(20)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 6, color: 'rgba(0,0,0,0.05)', offsetX: 0, offsetY: 2 })
4.2 章节目录自动解析
从全文内容中自动提取以"第X章"开头的行作为章节目录:
typescript
parseChapters(content: string): void {
let chapters: Array<string> = []
let lines: Array<string> = content.split('\n')
for (let i = 0; i < lines.length; i++) {
let line: string = lines[i].trim()
if (line.startsWith('第') && line.indexOf('章') >= 0 && line.length < 30) {
chapters.push(line)
}
}
this.chapterList = chapters
}
解析规则:
- 以"第"开头
- 包含"章"字
- 行长度小于 30(避免误匹配正文内容)
4.3 内容简介提取
从全文中定位 【内容简介】 标记,提取其后的内容:
typescript
getSynopsis(): string {
let idx: number = this.fullContent.indexOf('【内容简介】')
if (idx >= 0) {
let after: string = this.fullContent.substring(idx + 7).trim()
let chapterIdx: number = after.indexOf('【章节目录】')
if (chapterIdx >= 0) {
return after.substring(0, chapterIdx).trim()
}
return after
}
return this.param.preview // 降级到预览文本
}
五、完整数据流
用户点击卡片
│
▼
navigateToDetail(novel)
│ 提取 NovelDetailParam
▼
pathStack.pushPathByName('NovelDetail', param)
│ 压入导航栈
▼
navDestination 回调触发
│ name='NovelDetail', param=detailParam
▼
@Builder pageMap → NovelDetailPage({ param })
│ @Param 接收参数
▼
aboutToAppear → loadContent()
│
▼
NovelQueryHelper.readContent(filePath)
│
▼
taskpool.execute(readNovelContent)
│ 子线程读取文件
▼
fullContent 更新 → @Local 触发 UI 刷新
│
▼
显示全文内容 + 解析章节目录
六、关键配置对比
6.1 Navigation 属性配置
| 属性 | 首页设置 | 详情页设置 | 说明 |
|---|---|---|---|
.hideTitleBar() |
true |
false |
首页有自定义标题栏,详情页使用系统标题栏 |
.hideToolBar() |
true |
true |
两端都不需要底部工具栏 |
.title() |
--- | 小说名称 | 详情页标题栏显示小说名称 |
.navDestination() |
注册映射 | --- | 只在首页注册 |
6.2 @ComponentV2 状态装饰器使用
| 装饰器 | 使用位置 | 说明 |
|---|---|---|
@ComponentV2 |
Index、NovelDetailPage | 声明 V2 组件 |
@Local |
Index(viewModel、searchInput)、Detail(fullContent、isLoading) | 页面内部状态 |
@Param |
NovelDetailPage(param) | 接收路由参数(只读) |
@ObservedV2 |
NovelFileInfo、NovelListViewModel | 响应式类定义 |
@Trace |
NovelFileInfo 各属性 | 细粒度属性级响应式 |
6.3 NavPathStack 常用 API
| 方法 | 作用 | 使用场景 |
|---|---|---|
pushPathByName(name, param) |
压入新页面 | 点击卡片跳转详情 |
pop() |
弹出当前页面 | 手动返回(系统返回按钮自动调用) |
clear() |
清空导航栈 | 返回首页后清空 |
getAllPathName() |
获取栈中所有页面名 | 调试/日志 |
getSize() |
获取栈深度 | 判断是否在首页 |
七、涉及的文件变更
7.1 新增文件
| 文件 | 路径 | 职责 |
|---|---|---|
NovelDetailPage.ets |
ets/pages/ |
详情页组件 + NovelDetailParam 接口 |
7.2 修改文件
| 文件 | 变更内容 |
|---|---|
Index.ets |
引入 NavPathStack + Navigation 包裹 + 路由映射 + 卡片点击跳转 |
NovelQueryService.ets |
新增 readNovelContent @Concurrent 函数 + readContent 方法 |
7.3 不需要修改的文件
| 文件 | 说明 |
|---|---|
main_pages.json |
NavDestination 无需注册路由 |
NovelFileInfo.ets |
数据模型不变 |
NovelListViewModel.ets |
视图模型不变 |
Constants.ets |
常量不变 |
八、踩坑与注意事项
8.1 @ObservedV2 实例不可作为路由参数
错误做法:
typescript
// ❌ NovelFileInfo 是 @ObservedV2 类实例,不可序列化
this.pathStack.pushPathByName('NovelDetail', novel)
正确做法:
typescript
// ✅ 提取为纯 interface 后传递
let param: NovelDetailParam = {
title: novel.title,
author: novel.author,
// ...其他基本类型属性
}
this.pathStack.pushPathByName('NovelDetail', param)
8.2 @Param 必须提供默认值
typescript
// ❌ 编译错误:@Param 必须有默认值
@Param param: NovelDetailParam
// ✅ 正确:提供完整的默认值
@Param param: NovelDetailParam = {
title: '', author: '', category: '', fileName: '',
filePath: '', fileSize: 0, lastModified: 0, preview: '', matchScore: 0
}
8.3 NavDestination 返回按钮是系统自动的
NavDestination 配合 .hideTitleBar(false) 时,标题栏左侧会自动显示返回箭头按钮,点击后系统自动调用 pathStack.pop(),无需手动编写返回逻辑。
8.4 for 循环替代高阶函数
解析章节目录时,使用 for 循环而非 Array.filter(),避免触发 ArkTS 的 arkts-no-inferred-generic-params 编译限制。
8.5 首页 hideTitleBar 与详情页的区别
| 页面 | hideTitleBar | 原因 |
|---|---|---|
| 首页 | true |
已有自定义标题栏(搜索+TaskPool标签) |
| 详情页 | false |
使用系统标题栏,自动带返回按钮 |
九、效果预览
9.1 交互流程
┌──────────────────────┐ 点击卡片 ┌──────────────────────┐
│ 小说查询 TaskPool │ ──────────→ │ ← 星际迷航纪 │
├──────────────────────┤ ├──────────────────────┤
│ 🔍 搜索... │ │ 科幻 匹配度100%│
├──────────────────────┤ │ │
│ 全部 科幻 悬疑 ... │ │ 星际迷航纪 │
├──────────────────────┤ │ 作者:陈星河 │
│ 共 100 个结果 │ │ 📄 1.2KB 📅 2024... │
├──────────────────────┤ ├──────────────────────┤
│ ┌──────────────────┐ │ │ 内容简介 │
│ │ 科幻 匹配度100%│ │ │ │
│ │ 星际迷航纪 │ │ ← 返回 │ 公元2387年,人类... │
│ │ 作者:陈星河 │ │ ──────────→ ├──────────────────────┤
│ │ 公元2387年... │ │ │ 章节目录(共5章) │
│ └──────────────────┘ │ │ 第一章 星海彼岸 → │
│ ┌──────────────────┐ │ │ 第二章 空间异变 → │
│ │ 悬疑 匹配度80%│ │ │ ... │
│ │ 古城密码 │ │ ├──────────────────────┤
│ └──────────────────┘ │ │ 全文内容 │
└──────────────────────┘ │ 标题:星际迷航纪... │
首页列表页 └──────────────────────┘
详情页
9.2 返回行为
- 点击左上角
←返回箭头 → 系统自动pop()→ 返回首页列表 - 首页搜索状态、分类筛选、滚动位置均保持不变(
@Local状态保留)
十、总结
10.1 核心收获
| 知识点 | 掌握内容 |
|---|---|
| Navigation 容器 | 将首页包裹在 Navigation 中,通过 NavPathStack 管理页面栈 |
| NavDestination | 详情页使用 NavDestination 作为容器,自带标题栏和返回按钮 |
| 路由参数 | 使用纯 interface 定义参数,通过 pushPathByName 传递 |
| @Builder 映射 | 通过 @Builder + navDestination 实现路由名称到组件的映射 |
| 数据转换 | @ObservedV2 实例 → 纯 interface → @Param 接收 |
10.2 扩展方向
- 深层导航 :从详情页继续跳转到章节阅读页(二次
pushPathByName) - 转场动画:自定义页面转场动画效果
- 生命周期监听 :通过
onWillAppear、onWillDisappear等钩子处理页面生命周期 - 返回拦截 :使用
interception在返回前弹出确认对话框
系列文档:
📘
@ohos.taskpool 使用指南--- TaskPool API 详解📗
小说文件查询案例指南--- 查询功能实现📙
本文--- 详情页跳转实现📕
小说查询案例总体介绍指南--- 总体效果与架构📓
小说初始化实现指南--- 种子数据与批量初始化
参考文档: