前一条 InsightBoard 连载已经把手机、折叠屏、平板、PC 的响应式布局做完,这次我换了一条明显不同的主线:不再由应用自己画两栏,而是把 Navigation 路由关系整理成平行视界可以识别和利用的页面关系。

Demo 叫 DualCart,是一个购物类应用。手机上仍然按普通页面栈运行;折叠屏展开态和平板大屏场景下,希望用户点击商品以后,左侧继续保留列表,右侧打开详情。

这件事看起来和 Navigation(mode: Split) 很像,但工程目标不一样。

普通 Split 是应用自己决定“现在画成两栏”;平行视界更强调系统级的应用内分屏体验,当前 HarmonyOS 官方示例也采用 Navigation 路由购物应用 + 配置 的方式实现,并覆盖主页关联页、路由模式、过渡页、请求全屏、横屏、虚拟容器以及平行世界下应用内分屏等典型场景。

所以这一期不追求把所有配置一次写完,只先把最短链路固定住:

Home
→ Category
→ ProductDetail

展开大屏后:

左侧:ProductList
右侧:ProductDetail

本轮统一数据:

taskId: parallel_20261002_01

device:
Foldable / Unfolded

viewport:
824vp

leftPane:
304vp

rightPane:
520vp

splitRatio:
37 : 63

routeStack:
Home → Category → ProductDetail

pairedRoutes:
ProductList | ProductDetail

selectedProduct:
product_1042 / Watch 5

status:
PAIRED_READY

一、先把“分栏”理解成路由关系,而不是 Row

最开始我确实想过最简单的方案:

Row() {
  ProductList()
  ProductDetail()
}

页面马上能做出来,而且截图也很好看。

问题出在手机。

手机仍然需要:

列表
→ 点击
→ 跳详情

如果大屏再维护一套 Row 逻辑,就会很快出现两条路由体系:

手机:
Navigation / NavDestination

大屏:
Row / 手动组件切换

同一个商品详情要维护两份进入方式,返回、Deep Link、状态恢复都容易分叉。

所以 DualCart 这一系列把 Navigation 作为唯一业务路由。

大屏只是让系统和配置知道:

哪些页面可以形成主从关系
哪些页面应该落到另一侧

业务本身仍然是一条路由树。

二、Navigation 路由名必须先稳定下来

第一篇我先把路由名字从页面文件名里抽出来。

这段代码解决的是“后面配置写了 ProductDetail,代码却改名成 ProductInfoPage”的问题:

export enum ShopRoute {
  HOME = 'Home',
  CATEGORY = 'Category',
  PRODUCT_LIST = 'ProductList',
  PRODUCT_DETAIL = 'ProductDetail'
}

export interface ProductRouteParam {
  productId: string
  categoryId: string
}

export class ShopRouteNames {
  static all(): string[] {
    return [
      ShopRoute.HOME,
      ShopRoute.CATEGORY,
      ShopRoute.PRODUCT_LIST,
      ShopRoute.PRODUCT_DETAIL
    ]
  }
}

路由名一旦进入平行视界配置,就不应该继续被当作“页面内部字符串”随便修改。

我现在把它看成一种对外契约。

文件名可以从 ProductDetailPage.ets 改成别的,组件可以重构,真正进入配置和 Navigation 的 route name 尽量稳定。

三、系统路由表和业务参数先保持一致

当前 HarmonyOS 的 Navigation 支持系统路由表,通过 router_map.json 配置后可以按需加载页面;官方当前文档也明确推荐 Navigation 作为主路由能力。

DualCart 当前的路由表概念保持简单:

{
  "routerMap": [
    {
      "name": "Home",
      "pageSourceFile": "src/main/ets/pages/HomePage.ets",
      "buildFunction": "HomeBuilder"
    },
    {
      "name": "ProductList",
      "pageSourceFile": "src/main/ets/pages/ProductListPage.ets",
      "buildFunction": "ProductListBuilder"
    },
    {
      "name": "ProductDetail",
      "pageSourceFile": "src/main/ets/pages/ProductDetailPage.ets",
      "buildFunction": "ProductDetailBuilder"
    }
  ]
}

