HarmonyOS应用<民族图鉴>开发第8篇:路由导航——页面跳转与参数传递深度解析

在这里插入图片描述

📖 引言

前七篇我们学习了 ArkTS 语言、声明式 UI、基础组件和状态管理,已经能够构建单个页面的应用了。但真实的应用不可能只有一个页面——首页点进去是列表,列表点进去是详情,还有设置页、个人中心等等。

页面之间怎么跳转?跳转的时候怎么传数据?返回的时候怎么把结果带回来?页面栈是怎么管理的?这些都是路由导航要解决的问题。

你可能会问:不就是 router.pushUrl 吗?有什么好讲的?

如果你只是简单地从 A 跳到 B,那确实很简单。但实际开发中,你会遇到各种各样的问题:

  • 跳转参数怎么传?对象类型的参数怎么序列化?
  • 返回上一页的时候,怎么通知上一页刷新数据?
  • 怎么跳转到指定页面,并清除中间的页面栈?
  • 路由守卫怎么做?未登录怎么跳转到登录页?
  • 页面切换动画怎么自定义?

这些问题在「民族图鉴」项目中都有实际的应用场景。比如从民族列表页跳转到详情页,要传民族 ID;从详情页返回来,列表页的收藏状态要更新。

本文将以「民族图鉴」项目为载体,从基础的页面跳转,到参数传递,到页面栈管理,再到路由设计模式,系统讲解 HarmonyOS 应用的路由导航。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 掌握页面跳转的多种方式与区别(push/replace/back)
  • ✅ 熟练掌握页面间参数传递的各种方式
  • ✅ 理解页面栈的管理机制
  • ✅ 掌握返回传参与结果回调的实现
  • ✅ 学会命名路由与路由表的设计
  • ✅ 理解路由守卫与权限控制的实现思路
  • ✅ 写出结构清晰、易于维护的路由代码

💡 需求分析

为什么需要路由管理?

一个没有路由系统的应用,所有页面都堆在一起,跳转全靠手动管理状态。页面少了还好,页面多了之后:

  1. 跳转混乱:不知道从哪来,不知道往哪去
  2. 参数传递麻烦:各种全局变量传来传去
  3. 返回栈混乱:返回到哪个页面全靠猜
  4. 权限控制难:每个页面都要判断一遍登录状态

路由系统就是为了解决这些问题的——它统一管理页面的跳转、参数、栈、权限,让页面导航变得清晰可控。

「民族图鉴」的路由场景

「民族图鉴」虽然是个中等规模的应用,但路由场景很丰富:

场景说明跳转方式
启动页 → 首页应用启动,Splash 页跳转到首页replace(不保留启动页)
首页 → 详情页点击民族卡片,进入详情页push(进栈)
列表 → 详情页百科列表点击进入详情push(进栈)
个人页 → 收藏页个人中心进入收藏记录push(进栈)
个人页 → 设置页个人中心进入设置push(进栈)
设置 → 意见反馈设置里进入反馈页push(进栈)
详情页 → 返回返回上一页back

让我们从最基础的开始,一步步深入。


🛠️ 核心实现

步骤1:路由基础——页面注册与跳转方式

1.1 页面注册:main_pages.json

在讲跳转之前,先说说页面注册。

HarmonyOS 的路由是静态注册的——所有页面都必须在 main_pages.json 中声明,才能被路由系统找到。

// resources/base/profile/main_pages.json
{
  "src": [
    "pages/SplashPage",
    "pages/Index",
    "pages/EthnicDetailPage",
    "pages/CollectionPage",
    "pages/HistoryPage",
    "pages/SettingsPage",
    "pages/FeedbackPage"
  ]
}

为什么要静态注册?

原因说明
安全防止恶意代码随意跳转到内部页面
性能编译时就能构建路由表,启动更快
包管理原子化服务按需分发时,知道哪些页面在哪个包里
可追溯所有页面都在配置文件里,一目了然

⚠️ 注意:页面路径必须完全一致,大小写敏感。写错了一个字母,运行时就会报错找不到页面。

1.2 三种跳转方式

HarmonyOS 的路由系统提供了三种核心跳转方式:

方式API页面栈变化适用场景
推入pushUrl新页面入栈正常跳转(列表→详情)
替换replaceUrl替换当前栈顶启动页→首页(不保留启动页)
返回back出栈返回上一页

页面栈示意图

pushUrl 的情况:
  [A, B] → pushUrl(C) → [A, B, C]
  栈里有 A、B,跳转到 C,C 入栈

replaceUrl 的情况:
  [A, B] → replaceUrl(C) → [A, C]
  栈里有 A、B,用 C 替换掉 B

back 的情况:
  [A, B, C] → back() → [A, B]
  栈里有 A、B、C,返回后 C 出栈
1.3 pushUrl:最常用的跳转

pushUrl 是最常用的跳转方式——新页面入栈,用户可以返回来。

import router from '@ohos.router';

// 跳转到民族详情页
router.pushUrl({
  url: 'pages/EthnicDetailPage',
  params: {
    ethnicId: '01'
  }
});

参数说明

参数类型说明
urlstring目标页面路径,和 main_pages.json 里的一致
paramsObject跳转参数,目标页面通过 getParams() 获取
1.4 replaceUrl:替换当前页

replaceUrl 会替换掉当前栈顶的页面,用户返回的时候不会回到被替换的那个页面。

// 启动页跳转到首页
// 用 replaceUrl,这样用户按返回键不会回到启动页
router.replaceUrl({
  url: 'pages/Index'
});

什么时候用 replaceUrl?

  • 启动页 → 首页(启动页用完就扔,不保留)
  • 登录页 → 首页(登录完就不用回去了)
  • 引导页 → 首页(引导页只看一次)

