前四篇把 TwinShelf 的“大屏阅读主线”基本铺完了。

01 先把 Navigation 做成主从分栏;02 在 1:2 / 2:1 比例之间切换,同时让右栏继续维护自己的阅读路由;03 把过渡页、图片画廊和沉浸式全屏接进同一条文章状态链;04 再把 Chart / Notes / Discussion 收进 reader_workspace,内部页面切换不污染外层 NavPathStack。

到了 05,我开始碰一个更像“真正大屏多任务”的问题。

用户正在读 article_042,看到一句值得引用的话,想临时打开“对照区”,一边保留主文章,一边查看另一篇文章和自己的引用摘录。这个需求和普通右栏详情不一样,也和 04 的虚拟容器不一样:

主阅读区
需要一直保留;

对照区
是临时打开的第二工作区;

关闭以后
主文章不能跳回顶部;

再次打开
对照区还应该恢复到上次的文章、引用和滚动位置。

HarmonyOS 当前官方平行视界示例已经覆盖“平行世界场景下触发应用内分屏”,多设备适配页面也把平行视界定义成面向购物浏览、内容阅读等场景的灵活应用内分屏体验。TwinShelf 05 不虚构一个公开的 InAppSplit() ArkTS API,而是把这个官方场景能力落成项目侧状态与路由模型:

主阅读区:
外层 Navigation / article_042

对照区:
compare_workspace / SecondaryRouteStore

打开:
2:1

关闭:
保存 Secondary Snapshot

再次打开:
恢复 article_118 / quote_17 / 288vp

本轮统一数据:

taskId:
parallel_20261003_05

window:
1600 × 900vp

splitRatio:
2:1

primaryArticleId:
article_042

primaryScrollOffset:
612vp

workspaceId:
compare_workspace

secondaryArticleId:
article_118

selectedQuoteId:
quote_17

secondaryRouteSequence:
SavedQuotes
→ CompareArticle
→ QuoteDetail
→ CompareArticle
→ SavedQuotes

secondaryStackDepth:
1 → 2 → 3 → 2 → 1

secondaryScrollOffset:
288vp

notesDraftChars:
86

splitOpenCount:
2

splitCloseCount:
2

splitRestoreCount:
1

duplicateSecondaryRouteBlocked:
2

primarySelectionRetained:
true

secondarySelectionRetained:
true

contentRebuildCount:
0

avgSplitOpenCost:
5.4ms

status:
IN_APP_SPLIT_READY

一、应用内分屏不是“再开一篇文章”,而是再开一个临时工作区

如果只做:

article_042
→ push article_118

用户就失去了原文章上下文。

如果做成:

左栏 article_042
右栏 article_118

又会和 01/02 的主从阅读结构混在一起。

所以 TwinShelf 05 先明确两个 Owner:

Primary Reading Context
属于主阅读区;

Compare Workspace Context
属于临时对照区。

主区状态不因为 Split 打开而改变。

对照区状态也不反向修改主区。

这一步非常关键,因为后面所有“关闭 / 恢复”都建立在这层隔离上。

二、主区先做 Snapshot,再创建对照区

第一段代码解决的是:打开对照区时先冻结主阅读现场,不让后续第二工作区操作覆盖它。

export interface InAppSplitSnapshot {
  primaryArticleId: string
  primaryScrollOffsetVp: number

  workspaceId: string

  secondaryArticleId: string
  selectedQuoteId: string

  secondaryScrollOffsetVp: number
}

export class InAppSplitCoordinator {
  private snapshot:
    InAppSplitSnapshot = {
      primaryArticleId:
        'article_042',

      primaryScrollOffsetVp:
        612,

      workspaceId:
        'compare_workspace',

      secondaryArticleId:
        'article_118',

      selectedQuoteId:
        'quote_17',

      secondaryScrollOffsetVp:
        288
    }
}

Snapshot 不是为了“备份所有页面变量”。

它只保存真正跨 Split 生命周期需要恢复的业务状态。

例如:

articleId
scroll
quoteId
workspace route

而 Hover、动画进度这类瞬时 UI 状态不进入持久快照。

三、对照区拥有独立 SecondaryRouteStore

第二段代码解决的是:对照区内部路由不进入主 Navigation 栈。

export class SecondaryRouteStore {
  private routes:
    string[] = [
      'SavedQuotes'
    ]

