HarmonyOS 7 平行视界实战 02:左右推挤 + 路由关联完善“双栏研读台”连续浏览链路【鸿蒙心迹】
第一篇里,我先把“双栏研读台”的骨架搭了起来:左边是资料列表,右边是文章详情,Navigation 负责把主列表和内容区组织在同一个大屏里。第一版已经能看,但我连续点几篇文章以后,很快就发现了下一个问题:列表点详情没问题,可如果用户在详情里继续点“相关阅读”,页面应该怎么继续往前走?
这正好对应 HarmonyOS 7 平行视界里很典型的一类体验——左右推挤、连续浏览。这一篇我不再停留在“左列表 + 右详情”这个静态结构上,而是继续把 Navigation 路由、详情关联关系和连续浏览链路补起来,让“双栏研读台”从“同时显示两页”走向“在大屏里自然地一路读下去”。

一、第一篇跑通以后,真正暴露的问题是“详情不能停在第一层”
第一篇做完以后,我专门用平板连续点了很多次文章列表。
刚开始体验挺顺:
左侧选择文章
↓
右侧显示详情
但很快我就遇到一个特别真实的问题。
假设我正在右侧读《HarmonyOS 7 全新特性解析》,文章底部出现了三篇相关阅读:
- ArkUI 组件化开发实践;
- 分布式能力深入浅出;
- 平行视界交互设计要点。
如果我点“ArkUI 组件化开发实践”,最粗暴的做法当然是直接把右侧正文替换掉。这样看起来也能工作,但上下文会马上变得很弱。
因为用户其实经历的是:
目录
↓
HarmonyOS 7 全新特性解析
↓
ArkUI 组件化开发实践
↓
另一篇关联文章
它本质上已经不是“切换一块内容”,而是一个连续阅读链。
HarmonyOS 7 当前对平行视界“购物模式”的描述,正好强调了这类连续浏览:左侧点击列表,右侧打开详情;如果在右侧详情里继续点击关联内容,原页面可以向左推,新页面从右侧继续展开。官方当前的示例也使用 Navigation 路由应用来展示平行视界的典型场景。换到“双栏研读台”里,这个模式特别自然——只是把“商品详情”换成了“文章详情”。
所以第二篇真正要解决的不是“再加一个动画”,而是:
让文章之间形成真正的路由关系,而不是每次都把右侧当成一块可以随便覆盖的容器。
二、先说明一个边界:Navigation 分栏不是平行视界的全部
写到这里,我想先把一个很容易混淆的点说清楚。
第一篇里我们用 NavigationMode.Split 把应用自身的双栏结构搭起来,这个非常适合作为大屏页面骨架。Navigation 官方当前的分栏能力支持:
- 分栏模式;
- NavPathStack 页面栈;
- 导航栏与子页面之间的拖拽分隔;
navBarWidth/navBarWidthRange;- 内容区最小宽度等。
但 Navigation 的 Split 本身不等于平行视界系统级方案的全部。
HarmonyOS 当前的平行视界是面向大屏设备的系统级应用内分栏能力,官方资料把它区分成“左右推挤”和“固定覆盖”等路由模式,并通过平行视界配置和应用路由关系共同实现最终效果。
所以“双栏研读台”第二篇的代码重点,会放在应用自己的 Navigation 路由链上:
列表页
↓
详情页 A
↓
详情页 B
↓
详情页 C
而真正到了支持平行视界的设备上,这条清晰的路由关系再交给系统侧配置去组织左右推挤效果。
我觉得把这层边界讲清楚很重要。否则很容易写成“加一个 Split 就等于平行视界”,那会把两层能力混在一起。
三、第二篇的项目目录也跟着路由关系变了
第一篇里目录还比较简单,第二篇开始要支持“详情继续打开详情”,所以我加了一层 route 和 history。
DualReadDemo
├─ entry
│ ├─ src/main/ets
│ │ ├─ pages
│ │ │ ├─ Index.ets
│ │ │ ├─ ArticleListPage.ets
│ │ │ ├─ ArticleDetailPage.ets
│ │ │ └─ EmptyDetailPage.ets
│ │ ├─ model
│ │ │ ├─ ArticleItem.ets
│ │ │ └─ ArticleRouteContext.ets
│ │ ├─ route
│ │ │ ├─ ArticleRouteService.ets
│ │ │ └─ RouteHistoryStore.ets
│ │ ├─ mock
│ │ │ └─ MockData.ets
│ │ ├─ components
│ │ │ ├─ ArticleListItem.ets
│ │ │ ├─ RelatedArticleCard.ets
│ │ │ └─ TagChip.ets
│ │ └─ common
│ │ └─ Logger.ets
│ └─ resources
└─ module.json5
我把路由单独拆出来,主要是因为第二篇以后,打开文章这件事已经有两种来源:
- 从左侧目录打开;
- 从右侧详情里的相关阅读继续打开。
如果它们还各自在页面里调用一次 pushPath(),后面返回、恢复和状态记录会越来越难管。
四、平行视界和 Navigation 要分清:一个管系统分栏,一个管应用路由
这一篇我特意把这层关系再说透一点。
HarmonyOS 当前的平行视界,是系统级的大屏应用内分栏方案;官方当前把它区分成“左右推挤”和“固定覆盖”等典型路由模式,并且强调可以通过配置快速把现有应用适配到折叠屏、平板等大屏设备。官方示例里,购物模式使用 Navigation 路由应用作为载体,通过配置实现平行视界效果。
Navigation 则是应用内部的页面路由框架。
它负责:
- 页面栈;
pushPath();pop();- 页面参数;
- NavDestination;
- 单栏 / 分栏 / 自适应等布局模式。
所以在“双栏研读台”里,我把两层职责分得很清楚:
应用层 Navigation
负责:
文章 A → 文章 B → 文章 C
路由栈、参数、返回关系
系统层平行视界
负责:
这些路由页面在大屏上如何以
左右推挤 / 固定覆盖等方式呈现
这样写有一个很大的好处:后面调试时不会把问题混在一起。
如果 pushPath() 都没有成功,问题在应用路由。
如果应用路由已经正常推进,但真机上的左右推挤效果没有出现,那就该回头检查平行视界配置、目标设备和当前路由模式,而不是一直改 ArkUI 页面。
这也是我第二篇开始特别强调“路由关联”的原因。
我们不是为了做一段动画,而是在给系统提供一条清晰、可追踪、可连续推进的页面关系。