这些场景的共同点:目标页面之后,不需要再回到当前页面

1.5 back:返回上一页

back 用于返回上一页,就是栈顶出栈。

import router from '@ohos.router';

// 简单返回
router.back();

// 返回指定页面(返回到栈中的某个页面)
router.back({
  url: 'pages/Index'
});

// 返回并传参数
router.back({
  url: 'pages/EthnicListPage',
  params: {
    refreshed: true
  }
});

💡 back 也能传参! 很多人不知道,back 的时候也能带参数回去。下一节会详细讲。

1.6 路由底层原理:页面栈、路由表与路由匹配

了解了基础用法后,我们深入一点,看看路由系统的底层是怎么工作的。理解了原理,遇到问题才能快速定位。

1. 页面栈的实现原理

页面栈本质上是一个栈(Stack)数据结构,遵循后进先出(LIFO)的原则。

页面栈的数据结构示意:

┌─────────────────┐
│   栈顶(当前页)  │  ← push 入栈,pop 出栈
├─────────────────┤
│   详情页         │
├─────────────────┤
│   列表页         │
├─────────────────┤
│   首页           │  ← 栈底(根页面)
└─────────────────┘

操作:
- pushUrl(详情页) → 详情页入栈,成为新的栈顶
- back()         → 栈顶出栈,上一页成为新的栈顶
- replaceUrl(X)  → 栈顶出栈,X 入栈(替换)

栈的关键特性

  • 只能从栈顶操作(入栈、出栈)
  • 栈底是根页面,永远不会被弹出(除非应用退出)
  • 当前显示的页面永远是栈顶页面
  • 栈的深度就是当前打开的页面数量

2. 路由表的构建

HarmonyOS 的路由表是在编译时构建的,不是运行时动态生成的。

编译流程:
  main_pages.json
      ↓
  编译器解析所有页面路径
      ↓
  构建静态路由表(Route Table)
      ↓
  打包进应用安装包
      ↓
  运行时直接查表,快速定位页面

为什么要静态构建路由表?

优势说明
性能运行时不需要动态查找,直接查表,跳转更快
安全所有页面都是已知的,防止恶意跳转
包管理原子化服务可以按页面分发,知道哪些页面在哪个包
可分析编译时就能分析页面依赖关系

3. 路由匹配过程

当调用 router.pushUrl({ url: 'pages/EthnicDetailPage' }) 时,系统内部做了什么?

路由匹配流程:
  1. 解析 URL → 'pages/EthnicDetailPage'
       ↓
  2. 在路由表中查找匹配项
       ├─ 精确匹配:完全一致的路径
       └─ 没有匹配 → 抛出错误(找不到页面)
       ↓
  3. 找到对应页面的组件类
       ↓
  4. 创建页面实例
       ↓
  5. 传递 params 参数
       ↓
  6. 页面入栈,触发生命周期
       ↓
  7. 页面渲染显示

4. 参数传递的底层机制

路由参数是怎么从 A 页面传到 B 页面的?

参数传递机制:
  A 页面调用 pushUrl({ params: { id: '01' } })
       ↓
  参数对象被序列化(JSON.stringify)
       ↓
  存入路由系统的参数缓冲区
       ↓
  B 页面创建完成
       ↓
  B 页面调用 getParams()
       ↓
  从缓冲区取出参数,反序列化(JSON.parse)
       ↓
  返回给调用方

为什么参数必须可序列化?

  • 参数要跨页面传递,需要经过序列化/反序列化
  • 函数、Symbol、循环引用的对象无法序列化
  • 所以传参会有限制,这也是为什么推荐传 ID 而不是传大对象

步骤2:参数传递——页面间的数据通信

2.1 push 时传参 + getParams 接收

这是最常用的参数传递方式:跳转的时候把参数带上,目标页面取出来。

发送方(A 页面)

// pages/EthnicListPage.ets
import router from '@ohos.router';

// 跳转到详情页,带上民族 ID
navigateToDetail(ethnicId: string): void {
  router.pushUrl({
    url: 'pages/EthnicDetailPage',
    params: {
      ethnicId: ethnicId
    }
  });
}

接收方(B 页面)

// pages/EthnicDetailPage.ets
import router from '@ohos.router';

@Entry
@Component
struct EthnicDetailPage {
  @State ethnic: EthnicGroup | undefined = undefined;

  aboutToAppear(): void {
    // 获取路由参数
    const params = router.getParams() as Record<string, string>;

    if (params?.ethnicId) {
      // 根据 ID 查询民族信息
      this.ethnic = getEthnicById(params.ethnicId);
    }
  }

  build() {
    // ...
  }
}

注意事项

  1. params 是 any 类型:要自己做类型断言,最好定义好接口
  2. 参数要可序列化:不能传函数、不能传循环引用的对象
  3. 参数大小有限制:不要传太大的数据(建议几 KB 以内)
  4. aboutToAppear 中获取:在页面即将出现的时候获取参数
2.2 返回时传参:back + params

很多人不知道,返回的时候也能传参数。比如从详情页返回列表页,告诉列表页"收藏状态变了,刷新一下"。

发送方(详情页,返回时)

// pages/EthnicDetailPage.ets
// 返回到列表页,带上刷新参数
goBackAndRefresh(): void {
  router.back({
    url: 'pages/EthnicListPage',
    params: {
      needRefresh: true,
      ethnicId: this.ethnic?.id,
      isFavorite: this.isFavorite
    }
  });
}

接收方(列表页)

// pages/EthnicListPage.ets
import router from '@ohos.router';

@Entry
@Component
struct EthnicListPage {
  @State favoriteStatusMap: Record<string, boolean> = {};

