JsonPulse 系列已经在上一轮收口,这一轮我把主线切到 HarmonyOS 7 平行视界。

新 Demo 叫 TwinShelf,是一个内容阅读应用:左侧是文章目录,右侧是文章详情。手机上它仍然是普通的“列表 → 详情”单栏路由;平板、折叠屏展开态或大窗口进入平行视界后,希望列表持续留在左边,右边显示当前文章,而且返回、滚动、收藏和选中态都不能乱。

这个系列固定 6 篇,不中途改 X:

01 ParallelView × Navigation:单栈主从分栏、默认详情与选中态连续
02 NavigationMode × Split Ratio:1:2 / 2:1 分栏比例、右栏连续路由与返回收口
03 Transition Page × Fullscreen:过渡页、临时全屏预览与分栏恢复
04 Landscape × Virtual Container:横屏请求、虚拟容器与大屏形态恢复
05 In-App Split × Global Route:平行世界中的应用内分屏与深链跳转
06 Metrics × Regression:折叠屏、平板、路由、比例与生命周期回归

HarmonyOS 7 当前官方示例已经以 Navigation 路由实现购物类应用,并通过配置实现平行视界,覆盖主页与关联页、路由模式、过渡页面、临时全屏、横屏、虚拟容器和应用内分屏等典型场景。官方多设备适配入口也把平行视界定义为适用于购物浏览、内容阅读的大屏应用内分屏体验。

01 先不碰全屏和横屏,只把最基础的一条链做稳:

同一个 NavPathStack 管业务路由,系统负责分栏展示;应用自己维护选中态和详情状态。

本轮统一数据:

taskId:
parallel_20261003_01

project:
TwinShelf

page:
HomePage.ets

window:
1280 × 800vp

navMode:
AUTO → SPLIT

navBarWidth:
360vp

contentWidth:
920vp

articleCount:
36

selectedArticleId:
article_042

selectedArticleTitle:
ArkUI Navigation 状态机

pathStackDepth:
2

detailScrollOffset:
612vp

routePushes:
1

duplicateRouteBlocked:
2

modeChangeCount:
1

selectionRetained:
true

contentRebuildCount:
0

status:
PARALLEL_READY

一、平行视界不是“再写一套双栏页面”

最开始我也想过最直接的做法:

手机:
ArticleListPage → ArticleDetailPage

平板:
LeftArticleList + RightArticleDetail

看起来很快,问题是很快就会出现两套状态:

手机路由状态一套;
平板选中态一套;
详情滚动位置一套;
返回逻辑又一套。

真正进入折叠、展开、窗口缩放以后,两套页面会越来越难同步。

TwinShelf 最后坚持一个原则:

业务层只维护一条 Navigation 路由;平行视界改变的是“页面怎么摆”,不是“业务页面是什么”。

HarmonyOS 当前 Navigation 支持 Stack / Split / Auto / AUTO_WITH_ASPECT_RATIO 等模式,NavPathStack 继续负责页面栈;分栏时导航栏固定显示,内容区由 NavDestination 切换。

二、主页先用一个 NavPathStack,而不是左右各建一个栈

第一段代码解决的是最容易过度设计的问题:不要为了左右两栏就先创建 leftStack / rightStack。

@Entry
@Component
struct HomePage {
  @State
  pathStack:
    NavPathStack =
      new NavPathStack()

  @State
  actualMode:
    NavigationMode =
      NavigationMode.Stack

  @State
  selectedArticleId:
    string =
      'article_042'

  @State
  detailScrollOffset:
    number =
      612

  build() {
    Navigation(this.pathStack) {
      this.ArticleList()
    }
    .mode(
      NavigationMode.Auto
    )
    .navBarWidthRange([
      320,
      420
    ])
    .minContentWidth(520)
    .splitPlaceholder(
      this.EmptyDetail
    )
    .onNavigationModeChange(
      (mode:
        NavigationMode) => {
        this.actualMode =
          mode
      }
    )
    .navDestination(
      this.PageMap
    )
  }
}

这里真正决定业务栈的仍然只有:

pathStack

当前宽屏下:

Auto
→ Split

