前两期把 DualCart 的基础购物链路做稳定了。

01 解决的是 ProductList | ProductDetail 怎么成为一组稳定的双栏关系;02 又把 ProductBridge 这种过渡页从可见分栏里剥离掉,保证右栏切换商品时只替换详情,不污染左侧列表状态。

做到这里,普通购物浏览已经比较顺。

真正继续点“商品视频”以后,新的工程矛盾很明显:视频不适合被困在右侧 520vp 的详情栏里。

当前官方“平行视界”示例把“路由跳转过程请求全屏”和“路由跳转过程请求横屏显示”都列为典型场景。也就是说,大屏浏览虽然默认是双栏,但某些沉浸页面本来就应该暂时离开双栏。与此同时,HarmonyOS 当前窗口沉浸式和旋转能力也要求应用根据场景处理全屏、系统栏和方向变化。

这一篇就只处理这件事:

ProductList | ProductDetail
→ ProductVideo 全屏横屏
→ 退出视频
→ ProductList | ProductDetail

本轮统一数据:

taskId:
parallel_20261002_03

viewport:
824vp

before:
ProductList | ProductDetail

immersiveRoute:
ProductVideo

product:
product_1047 / MateBook Air

orientation:
LANDSCAPE

fullscreen:
true

leftScrollOffset:
960vp

enterCost:
53ms

exitCost:
47ms

after:
ProductList | ProductDetail

status:
RESTORED

一、不是所有关联页都应该继续占右栏

第二篇已经把路由分成:

PRIMARY
ASSOCIATED
TRANSITION
FULLSCREEN

现在 ProductVideo 正式进入 FULLSCREEN。

这一步很重要,因为商品详情本身是关联页:

ProductList
|
ProductDetail

但商品视频的内容目标完全不同。

详情页希望用户:

看规格
看价格
看评价
继续切商品

视频页希望用户:

把视觉注意力集中到播放内容
尽量减少周围 UI 干扰
必要时横屏

如果继续硬塞右栏,最终只能看到一块窄视频,既浪费大屏,也不符合沉浸内容的使用预期。

所以 DualCart 的规则不是:

进入平行视界后
所有后续页面都保持双栏

而是:

浏览型路由
→ 双栏

沉浸型路由
→ 临时全屏

退出沉浸路由
→ 恢复原双栏上下文

真正难的不是进入全屏,而是回来以后左右两栏必须和进入前一致。

二、进入全屏以前,先保存“成对状态快照”

这一篇新增 ImmersiveRouteCoordinator.ets。

进入视频页前不直接改 Window,而是先保存当前平行视界上下文:

export interface PairedStateSnapshot {
  taskId: string

  leftRoute: string
  rightRoute: string

  selectedProductId: string
  categoryId: string
  leftScrollOffsetVp: number
}

export class ParallelStateStore {
  snapshot(): PairedStateSnapshot {
    return {
      taskId: 'parallel_20261002_03',
      leftRoute: 'ProductList',
      rightRoute: 'ProductDetail',
      selectedProductId: 'product_1047',
      categoryId: 'computer_office',
      leftScrollOffsetVp: 960
    }
  }
}

这段代码解决的是“视频退出后只知道回 ProductDetail,却不知道左侧浏览到了哪里”的问题。

保存的不是组件实例,也不是 Window 对象。

真正需要恢复的是:

左栏路由
右栏路由
当前商品
分类
列表滚动位置

和 InsightBoard 那条多形态连载里一样,业务状态应该独立于 UI 容器生命周期。

三、全屏路由只通过 Coordinator 切换,不让视频页自己改双栏

第一版里,我把“隐藏双栏”“请求横屏”“打开视频页”都写进了 ProductVideoPage.aboutToAppear()。

看起来集中,实际很危险。

因为页面本身并不知道:

它从普通手机详情进来
还是从平行视界右栏进来

如果视频页无条件修改全局双栏状态,手机路径也会被一起影响。

所以现在由 ImmersiveRouteCoordinator 决定:

export interface ImmersiveWindowAdapter {
  enterFullscreenLandscape(): Promise<void>
  leaveFullscreenRestore(): Promise<void>
}

export class ImmersiveRouteCoordinator {
  private saved:
    PairedStateSnapshot | null = null

  constructor(
    private windowAdapter:
      ImmersiveWindowAdapter
  ) {}

