前面三篇,我把“双栏研读台”这个 Demo 一步一步搭到了一个比较像样的阶段。

第一篇先把双栏骨架立起来,让左边有目录、右边能读文章;第二篇继续把“连续浏览”这条主线接上,让列表、详情和相关阅读之间能顺着读下去;第三篇则把“固定导航 + 右侧覆盖切换”理顺,让大屏阅读不再只是一个双栏页面,而开始像真正的工作台。

但第三篇写完以后,我在平板和大屏模拟器上来回试,还是很快碰到一个新的现实问题:同样一套双栏研读台,放到不同宽度、不同窗口状态、不同分栏比例下,体验并不会天然一致。

比如窗口较窄时,1:2 的比例看起来舒服;可一旦到了更宽的平板横屏,右侧内容可能又会显得过宽,左侧信息密度过低。再比如我把应用从一个尺寸切换到另一个尺寸,如果分栏比例变了,但阅读状态没跟着恢复,用户会明显感觉“刚才还在这里,怎么突然换了感觉”。

于是第四篇,我就不再单纯写“页面怎么摆”,而是开始处理一个更像真实项目的问题:

如何在 HarmonyOS 7 平行视界场景下,为“双栏研读台”建立一套可解释、可恢复、可跟随窗口变化自动调整的分栏比例系统,并把阅读状态同步起来。

这篇文章的重点会落在两个词上:

  • 分栏比例:不同宽度下,左右区域怎么分;
  • 状态同步:比例变化以后,阅读上下文怎么跟着走,应用下次再打开时又怎么恢复。

在这里插入图片描述

一、第四篇为什么一定要单独写“比例”和“状态”

如果只是做一个静态截图 Demo,分栏比例其实很好糊弄。你把左边设成 1,右边设成 2,一张图看起来差不多也就交差了。

但只要你把应用真的跑起来,问题马上就会变得很具体:

  1. 小平板横屏和大平板横屏,真的适合同一个比例吗?
  2. 如果用户手动切到了 1:1,下次打开时要不要继续记住?
  3. 窗口尺寸变化后,是立即重算比例,还是优先恢复用户上一次选择?
  4. 分栏比例变了以后,当前文章、左侧导航、列表滚动位置这些状态还在不在?

这些问题如果不处理,平行视界就只剩一个“左右分开显示”的表面效果。真正决定体验是否稳定的,恰恰是这些在文章里最容易被忽略的细枝末节。

我自己给“双栏研读台”定下的目标很明确:

  • 初次进入时,系统可以根据窗口宽度自动挑选一个合理比例;
  • 用户手动切换比例后,应用要记住这次选择;
  • 再次进入时,优先恢复用户上次的阅读状态;
  • 如果窗口尺寸发生明显变化,比例和阅读状态都要重新校准,但不能让用户感觉“丢了位置”。

也就是说,第四篇要处理的不是“显示成双栏”,而是“让双栏在不同尺寸里看起来都像同一个产品”。

二、第四篇这次先把项目目录重新收一下

为了把“比例”和“状态”这件事做得不散,我又对项目目录做了一次整理。

DualReadDemo
├─ entry
│  ├─ src/main/ets
│  │  ├─ pages
│  │  │  └─ Index.ets
│  │  ├─ components
│  │  │  ├─ ArticleList.ets
│  │  │  ├─ ArticleDetail.ets
│  │  │  └─ RatioSelector.ets
│  │  ├─ route
│  │  │  └─ Router.ets
│  │  ├─ model
│  │  │  ├─ Article.ets
│  │  │  └─ ReadingState.ets
│  │  ├─ service
│  │  │  ├─ LayoutService.ets
│  │  │  └─ StateSyncService.ets
│  │  ├─ store
│  │  │  └─ ReadingSessionStore.ets
│  │  └─ common
│  │     ├─ Constants.ets
│  │     └─ Logger.ets
│  └─ resources
└─ module.json5

这次我最在意的新增角色有三个:

  • RatioSelector.ets:负责让用户明确切换 1:1 / 1:2 / 2:1;
  • LayoutService.ets:负责根据窗口宽度计算推荐比例;
  • StateSyncService.ets:负责把当前比例、当前文章、滚动位置、覆盖层深度等状态一起落下来。

