文章目录
前言
用户从分类页进入一份文档,再打开文档中的详细说明,左侧分类入口始终可见。用户此时点击返回,期待先回到文档;如果应用直接清空路由栈,返回操作就会跳过刚才的阅读位置。两侧内容已经有了合适的宽度,页面继续向下跳转时,屏幕上的左右位置却不足以说明用户经过了哪些层级。
开发者处理这种返回问题时,需要沿着用户的一次进入和退出检查路径:哪次点击增加了目标页面,哪个返回入口只退出当前层,哪个按钮明确要求回到首页。路由代码需要保留这些操作之间的区别,才能为每一种返回意图找到对应的目标。即使左右页面已经排列整齐,应用也不能省略中间的路径记录。

一、首页代表当前路径走到了哪里
Navigation 的导航栏显示分类首页,NavDestination 显示用户进入后的目标内容。导航模式可以让首页持续显示在左侧,因此用户进入更深页面时,左侧分类入口仍然可能保持原样。开发者需要检查路径栈中的目标记录,才能确认当前内容从哪里进入,以及用户退出一层以后应该回到哪个页面。
当前页面结构将导航栏记作 Page A,将两个目标页面命名为 PageB 和 PageC。Page A 不占用目标页面记录,所以初始目标栈为空。用户从 A 进入 Page B 后,路由栈增加 B 的记录;用户再从 B 进入 C 时,路由栈继续追加 C 的记录。页面名称在这里帮助读者辨认层级,即使业务内容换成文档列表、文档详情和章节说明,应用也仍然需要保存相同的进入顺序。
这些路径记录保存了用户连续打开的目标。用户当前阅读 C 时,B 的记录说明用户从哪个中间页面进入 C;左侧仍然可见的首页则提供重新选择内容的入口。开发者如果直接把可见页面数量当作路径深度,就容易遗漏暂时没有显示的中间目标。诊断记录因此需要包含进入顺序,其他开发者才能在返回异常后还原是哪次点击改变了路径。
首页与两个目标页共同使用 pageStack,routingNavigation 标识负责这些页面的 Navigation。配置中的 homePage 指向 navBar,relatedPage 指向 PageB,mode 为 1。这组设置说明左右页面如何组织,但一次按钮操作的分析还需要从当时实际存在的目标记录开始。首页和 B 同时可见这一现象,不能单独证明用户已经执行过入栈操作。
Page B 在页面中读取目标名称,让开发者能够对照按钮动作和路径变化。用户点击继续进入的按钮时,按钮回调会向共享栈追加 PageC。这次操作需要保留 B 的记录,用户后续返回时才有明确的上一层,因此代码采用追加目标的方式。
ts
Text(`当前栈:${this.pageStack.getAllPathName().join(' → ')}`)
.fontSize(15)
.fontColor('#5F6678')
.width('100%')
Button('push Page C')
.width('100%')
.height(52)
.onClick(() => {
this.pageStack.pushPathByName('PageC', null)
})
getAllPathName() 返回目标页面的名称,不把导航栏算作一条路径。页面使用箭头连接这些名称,帮助开发者检查 B 与 C 的顺序;名称查询本身不能证明各页面的业务数据正确。页面文字何时重新绘制仍然需要运行观察,已有构建结果没有确认这行文字会在每次路径改变后立即刷新。
如果路径文字与可见页面不一致,开发者需要先确认页面读取的是同一个 NavPathStack,再核对页面名称与 navDestination 构建分支。不同栈可能恰好拥有相同的页面名,名称文字相同并不足以说明页面属于同一条路径。当前首页与目标页面共享一个栈,尚未包含嵌套导航,因此这套页面结构只能帮助检查基础层级,不能覆盖多个导航容器相互切换的全部情况。
操作记录还需要写清检查发生在点击之前还是之后。用户点击进入 C 之前,开发者应当先确认栈中存在 B;点击完成以后,开发者再读取路径结果,对照当前显示的目标。点击前的文字与点击后的画面如果出现在同一份记录中,记录就可能造成路径与页面不一致的假象。一份包含点击前后状态的记录,能让其他开发者按相同步骤复现问题,单独的最终页面名称无法提供这些信息。
开发者可以沿同一条进入路径记录下表中的状态,让一次按钮操作对应一组明确的前后变化。表格列出的是按钮显式造成的目标栈变化,实际冷启动和关联页如何呈现仍然需要单独观察。
| 用户操作 | 操作前的目标栈 | 操作后的目标栈 | 应核对的问题 |
|---|---|---|---|
| 从 A 进入 B | 空 | B | 是否增加了预期目标 |
| 从 B 进入 C | B | B、C | B 的记录是否仍然保留 |
| 从 C 返回一层 | B、C | B | 是否回到刚才的 B |
| 从 B 返回一层 | B | 空 | 是否回到导航栏首页 |
| 从 C 回到首页 | B、C | 空 | 是否符合按钮明确表达的退出范围 |
这张表能够帮助开发者判断路由操作是否正确,却不能独立说明左右两栏当前如何分配。窗口宽窄和系统返回手势可能影响用户看到的过程,因此操作记录还需要同时保留路径和画面。开发者将两者对应起来以后,才能判断异常发生在路径维护、目标构建还是显示条件上。
当返回结果不符合表中关系时,开发者可以从最近一次成功操作开始重新执行。例如,用户从 A 进入 B 正常,而从 B 进入 C 后返回异常,检查就应集中到这次进入与返回:进入 C 的代码是否额外清除了 B,返回按钮是否调用了清空操作。路径变化能够帮助缩小原因,开发者也就有依据暂时保留左右位置配置,避免同时修改导航模式和退出逻辑而失去比较依据。
二、返回一层与回到首页需要不同的操作
用户读完 C 后返回 B,是为了继续刚才的阅读任务;用户主动回到首页,则表示结束当前目标链。如果两个入口都绑定清空操作,普通返回就会丢失用户仍然需要的中间页面。按钮文字已经表达了不同意图,处理按钮点击的代码也需要分别维护对应的退出范围。
Page C 的两个按钮回调分别调用 pop() 和 clear()。pop() 移除当前栈顶,clear() 清除全部目标记录,所以在包含 B、C 的这条路径上,两个按钮会得到不同的退出结果。
ts
Button('pop 返回 Page B')
.width('100%')
.height(52)
.onClick(() => {
this.pageStack.pop()
})
Button('clear 返回 Page A')
.width('100%')
.buttonStyle(ButtonStyleMode.TEXTUAL)
.onClick(() => {
this.pageStack.clear()
})
页面调用 pop() 以后,目标栈中仍然保留 B 的记录,用户可以返回之前的页面;页面调用 clear() 以后,目标栈为空,导航栏首页继续提供入口。这段代码可以确认两个按钮各自选择了哪种路径操作,但路径名的保留不能保证页面内容也原样保留。B 的滚动位置、输入内容和请求结果是否仍然存在,还取决于页面的数据保存方式,需要通过实际运行检查。
实际应用通常还提供导航栏返回图标、系统返回手势和业务按钮。多个入口都可能触发返回,因此同一次返回不能被多处逻辑重复处理,否则一次手势可能连续退出两层。当前代码只实现页面按钮,没有额外拦截系统返回。开发者接入现有项目时,需要沿每个实际入口记录操作前后的栈;记录如果显示重复退出,开发者再检查已有的返回拦截逻辑。
业务按钮的名称也需要与退出范围对应。如果按钮标注返回,处理代码却清除了多层路径,用户就难以预期哪些阅读位置会丢失。回到首页这个名称可以表达结束当前内容链的意图,但具体采用哪种名称和操作,仍然取决于页面任务。这属于交互设计判断,不要求所有返回入口统一清空;项目已有多级入口时,开发者需要逐个确认按钮含义,才能发现方法调用是否符合用户预期。
同级切换与向下进入需要保留的历史不同。用户从一个分类切换到另一个分类时,如果产品不要求用户返回之前的分类内容,应用可以先清除旧目标,再进入新分类的根页面。用户从详情继续打开下一级时,应用则需要保留此前的路径。用户返回时应该继续哪项任务,决定了记录是否需要保留,这是业务自身的导航决策。
例如,用户从收件箱切换到草稿箱时,应用可以把草稿箱作为新的内容起点。用户在草稿箱里打开一封草稿,再查看附件时,附件页的返回操作应当让用户回到草稿。两次点击都改变了右侧内容,却要求保留不同的返回目标。开发者需要在处理点击的位置确定目标层级,不能仅凭右栏内容发生变化就统一清空路由栈。
重复点击还可能增加未预期的路径记录。用户在操作尚未完成时又点击进入,业务需要明确是否允许连续打开同一目标。即使两个记录拥有同样的名字,排查人员也不能自动把其中一个当作无效数据,因为重复记录是否符合预期取决于这项业务约定。当前代码没有专门处理重复点击或同名目标复用,项目需要先确定期望行为,再比较实际进入次数与返回次数。

