写到第五篇,我终于可以把“双栏研读台”这个 Demo 当成一个完整系列来回看了。

第一篇,我解决的是“能不能搭起来”;第二篇,我处理的是“能不能顺着读下去”;第三篇,我开始把固定导航、右侧覆盖与阅读路径组织清楚;第四篇,则重点把分栏比例、多尺寸适配和状态同步做稳。到了这一篇,我最关心的已经不是“有没有新页面”,而是另一个更真实的问题:

如果把这个 Demo 当成一个接近可交付的成品来看,它还缺什么?

答案其实并不复杂:缺的是生命周期意识、恢复策略、细节一致性,以及最后那一层把工程做“收”的能力。

很多技术文章做到第四篇时,往往已经把主要能力讲得差不多了,但最后离“成品感”总差一点。差的往往不是大功能,而是下面这些东西:

  • 应用重新进入时,界面状态是不是自然恢复;
  • 用户上次看到哪篇文章、分栏比例是多少、列表滚到哪里了,这些状态是不是能准确回来;
  • 页面从前台到后台、从大屏到小窗、从平板到折叠屏展开态时,行为是不是一致;
  • 代码结构是否已经从“能跑”变成“能维护”。

所以第五篇,我决定不再继续加功能,而是专门把“双栏研读台”做一次真正意义上的收尾:

  1. 用更明确的生命周期方案,接住应用创建、显示、切后台、再恢复这些场景;
  2. 把快照恢复逻辑从“能恢复”提升到“恢复得自然”;
  3. 再次整理项目结构,把状态、布局、路由和存储边界彻底拉开;
  4. 给 Demo 加上接近成品的细节判断标准。

这篇写完,我希望这个系列不是停在“示例代码”,而是停在一个足够像真实项目原型的程度。

在这里插入图片描述

一、为什么第五篇不继续加功能,而是选择“收口”

一套平行视界 Demo 能不能让人信服,其实不是看它塞了多少页面,而是看它有没有一个稳定的结束姿势。

我自己做这套“双栏研读台”时,前四篇已经把“阅读场景”里最主要的能力搭完了:

  • 左侧稳定导航;
  • 中间文章列表;
  • 右侧阅读详情;
  • 多尺寸下的分栏比例;
  • 覆盖式详情切换;
  • 状态同步与基础恢复。

如果第五篇还只是继续加一个“收藏页”或者“评论页”,文章当然也能写,但它并不会让这个系列更完整。反过来,如果我认真把生命周期、恢复策略和成品化细节收好,这一篇反而会把前四篇都串起来。

这是因为平行视界这种能力,本质上不是一个单点功能,而是一种跨设备、多尺寸、长时段持续可用的界面组织方式。只要它不能稳定恢复、不具备自然的生命周期行为,那它看起来再漂亮,也仍然像个半成品。

所以第五篇的核心目标很明确:

让“双栏研读台”从“已经能演示的 Demo”,走到“已经具备成品感的原型”。

二、先重新看一遍项目目录:第五篇开始,结构比页面更重要

到第五篇,我对工程目录又做了一次收整。不是为了“看起来更高级”,而是因为第五篇开始,真正复杂的已经不是单个页面,而是状态和服务之间的边界。

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

这个目录里,我特别在意三个点:

1)页面只负责呈现和事件响应

Index.ets、ArticleList.ets、ArticleDetail.ets 仍然重要,但它们不再承担“存储状态”这种职责。页面该做的,是读取状态、展示状态、触发事件。

2)恢复和同步逻辑全部下沉到服务层

StateSyncService.ets 是第五篇的关键角色,它负责把“比例、文章、滚动位置、导航、覆盖层深度”等内容组装成统一快照,再决定何时存、何时取、如何恢复。

3)模型层不再只是数据结构,而是明确的状态协议

ReadingState.ets 和 RestoreSnapshot.ets 让“什么叫可恢复状态”这件事变得清楚。对于这种系列型文章来说,这个清晰度非常重要,因为它会直接决定文章讲出来是不是工程化。

三、我先把“可恢复状态”重新定义了一遍

前几篇已经有恢复逻辑,但到第五篇我越来越觉得,想真正做到成品化,状态不能只是零散字段,而应该是一份可以完整表达阅读现场的快照。

所以我把它统一成下面这个结构:

export interface RestoreSnapshot {
  navId: string
  articleId: string
  ratio: '1:1' | '1:2' | '2:1'
  overlayDepth: number
  listScrollOffset: number
  detailScrollOffset: number
  ratioPinned: boolean
  saveTime: number
}