这样拆以后,这篇文章的结构就会很清楚:布局逻辑归布局,状态逻辑归状态,页面只负责组合。

三、先把“比例”这件事定义清楚,否则后面全会乱

很多人写响应式布局时,最容易犯的一个问题,就是一上来就直接写:

Row() {
  LeftPane().layoutWeight(1)
  RightPane().layoutWeight(2)
}

代码当然没错,但它只是“结果”,不是“规则”。

我在第四篇里,先给分栏比例下了一个明确的模型:

export type RatioType = '1:1' | '1:2' | '2:1'

export interface RatioConfig {
  type: RatioType
  leftWeight: number
  rightWeight: number
}

export const RATIO_MAP: Record<RatioType, RatioConfig> = {
  '1:1': { type: '1:1', leftWeight: 1, rightWeight: 1 },
  '1:2': { type: '1:2', leftWeight: 1, rightWeight: 2 },
  '2:1': { type: '2:1', leftWeight: 2, rightWeight: 1 }
}

这一步看起来有点像“多写了一层”,但好处非常大。

因为从这以后:

  • 页面里不再到处写魔法数字;
  • 用户选择的是一个有语义的比例类型,而不是一串难解释的权重;
  • 日志、状态恢复、调试输出里都可以直接看到 1:2 这种对人友好的信息。

第四篇我越来越确定,像这种技术文章,如果只是把 layoutWeight(1) 和 layoutWeight(2) 摆出来,读者很难真的把它复用到项目里。把“比例”抽成一个稳定模型,反而更接近真实工程写法。

四、自动比例选择,不要拍脑袋,而要有窗口宽度断点

第四篇真正开始有意思的地方,是自动比例选择。

我不希望“双栏研读台”在任何宽度下都默认 1:2。因为有些窗口宽度根本撑不起这个比例,而有些更宽的窗口,1:1 或 2:1 反而更符合信息密度。

于是我专门做了一个 LayoutService:

export class LayoutService {
  static autoSelectRatio(windowWidth: number): RatioType {
    if (windowWidth < 900) {
      return '1:1'
    }
    if (windowWidth < 1360) {
      return '1:2'
    }
    return '2:1'
  }

  static resolveColumns(type: RatioType): number[] {
    const config = RATIO_MAP[type]
    return [config.leftWeight, config.rightWeight]
  }
}

我给这套 Demo 定下的断点策略是:

  • 小于 900:先走 1:1,让左右信息密度更均衡;
  • 900 到 1360:优先 1:2,适合“右侧阅读为主”的典型阅读场景;
  • 大于等于 1360:可以切到 2:1,让左侧容纳更多信息,适合目录、摘要、筛选条件更丰富的场景。

这里并不是说这套阈值是唯一标准,而是说你至少要给项目一套可解释的断点策略。

如果没有这层规则,你根本说不清楚“为什么这个窗口是 1:2,那边那个窗口却是 2:1”。

在这里插入图片描述

五、Index.ets 这次不只是装配页,它开始承担“比例驱动器”的职责

第四篇里,Index.ets 的职责明显变重了。

因为它不仅要把左侧列表和右侧详情拼起来,还得同时做三件事:

  1. 启动时恢复上次阅读状态;
  2. 根据窗口宽度自动选择比例;
  3. 用户手动切换比例后,重新应用布局并同步状态。

所以我的页面骨架大概会写成这样:

import { router } from '@kit.ArkUI'
import { ArticleDetail } from '../components/ArticleDetail'
import { ArticleList } from '../components/ArticleList'
import { RatioSelector, RatioType } from '../components/RatioSelector'
import { ReadingState } from '../model/ReadingState'

@Entry
@Component
struct Index {
  @State private currentRatio: RatioType = '1:2'
  @State private leftWeight: number = 1
  @State private rightWeight: number = 2
  @State private windowWidth: number = 0
  @State private selectedArticleId: string = ''