  push(
    route: string
  ): void {
    const top =
      this.routes[
        this.routes.length - 1
      ]

    if (top === route) {
      return
    }

    this.routes.push(
      route
    )
  }

  pop(): void {
    if (
      this.routes.length > 1
    ) {
      this.routes.pop()
    }
  }

  depth(): number {
    return this.routes.length
  }
}

当前序列:

SavedQuotes
→ CompareArticle
→ QuoteDetail
→ CompareArticle
→ SavedQuotes

所以 Secondary Depth:

1 → 2 → 3 → 2 → 1

而外层主阅读 Navigation 不因为这些操作增加深度。

这和 04 的虚拟容器思路一致,但 05 更进一步:它是一块可打开、可关闭、可恢复的临时 Split Workspace。

四、主区选中 article_042 不能因为右侧切 article_118 而改变

当前对照文章:

article_118

主文章:

article_042

两者同时存在。

如果对照区用同一个:

selectedArticleId

状态源,右侧一切换,左侧蓝色选中就会跟着跳到 article_118。

所以 TwinShelf 把状态拆成:

primaryArticleId

secondaryArticleId

并明确只有主区可以修改左侧目录选中态。

本轮:

primarySelectionRetained=true

五、引用 quote_17 是对照区自己的二级状态

右侧当前不仅打开:

article_118

还选中:

quote_17

这说明对照区本身已经不是一个单页。

它有自己的内部状态:

文章
引用
笔记
滚动

如果只是把“右栏页面”当成一个 Builder 临时创建,关闭 Split 后这些状态很容易全部丢掉。

所以 05 把 Compare Workspace 单独做成 Store。

六、关闭分屏时不清空 SecondaryRouteStore,而是保存 Snapshot

第三段代码解决的是:关闭 Split 只收 UI,不销毁业务现场。

closeSplit(): void {
  if (!this.splitOpen) {
    return
  }

  this.snapshot = {
    primaryArticleId:
      this.primaryStore.articleId,

    primaryScrollOffsetVp:
      this.primaryStore.scrollOffset,

    workspaceId:
      'compare_workspace',

    secondaryArticleId:
      this.secondaryStore.articleId,

    selectedQuoteId:
      this.secondaryStore.quoteId,

    secondaryScrollOffsetVp:
      this.secondaryStore.scrollOffset
  }

  this.splitOpen = false
  this.splitCloseCount++
}

这里没有:

secondaryStore.clear()

因为用户关闭对照区不等于“放弃对照任务”。

再次打开时:

restore snapshot

才是 05 的核心体验。

七、再次打开分屏时恢复的是完整工作现场

第二次打开:

splitOpenCount=2
splitRestoreCount=1

恢复内容:

article_118
quote_17
scroll=288vp

主区仍然:

article_042
scroll=612vp

两边都回到关闭前状态。

这比“重新打开默认 SavedQuotes 首页”更符合大屏多任务的预期。

八、为什么本轮使用 2:1,而不是固定 1:2

当前窗口:

1600×900vp

主文章优先。

所以 TwinShelf 项目策略使用:

2:1

让主阅读区更宽,对照区承担引用和辅助文章。

HarmonyOS 7 当前平行视界支持更多分栏比例,包括 1:2 和 2:1;具体这一轮选 2:1 是 TwinShelf 的产品策略,不是系统固定要求。

九、重复打开同一 QuoteDetail 必须被拦截

用户连续点两次:

quote_17

如果 SecondaryRouteStore 连续 push:

QuoteDetail
QuoteDetail

返回就会多退一层。

本轮:

duplicateSecondaryRouteBlocked=2

项目在 push 前检查:

top route
+
route params

相同就忽略。

对照区的路由规则虽然是项目实现,但也必须像外层 Navigation 一样有幂等性。

十、关闭 Split 以后主文章不应该触发 aboutToAppear 重载

如果关掉对照区后:

ArticleDetail
重新创建

即使 scroll 最后恢复到 612vp,用户仍可能看到图片重新加载、局部状态闪动。

所以 05 继续记录:

contentRebuildCount=0

Split 只改变页面组合关系。

主文章实例和 Store 没有重建。

十一、Notes 的 86 字继续从 04 延续下来

04 reader_workspace 里已经有:

notesDraftChars=86

05 打开 Compare Workspace 时,这份文章笔记仍然存在。