onNavigationModeChange 只记录实际生效模式,不在回调里重新创建页面。

三、splitPlaceholder 只是空态,不是“默认详情页”

HarmonyOS 当前 Navigation 分栏开发文档特别说明:

splitPlaceholder 只在分栏模式且内容栈为空时显示,它只是 UI 占位,不受路由栈管理,也不响应焦点和路由生命周期。

所以 TwinShelf 不把:

article_042

做成 Placeholder。

真正默认详情仍然进入 NavPathStack。

这样用户后续返回、深链、收藏时,article_042 是一条真正的业务路由,而不是一张假的右栏占位图。

四、默认详情要由业务状态触发,而且只入栈一次

第一版我在:

aboutToAppear
onNavigationModeChange
窗口恢复

三个地方都写了“如果是 Split 就打开默认文章”。

结果页面看起来没问题,栈里却出现:

ArticleDetail(article_042)
ArticleDetail(article_042)
ArticleDetail(article_042)

用户返回三次还是同一篇,像系统 Back 失效。

所以 01 新增统一入口。

class RouteCoordinator {
  constructor(
    private stack:
      NavPathStack
  ) {}

  openArticle(
    articleId:
      string
  ): boolean {
    const routes =
      this.stack.getPathStack()

    const top =
      routes.length > 0
        ? routes[
            routes.length - 1
          ]
        : undefined

    const topId =
      (top?.param as
        Record<string, Object>)
        ?.articleId as string

    if (
      top?.name ===
        'ArticleDetail' &&
      topId === articleId
    ) {
      return false
    }

    this.stack.pushPath({
      name:
        'ArticleDetail',
      param: {
        articleId
      }
    })

    return true
  }
}

本轮我故意连续点击两次同一文章。

最终:

routePushes=1
duplicateRouteBlocked=2

右栏只保留一个 article_042。

五、选中态不能靠“右栏当前显示谁”反推

大屏里左栏会一直存在。

如果选中态只靠:

NavPathStack 栈顶

反推,很容易出现二级详情把左栏高亮带跑。

TwinShelf 单独维护:

selectedArticleId=article_042

它表达:

左侧用户当前主选择

而不是:

右栏最上面现在显示什么

这两个概念 02 会更明显:右栏可以继续打开 related_107、related_205,但左侧仍然应该高亮 article_042。

六、手机 Stack 模式也复用同一个 openArticle

这一点很重要。

手机上:

NavigationMode.Auto
最终可能是 Stack

点击 article_042 后还是:

pushPath ArticleDetail

大屏 Split 模式也是同一个操作。

变化只在系统如何展示这条路由。

这让 TwinShelf 不需要:

if phone
  push detail

if tablet
  set right pane

业务逻辑可以保持一份。

七、详情滚动位置属于文章状态,不属于 Split 布局

本轮详情已经滚到:

612vp

窗口从窄屏切到 1280vp 以后:

detailScrollOffset=612vp

仍然保留。

TwinShelf 把它放进:

ArticleSessionStore

而不是:

RightPaneState

因为用户正在读到哪里,是文章状态。

是否显示在右栏,只是展示方式。

八、Navigation Auto 切 Split 时不要在生命周期里补 Push

HarmonyOS 当前 Navigation 文档不建议在 aboutToAppear 中随意管理自己的导航控制器,页面还未完全构建时可能出现白屏或跳转失败。

TwinShelf 也把路由入口统一放在:

用户动作
RouteCoordinator
明确的状态恢复入口

而不是每次页面出现就“检查一下,要不要 push”。

这个做法对平行视界尤其重要,因为单双栏切换本身就可能导致页面显示关系变化。

九、36 篇文章只加载一次,分栏不是重新请求数据

左栏:

articleCount=36

进入 Split 后没有重新拉取。

当前:

contentRebuildCount=0

也就是说:

列表数据
选中项
详情缓存

都来自同一 ViewModel。

平行视界不是“打开第二个应用实例”。

如果双栏一出现就重新请求一份详情和列表,用户会明显感到闪动。

十、DevEco 图要看的是“同一栈、同一文章、同一滚动位置”

