【HarmonyOS 7 平行视界深度实战】02 从普通页面到第一个平行视界 Demo
文章目录
前言
现有 Navigation 页面已经能从首页进入详情页,再进入二级详情页。应用放到 Tablet 宽窗口以后,希望首页继续留在左侧,详情链路显示在右侧。页面代码本身仍然保持单栏,系统根据 EasyGo 的页面关系组织左右区域。
这里要分清路由关系和显示位置。pushPathByName 决定哪个页面进入路由栈,它不会承诺页面出现在左栏、右栏或整窗。EasyGo 提供另一层信息,让系统在满足设备和窗口条件时识别主页、关联页与显示模式。两层信息共同生效,普通窗口仍按单页路由工作,宽窗口才有机会形成平行视界。

一、配置三个文件建立最小配置链路
要保持原有路由不动,新增变量就应先收敛到配置链路。easy_go.json 放在 entry/src/main/resources/base/profile/,文件名可以调整,但是module.json5 中的资源引用必须保持一致。平行视界配置只支持放在 entry 模块,配置后对应用生效。
现有多模块项目常在这里踩坑。业务页面可能位于 HAR 或 HSP,开发者便顺手把 EasyGo 放到页面所在模块。系统读取的是 entry 模块配置,页面代码放在哪里与配置入口属于两个问题。资源名也要同时检查文件名和 $profile: 后面的引用,改了其中一处会造成资源无法匹配。
模块配置通过 easyGo 引用 $profile:easy_go,让 entry 模块找到这份 profile 资源。打包检查还要确认资源引用随 HAP 一起生效。
EasyGo 使用两层结构。第一层选择设备类型,第二层的 displayModeOptions 决定窗口显示模式。我们这个 Demo 使用 common,让通用配置同时覆盖支持条件内的设备:
{
"common": {
"displayModeOptions": {
"wideWindowMode": "navigationSplit",
"squareWindowMode": "navigationSplit",
"navigationSplitOptions": {
"homePage": "navBar",
"relatedPage": "DetailPage"
}
}
}
}
| 字段 | 当前值 | 工程含义 |
|---|---|---|
wideWindowMode | navigationSplit | 长方形宽窗口使用 Navigation 路由的平行视界 |
squareWindowMode | navigationSplit | 方形宽窗口采用相同路由方案 |
homePage | navBar | Navigation 的首页作为主页 |
relatedPage | DetailPage | 详情页作为静态关联页 |
这四个字段把系统需要的最小信息分成两组:窗口模式决定何时采用 Navigation 路由的平行视界,主页与关联页决定初始页面关系。relatedPage 依赖 homePage,也不支持传递动态参数。它适合分类首页、首个详情页这类稳定的页面。商品 ID、文章 ID 等业务参数继续由 Navigation 路由传递,所以不能写进 EasyGo 页面关联。
我们将 homePage 设为 navBar,描述的是 Navigation 首页区域;将 relatedPage 设为 DetailPage,使用的是路由名称,必须与 pushPathByName 和 Builder 判断中的名称一致。这里的名称属于字符串约定,编译器很难替开发者发现拼写差异。我们的最小 Demo 只保留一个常量值便于人工核对;业务工程可以集中管理路由名称,减少配置与代码各写一份造成的漂移。
页面关联也不承担数据加载的功能。系统识别出 DetailPage 后,详情内容仍要根据 Navigation 参数、状态容器或业务仓库获取。如果详情页只能依靠首页对象的临时引用工作,宽窗口和进程恢复场景都容易出现空白。把路由参数控制在可序列化、可校验的 ID 或查询条件范围内,会让这个最小链路更容易扩展。配置已经说明系统怎样识别页面,页面先后顺序和返回关系仍要回到 Navigation。

