第一篇里,我先把“双栏研读台”的骨架搭了起来:左边是资料列表,右边是文章详情,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

我把路由单独拆出来,主要是因为第二篇以后,打开文章这件事已经有两种来源:

  1. 从左侧目录打开;
  2. 从右侧详情里的相关阅读继续打开。

如果它们还各自在页面里调用一次 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

我很喜欢这种验证方式,因为它能同时证明三件事:

  1. 页面内容确实在变化;
  2. 路由栈确实在推进;
  3. 返回时上下文确实能恢复。

对技术文章来说,这种日志比“连续浏览很丝滑”更有说服力。

十七、连续浏览还有一个容易忽略的问题:左栏高亮到底跟谁走

第二篇做到后面,我又碰到一个很细但很真实的问题。

假设用户从左侧目录点开文章 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 负责让这条链继续向前。

到了这里,“双栏研读台”终于不只是一个左右并排的页面,而开始有一点平行视界连续浏览的味道了。

第二篇做到这里,连续浏览这条主线已经具备完整的进入、推进、回退和上下文保持能力,也为后续固定导航模式留下了清楚的路由基础。

下一篇,我会继续处理另一个更明显的问题:

如果左侧目录必须一直保持稳定,右侧详情又要不断切换,怎么用“导航固定 + 覆盖切换”把阅读体验进一步收紧?

Logo

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

更多推荐