  aboutToAppear() {
    const state = ReadingState.getInstance().load()
    if (state) {
      this.currentRatio = state.ratio
      this.applyRatio(this.currentRatio)
      this.selectedArticleId = state.articleId
      console.info('restore reading state success, ratio=' + state.ratio)
    }
  }

  onPageShow() {
    const win = window.getLastWindow(getContext(this))
    if (win) {
      this.windowWidth = win.getWindowProperties().windowRect.width
      this.autoSelectRatio(this.windowWidth)
    }
  }
}

这段代码最重要的不是语法本身,而是顺序。

我在这里特别强调两件事:

  • 恢复状态要尽量早做,否则一闪而过的初始状态很影响体验;
  • 自动比例选择不能简单粗暴覆盖用户选择,而应该和恢复逻辑协商。

也就是说,第四篇写的其实已经不是“怎么分栏”,而是“比例系统怎么和页面生命周期对上”。

六、用户手动切换比例时,页面逻辑应该怎么走

自动断点很重要,但平行视界场景下,我并不想把用户完全锁死在“系统推荐值”里。

很多时候,用户自己知道自己更习惯哪种阅读方式:

  • 有的人喜欢左右均衡;
  • 有的人更愿意把空间留给正文;
  • 有的人反而希望左侧列表更宽一点,方便快速浏览大量条目。

所以我在页面顶部加了一个 RatioSelector,让用户可以手动切换 1:1 / 1:2 / 2:1。

@Component
export struct RatioSelector {
  @Prop currentRatio: RatioType = '1:2'
  onChange: (ratio: RatioType) => void = () => {}

  build() {
    Row({ space: 8 }) {
      this.buildItem('1:1')
      this.buildItem('1:2')
      this.buildItem('2:1')
    }
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor('#F5F7FA')
    .borderRadius(18)
  }

  @Builder
  private buildItem(value: RatioType) {
    Text(value)
      .fontSize(15)
      .fontWeight(this.currentRatio === value ? FontWeight.Medium : FontWeight.Regular)
      .backgroundColor(this.currentRatio === value ? '#EAF2FF' : 'transparent')
      .borderRadius(14)
      .padding({ left: 18, right: 18, top: 8, bottom: 8 })
      .onClick(() => this.onChange(value))
  }
}

然后在 Index.ets 里,切换逻辑就会变成:

private onRatioChange(nextRatio: RatioType) {
  this.currentRatio = nextRatio
  this.applyRatio(nextRatio)
  StateSyncService.getInstance().syncCurrentState({
    ratio: nextRatio,
    articleId: this.selectedArticleId,
    windowWidth: this.windowWidth
  })
}

这套写法的好处是,比例切换不是一次孤立的视觉变化,而是一次有记录的用户选择。

也正因为它被记下来了,应用下次恢复的时候才会有意义。

七、状态同步真正要保存的,绝不只是“当前比例”

第四篇里我反复提醒自己一件事:

如果只保存当前比例,而不保存阅读上下文,那么“恢复状态”就只是恢复了一个壳。

所以我最后给 ReadingState 定了这样一组字段:

export interface ReadingSnapshot {
  navId: string
  articleId: string
  ratio: RatioType
  listScrollOffset: number
  overlayDepth: number
  lastSaveTime: number
}

这些字段里,我最看重的并不是 ratio,而是它和其他信息之间的组合关系:

  • navId:我当前处在哪个导航栏目;
  • articleId:我当前正在读哪一篇;
  • listScrollOffset:左侧列表滚到了哪里;
  • overlayDepth:右侧是否还处于覆盖阅读状态;
  • lastSaveTime:这份状态是不是最新的。

如果只有比例,没有这些上下文,那用户切回应用时还是会迷路。

所以第四篇真正要恢复的,是“比例 + 阅读现场”。

八、StateSyncService 这次是第四篇的灵魂

为了解决状态同步太分散的问题,我专门加了一个 StateSyncService,把“收集当前页面状态”和“持久化”这两件事收进来。

export class StateSyncService {
  private static instance: StateSyncService = new StateSyncService()

  static getInstance(): StateSyncService {
    return StateSyncService.instance
  }