五、连续浏览时,push 和 replace 不能混着用
做第二篇时,我还专门踩过一个很容易忽略的问题:为了让右侧内容更新得更快,直接用“替换当前详情”的思路,看起来也能得到类似效果。
比如用户现在正在看文章 A。
他点击“相关阅读 B”。
如果你只是把页面里的 article 对象改成 B,界面当然会马上刷新。
但这时候路由栈里实际上还是一层。
再点 C,页面继续变成 C。
最后用户按返回,系统根本不知道 A、B、C 的关系。
这就失去了“连续浏览”的意义。
所以第二篇里,我会明确区分两个动作:
replace
适合:
当前层级仍然是同一个页面,只是内容发生替换
push
适合:
用户真正进入了一个新的阅读层级
相关阅读属于后者。
因为用户心里的路径确实是:
A → B → C
所以我宁愿把每次相关阅读点击都写成一次真实 pushPath()。
这样返回逻辑才能自然成立。
而左侧目录点击另一篇文章,则可以根据产品需求决定是否新开一条阅读链,或者先清理右侧旧链再从新文章重新开始。
第一版里我先选择更容易理解的方式:
左侧目录点击,开启一条新的主阅读链;右侧相关阅读点击,在当前链路上继续 push。
这个规则一旦固定,文章后面的所有代码都会更容易解释。
六、我还给连续阅读补了一层“去重”,避免同一篇文章反复入栈
连续 push 还有一个实际问题。
假设文章 A 里推荐了 B。
B 的相关阅读里又推荐了 A。
如果用户来回点击,很容易形成:
A → B → A → B → A
从技术上说它当然能跑,但阅读体验会越来越怪,路由栈也会迅速膨胀。
所以我在 RouteService 里补了一层非常轻的去重判断。
private canPush(articleId: string): boolean {
const current = this.history.getCurrent()
if (!current) {
return true
}
if (current.articleId === articleId) {
Logger.info(
`[Route] ignore duplicated article=${articleId}`
)
return false
}
return true
}
然后 openRelated() 里先判断:
openRelated(articleId: string, parentArticleId: string) {
if (!this.canPush(articleId)) {
return
}
const depth = this.history.getDepth() + 1
this.pushArticle({
articleId,
source: 'related',
parentArticleId,
depth
})
}
这一层非常简单,却很符合真实工程习惯。
因为“连续浏览”不是无限把页面往栈里塞,而是要让路径既可追踪,又不过度膨胀。
七、先给路由参数补一个“来源”,否则连续浏览以后很难解释上下文
第一篇里,详情页只要拿到一个 ArticleItem 就够了。
第二篇开始,我更愿意把路由参数包装一下:
文件位置: entry/src/main/ets/model/ArticleRouteContext.ets
export type RouteSource = 'list' | 'related'
export interface ArticleRouteContext {
articleId: string
source: RouteSource
parentArticleId?: string
depth: number
}
为什么要加 source 和 depth?
因为当右侧已经连续打开三层详情以后,我希望日志能明确看懂:
list -> article-001
related -> article-004
related -> article-007
而不是只看到三次 pushPath。
这种信息第一眼看起来像“调试字段”,实际上后面特别有用。
比如:
- 第三篇做固定导航时,可以判断页面是从目录进入还是从详情继续进入;
- 第五篇做路由恢复时,可以知道恢复后的层级;
- 日志里也能清楚复盘一条阅读路径。
八、把所有打开详情的动作收口到 ArticleRouteService
第二篇最重要的一次重构,就是我不再让列表页和详情页各自直接操作路径栈。
我加了一个 ArticleRouteService。
文件位置: entry/src/main/ets/route/ArticleRouteService.ets
用途: 统一打开文章详情、打开关联文章和返回上一层。
import { ArticleRouteContext, RouteSource } from '../model/ArticleRouteContext'
import { Logger } from '../common/Logger'
export class ArticleRouteService {
constructor(
private pathStack: NavPathStack,
private history: RouteHistoryStore
) {}
openFromList(articleId: string) {
this.pushArticle({
articleId,
source: 'list',
depth: 1
})
}
openRelated(articleId: string, parentArticleId: string) {
const depth = this.history.getDepth() + 1
this.pushArticle({
articleId,
source: 'related',
parentArticleId,
depth
})
}
back() {
if (this.pathStack.size() <= 0) {
return
}
const current = this.history.getCurrent()
Logger.info(
`[Route] back from=${current?.articleId ?? 'unknown'}`
)
this.pathStack.pop()
this.history.pop()
}
private pushArticle(context: ArticleRouteContext) {
Logger.info(
`[Route] push article=${context.articleId}, source=${context.source}, depth=${context.depth}`
)
this.history.push(context)
this.pathStack.pushPath({
name: 'ArticleDetailPage',
param: context
})
}
}
这段代码解决了一个很重要的问题:
页面只负责表达“我要打开哪篇文章”,路由服务负责决定“这次应该怎么进入”。
左侧列表不需要关心深度。
右侧相关阅读也不需要自己算路径栈。
它们只要告诉 RouteService:
从列表打开 article-001
或者:
从 article-001 继续打开 article-004
这就是第二篇开始有工程味的地方。