  // 页面每次出现时都会调用
  onPageShow(): void {
    // 检查有没有返回参数
    const params = router.getParams() as Record<string, Object>;

    if (params?.needRefresh) {
      // 刷新收藏状态
      this.refreshFavoriteStatus();
    }
  }
}

⚠️ 注意:back 传参的时候,目标页面要用 onPageShow 来接收,而不是 aboutToAppear。因为返回的时候,页面已经创建过了,不会再走 aboutToAppear,但每次显示都会走 onPageShow。

2.3 全局状态:@StorageLink

如果数据需要在很多页面间共享,而且需要持久化,可以用 @StorageLink。

// 所有需要这个状态的页面,都用同一个 key
@StorageLink('current_language') currentLanguage: AppLanguage = AppLanguage.ZH_CN;

一个页面改了,所有页面自动同步,而且重启应用还在。

适用场景

  • 主题设置
  • 语言设置
  • 用户登录状态
  • 全局配置

不适用场景:

  • 临时的跳转参数(用完就没用了)
  • 大量的数据(Preferences 不适合存大数据)
2.4 Service 单例模式

复杂的业务数据,比如收藏列表、浏览历史,放在 Service 层,用单例模式管理。

// services/StorageService.ets
export class StorageService {
  private static instance: StorageService;

  static getInstance(): StorageService {
    if (!StorageService.instance) {
      StorageService.instance = new StorageService();
    }
    return StorageService.instance;
  }

  private favorites: string[] = [];

  async toggleFavorite(ethnicId: string): Promise<boolean> {
    // ... 切换收藏状态
  }

  isFavorite(ethnicId: string): boolean {
    return this.favorites.includes(ethnicId);
  }
}

所有页面都调用同一个 Service 实例,数据是共享的。这是中大型应用最常用的方式。

2.5 路由传参的三种方式深度对比

HarmonyOS 路由系统中,参数传递主要有三种方式:query 方式params 方式state 方式(通过全局状态)。每种方式有各自的适用场景。

方式一:params 传参(最常用)

// 发送方
router.pushUrl({
  url: 'pages/EthnicDetailPage',
  params: {
    ethnicId: '01',
    source: 'home'
  }
});

// 接收方
const params = router.getParams() as Record<string, string>;
const ethnicId = params?.ethnicId;

特点:

  • 参数和 URL 分离,不体现在 URL 上
  • 支持多种类型(字符串、数字、布尔、对象、数组)
  • 数据量较小(建议 < 10KB)
  • 需要可序列化

方式二:URL query 传参

// 发送方:把参数拼在 URL 后面
router.pushUrl({
  url: 'pages/EthnicDetailPage?ethnicId=01&source=home'
});

// 接收方:需要自己解析 URL
const params = router.getParams() as Record<string, string>;
// query 参数也会被解析到 params 中
const ethnicId = params?.ethnicId;

特点:

  • 参数在 URL 上,一目了然
  • 只能传递字符串类型
  • 适合简单的、少量的参数
  • 可以直接分享 URL(类似网页的 query string)

方式三:全局 state 传参

// 方式A:@StorageLink(持久化)
@StorageLink('current_ethnic_id') currentEthnicId: string = '';

// 方式B:Service 单例(内存中)
StorageService.getInstance().setCurrentEthnic(ethnic);

// 方式C:AppStorage(全局状态)
AppStorage.Set('currentEthnic', ethnic);

特点:

  • 不通过路由传递,数据存在全局
  • 数据量大也没关系
  • 可以传任意类型(包括函数,不推荐)
  • 多个页面都能访问

三种方式对比表

对比项params 传参URL query全局 state
数据类型任意可序列化类型只能字符串任意类型
数据量小(<10KB)很小(URL长度限制)
URL可见
持久化可选
多页面共享
适用场景页面跳转传参简单标记、分享链接复杂业务数据
「民族图鉴」使用场景详情页传民族ID来源页面标记收藏、历史记录

「民族图鉴」的选型实践

场景选用方式原因
首页→详情页,传民族IDparams数据量小,跳转专用
详情页→列表页,通知刷新back params返回时传结果
收藏状态,多页面共享Service 单例业务数据,需要共享
主题设置,全局生效@StorageLink需要持久化,全局共享
搜索关键词,分享链接URL query(可选)需要体现在URL上
2.6 参数传递方式对比总结
方式适用场景数据量持久化双向
push params页面跳转时传参小(<10KB)单向(A→B)
back params返回时传结果单向(B→A)
URL query简单参数、分享链接很小单向
@StorageLink全局设置、用户偏好双向(全局共享)
Service 单例复杂业务数据可选(自己实现)双向(全局共享)

选型建议

  • 跳转带个 ID → push params
  • 返回带个结果 → back params
  • 需要分享链接 → URL query
  • 全局设置 + 要持久化 → @StorageLink
  • 业务数据 + 很多页面用 → Service 单例

步骤3:页面栈管理

3.1 什么是页面栈?

页面栈是路由系统的核心概念。所有打开的页面,按顺序压入栈中。

栈顶(当前页面)
  │
  ▼
[首页] → [列表页] → [详情页]
  ▲
  │
栈底

特点

  • 新页面从栈顶入栈(push)
  • 返回时栈顶出栈(pop)
  • 栈顶就是当前显示的页面
  • 栈底是应用的根页面
3.2 栈长度与性能

页面栈不是无限的,HarmonyOS 对页面栈深度有限制(通常最多 32 个页面)。超过了可能会出问题。

什么时候栈会很深?

  • 列表页 → 详情页 → 列表页 → 详情页 → 列表页…
  • 这种循环跳转,栈会越来越深

解决方案

  • 对于循环跳转,考虑用 replaceUrl 而不是 pushUrl
  • 或者跳转到指定页面,中间的清掉
3.3 清空栈:跳转到根页面