  syncCurrentState(payload: {
    ratio: RatioType,
    articleId: string,
    windowWidth: number,
    navId?: string,
    listScrollOffset?: number,
    overlayDepth?: number
  }) {
    const snapshot: ReadingSnapshot = {
      navId: payload.navId ?? '开发实践',
      articleId: payload.articleId,
      ratio: payload.ratio,
      listScrollOffset: payload.listScrollOffset ?? 0,
      overlayDepth: payload.overlayDepth ?? 0,
      lastSaveTime: Date.now()
    }

    ReadingState.getInstance().save(snapshot)
    Logger.info(`[State] save ratio=${snapshot.ratio}, article=${snapshot.articleId}`)
  }
}

我非常喜欢这种“显式状态快照”的写法。

因为它让你在调试时,能非常直接地看到当前到底保存了什么。比如日志里一旦打印出:

[State] save ratio=1:2, article=1024
[State] save ratio=2:1, article=1024

你马上就能知道:刚才是不是发生了比例切换,切换时文章有没有丢,恢复顺序有没有问题。

在这里插入图片描述

九、窗口尺寸变化时,最怕的不是切换,而是“切换得没道理”

到了第四篇,我最想避免的一种感觉就是:用户不知道应用为什么突然变了。

所以我给窗口变化时的逻辑定了一个非常明确的顺序:

  1. 先拿到当前窗口宽度;
  2. 用断点规则算出推荐比例;
  3. 如果当前比例是用户主动锁定的,就尊重用户;
  4. 如果当前比例来自自动模式,才根据新宽度切换;
  5. 比例切换完成后,再同步状态。

代码大概是这样:

private onWindowSizeChange(width: number) {
  this.windowWidth = width

  const recommendRatio = LayoutService.autoSelectRatio(width)
  const isUserPinned = ReadingState.getInstance().isRatioPinned()

  if (!isUserPinned && recommendRatio !== this.currentRatio) {
    this.currentRatio = recommendRatio
    this.applyRatio(recommendRatio)
  }

  StateSyncService.getInstance().syncCurrentState({
    ratio: this.currentRatio,
    articleId: this.selectedArticleId,
    windowWidth: width
  })
}

这里有个特别关键的判断:isUserPinned。

如果没有这个标记,系统每次窗口变化都强行重算比例,就会把用户手动选的值覆盖掉。这样虽然“自动”,却一点都不体贴。

第四篇写到这里,我越来越觉得“自动”和“可控”不是对立的。好的体验反而应该是:系统会帮你,但不会抢你。

十、列表滚动位置恢复,是第四篇里最容易被低估的细节

写第四篇时,我一开始只盯着“当前文章恢复”这件事,后来越用越发现,仅仅恢复文章 ID 其实还不够。

因为在双栏阅读场景里,左侧列表不是装饰,它承担的是“上下文索引”的作用。用户往往会先在左侧快速浏览,再在右侧慢慢读。如果下次恢复时右侧文章回来了,但左侧列表又从第一条开始,那种体验还是会断。

所以我后来专门把 listScrollOffset 当成一等公民来处理,而不是把它当成一个可有可无的小优化。

private onListScroll(offset: number) {
  this.currentListOffset = offset
  StateSyncService.getInstance().syncCurrentState({
    ratio: this.currentRatio,
    articleId: this.selectedArticleId,
    windowWidth: this.windowWidth,
    navId: this.currentNavId,
    listScrollOffset: offset,
    overlayDepth: this.overlayDepth
  })
}

private restoreListOffset() {
  const state = ReadingState.getInstance().load()
  if (state) {
    this.listScroller.scrollTo({ yOffset: state.listScrollOffset })
  }
}

这段逻辑的意义其实很朴素:用户重新回到应用时,左侧看到的世界,也应该尽量还是他离开前看到的那个世界。

很多时候,阅读体验的“连续性”并不只取决于正文停在第几段,而是取决于你是不是还能顺着刚才那组文章继续往下找。

十一、自动推荐和用户固定,到底谁优先,我专门给它定了规则

