平行视界把大屏应用拆成左右两栏以后,我遇到的第一个麻烦不是布局,而是路由:右栏打开附件临时全屏,再返回时,系统看起来回来了,业务路由栈却可能已经丢了一层。

一、这次我从一条“返回错误”日志开始查

Demo 叫 MailDesk,是一个邮件阅读应用。

在大屏设备上,我让它使用平行视界的导航类分栏:左侧邮件列表保持稳定,右侧显示当前邮件详情。当前测试邮件是:

MAIL-20260930-108

分栏模式记为:

NAVIGATION
ratio = 1:2

正常链路很简单:

Inbox → MailDetail

问题出在附件。

用户在 MailDetail 里点击 产品评审材料.pdf,我希望附件进入一个临时全屏 AttachmentPreview。看完以后返回,应该重新回到左右分栏,而且右栏仍然是原来的 MailDetail。

第一版偶尔会出现这种情况:

AttachmentPreview → Inbox

也就是全屏页退出后,右栏详情丢了。

开始我怀疑是平行视界恢复慢,后来对照 HiLog 才发现,真正出错的是我们自己的导航状态:打开全屏页时把“当前页面”覆盖掉了,却没有留下进入全屏前的右栏快照。

HarmonyOS 7 的平行视界面向折叠屏、平板等大屏场景,官方介绍了左右推挤、固定覆盖等分栏方式,也支持 1:1、1:2、2:1 等比例和拖拽调整。能力负责的是分栏体验,但应用自己的 Navigation 路由和业务选中态仍然要管理好。

所以这篇我不展开讲如何配置整套 EasyGo,而只盯住一个具体问题:临时全屏前后,右栏路由怎么不丢。

二、布局状态和路由状态一开始就不能混成一个字符串

我最早写过这样的变量:

@State currentPage: string = 'Inbox'

在单栏页面里勉强够用,到了平行视界就不够了。

因为此时至少同时存在三种信息:

  • 左栏是谁;
  • 右栏是谁;
  • 有没有临时全屏页面覆盖在上面。

如果只用一个 currentPage,打开 AttachmentPreview 后它会变成附件页;等附件页关闭时,代码已经不知道之前右栏是什么。

后来我把状态拆成一个快照。

这段代码解决什么问题:把分栏路由和临时全屏路由拆开记录。

export interface RouteSnapshot {
  routeStack: string[]
  currentRoute: string
  selectedMailId: string
  splitMode: 'NAVIGATION' | 'SHOPPING'
  splitRatio: '1:1' | '1:2' | '2:1'
  fullscreenRoute?: string
}

export class ParallelRouteStore {
  snapshot: RouteSnapshot = {
    routeStack: ['Inbox'],
    currentRoute: 'Inbox',
    selectedMailId: '',
    splitMode: 'NAVIGATION',
    splitRatio: '1:2'
  }

  save(): RouteSnapshot {
    return JSON.parse(JSON.stringify(this.snapshot)) as RouteSnapshot
  }
}

这里 splitMode 是 MailDesk 自己为了调试定义的业务枚举,并不是我在模拟某个官方字段。这样做的目的,是把 EasyGo 分栏策略和应用当前业务页面同时记录下来。

当右栏打开 MailDetail 时,快照变成:

routeStack = [Inbox, MailDetail]
currentRoute = MailDetail
selectedMailId = MAIL-20260930-108
splitMode = NAVIGATION
splitRatio = 1:2

到这里,页面到底长什么样已经可以从状态推回来。

三、临时全屏之前,先保存“返回以后要恢复什么”

附件预览是一次特殊跳转。

它和普通右栏详情跳转不一样:用户只是暂时离开分栏场景,并没有放弃当前邮件上下文。

所以我在打开附件前先保存当前快照。

这段代码解决什么问题:进入全屏附件前保存右栏路由,避免返回时只能猜。