有时候需要返回到首页,把中间的页面都清掉。比如:

  • 退出登录,回到登录页
  • 某个操作完成后,回到首页

方法1:多次 back(简单粗暴)

// 不行,不知道有多少层
router.back();
router.back();
router.back();

方法2:back 到指定 URL

// 返回到首页,中间的都出栈
router.back({
  url: 'pages/Index'
});

这个方法会一直出栈,直到找到指定 URL 的页面。

💡 注意:如果指定的 URL 不在栈里,会怎么样?会报错。所以要确保目标页面在栈中。


步骤4:路由命名与常量管理

4.1 为什么要管理路由路径?

如果跳转路径都是硬编码的字符串,散落在各个文件里:

// 文件 A 里
router.pushUrl({ url: 'pages/EthnicDetailPage' });

// 文件 B 里
router.pushUrl({ url: 'pages/EthnicDetailPage' });

// 文件 C 里
router.pushUrl({ url: 'pages/ethnicDetailPage' });  // 写错了!

问题来了:

  1. 容易写错,大小写、拼写错误
  2. 要改路径的时候,要找所有地方改
  3. 不知道总共有多少页面,哪些用了哪些
4.2 路由常量管理

最好的方式是统一管理路由路径,定义成常量,要用的时候引用常量。

// common/constants/RouteConstants.ets
/**
 * 路由路径常量
 * 统一管理所有页面路径,避免硬编码和拼写错误
 */
export class RouteConstants {
  // ========== 主流程 ==========
  static readonly SPLASH: string = 'pages/SplashPage';
  static readonly INDEX: string = 'pages/Index';

  // ========== 民族相关 ==========
  static readonly ETHNIC_DETAIL: string = 'pages/EthnicDetailPage';
  static readonly ETHNIC_LIST: string = 'pages/EthnicListPage';

  // ========== 个人中心 ==========
  static readonly PROFILE: string = 'pages/ProfilePage';
  static readonly COLLECTION: string = 'pages/CollectionPage';
  static readonly HISTORY: string = 'pages/HistoryPage';
  static readonly SETTINGS: string = 'pages/SettingsPage';
  static readonly FEEDBACK: string = 'pages/FeedbackPage';

  // ========== 功能页面 ==========
  static readonly MAP: string = 'pages/MapPage';
  static readonly QUIZ: string = 'pages/QuizPage';
  static readonly MUSIC: string = 'pages/MusicPage';
  static readonly AI_CHAT: string = 'pages/AIChatPage';
  static readonly IMAGE_TOOL: string = 'pages/ImageToolPage';
}

使用方式

import { RouteConstants } from '../common/constants/RouteConstants';

// 跳转详情页
router.pushUrl({
  url: RouteConstants.ETHNIC_DETAIL,
  params: { ethnicId: id }
});

// 返回首页
router.back({
  url: RouteConstants.INDEX
});

好处

  1. 不会拼错(写错了编译报错)
  2. 改路径只改一个地方
  3. 所有路由一目了然
  4. IDE 自动补全

步骤5:路由守卫与权限控制

5.1 什么是路由守卫?

路由守卫就是在跳转之前做检查,满足条件才跳转,不满足就拦截。

常见的场景:

  • 未登录 → 跳转到登录页
  • 没权限 → 提示无权限
  • 页面需要参数 → 参数不对就跳转到错误页
  • 埋点统计 → 记录页面访问路径

路由守卫通常有两种:

  • beforeEach:跳转前调用,可以拦截跳转
  • afterEach:跳转后调用,用于埋点、统计等
5.2 完整的路由守卫实现(beforeEach + afterEach)

ArkUI 目前没有内置的路由守卫 API,但我们可以自己封装一个功能完整的 RouterHelper,支持 beforeEach 和 afterEach。

// utils/RouterHelper.ets
import router from '@ohos.router';
import { RouteConstants } from '../common/constants/RouteConstants';

/**
 * 路由守卫回调类型
 */
type BeforeEachGuard = (
  from: string,
  to: string,
  params?: Record<string, Object>
) => boolean | { redirect: string; params?: Record<string, Object> };

type AfterEachHook = (
  from: string,
  to: string,
  params?: Record<string, Object>
) => void;

/**
 * 路由工具类
 * 封装跳转逻辑,支持 beforeEach/afterEach 守卫
 */
export class RouterHelper {
  private static beforeEachGuards: BeforeEachGuard[] = [];
  private static afterEachHooks: AfterEachHook[] = [];

  /**
   * 注册前置守卫
   */
  static beforeEach(guard: BeforeEachGuard): void {
    this.beforeEachGuards.push(guard);
  }

  /**
   * 注册后置钩子
   */
  static afterEach(hook: AfterEachHook): void {
    this.afterEachHooks.push(hook);
  }

  /**
   * 跳转页面(带守卫检查)
   */
  static push(url: string, params?: Record<string, Object>): void {
    const fromUrl = this.getCurrentUrl();

    // 1. 执行所有 beforeEach 守卫
    const guardResult = this.runBeforeEachGuards(fromUrl, url, params);

    if (guardResult === false) {
      // 守卫返回 false,拦截跳转
      console.info(`[Router] 跳转被拦截: ${fromUrl}${url}`);
      return;
    }

    let targetUrl = url;
    let targetParams = params;

    if (typeof guardResult === 'object' && guardResult.redirect) {
      // 守卫返回重定向
      targetUrl = guardResult.redirect;
      targetParams = guardResult.params || params;
      console.info(`[Router] 路由重定向: ${url}${targetUrl}`);
    }

    // 2. 执行跳转
    router.pushUrl({ url: targetUrl, params: targetParams });

    // 3. 执行 afterEach 钩子
    this.runAfterEachHooks(fromUrl, targetUrl, targetParams);
  }