九、Index.ets 也要改:它不再直接承担所有路由动作
有了 RouteService,Index.ets 会干净很多。
@Entry
@Component
struct Index {
private pathStack: NavPathStack = new NavPathStack()
private history: RouteHistoryStore = new RouteHistoryStore()
private routeService: ArticleRouteService =
new ArticleRouteService(this.pathStack, this.history)
build() {
Navigation(this.pathStack) {
ArticleListPage({
onSelect: (articleId: string) => {
this.routeService.openFromList(articleId)
}
})
}
.title('双栏研读台')
.mode(NavigationMode.Split)
.navBarWidth('34%')
.enableDragBar(true)
.navDestination(this.pageMap)
}
@Builder
pageMap(name: string, param: object) {
if (name === 'ArticleDetailPage') {
const context = param as ArticleRouteContext
ArticleDetailPage({
context,
onOpenRelated: (articleId: string) => {
this.routeService.openRelated(
articleId,
context.articleId
)
},
onBack: () => {
this.routeService.back()
}
})
}
}
}
这一版和第一篇最大的差别,就是详情页开始具备“继续往下打开详情”的能力。
而且每一次打开,都不是把当前文章对象直接替换掉,而是向路径栈继续推进。
这正是连续浏览的基础。
十、详情页里的相关阅读,终于不只是装饰了
第一篇里我在文章底部放了“相关阅读”,但它更多只是为了把页面做完整。
第二篇里,这一块开始承担真正的路由作用。
文件位置: entry/src/main/ets/pages/ArticleDetailPage.ets
@Component
export struct ArticleDetailPage {
@Prop context: ArticleRouteContext
@State article?: ArticleItem
onOpenRelated: (articleId: string) => void = () => {}
onBack: () => void = () => {}
aboutToAppear() {
this.article = MockData.getArticleById(
this.context.articleId
)
Logger.info(
`[Detail] appear id=${this.context.articleId}, source=${this.context.source}, depth=${this.context.depth}`
)
}
build() {
if (!this.article) {
this.buildEmpty()
return
}
Scroll() {
Column({ space: 16 }) {
this.buildHeader()
this.buildContent()
Text('相关阅读')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.width('100%')
Column({ space: 10 }) {
ForEach(
this.article.relatedArticles,
(item: ArticleItem) => {
RelatedArticleCard({ article: item })
.onClick(() => {
this.onOpenRelated(item.id)
})
},
(item: ArticleItem) => item.id
)
}
.width('100%')
}
.padding(22)
}
}
}
这时候一条阅读链就真正成立了:
左侧列表
↓
文章 A
↓
文章 B
↓
文章 C
更重要的是,左侧列表仍然存在。
用户并没有“离开资料目录”,只是右侧内容在不断向更深层推进。