export class ParallelRouteController {
  private stack: string[] = ['Inbox']
  private lastSplitSnapshot?: RouteSnapshot

  openMailDetail(mailId: string): void {
    this.stack = ['Inbox', 'MailDetail']
    AppStorage.setOrCreate('selectedMailId', mailId)

    console.info(
      `[MailDesk] route=MailDetail stack=${JSON.stringify(this.stack)}`
    )
  }

  openAttachmentPreview(fileId: string): void {
    this.lastSplitSnapshot = this.captureSplitSnapshot()
    this.stack.push('AttachmentPreview')

    AppStorage.setOrCreate('fullscreenRoute', 'AttachmentPreview')
    AppStorage.setOrCreate('previewFileId', fileId)

    console.info(
      `[MailDesk] fullscreen AttachmentPreview fileId=${fileId}`
    )
  }
}

这里没有把临时全屏理解成“新的主业务页面”,而是把它当成覆盖在当前分栏任务上的一个短生命周期页面。

这个判断很重要。

如果用户从邮件列表主动进入另一个一级模块,比如日历,那么当然应该重新组织导航栈;但附件预览、图片放大、文档阅读这类页面,退出后通常应该回到原来的业务上下文。

四、返回不是简单 pop,一定要看被 pop 的是什么

第一版 Bug 就发生在这里。

我当时统一调用 pop(),然后根据栈顶页面重新渲染。问题是全屏页进入期间,平行视界自身的布局变化和页面生命周期可能让 UI 重建,如果只依赖当前组件里的局部状态,恢复时信息不完整。

后来我专门为临时全屏做了一条恢复路径。

这段代码解决什么问题:关闭 AttachmentPreview 时恢复进入全屏前的分栏快照。

closeAttachmentPreview(): void {
  const snapshot = this.lastSplitSnapshot
  if (!snapshot) {
    this.stack = ['Inbox']
    return
  }

  this.stack = [...snapshot.routeStack]

  AppStorage.setOrCreate('selectedMailId', snapshot.selectedMailId)
  AppStorage.setOrCreate('splitMode', snapshot.splitMode)
  AppStorage.setOrCreate('splitRatio', snapshot.splitRatio)
  AppStorage.setOrCreate('fullscreenRoute', '')

  this.lastSplitSnapshot = undefined

  console.info(
    `[MailDesk] restore route=${snapshot.currentRoute} ` +
    `stack=${JSON.stringify(this.stack)} ratio=${snapshot.splitRatio}`
  )
}

这段代码的关键不是多存几个字段,而是恢复动作有明确来源:恢复保存过的状态,不重新猜状态。

测试里,从全屏页返回后的结果应该始终是:

currentRoute = MailDetail
stack = [Inbox, MailDetail]
splitMode = NAVIGATION
ratio = 1:2

如果右栏内容是靠“默认打开第一封邮件”重新生成的,即使页面看起来也有邮件详情,那仍然是错误恢复。

DevEco Studio 截图里我保留了这条完整链路。右侧大屏模拟器是 MailDesk 的双栏邮件页,底部日志依次记录打开 MAIL-20260930-108、进入 AttachmentPreview、再恢复到 MailDetail。

五、平行视界下,右栏选中态其实也是路由的一部分

这个问题是附件修好以后才暴露出来的。

全屏返回正常了,但左栏有时没有继续高亮 MAIL-20260930-108。右栏显示 A 邮件,左栏却选中了 B 邮件,功能没崩,体验却很奇怪。

原因是我只保存了页面名,没有把 selectedMailId 当成导航上下文。

大屏双栏和手机单页最大的不同,是多个区域同时向用户表达“我现在在哪”。

因此,右栏路由和左栏选择必须来自同一份状态源。

我后来不再让 InboxPage 自己保存选中邮件,而是统一读取:

@StorageLink('selectedMailId')
private selectedMailId: string = ''

点击邮件以后先更新 selectedMailId,再执行右栏跳转。全屏返回时也从快照恢复它。