第四篇还有一个特别值得单独拿出来说的问题:系统推荐比例和用户手动固定比例,到底谁优先?

如果完全听系统的,用户会觉得它太强势;如果完全听用户的,窗口宽度已经明显变化了,界面又可能显得不合适。

我最后给 Demo 定的是一个偏工程化、也比较好解释的规则:

  1. 首次进入,没有历史记录时,听系统推荐;
  2. 有历史记录但没有“用户固定”标记时,先恢复,再根据当前窗口宽度校正;
  3. 用户主动点过比例按钮以后,标记为固定模式;
  4. 固定模式下,普通尺寸变化不覆盖用户选择;
  5. 只有发生跨设备级别的明显变化时,才给出重新推荐的机会。

这套规则最终落实成了两个状态位:

export interface ReadingSnapshot {
  navId: string
  articleId: string
  ratio: RatioType
  listScrollOffset: number
  overlayDepth: number
  ratioPinned: boolean
  lastSaveTime: number
}

以及一段优先级判断:

const state = ReadingState.getInstance().load()
const recommend = LayoutService.autoSelectRatio(this.windowWidth)

if (!state) {
  this.applyRatio(recommend)
} else if (state.ratioPinned) {
  this.applyRatio(state.ratio)
} else {
  this.applyRatio(recommend)
}

这段判断不复杂,但它把第四篇最关键的一层产品语义补齐了:

  • 系统不是胡乱推荐;
  • 用户也不是点了就白点;
  • 规则透明,日志可追,行为可解释。

当一个 Demo 开始能把这种优先级说清楚时,它就已经不只是“示例页面”,而更像一个可以继续演进的项目原型。

十二、无边框运行图里,第四篇最应该表现的是“同一套界面,不同策略下依然稳定”

如果第三篇的大屏图主要强调“固定导航 + 覆盖切换”,那第四篇的无边框运行图就更应该突出另一件事:

左边还是那个左边,右边还是那个右边,但比例策略和恢复状态已经让这套界面真正能跨尺寸稳定工作了。

所以这张图里,我刻意保留了:

  • 左侧稳定导航;
  • 中间文章列表;
  • 右侧正文;
  • 顶部当前比例按钮;
  • “已恢复上次阅读状态”的轻提示。

这些元素放在一起,其实就是第四篇的结论:分栏不只是静态布局,而是一个可恢复、可解释的阅读系统。

在这里插入图片描述

十三、类型二综合讲解图,在第四篇里尤其有价值

第四篇我还是保留了“类型二综合讲解图”,而且我觉得这一篇里它的价值比前三篇还明显。

原因很简单,第四篇需要同时解释好几类信息:

  • DevEco 里的代码结构;
  • 真实运行界面;
  • 分栏比例示意;
  • 真机效果;
  • 状态流转关系。

这些内容如果拆得太散,读者会感觉在来回跳。可如果把它们压进一张信息清晰的综合图里,读者一眼就能看明白:

  1. 左上角是代码和模拟器,说明怎么实现;
  2. 右上角是最终运行界面,说明结果长什么样;
  3. 左下角是比例关系图,说明为什么会选 1:1 / 1:2 / 2:1;
  4. 中下部是真机运行图,说明在设备上确实像一个成熟产品;
  5. 右下角是状态流转图,说明窗口变化与阅读恢复是怎么衔接的。

这张图其实就把第四篇最复杂的部分压缩成了一个“可一眼理解”的总览。

在这里插入图片描述

十四、第四篇怎么验收,我会看五条线

这篇做完以后,我给自己定的验收并不是“截图好不好看”,而是下面五条线能不能同时成立。

1)自动断点是否合理

窗口宽度变化后,系统推荐的比例要说得通,而不是随机切换。

2)手动切换比例后是否被记住

用户如果主动选了 1:1 或 2:1,应用要能记住,而不是下次又回到默认值。

3)比例变化后阅读状态是否还在

文章不能因为重新布局就丢掉,左侧导航和列表滚动位置也不能白白回零。

4)再次进入时恢复是否自然

