《文件查询》四、Navigation页面导航实现指南

效果

前言

在 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
}

设计要点

  • 只包含基本类型(stringnumber),确保可序列化
  • 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,尤其在文件较大时效果明显。

将原有的 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)
}

关键操作

  1. NovelFileInfo@ObservedV2 实例)的属性提取为纯 NovelDetailParam 对象
  2. 调用 pathStack.pushPathByName('NovelDetail', param) 压入新页面
  3. 系统自动执行页面转场动画(从右滑入)

四、详情页 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 刷新
     │
     ▼
显示全文内容 + 解析章节目录

六、关键配置对比

属性 首页设置 详情页设置 说明
.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 各属性 细粒度属性级响应式
方法 作用 使用场景
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
}

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
  • 转场动画:自定义页面转场动画效果
  • 生命周期监听 :通过 onWillAppearonWillDisappear 等钩子处理页面生命周期
  • 返回拦截 :使用 interception 在返回前弹出确认对话框

系列文档:

  • 📘 @ohos.taskpool 使用指南 --- TaskPool API 详解

  • 📗 小说文件查询案例指南 --- 查询功能实现

  • 📙 本文 --- 详情页跳转实现

  • 📕 小说查询案例总体介绍指南 --- 总体效果与架构

  • 📓 小说初始化实现指南 --- 种子数据与批量初始化
    参考文档:

  • Navigation 组件参考

  • NavPathStack API 参考

  • State Management V2