这样“路由”和“选择”就不会各走各的。

六、临时全屏不能顺手把分栏比例恢复成默认值

平行视界允许灵活分栏。官方介绍中给出了 1:1、1:2、2:1 等常用比例,并支持用户调整。

这意味着用户可能已经把右栏拉得更宽。

如果附件全屏返回后,应用把分栏重新初始化成默认 1:1,虽然内容没丢,用户刚才调整过的工作区却被重置了。

MailDesk 测试固定使用 1:2,所以我把比例也放进快照。正式项目如果允许拖拽,还应该保存实际比例或者保存能重新计算比例的状态。

布局状态不一定都要永久写数据库,但在一次连续任务里,至少应该跨过临时全屏。

七、我最后把“全屏前、全屏中、全屏后”直接做进调试页

为了验收方便,我做了一个手机端的路由状态页。

运行数据如下:

邮件 ID:MAIL-20260930-108
当前模式:NAVIGATION
分栏比例:1:2
路由栈:[Inbox, MailDetail]
临时全屏:AttachmentPreview

时间线是:

10:24 打开 MailDetail
10:25 打开 AttachmentPreview
10:26 关闭附件,恢复 MailDetail

图三显示的是最终恢复结果。

最下方不是一句模糊的“恢复成功”,而是把结果写得很具体:

MailDetail(右栏)
NAVIGATION(1:2)
[Inbox, MailDetail]

我越来越喜欢这种调试页。它不需要开发者在演示时不停切 DevEco Studio 看日志,也能让测试同学快速判断“页面看起来对”之外,路由栈到底对不对。

八、全局返回和右栏返回要先定义产品语义

平行视界还有一个容易被忽略的问题:返回键到底退谁。

在手机单栏里,返回通常就是退出当前页面。在双栏里,左栏可能一直停在 Inbox,右栏却已经走了多层:

MailDetail
→ ContactDetail
→ MailDetail
→ AttachmentPreview

这时候如果没有明确规则,全局返回很容易一会儿退出右栏,一会儿退出整个页面。

MailDesk 的约定是:

临时全屏存在时,优先关闭全屏;右栏有业务子层级时,优先回退右栏;右栏已经回到首层详情时,再由应用决定是保持双栏还是退出当前模块。

真正项目当然可以有不同规则,但一定要先定义,再实现。不能让组件层谁先收到返回事件谁就处理。

九、右栏连续点击时,还要防止同一详情被重复压栈

附件全屏修完以后,我又做了一轮连续点击测试。

在邮件列表里快速点击同一封邮件两次,如果每次都无条件 push,栈会变成:

[Inbox, MailDetail, MailDetail]

视觉上仍然是同一封邮件,所以这个问题很容易漏过去。真正暴露是在用户按返回时:第一次返回仍然停在同一详情,看起来像返回键失效。

因此我把“打开右栏详情”从单纯 push 改成了业务去重:当前右栏已经是 MailDetail,而且 selectedMailId 没有变化时,只刷新内容,不增加新的路由层级。

如果用户从 A 邮件切到 B 邮件,导航模式下也不一定要把 A、B 都压进栈。是否保留右栏历史,要根据产品语义决定。

MailDesk 的定位更像桌面邮件客户端:左侧列表负责切换选择,右侧详情只是当前选择的投影。所以 A → B 更适合替换右栏内容,而不是产生:

Inbox → MailA → MailB → MailC

否则用户连续查看十封邮件后,按十次返回才能退出详情,和大屏“快速浏览”的目标正好相反。

这件事让我意识到,平行视界不是把手机 Navigation 原样拉到大屏。左右栏同时存在以后,页面层级本身就需要重新定义。

十、进程恢复时,我不直接复用上一次的全屏状态

会话内恢复和进程级恢复也不是一回事。

如果用户正在 AttachmentPreview 全屏查看附件,然后系统回收进程,应用下一次重新打开时,是否应该直接回到全屏附件?这个答案未必是“是”。