  /**
   * 替换页面(带守卫)
   */
  static replace(url: string, params?: Record<string, Object>): void {
    const fromUrl = this.getCurrentUrl();

    const guardResult = this.runBeforeEachGuards(fromUrl, url, params);

    if (guardResult === false) {
      return;
    }

    let targetUrl = url;
    let targetParams = params;

    if (typeof guardResult === 'object' && guardResult.redirect) {
      targetUrl = guardResult.redirect;
      targetParams = guardResult.params || params;
    }

    router.replaceUrl({ url: targetUrl, params: targetParams });
    this.runAfterEachHooks(fromUrl, targetUrl, targetParams);
  }

  /**
   * 返回上一页
   */
  static back(url?: string, params?: Record<string, Object>): void {
    const fromUrl = this.getCurrentUrl();

    if (url) {
      router.back({ url: url, params: params });
    } else {
      router.back();
    }

    // 返回的目标 URL 不太好确定,可以简单处理
    // 实际项目中可以维护一个路由栈来精确追踪
    setTimeout(() => {
      const toUrl = this.getCurrentUrl();
      this.runAfterEachHooks(fromUrl, toUrl, params);
    }, 100);
  }

  /**
   * 执行所有 beforeEach 守卫
   */
  private static runBeforeEachGuards(
    from: string,
    to: string,
    params?: Record<string, Object>
  ): boolean | { redirect: string; params?: Record<string, Object> } {
    for (const guard of this.beforeEachGuards) {
      const result = guard(from, to, params);
      if (result === false || (typeof result === 'object' && result.redirect)) {
        return result;
      }
    }
    return true;
  }

  /**
   * 执行所有 afterEach 钩子
   */
  private static runAfterEachHooks(
    from: string,
    to: string,
    params?: Record<string, Object>
  ): void {
    for (const hook of this.afterEachHooks) {
      try {
        hook(from, to, params);
      } catch (e) {
        console.error('[Router] afterEach hook error', e);
      }
    }
  }

  /**
   * 获取当前页面 URL
   */
  private static getCurrentUrl(): string {
    try {
      const state = router.getState();
      return state.path;
    } catch (e) {
      return '';
    }
  }
}

使用方式

// 在应用启动时注册守卫(EntryAbility.ets 或 SplashPage)
import { RouterHelper } from '../utils/RouterHelper';
import { RouteConstants } from '../common/constants/RouteConstants';

// 注册登录守卫
RouterHelper.beforeEach((from, to, params) => {
  // 需要登录的页面列表
  const loginRequiredPages = [
    RouteConstants.FAVORITES,
    RouteConstants.PROFILE
  ];

  if (loginRequiredPages.includes(to)) {
    const isLoggedIn = checkLoginStatus();
    if (!isLoggedIn) {
      // 未登录,重定向到登录页,并带上原始目标地址
      return {
        redirect: RouteConstants.LOGIN,
        params: { redirectUrl: to }
      };
    }
  }

  return true;
});

// 注册页面埋点
RouterHelper.afterEach((from, to, params) => {
  // 上报页面访问埋点
  reportPageView(from, to);
  console.info(`[Router] 页面跳转: ${from}${to}`);
});

支持的守卫返回值

返回值含义效果
true允许跳转正常跳转
false拦截跳转不跳转,停留在当前页
{ redirect: 'xxx' }重定向跳转到指定页面
{ redirect: 'xxx', params: {} }重定向带参跳转到指定页面并传参
5.3 「民族图鉴」中的权限场景

「民族图鉴」大部分页面是公开的,不需要登录。但有些功能(比如收藏同步、用户资料)可能需要登录:

页面是否需要登录
首页
民族列表
民族详情
个人中心否(游客模式也能看)
收藏记录是(但本地收藏不用)
设置

目前「民族图鉴」是纯本地应用,不需要登录,所以路由守卫用得不多。但如果以后加了云端同步、用户系统,就需要了。


步骤6:命名路由与路由表最佳实践

6.1 为什么要管理路由路径?

如果跳转路径都是硬编码的字符串,散落在各个文件里:

// 文件 A 里
router.pushUrl({ url: 'pages/EthnicDetailPage' });

// 文件 B 里
router.pushUrl({ url: 'pages/EthnicDetailPage' });

// 文件 C 里
router.pushUrl({ url: 'pages/ethnicDetailPage' });  // 写错了!

问题来了:

  1. 容易写错,大小写、拼写错误
  2. 要改路径的时候,要找所有地方改
  3. 不知道总共有多少页面,哪些用了哪些
6.2 路由常量管理最佳实践

最好的方式是统一管理路由路径,定义成常量,要用的时候引用常量。

「民族图鉴」项目的路由常量定义:

// common/constants/RouteConstants.ets
/**
 * 路由路径常量
 * 统一管理所有页面路径,避免硬编码和拼写错误
 */
export class RouteConstants {
  // ========== 启动页 ==========
  static readonly SPLASH: string = 'pages/SplashPage';

  // ========== 主Tab页面 ==========
  static readonly HOME: string = 'pages/HomePage';
  static readonly ENCYCLOPEDIA: string = 'pages/EncyclopediaPage';
  static readonly MAP: string = 'pages/MapPage';
  static readonly QUIZ: string = 'pages/QuizPage';
  static readonly PROFILE: string = 'pages/ProfilePage';

  // ========== 详情页 ==========
  static readonly ETHNIC_DETAIL: string = 'pages/EthnicDetailPage';

  // ========== 测验相关 ==========
  static readonly QUIZ_PAGE: string = 'pages/QuizPage';
  static readonly QUIZ_RESULT: string = 'pages/QuizResultPage';
  static readonly WRONG_BOOK: string = 'pages/WrongBookPage';