二、Navigation 只维护一条普通路由栈
EasyGo 已经确定页面怎样进入左右区域,Navigation 还要提供唯一而稳定的页面顺序。页面创建一个 NavPathStack,并让 Navigation 和它一一对应,pageMap 再根据名称构建两个 NavDestination。
一个 Navigation 对应一个 NavPathStack,这个约束决定了示例中的首页、详情和二级详情共享同一条返回路径。初学者有时会在每个子页面重新创建栈对象,按钮仍然可以执行,返回行为却落到另一条空栈上。示例使用 @Provide 和 @Consume 传递同一个实例,目的正是让入栈、返回一级和清空回首页作用于同一份状态。
@Provide('pageStack')
pageStack: NavPathStack = new NavPathStack()
@Builder
pageMap(name: string) {
if (name === 'DetailPage') {
DetailPage()
} else if (name === 'MorePage') {
MorePage()
}
}
// 首页点击按钮后执行
let param: RouteParam = new RouteParam()
param.title = '商品详情 A'
this.pageStack.pushPathByName('DetailPage', param)
这段代码让首页、详情页和二级详情页共享了同一个栈,同时没有创建任何左右容器。首页把 DetailPage 放到栈顶,参数对象使用显式类型,避开 arkts-no-untyped-obj-literals。同一条栈确认后,DetailPage 才继续把 MorePage 入栈:
this.pageStack.pushPathByName('MorePage', param)
this.pageStack.pop()
this.pageStack.clear()
两个子页面通过 @Consume 使用同一个 NavPathStack,因此 pop() 只移除当前栈顶,clear() 清空全部 NavDestination 节点并回到 NavBar。为了让模拟器画面只受 EasyGo 影响,Navigation 继续保持普通单栏栈模式:
.mode(NavigationMode.Stack)
.title('首页')
.navDestination(this.pageMap)
到这里,路由代码只能产生单页覆盖和正常返回。满足 EasyGo 条件后若出现左右区域,就可以确认分栏来自系统平行视界。页面文件中没有 Row、SideBarContainer 或 NavBar 双栏代码。
实际业务还要分别处理路由返回和业务状态关闭。例如用户在 MorePage 修改了草稿,pop() 只改变页面栈,草稿是否保留取决于状态存放位置;clear() 也不会自动清除全局选择或缓存。最小 Demo 用静态文字排除了这类干扰,接入真实数据时应给离开页面、返回首页和重新进入分别定义预期。
如果现有项目已经设置 NavigationMode.Auto 或 NavigationMode.Split,建议在复现阶段临时回到 Stack,再观察 EasyGo 是否单独生效。这样做不会决定最终产品方案,只是减少证据混杂。确认平行视界链路以后,再恢复项目原有模式并完成组合回归。
三、观察系统如何跳转页面
配置链路与路由链路都已经固定了,运行阶段只需要让两者在同一个宽窗口里合流。最小操作路径是 首页 → DetailPage → MorePage,两次跳转足以观察左栏是否保留,以及右栏是否跟随栈顶变化。
当前 EasyGo 没有显式设置 mode。API 26 的 mode 默认值为 1,对应导航模式。导航模式为保持左侧主页,路由跳转继续发生在右侧。我们的当前 Demo 只确认最小页面链路,不引入选中状态等额外变量。
大家观察画面时需要同时核对三项结果:左栏内容来自首页,右栏标题随栈顶页面变化,返回按钮按 MorePage→DetailPage→首页的顺序工作。若左栏出现手写列表,或两栏在窄窗口中仍强制存在,当前画面可能混入应用自己的分栏实现。
操作到 MorePage,观察首页是否保留在左侧,MorePage 是否位于右侧,中间是否出现系统分隔区域,并检查 NavDestination 标题栏与返回按钮。