MailDesk 目前只持久化稳定业务状态:

selectedMailId
splitMode
splitRatio
stableRoute

临时全屏 AttachmentPreview 被我当成会话状态,不作为冷启动默认恢复点。

原因很简单:附件文件可能已经失效,权限可能变化,下载缓存可能被清理。冷启动时强行恢复一个临时页面,反而容易进入错误状态。

所以重启以后,我先恢复到:

[Inbox, MailDetail]

然后确认 MAIL-20260930-108 仍然存在,再渲染右栏。

如果产品确实要求恢复附件预览,那也应该把附件资源有效性检查放在恢复前,而不是看到上次 route 名叫 AttachmentPreview 就直接 push。

这种区分可以让路由状态更稳定:稳定业务上下文可以持久化,短生命周期覆盖页只服务当前会话。

十一、分栏比例的恢复也要有设备边界

本文测试一直使用 1:2,但真实设备上不能把这个比例当成绝对规则。

用户可能在大平板上把右栏拉得很宽,换到更窄的折叠屏以后,原来的像素宽度可能已经不适用。所以保存“比例”比保存“左右各多少像素”更有迁移性。

即便保存的是比例,恢复时仍然要做约束。

例如当前窗口宽度不足以舒适显示 1:2,应用可以回落到当前设备更合理的预设值。恢复用户工作区的含义不是机械恢复每一个数字,而是在当前环境允许的范围内尽量恢复用户的上下文和偏好。

我会把这类恢复分成两步:

读取历史偏好
→ 按当前窗口能力重新校验
→ 得到本次实际分栏

日志里同时打印“请求比例”和“实际比例”,调试时就能看出是历史状态错误,还是当前设备做了合理降级。

十二、最终回归我不再只测“打开和返回”

MailDesk 最后的回归链路是:

打开 Inbox,选择 MAIL-20260930-108;确认右栏出现 MailDetail;调整一次分栏比例;打开附件全屏;返回;切另一封邮件;再切回 108;再次打开附件;返回;最后执行全局返回。

每一步都记录当前:

selectedMailId
currentRoute
fullscreenRoute
routeStack
splitMode
splitRatio

只有这些值和 UI 同时正确,我才认为这一轮通过。

大屏应用特别容易出现“看起来还能用”的状态 Bug。左栏有列表、右栏有内容,页面没有崩,测试很容易顺手点过去。但只要选中态、返回栈或比例恢复不一致,用户连续操作几次以后就会感觉这个应用“不听话”。

所以平行视界的调试重点不只是每一帧长什么样,而是用户操作五分钟以后,路由状态还能不能解释得通。

十三、我给这个问题留下的三个工程判断

第一个判断:分栏是布局能力,Navigation 是业务导航,两者不能互相替代。

EasyGo 能帮助应用获得更自然的大屏分栏体验,但应用仍然需要清晰维护当前业务页面和选中态。

第二个判断:临时全屏是一种覆盖状态,不一定是新的主路由上下文。

附件预览、图片查看、扫码等场景,如果退出后应该回到原任务,就值得在进入前保存快照。

第三个判断:恢复要恢复用户刚才的工作区,而不是恢复一个“看起来差不多”的默认页面。

当前邮件、右栏层级、分栏比例、左栏选中态,这些共同组成了用户的上下文。

HarmonyOS 7 的平行视界把大屏里的多任务体验做得更自然,官方给出的购物模式、导航模式和可调比例已经提供了很好的系统级基础。应用真正要补上的,是业务状态和路由恢复。

这次问题修完以后,我没有再把“附件能打开”当成验收结束点。

我真正关注的是:打开之前用户在哪,打开之后系统变成什么样,关闭以后能不能原封不动回到那个任务里。

当这条链路跑顺以后,平行视界才不是简单的“一分为二”,而是一个可以连续工作的应用空间。

参考资料

Logo

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

更多推荐