十一、为什么这种“右侧继续推进”的感觉很适合平行视界
HarmonyOS 7 当前对平行视界的“购物模式”强调的是:
- 左侧点击内容,右侧展开详情;
- 在右侧继续点击关联内容时,原页面左移,新页面从右侧继续打开;
- 用户无需反复退回列表,连续浏览会更自然。
虽然官方示例常用商品场景来讲,但我觉得资料阅读其实同样合适。
因为阅读本身就经常是“从一篇走向另一篇”。
比如:
平行视界介绍
↓
Navigation 分栏开发
↓
大屏响应式布局
↓
应用状态恢复
如果每点一次都回列表,思路会被打断。
如果右侧能继续推进,同时左边目录一直保留,阅读会更像“顺着知识关系往下走”。
这就是“双栏研读台”第二篇真正想证明的东西。
十二、返回逻辑也不能忽略,不然只会一直往前推
连续浏览很容易让人只关注“push”。
但只会 push 的路由一定是不完整的。
所以第二篇里我同时补了返回逻辑。
我希望用户在右侧连续读到第三层后,按返回时看到的是:
文章 C
↓ 返回
文章 B
↓ 返回
文章 A
而不是一下跳回左侧目录首页。
所以 RouteHistoryStore 会和 NavPathStack 同步维护。
export class RouteHistoryStore {
private stack: ArticleRouteContext[] = []
push(context: ArticleRouteContext) {
this.stack.push(context)
}
pop(): ArticleRouteContext | undefined {
return this.stack.pop()
}
getCurrent(): ArticleRouteContext | undefined {
return this.stack[this.stack.length - 1]
}
getDepth(): number {
return this.stack.length
}
clear() {
this.stack = []
}
}
它不是为了替代 NavPathStack,而是为了保留业务层上下文。
NavPathStack 负责页面栈。
RouteHistoryStore 负责“这条阅读链到底是怎么来的”。
这两个概念我会一直保留到第五篇。
十三、纯大屏截图里,第二篇最重要的是“左侧不动,右侧一路换”
第二篇的纯界面图,我最希望读者看到的不是某个按钮,而是整体阅读节奏。
左边:
- 文章列表一直保留;
- 当前选中的文章有高亮;
- 用户随时可以跳回目录里的另一篇。
右边:
- 当前详情正常显示;
- 底部有相关阅读;
- 点击相关阅读以后继续推进下一篇;
- 返回时能逐层回退。
所以第二篇的核心不是“更多页面”,而是更自然的连续浏览。