  // ========== 个人中心子页面 ==========
  static readonly FAVORITES: string = 'pages/FavoritesPage';
  static readonly COLLECTION: string = 'pages/CollectionPage';
  static readonly LEARNING_HISTORY: string = 'pages/LearningHistoryPage';
  static readonly SETTINGS: string = 'pages/SettingsPage';
  static readonly ABOUT: string = 'pages/AboutPage';
  static readonly PRIVACY: string = 'pages/PrivacyPage';
  static readonly AGREEMENT: string = 'pages/AgreementPage';
  static readonly FEEDBACK: string = 'pages/FeedbackPage';

  // ========== AI功能 ==========
  static readonly SOUL_TEST: string = 'pages/SoulTestPage';
  static readonly SOUL_TEST_RESULT: string = 'pages/SoulTestResultPage';

  // ========== 搜索 ==========
  static readonly SEARCH: string = 'pages/SearchPage';
}

命名规范

  • 全大写下划线分隔
  • 按模块分组,加注释分隔
  • 名称语义化,一看就知道是什么页面
  • 模块内的页面加模块前缀(如 QUIZ_RESULT、SOUL_TEST_RESULT)

使用方式

import { RouteConstants } from '../common/constants/RouteConstants';

// 跳转详情页
router.pushUrl({
  url: RouteConstants.ETHNIC_DETAIL,
  params: { ethnicId: id }
});

// 返回首页
router.back({
  url: RouteConstants.HOME
});

好处

  1. 不会拼错(写错了编译报错)
  2. 改路径只改一个地方
  3. 所有路由一目了然
  4. IDE 自动补全
  5. 可以快速查找引用
6.3 路由参数常量管理

除了路由路径,参数名也建议统一管理:

// common/constants/RouteConstants.ets
export class RouteParams {
  // 民族相关
  static readonly ETHNIC_ID: string = 'ethnicId';
  static readonly ETHNIC_NAME: string = 'ethnicName';

  // 来源页面
  static readonly SOURCE_PAGE: string = 'sourcePage';

  // 测验相关
  static readonly QUIZ_TYPE: string = 'quizType';
  static readonly SCORE: string = 'score';

  // 登录相关
  static readonly REDIRECT_URL: string = 'redirectUrl';
}

使用:

// 传参
router.pushUrl({
  url: RouteConstants.ETHNIC_DETAIL,
  params: {
    [RouteParams.ETHNIC_ID]: '01',
    [RouteParams.SOURCE_PAGE]: 'home'
  }
});

// 接参
const params = router.getParams() as Record<string, string>;
const ethnicId = params?.[RouteParams.ETHNIC_ID];

这样参数名也不会写错,改起来也方便。


步骤7:「民族图鉴」实战——完整的路由设计与流程图

让我们用「民族图鉴」项目来实践一下,看看一个完整的应用路由是怎么设计的。

7.1 页面清单与层级结构

「民族图鉴」总共有 20+ 个页面,按功能模块划分:

模块页面路径页面名层级说明
启动pages/SplashPage启动页L1品牌 Logo,跳转首页
主Tabpages/HomePage首页L2Tab1 - 首页推荐
主Tabpages/EncyclopediaPage百科页L2Tab2 - 民族列表
主Tabpages/MapPage地图页L2Tab3 - 民族分布地图
主Tabpages/QuizPage测验页L2Tab4 - 知识测验
主Tabpages/ProfilePage个人中心L2Tab5 - 个人中心
详情pages/EthnicDetailPage民族详情L3民族详细信息
搜索pages/SearchPage搜索页L3搜索民族
测验pages/QuizResultPage测验结果L3测验得分展示
测验pages/WrongBookPage错题本L3错题记录
个人pages/FavoritesPage收藏图鉴L3收藏的民族列表
个人pages/CollectionPage我的图鉴L3已解锁的民族
个人pages/LearningHistoryPage学习历史L3浏览历史记录
个人pages/SettingsPage设置L3应用设置
个人pages/AboutPage关于我们L4应用介绍
个人pages/PrivacyPage隐私政策L4隐私说明
个人pages/AgreementPage用户协议L4协议内容
个人pages/FeedbackPage意见反馈L4用户反馈
AIpages/SoulTestPage灵魂测试L3趣味测试
AIpages/SoulTestResultPage测试结果L4测试结果展示

层级说明

  • L1:启动页(一次性)
  • L2:主 Tab 页面(5个,平级切换)
  • L3:二级页面(从 Tab 页 push 进入)
  • L4:三级页面(从二级页面 push 进入)
7.2 页面跳转流程图
「民族图鉴」页面跳转流程图:

    ┌──────────────┐
    │  SplashPage   │  启动页
    └──────┬───────┘
           │ replaceUrl
           ▼
    ┌──────────────┐
    │   HomePage    │  首页 (Tab)
    │ Encyclopedia  │  百科 (Tab)
    │     Map       │  地图 (Tab)
    │     Quiz      │  测验 (Tab)
    │   Profile     │  个人 (Tab)
    └──────┬───────┘
           │
    ┌──────┴───────────────────────┐
    │                              │
    ▼                              ▼
┌──────────────┐           ┌──────────────┐
│EthnicDetail  │  push     │   Search     │  push
│   Page       │◄─────────►│    Page      │
└──────┬───────┘           └──────┬───────┘
       │                           │
       │ back                      │ back
       ▼                           ▼
    回到 Tab 页               回到 Tab 页

    ┌──────────────┐
    │  QuizResult  │  push
    │    Page      │◄──────  QuizPage
    └──────┬───────┘
           │
           ▼
    ┌──────────────┐
    │  WrongBook   │  push
    │    Page      │
    └──────────────┘

    ┌──────────────┐
    │   Profile    │  (Tab)
    └──────┬───────┘
           │
    ┌──────┼───────────────────────┐
    │      │       │       │       │
    ▼      ▼       ▼       ▼       ▼