这里比第四篇多出来的关键点有两个:

  • detailScrollOffset:右侧文章正文也要记住读到哪里;
  • ratioPinned:区分“系统推荐比例”和“用户主动固定比例”。

这两个字段看起来很小,但它们非常像“成品感开关”。

如果没有 detailScrollOffset,应用重新打开以后,用户虽然回到了同一篇文章,却可能又被送回正文顶部;如果没有 ratioPinned,系统每次窗口变化都可能覆盖用户刚刚选好的阅读习惯。

第五篇真正想传递的,就是这种“细节决定完成度”的感觉。

四、StateSyncService 是第五篇真正的主角

这一篇最重要的,不是页面长什么样,而是状态服务到底怎么设计。

我最后让 StateSyncService 接管了四类职责:

  1. 读取本地快照;
  2. 保存当前快照;
  3. 根据快照恢复界面关键状态;
  4. 把用户的手动选择固定下来。

它的核心代码大概像这样:

import { Preferences } from '@kit.ArkData'

export class StateSyncService {
  private static readonly STORE_KEY: string = 'dual_read_session'
  private static readonly RATIO_KEY: string = 'split_ratio'
  private static readonly ARTICLE_KEY: string = 'current_article'

  async syncCurrentState(articleId: string, listOffset: number, detailOffset: number,
    ratio: string, navId: string, overlayDepth: number): Promise<void> {
    await this.saveSnapshot({
      articleId,
      navId,
      listScrollOffset: listOffset,
      detailScrollOffset: detailOffset,
      ratio,
      overlayDepth,
      saveTime: Date.now()
    })
  }

  async saveSnapshot(snapshot: RestoreSnapshot): Promise<void> {
    const pref = await Preferences.getPreferences(StateSyncService.STORE_KEY)
    await pref.putSync('snapshot', JSON.stringify(snapshot))
  }

  async restoreSnapshot(): Promise<RestoreSnapshot | null> {
    const pref = await Preferences.getPreferences(StateSyncService.STORE_KEY)
    const raw = pref.getSync<string>('snapshot', '')
    return raw ? JSON.parse(raw) as RestoreSnapshot : null
  }
}

这一层一旦稳定,页面会轻松很多。

更重要的是,它让“保存状态”和“恢复状态”变成了可被单独理解、单独测试、单独扩展的能力,而不是散落在各个页面事件里的小碎片。

在这里插入图片描述

五、应用生命周期里,最需要拎清的是哪几个时间点

第五篇如果只讲“onPageShow” 或 “aboutToAppear” 肯定不够,因为真正的收尾一定要把应用从创建到恢复的几个关键节点讲清楚。

我最后重点关注了下面四个时机:

1)应用首次创建

第一次进入时,没有快照,就要走默认布局和默认内容。这个时候更重要的是“快速可用”,而不是“恢复”。

2)页面重新显示

这通常发生在从后台回前台、从其他页面返回、或者窗口重新激活。这个阶段最适合做“状态恢复”。

3)页面交互过程中

只要用户切文章、切分栏比例、滚动列表、滚动正文,就应该触发节流后的状态同步。这里不能每一个像素滚动都存,但也不能完全不存。

4)页面即将离开或应用进入后台

这是保存快照的关键兜底时机。哪怕前面的增量同步有遗漏,这里也要再做一次完整保存。

所以在 Index.ets 里,我最后会把生命周期组织成这样:

@Entry
@Component
struct Index {
  @State private ratio: RatioType = '1:2'
  @State private navId: string = 'home'
  @State private articleId: string = ''
  @State private overlayDepth: number = 0

  private stateSync: StateSyncService = new StateSyncService()

  async onWindowStageCreate() {
    const snapshot = await this.stateSync.restoreSnapshot()
    if (snapshot) {
      this.applySnapshot(snapshot)
    }
  }

  async onPageShow() {
    const snapshot = await this.stateSync.restoreSnapshot()
    if (snapshot) {
      this.applySnapshot(snapshot)
    }
  }

  async onPageHide() {
    await this.saveCurrentSnapshot()
  }
}

我知道很多人看到这里会说:onWindowStageCreate 和 onPageShow 里都恢复,会不会重复?

答案是:会有重叠,但这是有意的。

因为成品化收尾时,我更在意的是“恢复可靠”,而不是“生命周期写得多短”。真正好的做法不是完全避免重复,而是让重复依然有规则,比如:

  • onWindowStageCreate 负责首次进入时的基础恢复;
  • onPageShow 负责重新显示时的二次校正;
  • 如果两次读到的快照一致,页面只会做轻量更新,不会造成闪动。

六、恢复不是把值塞回去就完了,而是要“按顺序恢复”

这是第五篇我觉得最值得拿出来单写的一点。

