真正开始做平行视界这件事之前,我以为它更像“把一个页面拆成左右两块”。真做进项目里以后,很快就发现完全不是这么简单。

我这次接的是一个阅读类 Demo,项目名叫 ParallelRead,页面名叫 阅读工作台。目标很直接:在更大的设备和更宽的窗口里,把“列表 + 详情”的阅读链路做成一个连续的工作面。用户左侧挑文章,右侧看详情;切换分类时状态不能乱,切到后台再回来也不能把右侧内容清空,最好还能把上一次阅读落点带回来。

这个需求听起来像 UI 适配,真正难的却是 双实例状态同步 和 落点恢复。如果这两件事没做好,平行视界就会变成“看起来分栏了,实际上体验更碎”。

一、我先遇到的不是布局问题,而是“右侧详情总丢失”

第一版做完时,左侧文章列表和右侧详情页已经能同时显示,但一跑就暴露出三个问题:

  1. 左侧切换文章后,右侧详情偶尔不更新;
  2. 应用退到后台再回来,右侧内容会回到默认页;
  3. 窗口重新激活后,列表高亮和详情页 ID 对不上。

这里最容易误判的是,把它当成一个“普通页面状态管理问题”。实际上不是。平行视界场景里,左侧和右侧虽然看起来像一个页面,但它们的 路由上下文、生命周期感知、内容恢复时机 都更复杂。尤其当设备处于更大窗口或者平板环境时,用户操作节奏和普通单页浏览完全不同。

我后面把问题重新拆了一次,发现真正要收住的是三层状态:

  • 选中文章状态:当前文章 ID,比如图里的 A2407;
  • 分栏结构状态:当前是否处于双栏协同模式,以及 splitRatio=0.42 这样的布局参数;
  • 恢复锚点状态:当页面切回前台时,详情页该落在哪个内容位置,也就是 restoreToken=R-2407 这类恢复凭据。

只有把这三层拆开,后面的同步和恢复才不会互相打架。

二、我最后没有让页面自己互相通知,而是抽了一层控制器

一开始我的写法比较直觉:左侧列表点选以后,直接在页面内部改状态,让右侧详情页订阅变化。这样写起来快,但越往后越别扭——页面里同时掺了选中状态、路由状态、恢复状态,还要处理窗口激活与失活事件,逻辑越来越散。

后面我把这块重新收成一个 EasyGoController,让它专门管三件事:

  • 选中文章时同步当前文章 ID;
  • 恢复时回填分栏路由和比例;
  • 在窗口生命周期变化时保存或恢复状态。

下面这段代码解决的,就是左侧点选后如何把当前文章状态同步给另一侧实例。

export class EasyGoController {
  private currentArticleId: string = ''
  private splitRatio: number = 0.42
  private paneState: PaneStateStore = PaneStateStore.getInstance()

  syncSelectedArticle(id: string) {
    this.currentArticleId = id
    this.paneState.setCurrentArticleId(id)
    this.paneState.notifyChange('syncSelectedArticle', { id })
    hilog.info(0x0000, 'ParallelRead', `[EasyGo] selected=${id}`)
  }
}

这段代码看起来不长,但它解决的不是“把一个 ID 传过去”这么简单,而是把 当前文章是谁 这件事从页面事件里抽成了明确状态。这样做之后,左侧列表项高亮、右侧详情装载、日志追踪都能围绕同一个文章 ID 工作。

这里我特别在意的是 hilog.info 这一行。因为平行视界的问题很多都不是必现的,只有先把关键状态打出来,后面排查时才知道到底是“没发同步”,还是“发了但没接住”。

从这张 DevEco Studio 截图里,你能看到我实际是怎么收这条链路的:左侧工程目录里把 PaneStateStore.ets、EasyGoController.ets 和页面文件分开;中间代码聚焦 syncSelectedArticle() 和 restorePaneRoute();右侧模拟器显示的是 阅读工作台 在双栏协同模式下的实际效果;底部日志则明确打出了 selected=A2407、restorePaneRoute success、splitRatio=0.42 这些关键字段。

三、落点恢复的关键,不是“记住文章”,而是“知道何时恢复”

真正让我多绕了一圈的,不是文章选中状态,而是落点恢复。

最开始我只保存了 currentArticleId。后来发现不够。因为文章 ID 一样,不代表右侧详情已经处于正确状态。比如用户看到某一篇内容后切到后台,再回到前台时,页面虽然知道当前文章还是 A2407,但如果恢复时机不对,右侧路由没回来,页面还是会停在默认页。

所以后面我把恢复逻辑放到窗口阶段事件里处理。下面这段代码解决的问题,是窗口重新进入激活态时如何恢复分栏路由与状态。

restorePaneRoute() {
  const state = this.paneState.getSavedState()
  if (state) {
    this.currentArticleId = state.currentArticleId
    this.splitRatio = state.splitRatio ?? 0.42
    this.paneState.setSplitRatio(this.splitRatio)
    hilog.info(0x0000, 'ParallelRead',
      'restorePaneRoute success, id=%{public}s, splitRatio=%{public}f',
      this.currentArticleId, this.splitRatio)
  } else {
    hilog.warn(0x0000, 'ParallelRead', 'no saved state, use default')
  }
}

