【HarmonyOS 7 平行视界深度实战】06 页面路由、返回栈与多层跳转怎么处理

文章目录

前言

用户从分类页进入一份文档,再打开文档中的详细说明,左侧分类入口始终可见。用户此时点击返回,期待先回到文档;如果应用直接清空路由栈,返回操作就会跳过刚才的阅读位置。两侧内容已经有了合适的宽度,页面继续向下跳转时,屏幕上的左右位置却不足以说明用户经过了哪些层级。

开发者处理这种返回问题时,需要沿着用户的一次进入和退出检查路径:哪次点击增加了目标页面,哪个返回入口只退出当前层,哪个按钮明确要求回到首页。路由代码需要保留这些操作之间的区别,才能为每一种返回意图找到对应的目标。即使左右页面已经排列整齐,应用也不能省略中间的路径记录。

一、首页代表当前路径走到了哪里

Navigation 的导航栏显示分类首页,NavDestination 显示用户进入后的目标内容。导航模式可以让首页持续显示在左侧,因此用户进入更深页面时,左侧分类入口仍然可能保持原样。开发者需要检查路径栈中的目标记录,才能确认当前内容从哪里进入,以及用户退出一层以后应该回到哪个页面。

当前页面结构将导航栏记作 Page A,将两个目标页面命名为 PageBPageC。Page A 不占用目标页面记录,所以初始目标栈为空。用户从 A 进入 Page B 后,路由栈增加 B 的记录;用户再从 B 进入 C 时,路由栈继续追加 C 的记录。页面名称在这里帮助读者辨认层级,即使业务内容换成文档列表、文档详情和章节说明,应用也仍然需要保存相同的进入顺序。

这些路径记录保存了用户连续打开的目标。用户当前阅读 C 时,B 的记录说明用户从哪个中间页面进入 C;左侧仍然可见的首页则提供重新选择内容的入口。开发者如果直接把可见页面数量当作路径深度,就容易遗漏暂时没有显示的中间目标。诊断记录因此需要包含进入顺序,其他开发者才能在返回异常后还原是哪次点击改变了路径。

首页与两个目标页共同使用 pageStackroutingNavigation 标识负责这些页面的 Navigation。配置中的 homePage 指向 navBarrelatedPage 指向 PageBmode1。这组设置说明左右页面如何组织,但一次按钮操作的分析还需要从当时实际存在的目标记录开始。首页和 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
      }
    }
  }
}
相关推荐
2501_919749036 小时前
华为鸿蒙免费刷题软件—小羊免费刷题
华为·harmonyos·鸿蒙
游戏智眼6 小时前
HarmonyOS 7 适配升级:NIM SDK 释放端侧 AI 与网络能力
人工智能·harmonyos
昇腾知识体系7 小时前
昇腾 A5 ISA 指令集:文档入口与 mem_bar 等关键指令
人工智能·华为·架构·知识图谱
程序猿追8 小时前
react-native-elements 三方库鸿蒙版本适配与使用(MatePad Edge 双模式真机验证)
华为·harmonyos
lqj_本人8 小时前
白泽上手:给小鸿 SE 写一个温控风扇工程
harmonyos
贾伟康9 小时前
【口算王|12】HarmonyOS ArkTS 启动页实战:处理 Splash 到训练首页的稳定切换
harmonyos·arkts·启动优化·uiability·windowstage
万物智能信息科技10 小时前
RK3568 的多路显示移植—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
linux·开发语言·华为·开源·harmonyos
万物智能信息科技11 小时前
MIPI DSI屏幕输出—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
嵌入式硬件·华为·开源·harmonyos·鸿蒙
贾伟康11 小时前
【口算王|13】HarmonyOS ArkTS 应用启动链路实战:从 EntryAbility 到首屏加载保持窗口与路由稳定
harmonyos·arkts·arkui·应用启动·entryability