  async enterProductVideo(
    productId: string
  ): Promise<void> {
    this.saved =
      ParallelStateStore.shared()
        .snapshot()

    await this.windowAdapter
      .enterFullscreenLandscape()

    ProductRouteStack.shared()
      .push('ProductVideo', {
        productId,
        source: 'parallel_detail'
      })
  }

  async exitProductVideo():
    Promise<void> {
    await this.windowAdapter
      .leaveFullscreenRestore()

    if (this.saved !== null) {
      await ParallelRouteManager.shared()
        .restorePairedState(
          this.saved
        )
    }

    this.saved = null
  }
}

这里的 ImmersiveWindowAdapter 是 DualCart 自己的适配层。

它内部再对接当前 HarmonyOS Window / 方向能力以及平行视界官方示例里的“请求全屏 / 请求横屏”配置。这样系统配置字段有变化时,业务路由不需要跟着改。

四、全屏和横屏不是同一个状态

实际测试时我一开始把两个状态合并成:

immersive = true

后来发现不够用。

可能存在:

全屏但仍保持竖屏
横屏但没有隐藏全部系统 UI
从横屏退出时窗口已经回来了,但方向还没恢复

所以现在显式拆开:

export interface ImmersiveState {
  fullScreen: boolean
  orientation:
    'PORTRAIT' |
    'LANDSCAPE' |
    'AUTO'
  route: string
}

export class ImmersiveStateStore {
  private state:
    ImmersiveState = {
      fullScreen: false,
      orientation: 'AUTO',
      route: ''
    }

  enterVideo(): void {
    this.state = {
      fullScreen: true,
      orientation: 'LANDSCAPE',
      route: 'ProductVideo'
    }
  }

  leaveVideo(): void {
    this.state = {
      fullScreen: false,
      orientation: 'AUTO',
      route: ''
    }
  }
}

这段代码解决的是“退出视频以后双栏恢复了,但页面还保持横屏语义”的问题。

窗口恢复和方向恢复都完成以后,状态才真正算退出沉浸模式。

五、ProductVideo 页只负责媒体,不再知道平行视界细节

现在视频页职责很单纯:

@Entry
@Component
struct ProductVideoPage {
  @State
  private productId: string =
    'product_1047'

  @State
  private positionSec: number = 24

  aboutToAppear(): void {
    ProductVideoSession.shared()
      .prepare(this.productId)
  }

  aboutToDisappear(): void {
    ProductVideoSession.shared()
      .pause()
  }

  build() {
    Column() {
      VideoSurface({
        productId: this.productId,
        positionSec: this.positionSec
      })
    }
    .width('100%')
    .height('100%')
  }
}

页面里没有:

leftRoute
rightRoute
splitRatio
scrollOffset

这些全属于外部上下文。

这样 ProductVideoPage 以后从普通手机页进入,仍然可以复用,不会因为“它曾经属于平行视界”而带着一堆双栏判断。

六、视频退出时,恢复顺序比进入顺序更重要

我第一版退出顺序是:

恢复 ProductList | ProductDetail
→ 再退出全屏
→ 再恢复方向

视觉上会出现一瞬间:

横屏里的窄双栏

后来改成:

停止沉浸内容
→ 退出全屏
→ 方向恢复 AUTO
→ 等 Window 稳定
→ 恢复双栏 route
→ 恢复 selected / scroll

这样更干净。

当前测试数据:

enterCost=53ms
exitCost=47ms

这两个数字是 DualCart 当前真机 / 当前测试版本的工程观测,不是系统性能指标。

我把 exit 单独记,是因为真正容易卡顿的往往不是“进入视频”,而是“退出后两个 Pane 同时恢复”。

七、selectedProduct 和 leftScrollOffset 必须一起验证

进入视频前:

product_1047
MateBook Air

leftScrollOffset=960vp

退出以后如果右侧仍然是 MateBook Air,但左边列表回顶部,状态恢复仍然不完整。

因此恢复断言同时检查:

export class PairedRestoreAssert {
  verify(
    current: PairedStateSnapshot,
    expected:
      PairedStateSnapshot
  ): boolean {
    return (
      current.leftRoute ===
        expected.leftRoute &&
      current.rightRoute ===
        expected.rightRoute &&
      current.selectedProductId ===
        expected.selectedProductId &&
      current.leftScrollOffsetVp ===
        expected.leftScrollOffsetVp
    )
  }
}