很多 Demo 做恢复时,会把 ratio、articleId、navId 一次性全塞回去,页面表面上也能跑起来。但只要场景一复杂,就会出现顺序问题:

  • 文章还没加载出来,滚动位置先恢复了,结果无效;
  • 左侧列表还没切到对应栏目,文章却先跳到旧文章;
  • 比例还没应用完成,正文区域高度又变了,导致位置错位。

所以我在第五篇里,专门把恢复流程拆成了一个顺序化过程:

恢复导航 → 恢复分栏比例 → 恢复当前文章 → 恢复覆盖层深度 → 恢复列表滚动位置 → 恢复正文滚动位置

代码里大概像这样:

private async applySnapshot(snapshot: RestoreSnapshot) {
  this.navId = snapshot.navId
  this.applyRatio(snapshot.ratio)
  this.articleId = snapshot.articleId
  this.overlayDepth = snapshot.overlayDepth

  await this.restoreListOffset(snapshot.listScrollOffset)
  await this.restoreDetailOffset(snapshot.detailScrollOffset)
}

这段逻辑的重点不是复杂,而是顺序意识。

第五篇真正收尾的感觉,就是从这里出来的:以前我只是“会恢复”,现在我要让它“恢复得像真的”。

七、第五篇里,我还专门处理了“用户固定比例”和“系统推荐比例”的冲突

第四篇其实已经埋下这个问题,但第五篇必须把它处理完整。

因为当应用逐渐像成品时,用户对界面会形成记忆。这个时候,系统自动推荐比例当然仍然有价值,但它不能粗暴覆盖用户自己的选择。

所以我的策略是:

  • 默认情况下,系统按宽度断点推荐比例;
  • 用户一旦主动点击 1:1 / 1:2 / 2:1,就标记为 ratioPinned = true;
  • 后续普通尺寸变化只做提示,不强制改回推荐值;
  • 只有在跨形态场景(例如从窄窗切到大平板或折叠屏展开态)时,才提醒用户是否重新适配。

对应代码大概会这样写:

private onRatioChange(nextRatio: RatioType) {
  this.ratio = nextRatio
  this.readingState.ratioPinned = true
  this.saveCurrentSnapshot()
}

private handleWindowChange(width: number) {
  const recommended = LayoutService.autoSelectRatio(width)
  if (!this.readingState.ratioPinned) {
    this.applyRatio(recommended)
  }
}

这套规则其实体现的是一个很朴素的产品判断:

当用户已经明确表达了自己的偏好时,系统应该学会退后一步。

八、无边框运行图里,第五篇最重要的不是花哨,而是“像成品”

如果前几篇的运行图强调的是功能,这一篇的无边框纯界面图,更应该突出的是“完成度”。

所以这张图里,我最希望看到的是几个东西同时成立:

  • 左侧导航不再只是能点,而是层级和数量都显得稳定;
  • 顶部比例按钮不再像测试控件,而像产品的一部分;
  • 中间列表与右侧正文在信息密度上形成稳定节奏;
  • 页面上能看到“已恢复上次阅读状态”这类轻提示,但不过度抢眼。

说白了,第五篇要表达的是:

这不再只是一个为了配图而存在的双栏界面,而是一个用户可以真的在上面连续阅读的应用。

在这里插入图片描述

九、类型二综合讲解图,在收尾篇里像一张“总验收图”

我一直很喜欢“类型二综合讲解图”,因为它特别适合技术文章。到第五篇,它的价值就更明显了:它不只是说明某个点,而像一张总验收图。

这一张图里,我会把五类信息放在一起:

  1. DevEco 里的关键代码结构;
  2. 模拟器中的最终 UI;
  3. 生命周期与恢复流程关系图;
  4. 实机桌面场景;
  5. 状态保存和恢复的日志。

这样读者看到它时,不用来回切换上下文,就能一次看明白:

  • 这套 Demo 怎么写的;
  • 最终界面长什么样;
  • 生命周期如何组织;
  • 真机跑起来是不是自然;
  • 状态恢复有没有证据。

从写作者角度讲,这张图其实也是在帮助第五篇“收口”——它把前四篇铺下来的内容,用一种非常直接的方式重新汇总了一遍。

在这里插入图片描述

十、DevEco 图这次我最想展示的,是“恢复逻辑已经成为工程的一部分”

第五篇的第二张 DevEco 图,我刻意没有去放太多视觉噱头,而是让它更像真实开发中的一个状态:

  • 左侧项目目录清楚可见;
  • 中间是 Index.ets 或与生命周期相关的核心代码;
  • 右侧模拟器统一放在右边;
  • 底部日志能直接看到 restore、saveSnapshot、applyRatio 这些关键输出。