十四、第二篇我怎么验收
这一篇的验收比第一篇多了一层“路由连续性”。
1)左侧列表打开详情必须稳定
这是第一篇已经验证过的基础能力,第二篇不能因为加了 RouteService 就退化。
2)详情页可以继续打开关联文章
这是第二篇最重要的一条。
我会连续打开三层:
article-001
article-004
article-007
然后检查路径栈、日志和页面内容是否一致。
3)返回必须逐层回退
从第三层返回,要回到第二层。
再返回,回到第一层。
不能直接把右栏清空,也不能把左栏的选中上下文弄丢。
4)左侧列表始终保持可见
连续浏览时,左侧资料列表不应该消失。
这是双栏研读体验最核心的上下文。
5)日志必须能还原阅读路径
我会特别看这些日志:
[Route] push article=001 source=list depth=1
[Route] push article=004 source=related depth=2
[Route] push article=007 source=related depth=3
[Route] back from=007
如果日志能还原用户刚才的阅读动作,这条链就算站住了。
十五、实机上最明显的变化,是“阅读不再需要反复回列表”
第二篇跑到平板以后,我觉得变化比模拟器里更明显。
在大屏真机上,左侧列表一直保持在那里,右边连续换文章时,用户的注意力不会被“返回—再定位”打断。
尤其是读技术资料时,这种体验特别明显。
你看到某个陌生概念,点一篇关联文章。
读完以后返回上一层。
左边原来的目录还在那里。
整个过程很接近真实的“查资料”。