开发图:

HiLog 固定输出:

taskId=
parallel_20261003_01

window=
1280x800vp

mode=
SPLIT

navBarWidth=
360vp

contentWidth=
920vp

selected=
article_042

pathStackDepth=
2

detailScrollOffset=
612vp

duplicateRouteBlocked=
2

contentRebuildCount=
0

status=
PARALLEL_READY

这比只看“左边有列表、右边有详情”更能证明工程状态是连续的。

十一、运行图为什么把列表和详情同时放在纯手机截图里

运行图:

它是 TwinShelf 的诊断页,展示本次大屏运行快照,不是假装手机真实拥有 1280vp 宽窗口。

页面明确记录:

1280×800vp
AUTO → SPLIT
article_042
NavPathStack depth=2

还保留:

selectedArticleId
detailScrollOffset
duplicateRouteBlocked
contentRebuildCount

最终:

PARALLEL_READY

十二、平行视界和 Navigation Split 要分清职责

HarmonyOS 当前官方平行视界示例,是以 Navigation 路由应用为载体,通过配置实现平行视界效果。

而 Navigation 自己也具备:

Stack / Split / Auto
NavPathStack
navBarWidthRange
minContentWidth
splitPlaceholder

等能力。

TwinShelf 的理解是:

平行视界:
系统级大屏页面关系与交互体验;

Navigation:
应用自己的路由栈和页面状态。

项目不要把“系统分栏”误写成一套业务路由系统。

十三、01 最后固定七组反向测试

第一组,手机窄窗进入 Stack,列表 → 详情正常。

第二组,1280vp 宽窗进入 Split,列表与详情同屏。

第三组,默认文章只入栈一次。

第四组,连续点 article_042 两次,不重复压栈。

第五组,单双栏切换以后 selectedArticleId 不变。

第六组,详情滚动位置保持 612vp。

第七组,内容组件没有因为 Split 切换重新创建。

全部通过以后:

PARALLEL_READY

才成立。

十四、下一篇真正进入“右栏继续走路由”

01 右侧只有:

ArticleDetail(article_042)

但真实阅读应用会继续点击:

相关推荐
作者
专题
引用文章

右栏栈会继续变深。

HarmonyOS 7 当前平行视界还新增了 1:2、2:1 分栏能力。

02 会让 TwinShelf 在:

1:2
→ 2:1
→ 1:2

三种比例之间切换,同时右栏路由经历:

article_042
→ related_107
→ related_205
→ back
→ back

最终必须回到原来的 article_042,左栏选中态仍然不变。

十五、单栈方案的价值在“路由语义只有一份”

把整篇压缩成一句话,就是:

分栏由系统表现;
路由由 NavPathStack 维护;
选中态由业务 Store 维护。

这三层不互相抢职责以后,TwinShelf 才有机会在后续加入临时全屏、横屏和虚拟容器,而不用每次都重新发明返回逻辑。

第一篇先把单栈主从关系固定下来,是整个系列后面继续演进的基础。

十六、模式回调只记录“实际生效结果”,不要拿它当第二个路由入口

onNavigationModeChange 会在 Navigation 首次显示以及单双栏发生变化时回调。

TwinShelf 只做:

记录 Stack / Split;
更新诊断状态;
必要时刷新大屏辅助 UI。

不会在这里:

pushPath
replacePath
重新请求文章
重新设置 selectedArticleId

原因很简单:模式回调属于“展示结果”,路由跳转属于“业务动作”。

如果两边都能修改栈,很快就会出现:

用户点击 article_042
→ push 一次

系统切 Split
→ 回调再 push 一次

恢复窗口
→ 回调又 push 一次

然后页面肉眼正常,返回栈却越来越深。

因此 01 里 modeChangeCount=1 只是观测值,和 routePushes=1 是两套完全独立的统计。

十七、主页与关联页的配置属于系统分栏关系,不应该复制一份到业务 Store

HarmonyOS 7 官方平行视界示例会配置:

主页
关联页
路由模式
过渡页
全屏页
横屏请求
虚拟容器

这些信息负责告诉系统:

哪些页面适合形成分栏关系。