这份证据已经足够证明最小链路在当前 API 26 Tablet 模拟器中可见,也给后续实验提供了对照组。修改模式、比例或页面关系以后,只要最小组仍能复现,就可以把新增异常收敛到本次改动。若最小组也失效,应优先检查 entry 配置、资源打包和运行窗口条件。
构建阶段已经确认以下结果:
| 检查项 | 状态 | 证据范围 |
|---|---|---|
| ArkTSCheck | PASS | CompileArkTS 阶段没有类型与语法诊断 |
| CompileArkTS | PASS | API 26 源码完成编译 |
| PackageHap / assembleHap | PASS | 生成 unsigned HAP |
| EasyGo 资源 | PASS | 打包后的 module 包含 easyGo=$profile:easy_go |
| Tablet 视觉 | PASS,人工 | 只覆盖截图中的首页 + MorePage 状态 |
| 真机 | 待验证 | 没有扩大模拟器结论 |
这张表把证据分成编译、打包和视觉三层。编译通过说明代码能被 API 26 SDK 接受,打包通过说明 EasyGo 进入产物,人工截图才说明当前 Tablet 窗口出现了预期区域。三层结果合在一起,最小 Demo 才能作为后续参数实验的稳定对照组。
总结
普通 Navigation 工程接入最小平行视界时,可以保留原有首页、DetailPage、MorePage 和同一条 NavPathStack。module.json5 只负责找到 EasyGo,easy_go.json 再声明 navigationSplit、主页与关联页,系统由此获得宽窗口中的页面组织规则。
我目前手里还没有可以测试 HarmonyOS 7 的真机,所以相关内容现阶段主要通过 HarmonyOS 7 模拟器进行验证,真机上的系统表现、设备差异和实际体验,后面有条件再继续补测,最终还是以实际设备运行结果为准。
完整代码
Index.ets
class RouteParam {
title: string = ''
}
@Entry
@Component
struct Index {
@Provide('pageStack')
pageStack: NavPathStack = new NavPathStack()
@Builder
pageMap(name: string) {
if (name === 'DetailPage') {
DetailPage()
} else if (name === 'MorePage') {
MorePage()
}
}
build() {
Navigation(this.pageStack) {
Column({ space: 20 }) {
Text('平行视界验证')
.fontSize(30)
.fontWeight(FontWeight.Bold)
Text('当前页面:首页')
.fontSize(20)
.fontColor('#333333')
Text('点击按钮进入详情页,观察平行视界生效以后首页和详情页的位置变化。')
.fontSize(16)
.fontColor('#666666')
.lineHeight(24)
.width('100%')
Button('打开详情页')
.width('100%')
.height(52)
.onClick(() => {
let param: RouteParam = new RouteParam()
param.title = '商品详情 A'
this.pageStack.pushPathByName('DetailPage', param)
})
Column({ space: 10 }) {
Text('观察重点')
.fontSize(18)
.fontWeight(FontWeight.Medium)
Text('① 首页进入详情页时是否出现左右分栏')
.fontSize(15)
.width('100%')
Text('② 首页是否继续保留')
.fontSize(15)
.width('100%')
Text('③ 详情页出现在哪个区域')
.fontSize(15)
.width('100%')
Text('④ 二级详情继续打开时页面怎样推进')
.fontSize(15)
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor('#F2F3F5')
.borderRadius(16)
}
.width('100%')
.height('100%')
.padding(24)
.alignItems(HorizontalAlign.Start)
}
// 固定 Stack,避免 Navigation 自己进入普通 Split 模式。
.mode(NavigationMode.Stack)
.title('首页')
.navDestination(this.pageMap)
.width('100%')
.height('100%')
}
}
@Component
struct DetailPage {
@Consume('pageStack')
pageStack: NavPathStack
build() {
NavDestination() {
Column({ space: 20 }) {
Text('详情页')
.fontSize(30)
.fontWeight(FontWeight.Bold)
Text('当前页面:DetailPage')
.fontSize(20)
.fontColor('#333333')
Text('这个页面用于观察打开第一层详情后的页面位置。')
.fontSize(16)
.fontColor('#666666')
.lineHeight(24)
.width('100%')
Column({ space: 8 }) {
Text('商品 A')
.fontSize(22)
.fontWeight(FontWeight.Medium)
Text('价格 ¥299')
.fontSize(18)
Text('第一层详情内容')
.fontSize(15)
.fontColor('#666666')
}
.width('100%')
.padding(20)
.backgroundColor('#F2F3F5')
.borderRadius(16)
Button('继续打开二级详情')
.width('100%')
.height(52)
.onClick(() => {
let param: RouteParam = new RouteParam()
param.title = '商品详情 B'
this.pageStack.pushPathByName('MorePage', param)
})
Button('返回上一层')
.width('100%')
.height(52)
.buttonStyle(ButtonStyleMode.TEXTUAL)
.onClick(() => {
this.pageStack.pop()
})
}
.width('100%')
.height('100%')
.padding(24)
.alignItems(HorizontalAlign.Start)
}
.title('详情页')
}
}
@Component
struct MorePage {
@Consume('pageStack')
pageStack: NavPathStack
build() {
NavDestination() {
Column({ space: 20 }) {
Text('二级详情页')
.fontSize(30)
.fontWeight(FontWeight.Bold)
Text('当前页面:MorePage')
.fontSize(20)
.fontColor('#333333')
Text('这个页面用于观察连续打开详情时,左右区域以及返回栈怎样变化。')
.fontSize(16)
.fontColor('#666666')
.lineHeight(24)
.width('100%')
Column({ space: 8 }) {
Text('商品 B')
.fontSize(22)
.fontWeight(FontWeight.Medium)
Text('价格 ¥399')
.fontSize(18)
Text('第二层详情内容')
.fontSize(15)
.fontColor('#666666')
}
.width('100%')
.padding(20)
.backgroundColor('#F2F3F5')
.borderRadius(16)
Button('返回详情页')
.width('100%')
.height(52)
.onClick(() => {
this.pageStack.pop()
})
Button('返回首页')
.width('100%')
.height(52)
.buttonStyle(ButtonStyleMode.TEXTUAL)
.onClick(() => {
this.pageStack.clear()
})
}
.width('100%')
.height('100%')
.padding(24)
.alignItems(HorizontalAlign.Start)
}
.title('二级详情页')
}
}
module.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
{
"common": {
"displayModeOptions": {
"wideWindowMode": "navigationSplit",
"squareWindowMode": "navigationSplit",
"navigationSplitOptions": {
"homePage": "navBar",
"relatedPage": "DetailPage"
}
}
}
}
更多推荐



所有评论(0)