具体字段仍以当前 SDK 和工程路由表要求为准,这里真正想强调的是:

Navigation route name
平行视界配置里的页面语义
日志里的 route name
测试用例里的 route name

要保持同一套名字。

否则调试时会出现“页面明明打开了,但配置没有命中”的错觉。

四、主页和关联页,不等于把所有详情都固定到右侧

第一版策略里我把 Home 看成主页面,把 ProductDetail 看成关联页。

但商品业务还有:

Home
→ Category
→ ProductList
→ ProductDetail

如果简单理解成“详情永远右侧”,Category 和 ProductList 的关系就会变得模糊。

所以应用内部先增加一层 ParallelPolicy,只描述业务角色,不替代系统配置:

export type ParallelRole =
  'PRIMARY' |
  'ASSOCIATED' |
  'NORMAL'

export class ParallelPolicy {
  role(route: string): ParallelRole {
    if (
      route === ShopRoute.HOME ||
      route === ShopRoute.CATEGORY ||
      route === ShopRoute.PRODUCT_LIST
    ) {
      return 'PRIMARY'
    }

    if (route === ShopRoute.PRODUCT_DETAIL) {
      return 'ASSOCIATED'
    }

    return 'NORMAL'
  }

  canPair(
    leftRoute: string,
    rightRoute: string
  ): boolean {
    return (
      this.role(leftRoute) === 'PRIMARY' &&
      this.role(rightRoute) === 'ASSOCIATED'
    )
  }
}

这个 Policy 不是 HarmonyOS 平行视界 API。

它只是 DualCart 自己的业务镜像,用来保证“配置语义”和“业务代码理解”一致。

后面测试时,如果系统显示和 Policy 预期不一致,就可以优先检查配置,而不是把问题全部推给页面。

五、选中商品不能由右侧详情自己拥有

做成双栏以后,我遇到的第一个状态问题不是路由,而是选中态。

左侧点击 product_1042,右侧详情打开。

如果 selectedProductId 只存在 ProductDetail 页里,左侧列表不知道哪一项应该高亮。

反过来,如果列表自己保存,右侧被系统恢复时又可能拿不到。

所以状态上移到 Store:

export class ProductSelectionStore {
  private selectedProductId: string =
    'product_1042'

  get(): string {
    return this.selectedProductId
  }

  select(id: string): void {
    this.selectedProductId = id

    AppStorage.setOrCreate(
      'selectedProductId',
      id
    )
  }
}

左侧和右侧都消费同一个 ID。

这也解释了为什么本轮截图里:

selectedProduct:
product_1042

不属于某一栏,而属于整个购物上下文。

六、点击商品仍然走 Navigation,不写“大屏特例”

列表页点击:

private openProduct(
  productId: string
): void {
  ProductSelectionStore.shared()
    .select(productId)

  this.pathStack.pushPathByName(
    ShopRoute.PRODUCT_DETAIL,
    {
      productId,
      categoryId: this.categoryId
    } as ProductRouteParam
  )
}

没有:

if 平行视界
→ 更新右侧组件

else
→ push 页面

这是第一篇我最在意的地方。

业务动作只有一次。

系统是否把关联页放到右侧,是适配层和配置决定的。

这样手机模式、平板模式、折叠屏模式之间不会产生两套购买链路。

七、分栏比例先记录,不把它写成业务条件

当前运行图里:

left=304vp
right=520vp
split=37:63

这组值是当前 DualCart 在 824vp 视口下的结果,用于调试和视觉验收。

它不是:

if ratio == 37:63
业务才能运行

商品列表只要满足最小可用宽度即可。

详情区域更宽,是因为电商详情需要:

商品图
价格
规格
操作按钮

列表只需要:

缩略图
标题
价格
选中态