因为到了这一篇,读者最需要相信的已经不是“你会不会写 ArkTS”,而是“你有没有把一套阅读应用真正打磨到能长期工作的程度”。

所以我会特别保留这样的日志:

[Lifecycle] onWindowStageCreate start
[State] restore success, navId=home, articleId=1024
[Layout] apply ratio=1:2, overlayDepth=0
[State] save snapshot, ratio=1:2, articleId=1024
[Lifecycle] onPageShow: restore success

有了这些输出,第五篇就不只是“讲思路”,而是有了非常具体的工程证据。

在这里插入图片描述

十一、实机效果最能暴露问题,也最能说明“收尾是不是到位了”

做平行视界这种主题,模拟器当然必不可少,但我一直觉得,最后真正能说明问题的,还是实机。

因为一旦把它放到平板或者更大的横向设备里,很多小问题都会变得特别明显:

  • 导航区是不是太挤;
  • 列表密度是不是太松;
  • 正文区滚动后,重新进入能不能顺着读;
  • 顶部比例切换是不是影响沉浸感。

而第五篇的收尾,恰恰就靠这些问题来反推。

我自己在体验时,最满意的一点其实不是界面变得更好看,而是重新回到应用时,会明显感觉到:

  • 左边还是你刚才看的那个栏目;
  • 中间还是那组文章;
  • 右边还是那篇读到一半的内容;
  • 比例也还是你习惯的那个比例。

这种“继续感”,才是我心里对成品化最重要的判断标准。

在这里插入图片描述

十二、第五篇最后怎么验收,我给自己定了六条标准

到了最终篇,我不再只看“功能点都做了没有”,而是用下面六条标准来验收:

1)首次进入是否自然

没有历史快照时,应用能不能快速进入一个默认但合理的阅读状态。

2)恢复是否完整

重新进入后,能否把栏目、文章、比例、列表位置、正文位置一并恢复。

3)恢复是否按顺序

不能出现先滚再载入、先恢复正文再切栏目这种时序错乱。

4)手动选择是否被尊重

用户主动选中的分栏比例,不能轻易被系统窗口变化覆盖。

5)日志是否能解释行为

恢复失败也好,恢复成功也好,日志都应该能看出发生了什么。

6)代码是否还能继续维护

页面层、状态层、服务层是否已经分清楚,为后续继续扩展留出空间。

如果这六条都能成立,第五篇就算真的把这个 Demo 收住了。

十三、这一套五连载,到这里我会怎么总结

现在回头看这五篇,我觉得它们不是五个散点,而是一条很完整的学习路线:

  • 第一篇:从零搭好双栏阅读骨架,理解平行视界适合什么样的内容场景;
  • 第二篇:把列表、详情和连续阅读主链路串起来;
  • 第三篇:解决固定导航、右侧覆盖切换和阅读路径组织;
  • 第四篇:处理多尺寸下的分栏比例与状态同步;
  • 第五篇:用生命周期、恢复策略和成品化打磨,把 Demo 做到收口。

这五篇一路写下来,我自己最大的感受反而不是“又掌握了一个新 API”,而是越来越清楚:

平行视界真正考验的,从来不只是布局能力,而是内容组织能力、状态管理能力和跨设备体验设计能力。

这也是我想给社区读者留下的核心印象。

如果只想做一张好看的大屏图,可能第一篇就够了;但如果真想做出一个值得长期维护、也值得拿出来讲的 Demo,这第五篇就非常必要。

十四、本文小记

写到这里,“双栏研读台”这个系列终于完整了。

如果说前几篇更像是在不断向外长功能,那第五篇就是在向内收结构、收状态、收体验。它不再追求“再多一个能力点”,而是把已经有的东西真正打磨到能够交代清楚、恢复自然、工程边界稳定的状态。

这也是我越来越喜欢这种连载写法的原因:

  • 它能把一个看似抽象的系统能力,拆成一条可学习、可验证、可演进的路径;
  • 它也逼着我不只写“会用”,而是继续往“用得像产品”推进。

所以第五篇的收尾,我最想留下的一句话其实是:

HarmonyOS 7 平行视界的价值,不只在于把界面并列展示出来,而在于让阅读、切换、恢复、继续这些行为,在多设备场景里都变得自然。

到这一步,“双栏研读台”已经不再只是一个为文章服务的示例工程,而是一个足够像真实原型的平行视界学习样本。

如果后面还要继续扩展,我会优先考虑评论区折叠、文章批注、跨设备接力阅读和更细致的主题切换。但那已经不再是“补作业”,而是在一个稳定底座上的继续演进了。

Logo

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

更多推荐