当前:

selectedPreserved=true
scrollPreserved=true

这比只判断“ProductDetail 回来了”更有意义。

八、DevEco 里要看到进入和退出两段日志

开发图:

当前 HiLog:

taskId=parallel_20261002_03
enter immersive ProductVideo
product=product_1047

request fullscreen=true
orientation=LANDSCAPE

save pairedState
ProductList | ProductDetail
scroll=960vp

exit immersive

restore:
ProductList | ProductDetail
selected=product_1047
scroll=960vp

enterCost=53ms
exitCost=47ms
status=RESTORED

如果日志只看到“exit video success”,还不能证明双栏恢复成功。

一定要同时看到:

right route
selected product
left scroll

九、运行图第一次把“进入前 / 全屏中 / 退出后”放在同一条链上

最终运行图:

这一轮三段状态非常清楚:

进入前:
ProductList | ProductDetail

沉浸中:
ProductVideo
LANDSCAPE
fullscreen=true

退出后:
ProductList | ProductDetail
product_1047
scroll=960vp

最终状态:

RESTORED

这才是 03 真正想解决的问题。

不是“视频能全屏”,而是“全屏以后还能准确回到原来的购物上下文”。

十、高清大图和商品视频为什么先共用同一个沉浸策略

商品详情还有“查看高清大图”。

从业务上看,它和视频不一样;从平行视界角度看,两者有同一个特征:

短时间离开双栏
要求最大展示面积
退出后回原商品

所以我没有再写一套 GalleryFullscreenManager。

当前统一走:

ImmersiveRouteCoordinator

区别只在:

route=ProductVideo
route=ProductGallery

媒体 Session 各自管理自己的资源。

这样“平行视界恢复”逻辑只有一套。

十一、当前官方资料给这条链路的边界很明确

HarmonyOS 官方 2026-08-29 的“平行视界”示例明确列出:

路由跳转过程请求全屏
路由跳转过程请求横屏显示

作为七个典型场景的一部分。

与此同时,当前 Window 沉浸式开发文档强调,需要根据设备屏幕特性、窗口和系统界面元素设计沉浸体验;旋转能力也有单独的窗口方向适配指导。

DualCart 这一篇没有伪造新的系统 API,而是把系统能力包在 ImmersiveWindowAdapter 下面,业务层只维护:

进入前状态
沉浸路由
退出后恢复

这样代码更稳定。

十二、下一篇:比价不是再开一个 Detail

03 解决了“暂时离开双栏”。

04 会做另一种相反需求:

不离开当前浏览
反而要同时看两个商品

也就是:

MateBook Air
VS
MatePad Pro

如果直接在主路由里再 push 一个 Detail,很容易污染原来的 ProductList | ProductDetail。

所以下一篇会接官方示例里的“虚拟容器”场景,用一个独立的比较上下文承载双详情,并在关闭比价以后恢复原来的列表 / 详情状态。

十三、真正容易错的是“返回键”和“视频页关闭”走了两条退出路径

做到这一轮时,我又碰到一个很典型的生命周期问题。

视频页里本来有自己的“关闭”按钮,它会调用 exitProductVideo();但系统返回键也能离开视频页。如果这两个入口最终没有走同一套收口逻辑,就会出现两种结果:

点页面关闭:
双栏恢复正常

按系统返回:
路由回来了
但横屏状态没恢复

所以我最后没有让页面按钮自己直接 pop 路由,而是统一发出一个“结束沉浸会话”的动作。

对业务来说,退出视频只有一种语义:

结束媒体展示
恢复 Window / Orientation
恢复成对路由
恢复商品与滚动位置

无论触发源是:

页面按钮
系统返回
播放结束
异常关闭

都进入同一个 Coordinator。

这样做以后,真正的退出条件就不会散落在页面事件里。

十四、进入全屏失败时,不能把已保存的双栏快照丢掉

进入沉浸模式并不是一定成功。

可能出现:

窗口状态正在变化
方向请求未完成
媒体初始化失败
页面路由打开失败

如果保存完 PairedStateSnapshot 以后,后续任意一步报错就把快照清掉,用户会回到一个“不知道该恢复什么”的状态。

现在 DualCart 的处理原则是:

保存快照
→ 进入沉浸流程
→ 全部成功后标记 ACTIVE

中途失败
→ 执行 rollback
→ 恢复原双栏
→ 最后再清快照

我把沉浸状态分成:

IDLE
ENTERING
ACTIVE
EXITING
ROLLING_BACK

这样日志里如果出现 ENTERING → ROLLING_BACK,说明问题发生在进入过程,不会和普通退出混为一谈。

本轮正常路径最终还是:

ENTERING
→ ACTIVE
→ EXITING
→ IDLE

十五、媒体资源释放和双栏恢复也不能抢顺序

视频播放时,播放器本身有一套生命周期。

如果先恢复双栏,再异步释放视频资源,短时间里会同时存在:

ProductList
ProductDetail
ProductVideo player

页面上看不出来,内存峰值却会明显抬高。

现在退出顺序固定为:

pause video
→ detach surface
→ release temporary player resources
→ restore orientation
→ leave fullscreen
→ restore ProductList | ProductDetail

真正需要长期保留的播放历史,只保存:

productId
positionSec

播放器实例不跨页面保存。

这和前面所有状态连续性原则是一致的:保存可重建的数据,不保存沉重的运行对象。

本轮视频停在:

00:24 / 02:18

如果以后需要“再次进入继续播放”,只恢复 24 秒的位置即可,不需要把播放器对象带回双栏。

十六、我给 03 做了六组验收,不只测一次正常进入退出

为了确认这条沉浸链路真的稳定,最后固定做了六组测试。

第一组,从 product_1047 / MateBook Air 进入视频,正常播放 24 秒后退出,要求恢复原商品和 960vp 滚动位置。

第二组,视频刚进入就按返回,验证 ENTERING 阶段也能正确回滚。

第三组,模拟视频资源加载失败,确认页面不会停在黑色全屏,也不会丢失原双栏。

第四组,连续进入 / 退出 10 次,检查方向状态最终都回 AUTO,没有一次残留 LANDSCAPE。

第五组,在视频期间切后台再回来,媒体会话可以暂停,但双栏快照不能被后台生命周期清掉。

第六组,从普通手机单栏进入同一个 ProductVideo,确认它不会误执行“恢复平行视界双栏”的逻辑。

这些测试完成以后,我才把本轮状态写成:

RESTORED

它代表的不是“页面回来”,而是:

Window 恢复
Orientation 恢复
Route 恢复
Product 恢复
Scroll 恢复

五项全部成立。

十七、这一期最重要的取舍,是不让沉浸页面知道自己来自哪种设备

从工程维护角度看,这其实比全屏本身更重要。

如果 ProductVideoPage 里出现:

if 折叠屏
if 平行视界
if 平板
if 手机

那这个页面很快就会变成设备判断中心。

现在设备和窗口差异全部停在外部 Coordinator。

ProductVideo 只知道:

我要播放 product_1047
当前 position=24s
页面可见 / 不可见

这意味着以后 DualCart 加 PC 或普通平板时,视频页仍然是同一份代码。

平行视界只是“如何进入和如何退出”的环境能力,不应该侵入视频业务本身。

这也是我认为 03 真正完成的标志。

十八、恢复完成以后再开放下一次沉浸请求

最后还有一个很小但很实用的保护。

用户退出视频的 47ms 内如果马上再次点击“播放视频”,旧版本可能同时出现:

上一轮正在恢复双栏
下一轮又开始请求全屏

于是 saved snapshot 会被第二次进入覆盖。

现在 Coordinator 增加一个简单状态门:

IDLE
才允许 enter

ENTERING / ACTIVE / EXITING / ROLLING_BACK
都拒绝重复 enter

页面按钮在非 IDLE 状态下也会暂时禁用。

这不是为了限制用户,而是保证每次沉浸会话都有唯一的进入快照和退出收口。对于这种跨 Window、Orientation、Navigation 的组合动作,宁可把状态机做明确,也不要依赖“用户不会点那么快”。

参考资料

  • HarmonyOS 官方示例:平行视界:https://developer.huawei.com/consumer/cn/samples/
  • Navigation 分栏开发:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode
  • 窗口沉浸式:https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/immersive-window-feature
  • Window Rotation:https://developer.huawei.com/consumer/en/doc/harmonyos-guides/rotation
Logo

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

更多推荐