比例是体验参数,不应该渗进业务逻辑。

八、DevEco 里重点看“路由对”,不是看两栏是否存在

本轮 DevEco 图:

HiLog 固定记录:

taskId=parallel_20261002_01

route:
Home → Category

paired route:
ProductList | ProductDetail

selected:
product_1042

viewport:
824vp

left:
304vp

right:
520vp

split:
37:63

status:
PAIRED_READY

只看“两栏出来了”是不够的。

如果左边实际是 Category,右边是 ProductDetail,但业务预期要求 ProductList 保持,就说明主从关系还没整理好。

所以日志必须同时记录 route role 和 selected state。

九、运行图第一次证明“一次点击、两种体验”

最终运行图:

当前大屏:

左:
ProductList

右:
ProductDetail(product_1042)

路由栈:
Home → Category → ProductDetail

手机上同一条点击代码仍然进入普通详情页面。

展开设备上,同一条路由在平行视界配置下形成应用内分屏。

这才是我想要的“一次开发”——不是一套页面在不同设备上强行复制布局,而是同一条业务动作被不同设备能力以更合适的方式呈现。

十、第一篇我故意没做“所有页面都并排”

平行视界很容易陷入一个误区:大屏就是能同时放两个页面,于是每个跳转都想占右栏。

实际产品里有很多页面并不适合:

登录
支付
隐私确认
图片沉浸预览
全屏视频
一次性中间处理页

这些会在后面几篇分别处理。

第一篇只建立最稳定的一对:

ProductList
+
ProductDetail

主页、分类、选中态和路由名固定以后,再继续加路由模式才不会越配越乱。

十一、当前官方示例为什么值得作为这条系列的基线

HarmonyOS 官方样例页在 2026-08-29 更新的“平行视界”示例里,明确使用 Navigation 路由的购物类应用 作为载体,并列出七个典型场景:

主页与关联页
路由模式
过渡页面
请求全屏
请求横屏
虚拟容器
平行世界下应用内分屏

这和 DualCart 的业务非常接近。

所以这个系列不沿用旧 Android Activity Codelab 去硬套,而是以当前 HarmonyOS Navigation 示例作为工程基线,再用 EasyGo / 平行视界的配置思路去组织页面关系。

十二、下一篇真正的问题是“中间页到底占不占一栏”

第一篇结束以后,最自然的下一步不是换商品。

而是下面这条路由:

ProductList
→ ProductBridge
→ ProductDetail

ProductBridge 是一个中间过渡页面:

参数补全
埋点
权限检查
跳转整形

它存在,但不应该长期占据右栏。

如果配置和业务都把它当正常关联页,用户会看到:

左边商品列表
右边一闪而过过渡页
再切到商品详情

体验很差。

所以下一篇会专门做路由模式、过渡页语义、右栏替换、左栏滚动与选中态连续性,并继续使用同一个 DualCart,不重建 Demo。

十三、配置层和业务层必须做一次“对表”检查

平行视界适配有一个很实际的风险:代码改了,配置没改;或者配置改了,测试工程还在跑旧路由。

DualCart 现在启动时会做一次轻量校验,把业务侧知道的路由角色输出出来:

export class ParallelConfigValidator {
  validate(
    expectedPairs: Array<[string, string]>
  ): string[] {
    const errors: string[] = []

    expectedPairs.forEach(([left, right]) => {
      if (!ParallelPolicy.shared()
        .canPair(left, right)) {
        errors.push(`${left} -> ${right}`)
      }
    })

    return errors
  }
}

它当然不能替系统验证真正的平行视界配置是否生效,但至少能提前发现应用内部对路由角色的理解已经漂移。

例如业务重构以后把 ProductList 拆成 SearchResult 和 CategoryResult,如果仍然只把旧路由当 PRIMARY,就应该在进入真机联调前先补齐业务角色和测试用例。

我现在把这类问题当成“配置漂移”,而不是页面 Bug。

十四、手机回退路径必须始终可用

