HarmonyOS HMRouter 路由库 Demo 练习总结:从路由配置到商品管理增删改查
前言
今天主要围绕鸿蒙项目中的 HMRouter 路由库做了一次完整 Demo 练习。
一开始只是一个简单的底部导航 Demo,通过 currentIndex 控制不同组件显示。后来逐步改造成真正的路由跳转,并在此基础上继续练习了路由传参、返回、拦截器、页面组件拆分、商品列表增删改查、搜索和详情页跳转等内容。
这篇文章主要是给自己做一个完整记录,后面如果忘记 HMRouter 的接入流程、push / replace / pop 区别、路由传参、拦截器、商品 Demo 的组件拆分方式,可以直接回来看这篇。
一、今天 Demo 的最终效果
最终 Demo 里主要有几个页面:
txt
首页 TabHome
Home 商品管理页
Setting 设置页
ProductDetail 商品详情页
底部导航通过 HMRouter 实现真实路由跳转:
txt
Index.ets
│
├── HMNavigation 路由容器
│
└── 底部导航栏
├── 首页 -> pages/TabHome
├── Home -> pages/Home
└── Setting -> pages/Setting
商品管理页实现了:
txt
商品列表展示
搜索商品
新增商品
删除商品
修改商品名称
点击详情跳转 ProductDetail
详情页实现了:
txt
接收商品 id/title/from 参数
展示参数内容
点击返回 pop 回上一页
二、HMRouter 接入流程
HMRouter 的接入不是只安装一个包就结束,它分成运行时依赖、编译插件、路由扫描配置、运行时初始化几个部分。
整体流程:
txt
安装 @hadss/hmrouter
↓
配置 @hadss/hmrouter-plugin
↓
配置 hmrouter_config.json
↓
EntryAbility 初始化 HMRouterMgr
↓
页面使用 @HMRouter 注册
↓
Index 使用 HMNavigation 承载页面
↓
HMRouterMgr.push / replace / pop 操作路由
三、安装运行库
在项目根目录执行:
bash
ohpm install @hadss/hmrouter
这个包主要提供运行时代码:
ts
HMRouter
HMRouterMgr
HMNavigation
也就是说,页面里能这样写:
ts
import { HMRouter, HMRouterMgr, HMNavigation } from '@hadss/hmrouter'
四、配置编译插件
HMRouter 不是运行时自动扫描页面,而是依赖编译插件扫描 @HMRouter 装饰器,然后生成路由表。
在:
txt
hvigor/hvigor-config.json5
中配置插件依赖:
json5
{
"modelVersion": "6.1.0",
"dependencies": {
"@hadss/hmrouter-plugin": "^1.2.2"
}
}
注意:
txt
@hadss/hmrouter 是运行库,用 ohpm install
@hadss/hmrouter-plugin 是编译插件,写在 hvigor-config.json5
五、entry 模块启用插件
在:
txt
entry/hvigorfile.ts
中启用插件:
ts
import { hapTasks } from '@ohos/hvigor-ohos-plugin'
import { hapPlugin } from '@hadss/hmrouter-plugin'
export default {
system: hapTasks,
plugins: [
hapPlugin()
]
}
如果这一步没配好,运行时容易出现:
txt
not exist pageUrl in store
因为页面没有被扫描到,路由表里没有对应页面。
六、配置路由扫描目录
在 entry 目录下创建:
txt
entry/hmrouter_config.json
内容:
json
{
"scanDir": ["src/main/ets/pages"],
"saveGeneratedFile": true
}
含义:
txt
扫描 entry/src/main/ets/pages 下面的 @HMRouter 页面
并保存生成文件,方便排查路由表是否生成
注意这个文件应该放在:
txt
entry/hmrouter_config.json
而不是项目根目录。
七、EntryAbility 初始化 HMRouter
在:
txt
entry/src/main/ets/entryability/EntryAbility.ets
中初始化 HMRouter:
ts
import { HMRouterMgr } from '@hadss/hmrouter'
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
HMRouterMgr.openLog('INFO')
HMRouterMgr.init({
context: this.context
})
}
如果没初始化,可能会报:
txt
ERR_INIT_NOT_READY
Framework initialization not completed
所以记住:
txt
路由使用前,必须先在 EntryAbility 初始化 HMRouterMgr
八、注册路由页面
每个需要被 HMRouter 跳转的页面,都需要加:
ts
@HMRouter({ pageUrl: 'pages/Home' })
例如 Home 页面:
ts
import { HMRouter } from '@hadss/hmrouter'
@HMRouter({ pageUrl: 'pages/Home' })
@ComponentV2
export struct HomeBuilder {
build() {
Column() {
Text('Home 页面')
}
}
}
这里的:
txt
pages/Home
就是这个页面的路由地址。
跳转时也要保持完全一致:
ts
HMRouterMgr.push({
pageUrl: 'pages/Home'
})
九、Index 页面使用 HMNavigation
入口页面 Index.ets 中使用 HMNavigation 作为路由容器。
ts
import { HMNavigation, HMRouterMgr } from '@hadss/hmrouter'
@Entry
@ComponentV2
struct Index {
@Local currentTab: string = 'pages/TabHome'
build() {
Column() {
HMNavigation({
navigationId: 'MainNavigation',
homePageUrl: 'pages/TabHome'
})
.layoutWeight(1)
.width('100%')
Row() {
this.TabItem('首页', 'pages/TabHome')
this.TabItem('Home', 'pages/Home')
this.TabItem('Setting', 'pages/Setting')
}
.width('100%')
.height(60)
.backgroundColor('#FFFFFF')
}
.width('100%')
.height('100%')
}
@Builder
TabItem(title: string, pageUrl: string) {
Column() {
Text(title)
.fontSize(14)
.fontColor(this.currentTab === pageUrl ? '#007DFF' : '#666666')
}
.layoutWeight(1)
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.currentTab = pageUrl
HMRouterMgr.push({
pageUrl: pageUrl,
param: {
title: title,
from: '底部导航栏'
}
})
})
}
}
十、push / replace / pop 的区别
今天练习后,对这三个 API 的理解更清楚了。
1. push
push 是进入一个新页面,会把页面压入路由栈。
适合:
txt
列表页 -> 详情页
商品页 -> 商品详情页
聊天列表 -> 聊天详情页
示例:
ts
HMRouterMgr.push({
pageUrl: 'pages/ProductDetail',
param: {
id: item.id,
title: item.name,
from: '商品列表'
}
})
路由栈类似:
txt
Home
↓ push
ProductDetail
2. pop
pop 是返回上一页。
适合:
txt
详情页返回列表页
二级页面返回上一级
示例:
ts
HMRouterMgr.pop()
路由栈:
txt
Home -> ProductDetail
↓ pop
Home
3. replace
replace 是替换当前页面,不会无限堆栈。
适合底部 Tab 切换:
txt
首页
Home
Setting
这几个页面是平级页面,所以更适合用 replace。
ts
HMRouterMgr.replace({
pageUrl: item.pageUrl,
param: {
title: item.title,
from: '底部导航栏'
}
})
总结:
txt
Tab 页面切换:replace
详情页跳转:push
页面返回:pop
十一、路由传参
跳转时通过 param 传参:
ts
HMRouterMgr.push({
pageUrl: 'pages/ProductDetail',
param: {
id: item.id,
title: item.name,
from: '商品列表'
}
})
目标页面通过:
ts
HMRouterMgr.getCurrentParam()
接收参数:
ts
interface ProductDetailParam {
id?: number
title?: string
from?: string
}
const param = HMRouterMgr.getCurrentParam() as ProductDetailParam
this.productId = Number(param?.id ?? 0)
this.title = param?.title ?? '商品详情'
this.from = param?.from ?? '默认入口'
今天通过日志验证,传参是正常的:
txt
详情页接收到参数:
{"id":1,"title":"苹果","from":"商品列表"}
十二、路由拦截器
今天还练习了全局路由拦截器,用于避免重复跳转同一个路由。
SameRouteInterceptor.ets
ts
import {
HMInterceptorAction,
HMInterceptorInfo,
IHMInterceptor
} from '@hadss/hmrouter'
export class SameRouteInterceptor implements IHMInterceptor {
interceptorName: string = 'SameRouteInterceptor'
priority: number = 100
handle(info: HMInterceptorInfo): HMInterceptorAction {
const fromPage: string = info.srcName ?? ''
const targetPage: string = info.targetName ?? ''
console.log(`[SameRouteInterceptor] from=${fromPage}, target=${targetPage}`)
if (fromPage.length > 0 && targetPage.length > 0 && fromPage === targetPage) {
console.log('[SameRouteInterceptor] 相同路由,拦截跳转')
return HMInterceptorAction.DO_REJECT
}
return HMInterceptorAction.DO_NEXT
}
}
注册方式:
ts
HMRouterMgr.registerGlobalInterceptor({
interceptor: new SameRouteInterceptor()
})
整体流程:
txt
发起路由跳转
↓
进入 SameRouteInterceptor
↓
判断当前页面和目标页面是否相同
│
├── 相同:DO_REJECT,拦截跳转
└── 不同:DO_NEXT,继续跳转
十三、路由常量抽取
为了避免到处写死字符串,抽出了路由常量。
RouteConstants.ets
ts
export class RouteConstants {
static readonly MAIN_NAVIGATION_ID: string = 'MainNavigation'
static readonly TAB_HOME: string = 'pages/TabHome'
static readonly HOME: string = 'pages/Home'
static readonly SETTING: string = 'pages/Setting'
static readonly PRODUCT_DETAIL: string = 'pages/ProductDetail'
}
export class RouteTitle {
static readonly TAB_HOME: string = '首页'
static readonly HOME: string = 'Home'
static readonly SETTING: string = 'Setting'
}
export class RouteFrom {
static readonly TAB_BAR: string = '底部导航栏'
}
export class TabRouteItem {
title: string = ''
pageUrl: string = ''
}
export const TAB_ROUTES: TabRouteItem[] = [
{
title: RouteTitle.TAB_HOME,
pageUrl: RouteConstants.TAB_HOME
},
{
title: RouteTitle.HOME,
pageUrl: RouteConstants.HOME
},
{
title: RouteTitle.SETTING,
pageUrl: RouteConstants.SETTING
}
]
底部导航可以用 TAB_ROUTES 映射出来:
ts
ForEach(TAB_ROUTES, (item: TabRouteItem) => {
this.TabItem(item)
}, (item: TabRouteItem) => item.pageUrl)
这样以后新增 Tab,只需要改配置。
十四、商品管理 Demo
今天商品 Demo 实现了基础增删改查和搜索。
功能包括:
txt
初始化商品假数据
展示商品列表
搜索商品
新增商品
删除商品
修改商品名称
点击详情跳转详情页
当前商品管理组件中包含输入状态、搜索状态、商品列表、编辑状态,以及新增、删除、修改、搜索等业务逻辑。:contentReference[oaicite:0]{index=0}
十五、Product 模型
Product.ets
ts
export class Product {
id: number = 0
name: string = ''
price: number = 0
stock: number = 0
desc: string = ''
}
字段含义:
txt
id:商品 ID
name:商品名称
price:价格
stock:库存
desc:描述
十六、商品 Store
为了让列表页和详情页共享商品数据,需要把商品列表抽到 Store 中。
ProductStore.ets
ts
import { Product } from '../models/Product'
@ObservedV2
export class ProductStore {
@Trace products: Product[] = []
initProducts(): void {
if (this.products.length > 0) {
return
}
const p1 = new Product()
p1.id = 1
p1.name = '苹果'
p1.price = 5
p1.stock = 100
p1.desc = '新鲜红苹果,适合日常购买'
const p2 = new Product()
p2.id = 2
p2.name = '香蕉'
p2.price = 3
p2.stock = 80
p2.desc = '软糯香甜,适合早餐'
const p3 = new Product()
p3.id = 3
p3.name = '牛奶'
p3.price = 12
p3.stock = 50
p3.desc = '早餐搭配,补充蛋白质'
this.products = [p1, p2, p3]
}
addProduct(name: string): void {
const p = new Product()
p.id = Date.now()
p.name = name
p.price = Math.floor(Math.random() * 20) + 1
p.stock = Math.floor(Math.random() * 100) + 1
p.desc = '这是一个新增商品'
this.products = [...this.products, p]
}
deleteProduct(id: number): void {
this.products = this.products.filter((item: Product) => {
return item.id !== id
})
}
updateProductName(id: number, name: string): void {
this.products = this.products.map((item: Product) => {
if (item.id === id) {
const p = new Product()
p.id = item.id
p.name = name
p.price = item.price
p.stock = item.stock
p.desc = item.desc
return p
}
return item
})
}
findProductById(id: number): Product | undefined {
return this.products.find((item: Product) => {
return Number(item.id) === Number(id)
})
}
}
export const productStore: ProductStore = new ProductStore()
理解:
txt
ProductStore 是商品数据中心
ProductManager 从这里展示商品
ProductDetail 通过 id 从这里查询商品
十七、ProductManager 商品管理组件
ProductManager 是业务组件,负责商品列表的 UI 和交互。
主要状态:
ts
@Local inputKeyword: string = ''
@Local searchKeyword: string = ''
@Local newName: string = ''
@Local editingId: number = -1
@Local editingName: string = ''
1. 搜索
这里区分了两个状态:
txt
inputKeyword:输入框正在输入的内容
searchKeyword:真正用于搜索的关键词
这样可以实现"点击搜索按钮后再搜索",而不是输入时自动搜索。
ts
Button('搜索')
.onClick(() => {
this.searchKeyword = this.inputKeyword.trim()
})
过滤逻辑:
ts
private getShowProducts(key: string): Product[] {
if (key.length === 0) {
return this.store.products
}
return this.store.products.filter((item: Product) => {
return item.name.includes(key)
})
}
2. 新增
ts
private addProduct(): void {
const name = this.newName.trim()
if (name.length === 0) {
return
}
this.store.addProduct(name)
this.newName = ''
}
核心思想:
txt
表单输入 newName
点击新增
调用 store.addProduct
清空输入框
3. 删除
ts
private deleteProduct(id: number): void {
this.store.deleteProduct(id)
}
核心思想:
txt
点击删除
根据 id 删除商品
列表重新渲染
4. 修改
ts
private startEditProduct(item: Product): void {
this.editingId = item.id
this.editingName = item.name
}
private saveEditProduct(): void {
const name = this.editingName.trim()
if (this.editingId < 0 || name.length === 0) {
return
}
this.store.updateProductName(this.editingId, name)
this.editingId = -1
this.editingName = ''
}
核心思想:
txt
点击改名
↓
记录 editingId 和 editingName
↓
显示编辑输入框
↓
点击保存
↓
更新商品名称
↓
清空编辑状态
5. 跳转详情
ts
private openDetail(item: Product): void {
HMRouterMgr.push({
pageUrl: RouteConstants.PRODUCT_DETAIL,
param: {
id: item.id,
title: item.name,
from: '商品列表'
}
})
}
流程:
txt
点击详情
↓
传 id/title/from
↓
跳转 ProductDetail
十八、ProductDetail 商品详情页
详情页先做最小版本:只展示路由参数。
ProductDetail.ets
ts
import { HMRouter, HMRouterMgr } from '@hadss/hmrouter'
import { PageShell } from '../components/PageShell'
interface ProductDetailParam {
id?: number
title?: string
from?: string
}
@HMRouter({ pageUrl: 'pages/ProductDetail' })
@ComponentV2
export struct ProductDetail {
@Local title: string = '商品详情'
@Local from: string = '默认入口'
@Local productId: number = 0
aboutToAppear(): void {
const param = HMRouterMgr.getCurrentParam() as ProductDetailParam
console.log('详情页接收到参数:' + JSON.stringify(param))
this.productId = Number(param?.id ?? 0)
this.title = param?.title ?? '商品详情'
this.from = param?.from ?? '默认入口'
}
@Builder
BackButtonBuilder() {
Text('‹')
.fontSize(32)
.width(40)
.height(40)
.textAlign(TextAlign.Center)
.onClick(() => {
HMRouterMgr.pop()
})
}
@Builder
ContentBuilder() {
Column({ space: 16 }) {
Text('商品详情页')
.fontSize(28)
.fontWeight(FontWeight.Bold)
Text(`商品ID:${this.productId}`)
.fontSize(18)
Text(`商品名称:${this.title}`)
.fontSize(18)
Text(`来源:${this.from}`)
.fontSize(18)
Button('返回')
.onClick(() => {
HMRouterMgr.pop()
})
}
.width('100%')
.height('100%')
.padding(20)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Start)
}
build() {
PageShell({
title: this.title,
from: this.from,
leftBuilder: this.BackButtonBuilder,
contentBuilder: this.ContentBuilder
})
}
}
为什么先这样写?
txt
先验证路由传参
再验证根据 id 查询 Store
不要一次把路由、Store、UI 全混在一起排错
十九、PageShell 和 BuilderParam
今天继续练习了公共页面壳组件。
PageShell 结构
txt
PageShell
│
├── leftBuilder
└── contentBuilder
含义:
txt
leftBuilder:左侧区域,比如返回按钮
contentBuilder:页面主体内容
@BuilderParam 本质就是:
txt
把一段 UI 当参数传给子组件
类似:
txt
Vue slot
React children / render props
二十、Home 页面变薄
原来 Home 页面里有很多内容:
txt
路由注册
路由参数
商品模型
商品假数据
搜索
新增
删除
修改
列表 UI
拆分后:
txt
Home.ets
│
├── @HMRouter 注册
├── 接收路由参数
├── 使用 PageShell
└── 挂载 ProductManager
这样页面职责更清晰。
二十一、当前目录结构
txt
entry/src/main/ets/
│
├── pages/
│ ├── Index.ets
│ ├── Home.ets
│ ├── ProductDetail.ets
│ ├── TabHome.ets
│ └── Setting.ets
│
├── components/
│ ├── PageShell.ets
│ └── product/
│ └── ProductManager.ets
│
├── models/
│ └── Product.ets
│
├── stores/
│ ├── ProductStore.ets
│ └── TabState.ets
│
├── constants/
│ └── RouteConstants.ets
│
└── interceptors/
└── SameRouteInterceptor.ets
理解:
txt
pages:路由页面
components:业务组件 / 公共 UI
models:数据模型
stores:共享状态
constants:常量配置
interceptors:路由拦截器
二十二、今天遇到的问题总结
1. not exist pageUrl in store
含义:
txt
路由表中没有这个 pageUrl
常见原因:
txt
@HMRouter 没写
pageUrl 不一致
插件没执行
scanDir 不对
没 Clean / Rebuild
用 Previewer 跑
解决:
txt
检查 @HMRouter
检查 hmrouter_config.json
检查 entry/hvigorfile.ts
Clean Project
Rebuild Project
Run 到模拟器
2. ERR_INIT_NOT_READY
含义:
txt
HMRouterMgr 还没有初始化
解决:
ts
HMRouterMgr.init({
context: this.context
})
3. install sign info inconsistent
含义:
txt
模拟器里有旧包残留,签名不一致
解决:
powershell
hdc list targets
hdc -t 设备ID uninstall com.example.myapplication
4. 参数正常,但详情页显示商品不存在
排查后发现:
txt
路由参数正常
问题不在 getCurrentParam
而是在根据 id 查询 Store 数据这一层
处理策略:
txt
先简化详情页,只展示路由参数
再逐步增加 Store 查询逻辑
二十三、鸿蒙基础知识点复盘
今天实际用到的鸿蒙基础包括:
txt
@ComponentV2
@Builder
@BuilderParam
@Param
@Local
@ObservedV2
@Trace
build()
aboutToAppear()
Column
Row
Stack
ForEach
TextInput
Button
gesture()
HMRouter
HMNavigation
HMRouterMgr.push
HMRouterMgr.replace
HMRouterMgr.pop
HMRouterMgr.getCurrentParam
二十四、整体流程图
txt
应用启动
│
▼
EntryAbility.onCreate
│
├── HMRouterMgr.openLog
└── HMRouterMgr.init
│
▼
Index.ets
│
├── HMNavigation
│
└── Bottom TabBar
├── TabHome
├── Home
└── Setting
│
▼
Home.ets
│
├── 接收路由参数
├── PageShell 外壳
└── ProductManager
│
├── 商品搜索
├── 商品新增
├── 商品删除
├── 商品修改
└── 点击详情
│
▼
ProductDetail.ets
│
├── getCurrentParam
├── 展示商品参数
└── pop 返回
二十五、学习心得
今天最大的收获不是简单写了一个商品增删改查 Demo,而是理解了一个鸿蒙业务页面从路由到组件再到状态的组织方式。
以前是:
txt
所有代码写在一个页面里
能跑就行
现在开始尝试拆成:
txt
路由页面
业务组件
模型
Store
常量
拦截器
这更接近公司真实项目的结构。
HMRouter 第一次接入确实有点绕,因为它涉及:
txt
运行库
编译插件
路由扫描
路由表
EntryAbility 初始化
模拟器运行
但跑通一次之后,后面就可以当模板复用。
二十六、后续计划
下一步继续完善:
txt
1. ProductDetail 根据 id 从 ProductStore 获取完整商品数据
2. ProductManager 继续拆成 ProductSearchBar / ProductAddBar / ProductList
3. 练习详情页修改商品后返回列表刷新
4. 继续理解 push / replace / pop 的使用边界
5. 将 Setting 页面改造成简单聊天 Demo
6. 模拟 AI 接口请求、打字机输出、聊天历史记录
总结
今天完成了从普通页面 Demo 到路由化业务 Demo 的完整练习。
掌握了:
txt
HMRouter 接入
路由跳转
路由传参
页面返回
路由拦截器
底部导航
商品 CRUD
组件拆分
Store 思想
日志排查
这次练习让我对鸿蒙项目有了更清晰的认识:
txt
页面不是只写 UI
还要理解路由、状态、组件拆分、工程配置和调试流程