Favorites  Collection  History  Settings  SoulTest
  Page       Page       Page     Page     Page
    │          │          │       │        │
    │          │          │       │        ▼
    │          │          │       │  SoulTestResult
    │          │          │       │      Page
    │          │          │       │
    │          │          │       ├───────┐
    │          │          │       │       │
    ▼          ▼          ▼       ▼       ▼
  About    Privacy   Agreement  Feedback
   Page     Page       Page      Page

跳转规则说明

跳转关系跳转方式说明
SplashPage → HomePagereplaceUrl启动页用完即弃,不保留
Tab 之间切换Tab 切换同一页面内的 Tab 切换,不入栈
Tab → 二级页pushUrl正常入栈,可返回
二级页 → 三级页pushUrl继续入栈
返回上一级back出栈
详情页之间replaceUrl避免栈过深(可选优化)
7.3 启动页跳转逻辑
// pages/SplashPage.ets
import router from '@ohos.router';
import { RouteConstants } from '../common/constants/RouteConstants';

@Entry
@Component
struct SplashPage {
  aboutToAppear(): void {
    // 延迟 2 秒跳转首页
    // 用 replaceUrl,不保留启动页在栈中
    setTimeout(() => {
      router.replaceUrl({
        url: RouteConstants.HOME
      });
    }, 2000);
  }

  build() {
    // 启动页 UI...
  }
}

为什么用 replaceUrl?

  • 启动页是一次性的,用完就没用了
  • 用户按返回键不应该回到启动页
  • 所以替换掉,栈里不保留
7.4 列表页 → 详情页跳转
// 百科列表页
import router from '@ohos.router';
import { RouteConstants } from '../common/constants/RouteConstants';
import { RouteParams } from '../common/constants/RouteConstants';

// 跳转到详情页
navigateToDetail(ethnicId: string): void {
  router.pushUrl({
    url: RouteConstants.ETHNIC_DETAIL,
    params: {
      [RouteParams.ETHNIC_ID]: ethnicId,
      [RouteParams.SOURCE_PAGE]: 'encyclopedia'
    }
  });
}

// 详情页接收参数
aboutToAppear(): void {
  const params = router.getParams() as Record<string, string>;
  if (params?.[RouteParams.ETHNIC_ID]) {
    const ethnicId = params[RouteParams.ETHNIC_ID];
    this.loadEthnicDetail(ethnicId);
  }
}
7.5 个人中心的二级页面跳转
// ProfilePage.ets 中的跳转逻辑
navigateToFavorites(): void {
  router.pushUrl({ url: RouteConstants.FAVORITES });
}

navigateToCollection(): void {
  router.pushUrl({ url: RouteConstants.COLLECTION });
}

navigateToHistory(): void {
  router.pushUrl({ url: RouteConstants.LEARNING_HISTORY });
}

navigateToSettings(): void {
  router.pushUrl({ url: RouteConstants.SETTINGS });
}

这些都是 pushUrl,因为用户需要能返回到个人中心。


⚠️ 常见问题与解决方案

问题1:跳转报错,说找不到页面?

现象
调用 router.pushUrl({ url: 'pages/XXX' }),运行时报错,说找不到页面。

常见原因

原因概率说明
没在 main_pages.json 注册40%最常见,忘了注册页面
路径写错了30%大小写、拼写错误
层级不对15%写了 pages/xxx 但实际路径不对
文件名和路径不一致10%文件叫 MyPage.ets,但路径写的是 MyPage 不一样
编译缓存5%加了页面但编译没更新

排查步骤

第1步:确认 main_pages.json 里有这个页面吗?
    ↓ 有
第2步:路径和文件名完全一致吗?
    - 大小写敏感!pages/MyPage 和 pages/mypage 不一样
    - 别多写或少写了 pages/ 前缀
    ↓ 一致
第3步:文件存在吗?
    - 路径对吗?ets/pages/ 下面有这个文件吗?
    ↓ 存在
第4步:Clean + Rebuild
    - 有时候是缓存问题

问题2:getParams() 拿不到参数?

现象
跳转的时候传了 params,但目标页面 getParams() 拿不到,或者是 undefined。

常见原因

原因说明
参数名写错了两边的 key 不一样
接收时机不对用了 aboutToAppear 但应该用 onPageShow
back 的时候拿不到back 传参要用 onPageShow 接收
参数类型不匹配传的是数字,当成字符串用
replace 的时候没传用了 replace 但没传 params

关键:区分 aboutToAppear 和 onPageShow

生命周期调用时机能不能拿到 params
aboutToAppear页面首次创建,即将显示✅ 能(第一次 push 的参数)
onPageShow每次页面显示都调用(包括返回)✅ 能(包括 back 带的参数)
onPageHide每次页面隐藏
aboutToDisappear页面销毁前

最佳实践

  • 第一次进入的参数 → aboutToAppear 里取
  • 返回时带的参数 → onPageShow 里取
  • 不确定就都在 onPageShow 里处理

问题3:页面栈太深了,怎么清理?

现象
用户来回跳转,页面栈越来越深,担心性能问题或栈溢出。

解决方案

方案1:用 replaceUrl 代替 pushUrl

对于循环跳转的场景(A→B→A→B…),用 replace 而不是 push:

// 不要这样:会无限入栈
// A → B → A → B → ...
router.pushUrl({ url: 'pageA' });

// 改成这样:替换当前页
router.replaceUrl({ url: 'pageA' });

方案2:back 到指定页面

如果要回到某个页面,中间的都清掉:

// 回到首页,中间的都出栈
router.back({
  url: 'pages/Index'
});

方案3:清空栈重新来