图中的目标记录与左右显示位置,分别呈现同一次操作对路径和画面的影响。大家可以先沿 A、B、C 的进入箭头查看新增记录,再沿两种退出箭头确认哪些记录会被保留。


路径记录能够帮助复现返回错误,但内容错误还需要更具体的诊断信息。如果多个节点都使用 ArticleDetail 这样的同名页面,名称列表就无法区分用户打开的两篇文章。应用可以在诊断日志中增加脱敏后的业务标识,让记录指向具体内容。当前代码传入 null,尚未实现这类参数读取,因此当前页面还不具备区分同名目标业务数据的功能。
三、路径还在时页面内容能否重新出现
用户回到了正确的目标以后,仍然可能发现原来的内容需要重新加载。路径记录描述用户经过了哪些目标,组件实例则保存当时创建的界面和部分内存状态。系统限制活动节点数量时,这两种对象可能保留不同的时间。开发者需要分别检查路径记录与组件实例,才能解释为什么记录仍在,页面却需要重新创建。
API 26 的 NavigationConfiguration 提供 stackSizeLimit,用于设置 Navigation 中活动节点的数量上限。这个属性的默认值为 0,表示没有设置正数限制。活动节点数量超过正数上限后,Navigation 按照先进先出的顺序销毁较早进入的活动节点,同时保留完整的 NavPathInfo 路径信息。用户返回并需要这些节点时,Navigation 可以依据保留的路径信息重新创建页面。
当前实现将 stackSizeLimit 设为 2,并通过 Navigation.configuration() 传入配置。这个限制作用于活动节点,原有路径信息仍然完整保留。因此,应用不需要为了配合节点上限而主动裁剪路径。
ts
private navigationConfig: NavigationConfiguration = {
stackSizeLimit: 2
}
这段配置已通过正式版类型检查与构建,但当前工程只有 B、C 两个目标页。用户进入这两个页面以后,活动目标数量仍然没有超过上限,所以现有路径不能证明超限销毁已经发生。开发者要检查回收后的重建,需要另行增加第三个目标页,并记录节点的生命周期。当前实现没有这个页面,超限回收也尚无运行结果。
应用是否设置活动节点限制,需要由实际页面占用和返回成本决定。内容较重的页面可能需要减少长期保留的实例,但页面重建也会重新执行界面创建和必要的数据读取。页面占用较小、层级较浅时,节点回收未必带来明显收益,却会增加需要检查的返回场景。这是通用工程取舍,项目需要结合测量结果确定数值,不能直接把 2 当作推荐值。
开发者在设置上限之前,可以先记录用户完成一次浏览时经过的层级深度和返回频率。如果用户经常在相邻两层之间往返,频繁重建可能让用户付出额外等待;如果用户很少再访问较早页面,应用保留全部实例又可能占用不必要的内存。项目需要用自己的测量结果比较这两种成本,再明确希望降低哪类占用,以及能够接受多少重新加载时间。这些成本明确以后,活动节点上限的选择才有具体依据。
应用允许节点重建以后,需要在组件实例之外保存业务数据。目标页面可以通过文档标识重新读取内容,再在布局完成后恢复阅读位置;应用还需要为未提交的输入选择合适的保存方式。NavPathInfo 保留路径信息,并不会自动持久化这些业务数据。进程结束后的恢复还涉及其他条件,超出了当前活动节点限制的讨论范围。
目标页面能够重新创建,也可能在读取数据时遇到网络失败或本地内容已经不存在的问题。应用需要让目标页区分加载中、加载失败和内容不存在的状态,并为用户提供继续操作的入口。当前静态页面没有这些状态,一次简单返回无法代替恢复失败检查。项目后续加入真实数据时,返回路径还需要覆盖这些数据读取失败的情况。
开发者检查恢复问题时,可以先记录具体出现了哪种现象:返回目标错误、目标正确但内容错误,或者内容正确但阅读位置丢失。这三种现象分别需要从路径操作、业务参数和状态保存开始检查。当前静态 B、C 页面只实现了第一类问题所需的基础操作,项目接入时还需要补充参数隔离、滚动恢复和草稿保存。
这些现象也决定了验证应该按什么顺序展开。开发者可以用普通 B、C 路径确认进入和退出正确,再用超限路径检查页面销毁与重建,随后通过业务页面检查数据及位置恢复。数据恢复检查依赖正确的返回路径,因此基础返回尚有错误时,开发者还不能把异常直接归为恢复失败。检查记录需要写清已经执行的操作和观察到的结果,才能区分构建成功、页面出现与用户继续完成任务。
总结
主页持续可见时,应用仍然需要根据目标栈确定返回位置。用户继续进入下一层时,路由栈需要保留上一层记录;用户返回一层时,页面调用 pop() 移除栈顶;用户明确结束当前路径时,页面才调用 clear() 清除全部目标。大家按照用户要继续的任务选择操作,才能让宽窗口中的两栏与窄窗口中的单页使用相同的导航含义。
活动节点限制增加了页面重新创建的可能,应用因此需要提前确定业务数据由谁保存。现有代码已经通过正式版编译与构建,但超限回收尚未验证,业务状态恢复也尚未实现。窗口变窄或应用进入系统分屏时,大家还需要确认页面显示变化没有同时重置原来的路径。
我目前手里的设备还不支持 HarmonyOS 7 ,所以相关内容现阶段主要通过 HarmonyOS 7 模拟器进行验证,真机上的系统表现、设备差异和实际体验,后面有条件再继续补测,最终还是以实际设备运行结果为准。
完整代码
Index.ets
ts
@Entry
@Component
struct Index {
@Provide('pageStack')
pageStack: NavPathStack = new NavPathStack()
private navigationConfig: NavigationConfiguration = {
stackSizeLimit: 2
}
@Builder
pageMap(name: string) {
if (name === 'PageB') {
PageB()
} else if (name === 'PageC') {
PageC()
}
}
build() {
Navigation(this.pageStack) {
Column({ space: 18 }) {
Text('Page A · 首页')
.fontSize(30)
.fontWeight(FontWeight.Bold)
.width('100%')
Text('NavBar 是返回栈清空后的落点。')
.fontSize(16)
.fontColor('#5F6678')
.width('100%')
Text(`当前 NavDestination:${this.pageStack.getAllPathName().join(' → ') || '空'}`)
.fontSize(15)
.lineHeight(22)
.width('100%')
Button('push Page B')
.width('100%')
.height(52)
.onClick(() => {
this.pageStack.pushPathByName('PageB', null)
})
}
.width('100%')
.height('100%')
.padding(24)
.alignItems(HorizontalAlign.Start)
}
.id('routingNavigation')
.mode(NavigationMode.Stack)
.title('路由栈实验')
.navDestination(this.pageMap)
.configuration(this.navigationConfig)
.width('100%')
.height('100%')
}
}
@Component
struct PageB {
@Consume('pageStack')
pageStack: NavPathStack
build() {
NavDestination() {
Column({ space: 18 }) {
Text('Page B · 详情')
.fontSize(30)
.fontWeight(FontWeight.Bold)
.width('100%')
Text(`当前栈:${this.pageStack.getAllPathName().join(' → ')}`)
.fontSize(15)
.fontColor('#5F6678')
.width('100%')
Button('push Page C')
.width('100%')
.height(52)
.onClick(() => {
this.pageStack.pushPathByName('PageC', null)
})
Button('pop 返回 Page A')
.width('100%')
.buttonStyle(ButtonStyleMode.TEXTUAL)
.onClick(() => {
this.pageStack.pop()
})
Button('clear 返回首页')
.width('100%')
.buttonStyle(ButtonStyleMode.TEXTUAL)
.onClick(() => {
this.pageStack.clear()
})
}
.width('100%')
.height('100%')
.padding(24)
.alignItems(HorizontalAlign.Start)
}
.title('Page B')
}
}
@Component
struct PageC {
@Consume('pageStack')
pageStack: NavPathStack
build() {
NavDestination() {
Column({ space: 18 }) {
Text('Page C · 更多详情')
.fontSize(30)
.fontWeight(FontWeight.Bold)
.width('100%')
Text(`当前栈:${this.pageStack.getAllPathName().join(' → ')}`)
.fontSize(15)
.fontColor('#5F6678')
.width('100%')
Button('pop 返回 Page B')
.width('100%')
.height(52)
.onClick(() => {
this.pageStack.pop()
})
Button('clear 返回 Page A')
.width('100%')
.buttonStyle(ButtonStyleMode.TEXTUAL)
.onClick(() => {
this.pageStack.clear()
})
}
.width('100%')
.height('100%')
.padding(24)
.alignItems(HorizontalAlign.Start)
}
.title('Page C')
}
}
module.json5
json5
{
"module": {
"name": "entry",
"type": "entry",
"description": "$string:module_desc",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone",
"tablet",
"2in1"
],
"deliveryWithInstall": true,
"installationFree": false,
"easyGo": "$profile:easy_go",
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:layered_image",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"ohos.want.action.home"
]
}
]
}
],
"extensionAbilities": [
{
"name": "EntryBackupAbility",
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
"type": "backup",
"exported": false,
"metadata": [
{
"name": "ohos.extension.backup",
"resource": "$profile:backup_config"
}
]
}
]
}
}
easy_go.json
json
{
"common": {
"displayModeOptions": {
"wideWindowMode": "navigationSplit",
"squareWindowMode": "navigationSplit",
"navigationSplitOptions": {
"homePage": "navBar",
"relatedPage": "PageB",
"homeNavigationId": "routingNavigation",
"mode": 1
}
}
}
}