因为 Notes 属于:

article_042

而不是属于:

reader_workspace 是否当前可见。

应用内分屏打开 / 关闭不能把前一篇已经建立的文章工作状态清掉。

这正好说明连载代码是在持续演进,而不是每篇重造一次 Demo。

十二、平均打开 5.4ms 只统计 Split 状态提交

本轮:

avgSplitOpenCost=5.4ms

只统计:

读取 Primary Snapshot
恢复 Secondary Snapshot
计算 Split Ratio
提交页面组合

不包含:

article_118 网络请求
图片下载
长列表渲染

性能指标继续保持明确口径。

十三、DevEco 图里要同时看到两套路由 Owner

开发图:

左侧目录:

split/
  InAppSplitCoordinator
  CompareWorkspaceStore
  SecondaryRouteStore

中间代码明确:

primary article 不动

secondary route 独立

右侧模拟器:

article_042
+
compare_workspace
+
quote_17

底部日志:

open split

secondary push article_118

secondary push quote_17

close split

reopen restore

status=IN_APP_SPLIT_READY

整个数据是一套。

十四、手机运行图展示的是双侧状态,而不是“手机真的分成两半”

最终运行图:

它是调试页。

页面把本轮大屏真实运行状态压缩展示:

主阅读区:
article_042 / 612vp

对照区:
article_118 / quote_17 / 288vp

Secondary Route:
1→2→3→2→1

Split:
2 / 2 / restore 1

rebuild:
0

最终:

IN_APP_SPLIT_READY

十五、05 最后固定九组应用内分屏测试

第一组,article_042 打开 compare_workspace。

第二组,主区 612vp 不变化。

第三组,对照区打开 article_118。

第四组,再进入 quote_17,Secondary Depth 到 3。

第五组,重复 quote_17 不重复入栈。

第六组,关闭 Split,主区不重建。

第七组,再打开 Split,恢复 article_118 / quote_17 / 288vp。

第八组,Notes 86 字仍然保持。

第九组,最终 contentRebuildCount=0。

全部通过以后:

IN_APP_SPLIT_READY

才成立。

十六、下一篇只做整个平行视界系列的回归验收

TwinShelf 到现在已经覆盖:

默认分栏
单栈 Navigation
1:2 / 2:1
右栏连续路由
Transition Page
Fullscreen
Landscape
Virtual Container
In-App Split
双侧状态恢复

06 不再增加新场景。

最后固定五个 Profile:

DEFAULT_SPLIT
RATIO_ROUTE
FULLSCREEN_MEDIA
VIRTUAL_WORKSPACE
IN_APP_SPLIT

每个 Profile 跑 6 轮。

从路由深度、选中态、Scroll、比例、全屏系统栏、横屏恢复、虚拟容器和分屏工作区一直验收到所有临时状态收口。

到 06,TwinShelf 正式结束。

十七、应用内分屏还要明确“主区优先”的资源策略

对照区虽然是独立工作区,但它不能和主阅读区抢所有资源。

TwinShelf 当前约束:

主区文章正文
保持最高优先级;

对照区文章
允许延迟加载图片;

QuoteDetail
优先加载文字和引用上下文;

后台隐藏的 Secondary Page
不继续预取大图。

原因很现实。

应用内分屏打开以后,同一个窗口里会同时出现两套内容树。如果主区和对照区都按“全屏页面”的方式加载图片、缓存文章、启动预取任务,内存和 I/O 会明显上涨。

因此 CompareWorkspaceStore 除了路由,还会记录:

visible
activeRoute
preloadLevel

只有当前可见页允许进入高优先级预取。

这不是系统平行视界替业务做的事,而是 TwinShelf 在同屏多任务下自己的资源取舍。

十八、关闭对照区时要区分“隐藏”和“结束”

用户点“关闭对照区”,产品语义是:

暂时收起

不是:

彻底结束比较任务。

所以这次只:

splitOpen=false
保存 snapshot
暂停对照区预取

不会清:

secondaryArticleId
selectedQuoteId
secondaryScrollOffset
notes draft

如果用户从设置里明确点:

结束对照

才调用:

workspaceStore.reset()

并释放完整 Secondary Context。

这种区分让“关闭 UI”和“结束业务会话”不再混在一起。

十九、Secondary Route 的返回键不能越过工作区边界

对照区当前深度:

SavedQuotes
→ CompareArticle
→ QuoteDetail

在 QuoteDetail 按返回:

回 CompareArticle

再返回:

回 SavedQuotes

当 Secondary Depth 已经是 1 时,再按返回:

关闭对照区

而不是继续 pop 外层 article_042。

这条边界非常重要。

否则一个右侧工作区的返回动作,会意外把主文章也退掉。

项目侧 SecondaryRouteStore 因此只拥有自己的子栈,根节点返回才向 InAppSplitCoordinator 发出:

requestCloseWorkspace

外层 Navigation 不直接接收子栈 pop。

二十、恢复对照区前要确认主文章还是同一个上下文

假设用户关闭对照区以后,从 article_042 切到了 article_090,再点击“恢复对照”。

这时不能机械恢复:

article_118 / quote_17

因为旧对照可能属于 article_042 的阅读上下文。

TwinShelf 的 Snapshot 里因此保存:

ownerArticleId

恢复前检查:

snapshot.ownerArticleId
==
primaryArticleId

相同才恢复旧 Secondary State。

不同则打开新的:

SavedQuotes root

这能避免“旧引用跑到新文章旁边”的上下文错位。

本轮主文章一直是 article_042,所以:

restoreCount=1

成功命中旧快照。

二十一、Split Ratio 改变时两边滚动状态都不能抖

从 2:1 临时拖到接近 1:1,再恢复 2:1,左右两栏宽度会变化。

文本重新排版以后,纯像素 ScrollOffset 未必完全等价。

TwinShelf 当前以:

anchorId + localOffset

作为更可靠的恢复信息。

本轮诊断页展示的是:

612vp
288vp

便于观察。

真实恢复逻辑会优先定位:

当前段落 / 当前引用卡片

再应用局部 offset。

这样不同分栏比例下不会因为文字换行导致阅读位置明显漂移。

二十二、对照区的 Notes 与 04 的 ReaderWorkspace Store 不应该复制两份

04 已经有文章笔记:

86 字

05 的对照区也有“加入笔记”动作。

如果 CompareWorkspace 再维护一份自己的 Notes Draft,用户会出现:

文章工作区看到 86 字;
对照区看到另一份草稿。

TwinShelf 让真正的文章笔记归:

ArticleNoteStore

ReaderWorkspace 和 CompareWorkspace 都只是访问它。

所以:

workspace 可以有两套;
业务笔记只有一个 Owner。

这也是为什么 05 打开 / 关闭分屏以后,86 字仍然保持。

二十三、双侧状态恢复还要处理“对照文章被删除”的异常

如果 Snapshot 保存的是:

article_118
quote_17

但用户随后把 article_118 从本地缓存或收藏里删除,再恢复工作区,不能直接进入一个无效详情。

恢复逻辑需要:

验证 article_118 是否存在;

验证 quote_17 是否仍可用;

失效时降级到 SavedQuotes;

并在诊断里记录:

secondaryRestoreFallback

本轮数据均有效,没有触发 fallback。

但恢复机制如果只考虑成功路径,就很容易在真实数据变更后留下空白右栏。

二十四、应用内分屏还要避免两侧同时抢输入焦点

大屏上对照区可能有:

搜索框
笔记输入框
Quote 操作

主区也可能有:

正文选择
快捷键
滚动

打开 Split 时,TwinShelf 不会自动把焦点强行切到对照区。

只有用户主动点击右侧输入控件时才迁移焦点。

关闭对照区以后,如果焦点原本在 Secondary Workspace,则恢复到:

主文章最近可用焦点

而不是落到页面根节点。

05 目前重点是路由和状态,输入焦点会在最终回归里作为一条完整体验检查继续保留。

二十五、IN_APP_SPLIT_READY 的完整边界

这个状态至少表示:

主文章 article_042 未重建;

主滚动现场 612vp 保持;

对照区有独立 Secondary Route;

article_118 / quote_17 可以连续进入和返回;

重复右侧路由被阻断;

关闭分屏保存 Secondary Snapshot;

再次打开恢复 article_118 / quote_17 / 288vp;

原有 Notes 86 字仍属于 article_042;

内部返回不会越过 Workspace Root;

最终没有多余内容实例。

做到这里,应用内分屏才不是“右边多画了一块 UI”,而是真正拥有了第二个可恢复的临时工作上下文。

参考资料

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

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

更多推荐