TwinShelf 业务层不再维护一份:

leftPage=ArticleList
rightPage=ArticleDetail

因为那会和系统配置形成第二个来源。

应用只维护:

selectedArticleId
NavPathStack
ArticleSessionStore

当系统把当前 Navigation 关系投影成左右区域时,业务状态自然跟过去。

这种“配置描述页面关系、Navigation 描述业务历史”的分层,是后面做临时全屏恢复的前提。

十八、深链进入详情时也要先归一化路由栈

真实应用不会永远从首页打开。

例如外部链接直接进入:

TwinShelf/article_042

手机窄窗下,希望直接显示详情;

大屏下,希望左侧仍有文章目录,右侧显示 article_042。

所以 TwinShelf 不把 Deep Link 处理成:

完全独立的 DetailPage 启动。

而是先把业务状态归一化:

restoreFromDeepLink(
  articleId: string
): void {
  this.selectedArticleId =
    articleId

  const expected: NavPathInfo[] = [
    {
      name: 'ArticleDetail',
      param: {
        articleId
      }
    }
  ]

  this.pathStack.setPathStack(
    expected,
    false
  )
}

setPathStack 当前从 API 19 开始可用。

它适合这种“我已经知道最终合法栈应该长什么样”的恢复场景。

这样无论当前展示是 Stack 还是 Split,业务入口都是同一篇文章。

十九、分栏以后两侧生命周期更值得关注,而不是猜“是不是创建了两个页面”

平行视界视觉上像:

左边一个页面
右边一个页面

很容易让人产生:

应用是不是同时运行两套页面实例

这种理解。

TwinShelf 更关注实际组件生命周期与路由状态:

ArticleList 是否重复请求;
ArticleDetail 是否重复 aboutToAppear;
ViewModel 是否被重建;
监听器是否重复注册。

01 最终 contentRebuildCount=0 只是第一层。

后续回归还会继续统计:

listLoadCount
detailLoadCount
listenerCount

避免视觉分栏掩盖生命周期问题。

二十、默认详情不是强制要求,而是当前阅读产品的选择

Navigation Split 在内容栈为空时完全可以展示:

splitPlaceholder

例如:

“请从左侧选择一篇文章”

TwinShelf 当前选择启动后直接选中 article_042,是因为阅读首页希望大屏第一屏就有内容。

如果换成:

邮箱
文件管理
设置

默认详情策略可能完全不同。

所以:

article_042

不是平行视界要求的“系统默认页”,而是 TwinShelf 当前业务默认项。

项目策略和系统能力一定要分开写清楚。

二十一、01 的工程基线会直接被 02 复用

02 不会重新创建:

另一份列表
另一套 Navigation
另一套 selected state

它只在 01 基线之上增加:

比例变化;
右栏连续路由;
Back 收口。

如果后面发现 02 返回异常,先验证:

01 的单栈、默认详情、选中态是否仍然成立。

这也是连载式 Demo 比每篇重新造工程更容易定位问题的地方。

二十二、最后再补一条:左栏内容本身也要允许独立滚动

平行视界里左侧列表持续可见,并不意味着它应该被右侧详情的滚动行为影响。TwinShelf 让文章目录和详情正文各自持有 Scroll 状态,左栏滚动锚点由 ArticleListStore 保存,右栏则继续使用 detailScrollOffset=612vp。这样用户在左侧翻到“性能优化实践”附近,再到右侧阅读长文,两边都能保持自己的当前位置。后续从 Split 回到 Stack 时,也只恢复当前实际可见页面对应的滚动状态,不会用一个全局 ScrollController 同时驱动两边。

这类细节看起来不属于“平行视界 API”,却会直接决定分栏是不是像真正的大屏产品。系统解决的是显示关系,列表、详情各自的交互状态仍然需要应用自己设计清楚。

参考资料

  • HarmonyOS 7 平行视界官方示例:
    https://developer.huawei.com/consumer/cn/samples/
  • Navigation 分栏开发:
    https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode
  • Navigation / NavPathStack API:
    https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-basic-components-navigation
  • 多设备通用适配指南:
    https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