恢复最好是“你打开时,它就已经像你上次离开那样”,而不是先闪一个默认页,再跳回之前状态。

5)日志是否能解释行为

第四篇我特别依赖日志,因为这种“比例 + 状态”的问题,很多时候光靠肉眼很难判断。理想的日志应该像这样:

[Init] window width=1280
[Layout] auto select ratio=1:2
[State] restore reading state success, articleId=1024, ratio=1:2
[Layout] apply ratio=1:2, leftWeight=1, rightWeight=2
[State] save ratio=1:2, article=1024

这样的输出一看就知道系统到底做了什么,排错会非常轻松。

为了让这套验收不是“凭感觉”,我自己还补了一组固定测试数据:

  • 当窗口宽度约为 820 时,预期进入 1:1;
  • 当窗口宽度约为 1180 时,预期进入 1:2;
  • 当窗口宽度约为 1480 时,预期进入 2:1。

然后我分别在“首次进入”“恢复进入”“手动切换后再次进入”“窗口变化后重新恢复”这四种情况下反复测试。只有当这四种情况都能稳定得到同样的结果,我才认为这篇真正达到了可复用的程度。

这套小测试看上去很土,但非常有用。因为像第四篇这种文章,一旦不做这种重复验证,很容易出现截图里一切正常、真实交互里却偶发错位的情况。

十五、真机体验给我的反馈,比模拟器更直接

模拟器里你会觉得“都挺对”,但真机一跑起来,很多感受会更直接。

比如在平板上,1:2 的阅读体验确实最像真正的双栏研读台:

  • 左边足够看清当前栏目和文章列表;
  • 右边能容纳完整正文和相关信息;
  • 顶部比例切换不会打断阅读。

当我把应用重新打开,看到它直接恢复到上一次的文章位置和比例时,那种“这是同一个工作台”的感觉会特别明显。

而这,恰恰就是第四篇想解决的核心:不是让页面会变,而是让它在变化之后依然像它自己。

在这里插入图片描述

十六、第四篇解决了什么,也给第五篇留下了什么

如果把前四篇串起来看,我会这样理解它们的分工:

  • 第一篇:把双栏骨架搭起来;
  • 第二篇:把连续浏览链路接起来;
  • 第三篇:把固定导航和右侧覆盖切换理顺;
  • 第四篇:把多尺寸适配、比例策略和状态恢复做稳。

到这一篇为止,“双栏研读台”已经不再是一个只能在某个固定尺寸里表现正常的 Demo,而开始有了跨设备、跨窗口尺寸依然稳定工作的基础。

但它也给第五篇留下了一个自然的问题:

  • 如果设备从平板切到折叠屏展开态,状态迁移要不要做进一步差异化?
  • 如果左右区域支持拖拽分隔线,比例系统怎么改?
  • 如果文章详情里还包含媒体、评论、锚点导航,右侧内容的滚动恢复是否还够用?

这些问题,其实都在提示我:下一篇已经不能只停留在“分栏阅读”,而该进一步进入“多形态设备上的完整体验打磨”了。

十七、本文小记

第四篇写完,我自己最满意的不是多写了几段 ArkTS,也不是多画了几张图,而是终于把“比例”和“状态”这两件看似很小的东西,变成了整套 Demo 可以被反复解释的一部分。

以前很多文章写到平行视界,容易停在“左右两栏”的视觉结果上。但真正决定一个 Demo 是否像真实产品的,往往不是“长得像不像”,而是:

  • 它在窗口变化时有没有明确策略;
  • 它在用户切换时会不会尊重选择;
  • 它在下一次进入时能不能恢复现场。

这也是我这篇最想传递的一点:

HarmonyOS 7 平行视界的价值,不只是让界面分开显示,而是让多尺寸、多形态场景里的内容组织变得更稳定、更自然、更连续。

第四篇做到这里,“双栏研读台”已经有了比较完整的自适应阅读底子。

下一篇,我准备继续往更接近收口和打磨的方向写:

如何把“双栏研读台”从一个已经能用的平行视界 Demo,继续打磨成一个更完整、更像成品的大屏阅读应用。

Logo

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

更多推荐