HarmonyOS 7 TwinShelf 平行视界工程实录 05:In-App Split × State Restore:应用内分屏、临时对照区与双侧状态恢复【鸿蒙心迹】
前四篇把 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
更多推荐




所有评论(0)