十六、我还专门用日志还原了一次完整阅读路径
这一篇既然讲“连续浏览”,我就不想只凭 UI 感觉说“好像顺了”。
我专门跑了一条完整路径:
目录点击 A
↓
A 详情点击关联 B
↓
B 详情继续点击关联 C
↓
返回到 B
↓
再返回到 A
日志大概会是:
[Route] push article=A source=list depth=1
[Detail] appear article=A
[Route] push article=B source=related depth=2
[Detail] appear article=B
[Route] push article=C source=related depth=3
[Detail] appear article=C
[Route] back from=C
[Detail] resume article=B
[Route] back from=B
[Detail] resume article=A
我很喜欢这种验证方式,因为它能同时证明三件事:
- 页面内容确实在变化;
- 路由栈确实在推进;
- 返回时上下文确实能恢复。
对技术文章来说,这种日志比“连续浏览很丝滑”更有说服力。
十七、连续浏览还有一个容易忽略的问题:左栏高亮到底跟谁走
第二篇做到后面,我又碰到一个很细但很真实的问题。
假设用户从左侧目录点开文章 A。
左侧 A 是高亮的。
接着用户在右侧文章 A 里点“相关阅读 B”,右侧已经进入 B 了。
这时候左侧应该高亮谁?
如果直接跟右侧当前详情走,那左侧会突然把 B 高亮出来。但 B 可能根本不是用户从目录里主动选择的文章,它只是从 A 延伸出去的一条关联阅读链。
如果左侧继续高亮 A,又会出现另一种疑问:
右侧明明已经读到 B,为什么左侧还停在 A?
我最后给“双栏研读台”定的规则是:
左栏高亮表示“主阅读入口”,右栏页面表示“当前阅读位置”。
也就是说:
左侧 A 高亮
代表:
这条阅读链从 A 开始
右侧 B / C
代表:
当前正在读到 B 或 C
这样一来,两边承担的语义就不同。
左栏不是一个实时同步的“当前页列表”。
它更像目录锚点。
右栏则是真正的阅读路径。
我专门加了一个 ReadingSessionStore 来保存这两个值。
export class ReadingSessionStore {
private rootArticleId: string = ''
private currentArticleId: string = ''
startFromList(articleId: string) {
this.rootArticleId = articleId
this.currentArticleId = articleId
}
moveToRelated(articleId: string) {
this.currentArticleId = articleId
}
backTo(articleId: string) {
this.currentArticleId = articleId
}
getRootArticleId(): string {
return this.rootArticleId
}
getCurrentArticleId(): string {
return this.currentArticleId
}
}
当用户从左侧重新点一篇新文章时:
sessionStore.startFromList(articleId)
routeService.openFromList(articleId)
当右侧继续打开相关阅读时:
sessionStore.moveToRelated(articleId)
routeService.openRelated(articleId, parentId)
这样做以后,日志也会更有解释力:
[Reading] root=A current=A
[Reading] root=A current=B
[Reading] root=A current=C
我很喜欢这个设计,因为它让“双栏”两边不再只是视觉上的左右关系,而开始有明确职责:
- 左栏给你“我从哪里开始”的上下文;
- 右栏给你“我现在读到哪里”的上下文。
这比简单让两边永远同步高亮,更适合连续阅读。
而且这层状态,到第五篇做路由恢复时还能继续用。
如果应用被切到后台再恢复,只要保存:
rootArticleId
currentArticleId
routeDepth
就能大致重建用户离开前的阅读位置。
十八、路由层级也要设一个边界,不能无限往右推
连续阅读很舒服,但它也不能没有上限。第二篇里我给 Demo 加了一个很轻的深度限制:如果关联阅读已经连续进入太多层,就不再继续无止境 push,而是提示用户回到上一级或从左侧目录重新开始。
private readonly maxDepth: number = 6
private canOpenNext(): boolean {
if (this.history.getDepth() >= this.maxDepth) {
Logger.warn('[Route] max reading depth reached')
return false
}
return true
}
这个限制不是系统要求,而是 Demo 自己的业务保护。它能避免测试数据之间互相推荐时,把路由栈越推越深,也能让返回体验保持可控。
我觉得这类“小边界”很适合放在第二篇里。因为连续浏览不是越深越好,真正好的体验应该是:能顺着内容往下读,也随时知道怎么回来。
十九、第二篇还没有解决什么
做到这里,我也很清楚第二篇并没有把所有问题解决。
比如:
- 左侧导航目前仍然属于第一版固定结构;
- 右侧页面层级越来越深以后,怎样做固定导航和覆盖切换,还没有处理;
- 不同分栏比例下,左栏是否应该一直保持 34%,还没有讨论;
- 折叠屏、平板不同尺寸下,内容区最小宽度怎么定,也还没进入;
- 页面被系统回收以后,路由栈怎样恢复,还没有处理。
这些正好就是后面三篇的空间。
二十、本文小记
如果第一篇解决的是:
“双栏研读台能不能成立?”
那第二篇解决的就是:
“双栏成立以后,阅读能不能继续往前走,而不是每次都退回起点?”
这一篇最重要的变化,并不是代码多了一个 RouteService,而是页面之间第一次有了真正的“关系”。
左侧列表负责给用户稳定上下文。
右侧详情负责承载阅读链。
Navigation 和 NavPathStack 负责让这条链继续向前。
到了这里,“双栏研读台”终于不只是一个左右并排的页面,而开始有一点平行视界连续浏览的味道了。
第二篇做到这里,连续浏览这条主线已经具备完整的进入、推进、回退和上下文保持能力,也为后续固定导航模式留下了清楚的路由基础。
下一篇,我会继续处理另一个更明显的问题:
如果左侧目录必须一直保持稳定,右侧详情又要不断切换,怎么用“导航固定 + 覆盖切换”把阅读体验进一步收紧?
更多推荐


所有评论(0)