1、前言
🎉 新作已上架 ------ 「古诗学习宝 」鸿蒙原生应用已在华为应用市场上架,搜索「古诗学习宝」即可下载体验。零广告 / 零内购 / 277 首小学必背古诗全收录,烦请帮忙点个五星 🌟。
写鸿蒙应用最绕不开的两个问题:
- 多页面跳转怎么管才不乱? ------ 13 个详情页 + 4 种背诵模式 + 弹窗式 NavDestination,如果每个页面都用
router.pushUrl散落各处,参数Record<string,any>满天飞,半年后回来改代码自己都看不懂。 - 详情页改了状态,怎么让首页知道? ------ 用户在 PoemDetailPage 点了收藏,pop 回首页后 PoemCard 的星标得变成实心,这个"跨页面状态同步"问题是 ArkUI V2 里最常见的坑,不少人栽在"V2 数组重赋值不触发刷新"上。
本文用「古诗学习宝」线上版本的真实做法,把这两个问题一次讲透:
- 统一 Navigation 根 + PageMap 路由表:13 个页面只在一个文件注册,参数对象化强类型
NavPathStack.pushPathByName替代router.pushUrl:避免 deprecated 警告 + 类型安全onShown+favVersion版本号双保险 :从详情页返回首页时自动重算@Computed,不丢任何状态变化- 真实审核反馈带来的 bug 修复 :V2 数组重赋值不触发刷新的解决方案------
[...spread]强引用 + 立即本地filter
代码全部摘自上架版本,可直接复制到任何 HarmonyOS 6 项目。
2、整体架构
2.1 技术架构图
┌──────────────────────────────────────────────────────────────────┐
│ App 根入口 (Index.ets) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ @Entry @ComponentV2 │ │
│ │ struct Index { │ │
│ │ @Local pathStack: NavPathStack = new NavPathStack(); │ │
│ │ @Local favVersion: number = 0; // 跨页同步版本号 │ │
│ │ @Local avatarRef: string = ''; │ │
│ │ │ │
│ │ Navigation(this.pathStack) { │ │
│ │ MainTabsPage({ │ │
│ │ pathStack: this.pathStack, │ │
│ │ favVersion: this.favVersion, │ │
│ │ avatarRef: this.avatarRef, │ │
│ │ onAvatarChange: (ref) => { this.avatarRef = ref; } │ │
│ │ }) │ │
│ │ } │ │
│ │ .navDestination(this.PageMap) // ★ 路由表 │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────┬───────────────────────────────┘
│ pushPathByName(name, params)
▼
┌──────────────────────────────────────────────────────────────────┐
│ PageMap @Builder (路由表) --- 13 个 NavDestination 子页面 │
│ │
│ if (name === RouteName.PoemDetail) { │
│ PoemDetailPage({ │
│ pathStack: this.pathStack, │
│ onFavChange: () => { this.favVersion++; } // ★ 关键回调 │
│ }) │
│ } else if (name === RouteName.PoemList) { │
│ PoemListPage({ pathStack: this.pathStack }) │
│ } else if (name === RouteName.ReciteFill) { ... } │
│ // ... │
└──────────────────────────────────┬───────────────────────────────┘
│ 用户点收藏
▼
┌──────────────────────────────────────────────────────────────────┐
│ PoemDetailPage 内部 │
│ │
│ toggleFavorite(id) → await FavoriteService.toggle(id) │
│ → this.onFavChange() ← 通知 Index │
│ │
│ Index.favVersion++ │
│ │ │
│ ▼ @Param favVersion 透传 │
│ HomeView / ProfileView / PoemListPage 等所有页面同步重渲染 │
└──────────────────────────────────────────────────────────────────┘
2.2 项目目录
entry/src/main/ets/
├── common/Constants.ets # RouteName 路由名常量
├── pages/
│ ├── Index.ets # ★ 单一 Navigation 根 + PageMap
│ ├── MainTabsPage.ets # 底部 5 Tab 容器
│ ├── CategoryPage.ets # 分类
│ ├── PoemListPage.ets # 诗词列表(按年级/主题/诗人/收藏)
│ ├── PoemDetailPage.ets # 诗词详情(收藏入口)
│ ├── ReciteModePage.ets # 背诵方式选择
│ ├── ReciteFillPage.ets # 填空背诵
│ ├── ReciteChoicePage.ets # 选择题背诵
│ ├── ReciteConnectPage.ets # 连线题背诵
│ ├── ReciteListenPage.ets # 听音造句背诵
│ ├── CharStudyPage.ets # 生字学习
│ ├── AchievementsPage.ets # 成就奖章
│ └── FeedbackPage.ets # 意见反馈
└── service/FavoriteService.ets # 收藏单例服务
2.3 架构原则
| 原则 | 说明 |
|---|---|
| 单一 Navigation 根 | 整个 App 只有 1 个 Navigation 组件实例,所有页面都是 NavDestination |
| 路由参数对象化 | 用 interface XxxRouteParam 强类型替代 Record<string,any> |
| 页面持有 pathStack 引用 | 通过 @Param 从父级透传,子页面用它做 push/pop |
| 跨页同步走版本号 | 收藏/学习记录等"会被多页面共享"的状态用 @Param favVersion: number 触发重算 |
| onShown 回调兜底 | 从子页面返回时,列表页 onShown 重新读 Service,处理边角场景 |
3、效果展示
3.1 首页 - 多入口跳详情

首页有至少 5 种跳转入口 全部通向 PoemDetailPage:① 今日推荐卡片 ② 8 快捷入口里的"今日一首"③"随机一首"④ 猜你喜欢列表 ⑤ 收藏列表(点星标进详情)。5 种入口共享同一个 pushPathByName(RouteName.PoemDetail, id) 调用,参数统一是诗词 ID。
3.2 诗词详情页 - 收藏交互

底部 3 按钮:收藏 (次按钮)/ 开始背诵(主按钮)/ 生字。点收藏后 FavoriteService.toggle(id) 写入 Preferences,同时通过 onFavChange 回调通知到 Index 根组件,favVersion++ 触发所有相关页面重算。
3.3 返回首页 - 收藏数同步

返回到「我的」Tab,4 张统计卡里"收藏 1"自动同步 (不需要刷新)。这就是 favVersion 版本号驱动的 @Computed 重算效果。
3.4 学习记录页

记录 Tab 下三个子 tab:学习记录 / 背诵记录 / 错题本,每次进入页面 onShown 钩子重新从 RecordService 读取,保证从背诵页返回后数据立刻同步。
3.5 分类页 - 子路由

点任意年级卡片 → pushPathByName(RouteName.PoemList, CategoryRouteParam) 进入诗词列表页,路由参数对象类型严格:
typescript
const param: CategoryRouteParam = {
type: 'grade', // 'grade' | 'theme' | 'poet' | 'favorite'
key: 'g1',
label: '一年级',
themes: [], // 可选
};
4、核心功能详解
4.1 第一步:单一 Navigation 根 + pathStack
整个 App 入口 Index.ets:
typescript
import { RouteName, StorageKey } from '../common/Constants';
import { FavoriteService } from '../service/FavoriteService';
import { RecordService } from '../service/RecordService';
import { PlanService } from '../service/PlanService';
import { WrongQuestionService } from '../service/WrongQuestionService';
import { Pref } from '../service/PreferencesUtil';
import { MainTabsPage } from './MainTabsPage';
// 13 个 NavDestination 子页面
import { CategoryPage } from './CategoryPage';
import { PoemListPage } from './PoemListPage';
import { PoemDetailPage } from './PoemDetailPage';
import { ReciteModePage } from './ReciteModePage';
import { ReciteFillPage } from './ReciteFillPage';
import { ReciteChoicePage } from './ReciteChoicePage';
import { ReciteConnectPage } from './ReciteConnectPage';
import { ReciteListenPage } from './ReciteListenPage';
import { CharStudyPage } from './CharStudyPage';
import { AchievementsPage } from './AchievementsPage';
import { FeedbackPage } from './FeedbackPage';
@Entry
@ComponentV2
struct Index {
/** ⭐ 核心:单一 NavPathStack 实例,整个 App 共享 */
@Local pathStack: NavPathStack = new NavPathStack();
/** ⭐ 跨页状态同步版本号(收藏/记录任意页面变化都 ++) */
@Local favVersion: number = 0;
/** 全局用户头像(透传到所有需要显示头像的页面) */
@Local avatarRef: string = '';
/** 闪屏控制 */
@Local showSplash: boolean = true;
async aboutToAppear(): Promise<void> {
// 冷启并发加载 4 个 Service(覆盖所有持久化数据)
await Promise.all([
FavoriteService.load(),
RecordService.load(),
PlanService.load(),
WrongQuestionService.load(),
]);
this.avatarRef = await Pref.getString(StorageKey.UserAvatar, '');
// 1 秒后关闭闪屏
setTimeout(() => { this.showSplash = false; }, 1000);
}
build() {
Stack() {
Navigation(this.pathStack) {
MainTabsPage({
pathStack: this.pathStack,
favVersion: this.favVersion, // 透传版本号
avatarRef: this.avatarRef,
onAvatarChange: (ref: string) => this.onAvatarChange(ref),
})
}
.navDestination(this.PageMap) // ★ 注册路由表
.hideTitleBar(true)
.mode(NavigationMode.Stack)
// 闪屏覆盖
if (this.showSplash) {
SplashPage()
}
}
.width('100%').height('100%')
}
/** ★ 路由表:13 个 NavDestination 全部在这里注册 */
@Builder
PageMap(name: string) {
if (name === RouteName.Category) {
CategoryPage({ pathStack: this.pathStack })
} else if (name === RouteName.PoemList) {
PoemListPage({ pathStack: this.pathStack })
} else if (name === RouteName.PoemDetail) {
PoemDetailPage({
pathStack: this.pathStack,
onFavChange: () => { this.favVersion++; }, // ⭐ 关键回调
})
} else if (name === RouteName.ReciteMode) {
ReciteModePage({ pathStack: this.pathStack })
} else if (name === RouteName.ReciteFill) {
ReciteFillPage({ pathStack: this.pathStack })
} else if (name === RouteName.ReciteChoice) {
ReciteChoicePage({ pathStack: this.pathStack })
} else if (name === RouteName.ReciteConnect) {
ReciteConnectPage({ pathStack: this.pathStack })
} else if (name === RouteName.ReciteListen) {
ReciteListenPage({ pathStack: this.pathStack })
} else if (name === RouteName.CharStudy) {
CharStudyPage({ pathStack: this.pathStack })
} else if (name === RouteName.Achievements) {
AchievementsPage({ pathStack: this.pathStack })
} else if (name === RouteName.Feedback) {
FeedbackPage({ pathStack: this.pathStack })
}
}
async onAvatarChange(ref: string): Promise<void> {
this.avatarRef = ref;
await Pref.setString(StorageKey.UserAvatar, ref);
}
}
坑点 1 :
Navigation(this.pathStack)必须传一个@Local NavPathStack实例,不能在组件外定义 。每次构造的 NavPathStack 都是新的,组件内必须用@Local持有它。
坑点 2 :navDestination(this.PageMap)必须传@Builder函数引用,不能传字符串路由表(这不是 React Router)。这意味着所有 NavDestination 子组件的 import 都在 Index.ets 里,编译期就能发现路由问题。
4.2 第二步:RouteName 集中管理路由名
typescript
// common/Constants.ets
export class RouteName {
static readonly Splash = 'SplashPage';
static readonly MainTabs = 'MainTabsPage';
static readonly Category = 'CategoryPage';
static readonly PoemList = 'PoemListPage';
static readonly PoemDetail = 'PoemDetailPage';
static readonly ReciteMode = 'ReciteModePage';
static readonly ReciteFill = 'ReciteFillPage';
static readonly ReciteChoice = 'ReciteChoicePage';
static readonly ReciteConnect = 'ReciteConnectPage';
static readonly ReciteListen = 'ReciteListenPage';
static readonly CharStudy = 'CharStudyPage';
static readonly StudyPlan = 'StudyPlanPage';
static readonly StudyRecord = 'StudyRecordPage';
static readonly Achievements = 'AchievementsPage';
static readonly Feedback = 'FeedbackPage';
}
集中常量的好处:
- 全局重命名
Achievements→Awards改一处即可 - IDE 自动补全防止打字错误(
'AchivementsPage'这种) - 编译期发现"路由名拼错"问题
4.3 第三步:路由参数对象化强类型
跨页面传参不要用 Record<string, any>,而是用 interface:
typescript
// pages/CategoryPage.ets
export interface CategoryRouteParam {
type: 'grade' | 'theme' | 'poet' | 'favorite';
key: string;
label: string;
themes?: string[];
}
// 触发跳转
this.pathStack.pushPathByName(
RouteName.PoemList,
{ type: 'grade', key: 'g1', label: '一年级' } as CategoryRouteParam,
);
// 在 PoemListPage 接收
aboutToAppear(): void {
const params = this.pathStack.getParamByName(RouteName.PoemList) as object[];
const p = (params && params.length > 0
? params[params.length - 1]
: null) as CategoryRouteParam | null;
if (!p) return;
// 之后访问 p.type / p.key / p.label 都有 TypeScript 类型保护
}
坑点 3 :
getParamByName返回的是object[](栈中所有同名路由的参数),不是单个对象。记得取最后一个params[params.length - 1]。
4.4 第四步:跨页状态同步的 favVersion 版本号
V2 模式下,"详情页改了状态、列表页要刷新"的标准做法不是用 EventBus,而是用一个简单的 number 版本号:
typescript
// Index.ets(根组件)
@Local favVersion: number = 0;
// PageMap 注册 PoemDetail 时
PoemDetailPage({
pathStack: this.pathStack,
onFavChange: () => { this.favVersion++; }, // 每次收藏切换 +1
})
// MainTabsPage 透传
MainTabsPage({
pathStack: this.pathStack,
favVersion: this.favVersion, // ← 透传
avatarRef: this.avatarRef,
onAvatarChange: ...
})
子组件用 @Param 接收 + @Computed 派生:
typescript
// pages/views/ProfileView.ets
@ComponentV2
export struct ProfileView {
@Param favVersion: number = 0; // ← 接收
@Computed
get stat(): StudyStat {
const _v = this.favVersion; // ⭐ 显式依赖 favVersion,触发重算
return RecordService.stat();
}
build() {
Row({ space: 8 }) {
StatCard({ label: '连续天数', value: `${this.stat.streak}` })
StatCard({ label: '已学诗词', value: `${this.stat.totalPoems}` })
StatCard({ label: '收藏', value: `${this.stat.favoriteCount}` })
StatCard({ label: '总天数', value: `${this.stat.totalDays}` })
}
}
}
坑点 4 :
@Computed必须显式读取 依赖的 @Param(即使你不真的用它),否则不会重新计算。这里const _v = this.favVersion看起来无用,实际是让 V2 编译器知道这个 Computed 依赖 favVersion------少了这行,favVersion 变化时不会重算 stat。
4.5 第五步:onShown 回调兜底列表刷新
不是所有数据都适合用 @Computed 派生,比如:
- 收藏列表 PoemListPage 的
list: PoemBrief[] - 错题本 RecordView 的
wrongList: WrongQuestion[]
这些是"具体数据数组",不是"派生统计",需要在 onShown 重新读:
typescript
// pages/PoemListPage.ets
@ComponentV2
export struct PoemListPage {
@Param pathStack: NavPathStack = new NavPathStack();
@Local title: string = '';
@Local list: PoemBrief[] = [];
@Local pageType: string = '';
aboutToAppear(): void {
const params = this.pathStack.getParamByName(RouteName.PoemList) as object[];
const p = (params && params.length > 0
? params[params.length - 1]
: null) as CategoryRouteParam | null;
if (!p) return;
this.title = p.label;
this.pageType = p.type;
if (p.type === 'favorite') {
this.list = PoemService.briefByIds(FavoriteService.list());
} else if (p.type === 'grade') {
this.list = PoemService.byGrade(parseInt(p.key.replace('g', '')));
}
// ...
}
/** ⭐ 收藏列表专用刷新:从详情页 pop 回来时重新读 Service */
refreshIfFavorite(): void {
if (this.pageType === 'favorite') {
// 用 spread 强制新数组引用,确保 V2 reactive 触发 ForEach diff
this.list = [...PoemService.briefByIds(FavoriteService.list())];
}
}
build() {
NavDestination() {
// ...
List() {
ForEach(this.list, (p: PoemBrief) => {
ListItem() {
PoemCard({
poem: p,
favorite: FavoriteService.isFav(p.id),
onTap: (id: string) => this.pathStack.pushPathByName(RouteName.PoemDetail, id),
onFav: (id: string) => this.toggleFav(id),
})
}
}, (p: PoemBrief) => p.id)
}
}
.hideTitleBar(true)
.onShown(() => this.refreshIfFavorite()) // ⭐ 每次显示触发刷新
}
async toggleFav(id: string): Promise<void> {
await FavoriteService.toggle(id);
// ⭐ 立即从本地 list 移除被取消的项,保证 V2 立即重渲染
if (this.pageType === 'favorite' && !FavoriteService.isFav(id)) {
this.list = this.list.filter((p: PoemBrief) => p.id !== id);
}
this.refreshIfFavorite();
}
}
坑点 5 :V2
@Local list: T[]数组重赋值在某些边角场景不会触发 ForEach diff 。审核员就遇到了"取消收藏后列表没更新"的 bug。解决方案双保险:① 立即本地
filter移除 (保证视觉上立即响应)②
[...spread]强制新数组引用(保证 service 端最终一致)
4.6 第六步:底部 Tab 切换 vs NavPathStack push
「古诗学习宝」有 5 个底部 Tab 和 13 个 NavDestination 子页面。两者职责严格区分:
- 底部 Tab 切换 :通过
MainTabsPage.@Local selectedKey直接切换显示哪个 View,不入 NavPathStack - 子页面跳转 :通过
pathStack.pushPathByName入栈,返回手势走系统左滑或 pop
typescript
// pages/MainTabsPage.ets
@Local selectedKey: string = TabKey.Home;
build() {
Column() {
Stack() {
if (this.selectedKey === TabKey.Home) HomeView({ ... })
else if (this.selectedKey === TabKey.Category) CategoryPage({ ..., embedded: true })
// ...
}
.layoutWeight(1)
BottomTabBar({
selectedKey: this.selectedKey,
onChange: (k: string) => { this.selectedKey = k; },
})
}
}
CategoryPage 在 Tab 内嵌时 embedded: true,在 NavPathStack 子页面时 embedded: false------同一个组件根据 embedded 参数决定是否显示 NavBar,复用代码。
4.7 第七步:从子页面跳转到 Tab 的特殊情况
「我的」页面点「我的收藏」要跳到 PoemList 的 favorite 模式。这是从 Tab 内的 View 跳出去到 NavDestination:
typescript
// pages/views/ProfileView.ets
openFavorites(): void {
const param: CategoryRouteParam = {
type: 'favorite',
key: 'fav',
label: '我的收藏',
};
this.pathStack.pushPathByName(RouteName.PoemList, param);
}
// 而点"成就奖章"则不需要参数
openAchievements(): void {
this.pathStack.pushPathByName(RouteName.Achievements, null);
}
坑点 6 :
pushPathByName第二个参数即使不需要也必须传 null 或 undefined,不能省略。否则 TypeScript 编译报错。
4.8 第八步:从子页面切回 Tab
PoemDetail 里可能有「返回首页」入口,这时要:① pop 到根 ② 把 Tab 切到 Home。
typescript
// PoemDetailPage 内部
backToHome(): void {
// ① 清空 NavPathStack
this.pathStack.clear();
// ② 通过 AppStorage 传递 "请切到 Home Tab"
AppStorage.setOrCreate('pending_tab_switch', TabKey.Home);
}
// MainTabsPage.aboutToAppear 或 onShown 接收
async onShown(): Promise<void> {
const pending = AppStorage.get<string>('pending_tab_switch');
if (pending) {
this.selectedKey = pending;
AppStorage.setOrCreate('pending_tab_switch', '');
}
}
AppStorage 是 ArkUI 内置的全局 KV,作为"Navigation 栈与底部 Tab 之间通信的桥梁"最合适。
5、完整数据流分析
以「首页点收藏 → 详情页点收藏 → pop 回首页 → 我的页统计同步」为例:
冷启
│
▼
Index.aboutToAppear
├─ Promise.all([FavoriteService.load(), ...]) 4 个 Service 并发
└─ pathStack = new NavPathStack()
│
▼
Navigation(pathStack) 渲染 MainTabsPage
│
▼
MainTabsPage.@Local selectedKey = TabKey.Home
│
▼
HomeView 显示,@Param favVersion=0 透传
─────────────────────────────────────────────────────────────────
用户点首页"今日一首"
│
▼
HomeView.openTodayPoem()
└─ this.pathStack.pushPathByName(RouteName.PoemDetail, '夏日绝句')
│
▼
Index.PageMap('PoemDetailPage')
└─ PoemDetailPage({
pathStack,
onFavChange: () => { this.favVersion++; } // 闭包持有 Index
})
─────────────────────────────────────────────────────────────────
PoemDetailPage 渲染 → 用户点"收藏"按钮
│
▼
toggleFavorite()
├─ await FavoriteService.toggle(id)
│ └─ cache.add(id) + Preferences.flush()
└─ this.onFavChange() // 通知 Index
│
▼
Index.@Local favVersion: 0 → 1
│
▼ V2 触发所有 @Param favVersion 的组件重渲染
HomeView.favVersion: 0 → 1
└─ @Computed stat 重算
↓ stat.favoriteCount: 0 → 1
↓ UI 自动刷新(但首页此时不可见)
ProfileView.favVersion: 0 → 1
└─ 同样重算
─────────────────────────────────────────────────────────────────
用户按系统左滑返回手势
│
▼
NavPathStack 自动 pop PoemDetailPage
│
▼
HomeView.onShown 触发(页面重新可见)
└─ 此时 favVersion 已经是 1,@Computed stat 早就重算完
│
▼ PoemCard 通过 FavoriteService.isFav(p.id) 同步显示星标
─────────────────────────────────────────────────────────────────
用户点底部"我的" Tab
│
▼
MainTabsPage.selectedKey = 'profile'
│
▼
ProfileView 显示
└─ @Computed stat = { favoriteCount: 1, ... }(无需任何刷新)
↓
4 张统计卡:连续天数 1 / 已学诗词 1 / 收藏 1 / 总天数 1
↑ 已同步
观察点:
- 冷启 4 Service 并发 load :Promise.all 让 4 个持久化 Service 同时加载,比串行快 4 倍。
- favVersion ++ 是状态同步唯一驱动力 :所有"会被多页面共享的派生数据"全部依赖它,一处变化、全局同步。
- @Computed 重算可在不可见页面发生 :HomeView 此时被 PoemDetail 遮挡,但 @Computed 仍然算了一次------等返回时直接展示,零延迟。
- onShown 用作 ForEach 数组重读兜底 :派生统计走 @Computed,具体列表走 onShown,两条数据流互补。
6、代码分析与优化建议
6.1 现有实现的亮点
- 单一 Navigation 根:13 个页面在一处注册,路由问题集中可控
- 路由参数对象化:interface CategoryRouteParam 让参数有类型保护
- favVersion 版本号驱动 @Computed:派生统计自动同步,无需 EventBus
- onShown + filter 双保险:解决 V2 数组重赋值边角问题
- AppStorage 桥接 Tab + Nav 栈:处理"返回首页切 Tab"特殊场景
6.2 可优化点
优化 1:路由表 Map<RouteName, Builder> 替代 if-else
问题:13 个 if-else 分支冗长,新增页面要改 Index.ets。
改进:把路由表抽到独立的 routes.ets:
typescript
// pages/routes.ets
export type PageBuilder = (name: string, stack: NavPathStack) => void;
export const ROUTE_TABLE: Record<string, PageBuilder> = {
[RouteName.Category]: (name, stack) => CategoryPage({ pathStack: stack }),
[RouteName.PoemList]: (name, stack) => PoemListPage({ pathStack: stack }),
// ...
};
// Index.ets
@Builder
PageMap(name: string) {
const builder = ROUTE_TABLE[name];
if (builder) builder(name, this.pathStack);
}
取舍 :@Builder 内部不支持 Map 查找直接调用 Builder,所以这个优化在 V2 上目前不可行------仍然得用 if-else。等 ArkUI 升级。
优化 2:把 favVersion 改成 StateStore 全局态
问题:favVersion 透传链路长:Index → MainTabs → ProfileView,新增页面要加 @Param。
改进 :用 HarmonyOS 6 的 StateStore 做全局态:
typescript
import { StateStore } from '@kit.ArkUI';
class AppState {
favVersion: number = 0;
}
export const appState = StateStore.create(new AppState());
// 任意组件
@Computed
get stat(): StudyStat {
const _v = appState.read('favVersion');
return RecordService.stat();
}
// 修改
appState.write('favVersion', appState.read('favVersion') + 1);
优化 3:用 Bundle Manager 做路由按需加载
问题:13 个页面 import 全在 Index.ets 顶部,冷启加载所有 .ets 文件。
改进 :用 import() 动态导入大页面,减少首屏体积:
typescript
@Builder
PageMap(name: string) {
if (name === RouteName.ReciteFill) {
LazyLoadPage({ name: 'ReciteFillPage', stack: this.pathStack })
}
}
优化 4:路由埋点统一处理
问题:每个页面 push 时手动加埋点,容易漏。
改进:包装 pathStack 为 InstrumentedNavPathStack:
typescript
class InstrumentedNavPathStack extends NavPathStack {
pushPathByName(name: string, params?: object): void {
hilog.info(0xC0DE, 'Route', 'push: %{public}s', name);
// 上报埋点
Analytics.track('page_view', { name, params });
super.pushPathByName(name, params);
}
}
优化 5:@Computed 依赖 @Param 必须显式读取
问题 :@Computed get x() { return service.calc(); } 没有读 favVersion,永远不会重算。
改进:标准模板:
typescript
@Computed
get stat(): StudyStat {
const _depV = this.favVersion; // 显式声明依赖(即使不用)
return RecordService.stat();
}
或者用更优雅的写法 - 把 favVersion 作为参数传给 service:
typescript
@Computed
get stat(): StudyStat {
return RecordService.statAtVersion(this.favVersion);
}
// RecordService
statAtVersion(_version: number): StudyStat {
return this.stat(); // version 参数仅用于触发 @Computed 重算
}
6.3 生产环境 Checklist
| 检查项 | 说明 |
|---|---|
| 整个 App 只有 1 个 Navigation 根 | 避免嵌套 Navigation 导致返回手势冲突 |
pathStack: NavPathStack 用 @Local 持有 |
不能在组件外部定义 |
所有路由名走 RouteName.XXX 常量 |
防止字符串拼错 |
| 路由参数用 interface 强类型 | Record<string,any> 容易隐藏 bug |
getParamByName 返回 object[] 取最后一个 |
不是单个对象 |
跨页同步用 @Param favVersion 而非 EventBus |
V2 reactive 自动重算 |
@Computed 必须显式读取依赖的 @Param |
否则不重算 |
列表数组用 onShown + [...spread] 兜底 |
避免 V2 数组重赋值不刷新边角问题 |
取消项立即 filter 移除 |
视觉上立即响应 |
Tab 切换用 @Local selectedKey,不入 NavStack |
底部 Tab 切换不应有返回栈 |
| 子页面跳 Tab 用 AppStorage 桥接 | NavStack 与 Tab 切换的解耦方案 |
7、关键 API 速查
| API | 作用 |
|---|---|
Navigation(pathStack) |
路由容器 |
NavPathStack |
路由栈实例 |
pathStack.pushPathByName(name, params?) |
入栈跳转 |
pathStack.pop() / pathStack.clear() |
出栈 / 清空 |
pathStack.getParamByName(name) |
获取参数数组(取最后一个) |
.navDestination(builder) |
注册路由表(@Builder) |
NavDestination() |
子页面容器 |
.hideTitleBar(true) |
隐藏默认 NavBar |
.mode(NavigationMode.Stack) |
栈模式(默认) |
.onShown(() => ...) |
页面显示回调 |
@Entry @ComponentV2 |
V2 入口组件 |
@Local, @Param, @Event, @Computed |
V2 状态装饰器 |
AppStorage.setOrCreate(key, value) |
全局 KV 存储 |
AppStorage.get<T>(key) |
全局 KV 读取 |
8、总结
本文用「古诗学习宝」线上版本的真实做法,把鸿蒙 6 应用最容易踩坑的两个问题------多页面路由管理 + 跨页状态同步------一次讲透:
-
单一 Navigation 根 + PageMap 路由表 :13 个 NavDestination 在一个 @Builder 函数里集中注册,所有路由问题编译期可见。
-
路由参数强类型对象化 :用
interface CategoryRouteParam替代Record<string,any>,路由参数从"运行时崩溃"提前到"编译时报错"。 -
favVersion 版本号驱动 @Computed :跨页同步不用 EventBus、不用 Provide/Consume,一个简单的 number 让 V2 reactive 自动重算------这是 HarmonyOS 6 V2 模式最干净的跨页同步方案。
-
@Computed 必须显式读取依赖 :
const _v = this.favVersion看起来没用,实际是给 V2 编译器的依赖声明------少了这行,favVersion 变化时不会重算。 -
onShown + [...spread] 双保险 :派生统计走 @Computed,具体列表数组用 onShown 重新读 Service + spread 强引用,规避 V2 数组重赋值不触发刷新的边角问题(这是真实审核反馈的 bug 修复经验)。
-
6 个真坑写进 Checklist:单一 Navigation / RouteName 常量 / interface 路由参数 / @Computed 显式依赖 / [...spread] / Tab 切换不入栈------任何一个踩错都会让用户体验断裂。
「古诗学习宝」上架版本的 13 个页面就是用这套方案串起来的,性能稳、bug 少、维护轻松。把这套「单一 Navigation + 路由参数对象化 + favVersion 版本号 + onShown 兜底」的范式吃透,任何 HarmonyOS 6 中等规模应用的路由架构都能稳如老狗。
🎁 下载体验
**「古诗学习宝」**已上架华为应用市场,搜索 古诗学习宝 即可下载。本文的 Navigation 路由架构 + 跨页同步方案在 App 13 页路由间真实可见:进收藏 → pop 回首页统计自动同步、做完背诵 → 错题本自动出现、改个头像 → 全局头像位置一起更新。
📚 文中代码全部摘自上架版本,可直接复制。
👋 欢迎在评论区聊聊你的鸿蒙路由架构方案,互相学习。