同一个菜谱详情页,不一定只从首页点进去。用户可能先搜"番茄",从搜索结果进详情;也可能从收藏页进来;还可能从购物清单里点某个食材,回到对应菜谱。这个时候返回按钮不能写死"回首页"。写死之后,用户刚才的关键词、筛选条件、滚动位置都会丢。中式美食后面做 HarmonyOS 版本时,NavPathStack 要解决的不是"能不能跳转",而是"从哪里来,就尽量回哪里去"。

| 应用名称 | 中式美食 |
|---|---|
| 这篇解决什么 | 多入口进入详情页后,返回时跳错页或丢筛选状态 |
| 谁会搜这篇 | 做 HarmonyOS 详情页、搜索列表、收藏页、推荐流的 ArkUI 开发者 |
| 点进来能拿到什么 | 来源参数、NavPathStack 路径模型、返回策略和验收用例 |
| 当前项目证据 | 中式美食已有搜索、收藏、首页推荐、购物清单等多入口 |
| HarmonyOS 目标能力 | ArkUI、ArkTS、NavPathStack、页面参数、ViewModel 状态 |
本章导读
这篇不泛讲路由,只处理详情页返回错页。中式美食这种内容类应用,一道菜可以从很多地方进详情。如果每个入口都只是 push('detail'),详情页就不知道自己从哪里来。后面返回时只能猜,猜错一次,用户就会觉得页面"不记事"。
| 章节 | 重点 |
|---|---|
| 问题从哪里来 | 为什么详情页不能只拿 recipeId |
| 来源参数怎么设计 | source、keyword、categoryId、fromPlanId 怎么拆 |
| NavPathStack 怎么用 | push 时带清楚参数,pop 时不要乱清状态 |
| ViewModel 怎么配合 | 列表状态不要全靠详情页重建 |
| 怎么验收 | 搜索、收藏、推荐、购物清单四条路径都要测 |
当前验证环境和技术栈
| 项目 | 说明 |
|---|---|
| DevEco Studio | 6.x 系列 |
| HarmonyOS SDK | API 12/ArkTS 工程 |
| UI 层 | ArkUI 声明式页面 |
| 路由层 | NavPathStack / 页面参数 |
| 状态层 | 列表 ViewModel 保留搜索词、筛选条件、滚动位置 |
| 当前项目参考 | 中式美食搜索页、收藏页、推荐流、购物清单入口 |
详情页不能只收 recipeId
只传 recipeId 能打开详情页,但不够解决返回问题。因为详情页知道自己是谁,却不知道自己从哪里来。
ts
// 这只能打开详情,不能解决返回来源
this.pathStack.pushPath({
name: 'RecipeDetail',
param: { recipeId: 'tomato-egg' }
});
更稳的做法是把入口来源也放进参数里。不是为了让详情页做更多事,而是为了让路由行为可解释。
| 来源 | 用户动作 | 返回时应该去哪 |
|---|---|---|
| 搜索页 | 搜"番茄"后点菜谱 | 回到搜索结果,关键词还在 |
| 收藏页 | 点收藏菜谱 | 回到收藏列表 |
| 首页推荐 | 点推荐卡片 | 回到首页推荐流 |
| 购物清单 | 点某个食材关联菜谱 | 回到当前购物清单 |
| 最近浏览 | 点历史记录 | 回到最近浏览列表 |
来源参数要小,但要够用
我不会把整个列表状态塞进详情页参数。参数太大,后面会很难维护。详情页只需要知道"来源类型"和"恢复列表所需的最小信息"。
ts
type RecipeDetailSource =
| 'search'
| 'favorite'
| 'recommend'
| 'shoppingList'
| 'history';
interface RecipeDetailParams {
recipeId: string;
source: RecipeDetailSource;
keyword?: string;
categoryId?: string;
planId?: string;
scrollKey?: string;
}
| 参数 | 什么时候需要 | 不要放什么 |
|---|---|---|
recipeId |
每次都需要 | 不要用菜名代替业务 id |
source |
每次都需要 | 不要让详情页自己猜来源 |
keyword |
从搜索页进入 | 不要塞完整搜索结果数组 |
categoryId |
从分类页进入 | 不要塞分类下所有菜谱 |
planId |
从购物清单进入 | 不要塞整张购物清单 |
scrollKey |
需要恢复位置时 | 不要塞像素级滚动细节 |
push 时就把来源讲清楚
入口页最清楚自己是谁,所以来源参数应该在入口页组装,不要等详情页再反推。
ts
function openRecipeFromSearch(
pathStack: NavPathStack,
recipeId: string,
keyword: string
) {
pathStack.pushPath({
name: 'RecipeDetail',
param: {
recipeId,
source: 'search',
keyword,
scrollKey: recipeId
} as RecipeDetailParams
});
}
收藏页就不应该假装自己是搜索页。
ts
function openRecipeFromFavorite(pathStack: NavPathStack, recipeId: string) {
pathStack.pushPath({
name: 'RecipeDetail',
param: {
recipeId,
source: 'favorite',
scrollKey: recipeId
} as RecipeDetailParams
});
}
这样写的好处是:详情页打开逻辑统一,但来源清楚。后面做埋点、恢复、返回策略都能看懂。
返回不是每次都手写跳转
如果用户是正常从列表 push 到详情,大多数情况下直接 pop() 就够了。最怕的是详情页里手写一个"返回首页"的逻辑,把路径栈破坏掉。
ts
@Component
struct RecipeDetailPage {
@ObjectLink pathStack: NavPathStack;
private params: RecipeDetailParams = this.pathStack.getParamByName('RecipeDetail') as RecipeDetailParams;
private back() {
if (this.pathStack.size() > 1) {
this.pathStack.pop();
return;
}
this.pathStack.replacePath({
name: 'Home',
param: { restoreFrom: this.params.source }
});
}
}
这个兜底只处理一种情况:详情页被外部直接打开,栈里没有上一级页面。正常列表进入详情,不要强行 replace。
| 情况 | 返回策略 |
|---|---|
| 列表 push 详情 | pop() |
| 外部链接直达详情 | replacePath(Home) 或去一个明确入口 |
| 详情页内部跳另一个详情 | 保留来源,或记录 parentRecipeId |
| 登录/权限页插入 | 返回后要恢复原目标页 |
| 页面被系统回收 | 用 source 和最小参数恢复 |
列表状态不要全靠详情页保存
详情页不应该保存完整列表。搜索页自己要记住关键词、分类、结果和滚动锚点。详情页只带一个 scrollKey,返回后由原列表页恢复。
ts
@Observed
export class SearchRecipeViewModel {
keyword: string = '';
categoryId: string = '';
items: RecipeCardModel[] = [];
lastScrollKey: string = '';
rememberScroll(recipeId: string) {
this.lastScrollKey = recipeId;
}
restoreAfterBack() {
if (!this.keyword && !this.categoryId) {
return;
}
// 保留原结果,不因为返回详情就重新清空列表
}
}
列表页自己的状态自己管。详情页只负责展示详情,不要把搜索页的职责抢过来。
入口策略要统一,不要每个页面各写一套
中式美食入口会越来越多:首页推荐、搜索、收藏、最近浏览、购物清单、餐桌方案。每个入口都手写 pushPath,后面一定会漏参数。可以把打开详情收口成一个函数。
ts
export class RecipeNavigator {
constructor(private readonly pathStack: NavPathStack) {}
openDetail(params: RecipeDetailParams) {
this.pathStack.pushPath({
name: 'RecipeDetail',
param: params
});
}
}
入口页只负责传自己的来源。
ts
this.navigator.openDetail({
recipeId: item.id,
source: 'shoppingList',
planId: currentPlanId,
scrollKey: item.id
});
| 入口 | 必传参数 | 可选参数 |
|---|---|---|
| 搜索 | recipeId、source=search |
keyword、scrollKey |
| 收藏 | recipeId、source=favorite |
scrollKey |
| 推荐 | recipeId、source=recommend |
推荐位 id |
| 购物清单 | recipeId、source=shoppingList |
planId、scrollKey |
| 最近浏览 | recipeId、source=history |
浏览时间 |
工程验收
| 验收项 | 操作 | 通过标准 |
|---|---|---|
| 搜索进入详情 | 搜"番茄",点第一道菜 | 返回后搜索词和结果还在 |
| 收藏进入详情 | 从收藏页点菜谱 | 返回收藏页,不跳首页 |
| 推荐进入详情 | 从首页推荐卡片进入 | 返回首页推荐流 |
| 购物清单进入详情 | 从清单食材点菜谱 | 返回当前清单,勾选状态不丢 |
| 外部直达详情 | 直接打开详情页 | 有合理兜底,不白屏 |
| 连续详情跳转 | 详情页点相关推荐 | 返回路径符合用户预期 |
可以用下面这种方式压住来源参数:
ts
function assertDetailParams(params: RecipeDetailParams) {
if (!params.recipeId) {
throw new Error('详情页缺少 recipeId');
}
if (!params.source) {
throw new Error('详情页缺少来源 source');
}
if (params.source === 'search' && !params.keyword) {
throw new Error('搜索入口进入详情时缺少 keyword');
}
if (params.source === 'shoppingList' && !params.planId) {
throw new Error('购物清单入口进入详情时缺少 planId');
}
}
这段检查不一定放线上,但很适合开发期和测试期。它能提前发现某个入口漏传参数,而不是等用户返回错页才发现。
常见问题和边界
| 问题 | 我的处理 |
|---|---|
| source 会不会太多 | 会,所以只保留真实入口,不为临时弹窗造 source |
| 要不要把整页状态塞进 param | 不要,参数只放最小恢复信息 |
| 直接打开详情怎么办 | 用兜底入口,不要假装有上一页 |
| 返回后要不要重新请求 | 看数据时效,搜索结果通常先保留再后台刷新 |
| 详情页能不能自己决定返回哪 | 尽量不要,入口页和路径栈更清楚 |
本章小结
NavPathStack 的重点不是把页面跳过去,而是让用户的路径保持可解释。中式美食这种菜谱应用,详情页是复用最多的页面之一:搜索能进、收藏能进、首页推荐能进、购物清单也能进。只传 recipeId,详情能打开,但返回就容易乱。
我的做法是:入口页带来源,详情页只做展示和轻量兜底,列表页自己保留自己的搜索词、筛选条件和滚动锚点。这样用户从哪里进来,返回时就能回到接近原来的位置。这个体验不花哨,但对内容类应用很关键,因为用户不是只看一页,他是在一串路径里找菜、比菜、收藏、加入清单。
如果你也在做 HarmonyOS 多入口详情页,可以先把所有入口列出来,然后问一句:从这个入口进详情后,用户点返回最希望看到什么?这句话答清楚,NavPathStack 的参数设计就不会跑偏。