适配大屏时最容易发生的一件事,是为了让双栏成立,反而把手机单栏流程改坏。

所以 01 的验收不是只在展开折叠屏上看一遍。

同一个 product_1042 至少跑两条路径:

Phone / COMPACT
ProductList
→ ProductDetail
→ Back
→ ProductList

以及:

Foldable / Unfolded
ProductList | ProductDetail
→ Back from detail
→ ProductList remains

两条路径共用同一个 openProduct()。

如果为了平行视界新增了 openProductForLargeScreen(),我会直接认为架构开始分叉。

这也是为什么第一篇一直把“业务动作只有一次”放在前面说。

十五、右栏首屏不能依赖用户先点击一次

另一个容易忽略的问题是:进入大屏页面以后,左侧有 24 个商品,右侧却是空白。

如果用户必须先点一条,双栏才有内容,第一屏会显得很浪费。

DualCart 当前策略是:

如果已有 selectedProductId
→ 恢复该商品

否则
→ 使用列表首个可用商品

但“默认第一条”只发生在没有历史选中的情况下。

代码示意:

ensureInitialSelection(
  products: Product[]
): string | null {
  const saved = ProductSelectionStore.shared().get()

  if (saved.length > 0 &&
      products.some((item) => item.id === saved)) {
    return saved
  }

  if (products.length === 0) {
    return null
  }

  ProductSelectionStore.shared()
    .select(products[0].id)

  return products[0].id
}

这样列表数据刷新以后,也不会因为第一条商品换了就强制覆盖用户原来的选择。

十六、这一篇的验收清单最终固定成 8 条

为了避免“看着像对了”就结束,我把 01 验收固定成:

1. 手机单栏路由正常
2. 折叠屏展开能形成列表 / 详情组合
3. selectedProduct 左右一致
4. 页面路由名与配置语义一致
5. ProductDetail 不重复创建业务 Store
6. 列表最小宽度下仍可操作
7. 回退后左栏仍保持
8. 不支持平行视界时自动退回普通 Navigation

最后一条很重要。

平行视界是增强体验,不应该成为应用可用性的前置条件。

如果设备形态、系统状态或配置没有进入平行视界,DualCart 必须仍然是一套完整的普通购物应用。

十七、最后再补一条异常路径:列表为空时不要强行建立配对

购物类应用并不是永远有商品。网络失败、筛选结果为空、商品下架,都可能让左侧暂时没有可选项。

如果这时仍然按照“进入大屏就一定要有右栏详情”的逻辑硬推一个 ProductDetail,页面会出现无效 productId,甚至把上一次残留商品继续显示出来。

所以当前策略是:

列表为空
→ 左侧展示空状态
→ 右侧展示占位说明
→ 不创建 ProductDetail 路由

列表恢复
→ 再执行 ensureInitialSelection()

这条边界看起来和 EasyGo 无关,却直接决定平行视界在异常数据场景下会不会留下“左右不一致”的假状态。

真正稳定的双栏不是任何时候都塞满两个页面,而是每一栏都能解释自己为什么存在。

十八、配置生效也要留一个可见的降级标记

DualCart 最后还保留 presentationMode 日志。进入平行视界时记录 PARALLEL_VIEW,普通单栏记录 SINGLE_STACK。这样遇到“大屏上为什么没有双栏”时,可以先确认系统能力和配置是否真的生效,再去查 ProductList 页面。

这能避免把配置问题误判成 ArkUI 渲染问题,也让后续 03、04 的全屏和虚拟容器测试有统一入口。

参考资料

  • HarmonyOS 7 官方示例:平行视界:https://developer.huawei.com/consumer/cn/samples/
  • 2026 年 7 月开发者月刊:平行视界一键适配:https://developer.huawei.com/consumer/cn/monthly/202607
  • HarmonyOS 多设备适配指南:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
  • Navigation 分栏开发:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode
Logo

一站式 AI 云服务平台

更多推荐