HarmonyOS 7 平行视界实战 04:分栏比例 + 状态同步解决“双栏研读台”多尺寸适配问题【鸿蒙心迹】
前面三篇,我把“双栏研读台”这个 Demo 一步一步搭到了一个比较像样的阶段。
第一篇先把双栏骨架立起来,让左边有目录、右边能读文章;第二篇继续把“连续浏览”这条主线接上,让列表、详情和相关阅读之间能顺着读下去;第三篇则把“固定导航 + 右侧覆盖切换”理顺,让大屏阅读不再只是一个双栏页面,而开始像真正的工作台。
但第三篇写完以后,我在平板和大屏模拟器上来回试,还是很快碰到一个新的现实问题:同样一套双栏研读台,放到不同宽度、不同窗口状态、不同分栏比例下,体验并不会天然一致。
比如窗口较窄时,1:2 的比例看起来舒服;可一旦到了更宽的平板横屏,右侧内容可能又会显得过宽,左侧信息密度过低。再比如我把应用从一个尺寸切换到另一个尺寸,如果分栏比例变了,但阅读状态没跟着恢复,用户会明显感觉“刚才还在这里,怎么突然换了感觉”。
于是第四篇,我就不再单纯写“页面怎么摆”,而是开始处理一个更像真实项目的问题:
如何在 HarmonyOS 7 平行视界场景下,为“双栏研读台”建立一套可解释、可恢复、可跟随窗口变化自动调整的分栏比例系统,并把阅读状态同步起来。
这篇文章的重点会落在两个词上:
- 分栏比例:不同宽度下,左右区域怎么分;
- 状态同步:比例变化以后,阅读上下文怎么跟着走,应用下次再打开时又怎么恢复。

一、第四篇为什么一定要单独写“比例”和“状态”
如果只是做一个静态截图 Demo,分栏比例其实很好糊弄。你把左边设成 1,右边设成 2,一张图看起来差不多也就交差了。
但只要你把应用真的跑起来,问题马上就会变得很具体:
- 小平板横屏和大平板横屏,真的适合同一个比例吗?
- 如果用户手动切到了
1:1,下次打开时要不要继续记住? - 窗口尺寸变化后,是立即重算比例,还是优先恢复用户上一次选择?
- 分栏比例变了以后,当前文章、左侧导航、列表滚动位置这些状态还在不在?
这些问题如果不处理,平行视界就只剩一个“左右分开显示”的表面效果。真正决定体验是否稳定的,恰恰是这些在文章里最容易被忽略的细枝末节。
我自己给“双栏研读台”定下的目标很明确:
- 初次进入时,系统可以根据窗口宽度自动挑选一个合理比例;
- 用户手动切换比例后,应用要记住这次选择;
- 再次进入时,优先恢复用户上次的阅读状态;
- 如果窗口尺寸发生明显变化,比例和阅读状态都要重新校准,但不能让用户感觉“丢了位置”。
也就是说,第四篇要处理的不是“显示成双栏”,而是“让双栏在不同尺寸里看起来都像同一个产品”。
二、第四篇这次先把项目目录重新收一下
为了把“比例”和“状态”这件事做得不散,我又对项目目录做了一次整理。
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 的职责明显变重了。
因为它不仅要把左侧列表和右侧详情拼起来,还得同时做三件事:
- 启动时恢复上次阅读状态;
- 根据窗口宽度自动选择比例;
- 用户手动切换比例后,重新应用布局并同步状态。
所以我的页面骨架大概会写成这样:
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
你马上就能知道:刚才是不是发生了比例切换,切换时文章有没有丢,恢复顺序有没有问题。

九、窗口尺寸变化时,最怕的不是切换,而是“切换得没道理”
到了第四篇,我最想避免的一种感觉就是:用户不知道应用为什么突然变了。
所以我给窗口变化时的逻辑定了一个非常明确的顺序:
- 先拿到当前窗口宽度;
- 用断点规则算出推荐比例;
- 如果当前比例是用户主动锁定的,就尊重用户;
- 如果当前比例来自自动模式,才根据新宽度切换;
- 比例切换完成后,再同步状态。
代码大概是这样:
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 定的是一个偏工程化、也比较好解释的规则:
- 首次进入,没有历史记录时,听系统推荐;
- 有历史记录但没有“用户固定”标记时,先恢复,再根据当前窗口宽度校正;
- 用户主动点过比例按钮以后,标记为固定模式;
- 固定模式下,普通尺寸变化不覆盖用户选择;
- 只有发生跨设备级别的明显变化时,才给出重新推荐的机会。
这套规则最终落实成了两个状态位:
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:1 / 1:2 / 2:1; - 中下部是真机运行图,说明在设备上确实像一个成熟产品;
- 右下角是状态流转图,说明窗口变化与阅读恢复是怎么衔接的。
这张图其实就把第四篇最复杂的部分压缩成了一个“可一眼理解”的总览。

十四、第四篇怎么验收,我会看五条线
这篇做完以后,我给自己定的验收并不是“截图好不好看”,而是下面五条线能不能同时成立。
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,继续打磨成一个更完整、更像成品的大屏阅读应用。
更多推荐




所有评论(0)