onWindowStageChanged(stage: window.WindowStage, type: window.WindowStageEventType) {
  if (type === window.WindowStageEventType.ACTIVE) {
    this.restorePaneRoute()
  } else if (type === window.WindowStageEventType.INACTIVE) {
    this.paneState.saveState({
      currentArticleId: this.currentArticleId,
      splitRatio: this.splitRatio
    })
  }
}

这里真正解决的,不是“从缓存里读回一个对象”,而是把 恢复时机 和 恢复内容 绑定起来。

  • 当窗口进入 ACTIVE,系统开始恢复;
  • 当窗口进入 INACTIVE,当前文章 ID 和分栏比例被保存;
  • 如果保存态存在,就按 A2407 + splitRatio=0.42 恢复;
  • 如果保存态不存在,就退回默认页。

这比在页面 aboutToAppear 里盲目恢复要稳很多。因为平行视界下真正影响体验的,不只是页面重新出现,而是整个窗口阶段有没有进入正确状态。

四、运行效果对不对,我最先看的是“模式、右侧详情、同步状态”三件事

等控制器和状态层收住以后,我开始盯最终运行结果。

我给“阅读工作台”页面保留了三个最直观的状态位:

  • 当前模式:双栏协同;
  • 右侧详情:已恢复;
  • 同步状态:OK。

这样做的原因很简单:文章里讲得再多,不如把“用户此刻看到的状态”直接露出来。开发阶段我最怕那种“代码自认为成功,界面上却看不出到底成功没”的场景。

这张运行图里,我重点看的是两块:

  1. 左侧列表当前选中的是 A2407;
  2. 顶部状态卡片明确显示当前处于 双栏协同,右侧详情已经恢复,且同步状态为 OK。

这说明分栏结构、文章选中态和详情装载态已经串起来了。也就是说,平行视界对用户来说不再只是“多显示了一块区域”,而是真正形成了一个可以连续工作的阅读界面。

五、我最后靠诊断页把“恢复成功”从感觉变成了证据

做到这里,其实功能已经能用了,但我还是补了一张“分栏状态诊断”页。原因也很现实:这类能力上线后,一旦用户说“有时候会丢右侧内容”,如果没有诊断位,排查会特别慢。

所以我把几项核心信息都做成了可视化字段:

  • 当前窗口:EXPANDED
  • 左侧路由:/pages/list
  • 右侧路由:/pages/detail?id=A2407
  • splitRatio = 0.42
  • syncState = OK
  • restoreToken = R-2407
  • 恢复前路由 / 恢复后路由
  • 恢复耗时:286 ms

你会发现这张图里的红色箭头和圈选并不是装饰,它们就是这篇文章真正想讲透的部分:

  • syncState = OK,说明左右两边的状态同步已经闭环;
  • restoreToken = R-2407,说明这次恢复不是模糊回到某一页,而是带着明确落点恢复;
  • 恢复前后路由一致,说明右侧详情没有“看起来回来、实际跳错页”;
  • 时间线里 READY -> RUNNING -> RESTORED,让整个恢复过程有了清晰证据链。

六、这类能力最容易被忽略的三个边界

这次做完以后,我觉得有三个边界特别值得记一下。

1. 不要把“左右两栏”当成一个普通列表页

平行视界真正复杂的地方,不是多画一栏,而是两边内容要连贯。只做布局,不做状态设计,体验一定会碎。

2. 落点恢复不是“记住最后一篇文章”

如果只记文章 ID,不记恢复时机和恢复凭据,切回前台时就很容易出错。真正稳的是把 currentArticleId、splitRatio 和恢复流程一起管理。

3. 日志和诊断位一定要提前留

很多分栏协同问题都不是必现问题。没有 selected=A2407、restorePaneRoute success 这类日志,没有诊断页里的 syncState / restoreToken / route,后面出问题时就只能靠猜。

七、最后的判断

我这次最大的感受是:平行视界不是一个“看起来更高级”的展示能力,而是一种会倒逼状态管理升级的工程能力。

如果你只是把它当适配任务做,做完大概率只能得到一个“左右两边能同时显示”的半成品;但如果你从一开始就把它当成“分栏协同 + 双实例同步 + 落点恢复”的完整链路来设计,体验会稳很多。

至少对这次的 ParallelRead 来说,真正让阅读工作台从 Demo 变成一个可交付方案的,不是分栏本身,而是下面这几件事都被收住了:

  • 选中文章 ID 统一管理;
  • 分栏比例与路由状态可恢复;
  • 窗口生命周期与恢复时机绑定;
  • 运行效果可见;
  • 诊断信息可查。

做到这一步,我才觉得这篇关于平行视界的文章,终于不是“讲一个概念”,而是在复盘一条真正跑通的工程主线。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