如果层级太多,直接跳转到根页面,重新开始:

// 用 replace 多次,或者 back 到最底层
// 视具体情况而定

💡 经验法则

  • 正常的页面跳转(列表→详情)→ push
  • 一次性的页面(启动页、登录页)→ replace
  • 返回上一页 → back
  • 回到指定页 → back({ url: ‘xxx’ })

问题4:路由参数大小有限制吗?

现象
传了一个很大的对象作为参数,结果出问题了。

答案:有,有限制。

路由参数是通过序列化传递的,不能太大。

建议

数据量传递方式
小数据(ID、配置项)路由 params
中等数据(几十 KB)Service 单例 / 全局状态
大数据(图片、大数组)存文件/数据库,传路径或 ID

最佳实践:传 ID,不传对象

// ❌ 不好:传整个对象,又大又容易序列化出错
router.pushUrl({
  url: 'pages/Detail',
  params: { ethnic: ethnicObject }  // 整个对象传过去
});

// ✅ 好:传 ID,目标页面自己去查
router.pushUrl({
  url: 'pages/Detail',
  params: { ethnicId: ethnicObject.id }  // 只传 ID
});

传 ID 的好处:

  1. 数据量小
  2. 不会有序列化问题
  3. 目标页面拿到的是最新数据(不是跳转那一刻的快照)
  4. 代码更解耦

问题5:怎么实现"返回并刷新"?

现象
A 页面跳转到 B 页面,B 页面做了一些操作,返回 A 页面的时候,A 页面要刷新数据。

解决方案

方案1:onPageShow 里刷新(最简单)

// A 页面
onPageShow(): void {
  // 每次页面显示都刷新
  // 简单粗暴,但可能没必要每次都刷
  this.loadData();
}

缺点:每次显示都刷新,包括正常的前进后退,可能浪费性能。

方案2:back 时传参,有需要才刷新

// B 页面:返回时带个标记
router.back({
  url: 'pages/PageA',
  params: { needRefresh: true }
});

// A 页面:在 onPageShow 里判断
onPageShow(): void {
  const params = router.getParams() as Record<string, boolean>;
  if (params?.needRefresh) {
    this.loadData();
  }
}

优点:只有需要的时候才刷新,性能好。

方案3:Service 层共享状态(推荐)

// B 页面修改数据
StorageService.getInstance().toggleFavorite(ethnicId);

// A 页面用 @State + Service
onPageShow(): void {
  // 从 Service 获取最新数据
  this.favorites = StorageService.getInstance().getFavorites();
}

优点:数据统一在 Service 层,各页面保持一致。

💡 「民族图鉴」用的是方案3:收藏、浏览历史都存在 StorageService 里,页面显示的时候从 Service 取最新数据,简单可靠。


📝 本章小结

核心知识点

本文系统讲解了 HarmonyOS 应用的路由导航:

1. 路由基础

  • 静态注册:所有页面必须在 main_pages.json 中声明
  • 三种跳转:push(入栈)、replace(替换)、back(出栈)
  • 页面栈:栈顶是当前页,入栈出栈管理页面

2. 参数传递

  • push params:跳转时传参,目标页用 getParams() 接收
  • back params:返回时传结果,用 onPageShow 接收
  • @StorageLink:全局共享 + 持久化,适合设置项
  • Service 单例:复杂业务数据的共享方式

3. 页面栈管理

  • 栈深度限制:不要无限入栈
  • back 到指定页面:清除中间页面
  • replaceUrl:一次性页面用替换,不留栈

4. 路由最佳实践

  • 路由常量统一管理,避免硬编码
  • 传 ID 不传大对象
  • 一次性页面用 replace
  • 返回刷新用 Service 共享状态

5. 路由守卫

  • 统一封装路由跳转,做权限检查
  • 未登录跳转到登录页
  • 可以自己封装 RouterHelper 工具类

最佳实践总结

路由路径用常量管理

// 定义常量
export class RouteConstants {
  static readonly ETHNIC_DETAIL: string = 'pages/EthnicDetailPage';
}

// 使用常量
router.pushUrl({ url: RouteConstants.ETHNIC_DETAIL });

跳转传 ID,不传大对象

// ✅ 传 ID,目标页面自己查
router.pushUrl({
  url: RouteConstants.DETAIL,
  params: { id: item.id }
});

一次性页面用 replaceUrl

// 启动页、登录页、引导页
// 用完就不用回去了,用 replace
router.replaceUrl({ url: RouteConstants.INDEX });

返回传参用 onPageShow 接收

// 不要在 aboutToAppear 里等返回参数
// 要用 onPageShow
onPageShow(): void {
  const params = router.getParams();
  // 处理参数
}

业务数据用 Service 共享

// 多个页面都要用的业务数据
// 放到 Service 层,单例管理
// 每个页面显示的时候从 Service 取最新
StorageService.getInstance().getFavorites();

页面栈不要太深

正常的 3-5 层没问题
10 层以上要考虑优化
循环跳转用 replace,不要一直 push

下一步预告

在下一篇文章中,我们将:

  • 📋 深入学习 List 列表组件的使用与原理
  • 🔄 掌握 ForEach 和 LazyForEach 的区别
  • 📐 学习 Grid 网格布局
  • ⚡ 理解列表性能优化的关键技巧
  • 🚀 实现高性能的民族列表页面

🔗 相关链接


💡 提示:路由导航看起来简单,但细节很多。很多开发者用了很久,还不知道 back 也能传参、不知道 onPageShow 的作用。把这些细节搞清楚了,遇到复杂的页面跳转需求,你才能从容应对。好的路由设计,能让应用的导航逻辑清晰、可维护性强;不好的路由设计,到处都是硬编码的字符串,改都不敢改。从一开始就养成好习惯,比以后重构省事多了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值