一套面向折叠屏和平板的阅读工作台,不能把用户的阅读位置完全寄托在当前页面组件上。此前的工程已经建立了 Navigation 的目录与详情关系,完成了重复入栈去重、返回后的目录高亮、窗口尺寸变化时的段落锚点恢复,并为异步正文设置了文章标识和激活代次。下一步要解决的是关闭应用再打开以后,阅读位置还能不能准确回来。

这个问题看起来只是保存一个字符串,实际涉及三份经常被混淆的数据:路由历史说明读者怎样走到当前位置,业务书签说明读者希望从哪里继续,页面排版位置则随窗口宽度和正文内容变化。将完整导航栈、像素滚动量直接写入持久存储,既容易让旧页面状态污染新布局,也很难应对文章内容修订。这里继续沿用“峡谷行记”“城市光谱”“云端书签”三篇目录记录,专门把轻量书签独立保存。

阅读书签与分栏工作台

一、持久化对象应该比界面状态更小

阅读工作台已经使用 canyon、metro、cloud 三个稳定的文章标识。详情路由仍然叫 ReaderDetail;selectedId 服务于目录高亮;段落锚点由 blockId 与段内进度构成。本节将这些既有概念组织成一条持久化记录:articleId、blockId、progress、contentRevision、schema。保存时携带正文修订号,是为了恢复时判断段落身份是否仍然可靠。

以“城市光谱”为例,读者停在 route 段落的百分之四十二处,当时的正文修订号为七。宽屏变为窄屏后,段落会重新换行,但 route 仍是稳定的内容块。恢复动作查找段落所在的新布局,再把块内进度映射到当前高度,而不是把上一次的纵向像素强行滚动到新页面。

这里不保存缩略图、整篇文章正文、导航栈对象或临时 Promise。书签是快速启动所需的极少量业务状态;原文数据仍由阅读内容管理模块负责。这样可以减少写入次数和存储大小,也把需要迁移的格式缩到最小。用户主动切换文章、离开详情、完成阅读位置保存时,记录必须以已经接受的内容修订号为准,不能让上一轮晚到的读取回执写回旧书签。

二、把写入和落盘作为两个明确步骤

ArkData 的 Preferences 适合存储这类轻量键值数据。入口应该获得 UIAbility 的上下文,将数据序列化为字符串,再通过 put 与 flush 完成一次持久化更新。下例只负责存取,格式合法性由后面的业务函数判断,避免存储层同时承担页面路由、内容查找和文本排版。

import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

interface ReadingCheckpoint {
  schema: number;
  articleId: string;
  blockId: string;
  progress: number;
  contentRevision: number;
}

class BookmarkStore {
  private store: preferences.Preferences | undefined;

  async open(context: common.UIAbilityContext): Promise<void> {
    this.store = await preferences.getPreferences(context, 'reader_bookmarks');
  }

  async save(item: ReadingCheckpoint): Promise<void> {
    if (!this.store) { throw new Error('STORE_NOT_READY'); }
    await this.store.put('last_position', JSON.stringify(item));
    await this.store.flush();
  }

  async read(): Promise<string> {
    if (!this.store) { throw new Error('STORE_NOT_READY'); }
    return String(await this.store.get('last_position', ''));
  }
}

open 应在能取得应用上下文以后执行;业务页面通过实例化的存储对象发起读取。read 返回原始字符串而不是未经检查的强制类型转换结果。后续读出对象时还要分别核对属性类型、文章是否存在、进度是否在零到一之间以及修订号是否有效。flush 的作用是把更新从内存状态推进到持久化存储;如果忽略它,进程退出时可能留下尚未持久化的数据。

用户快速拖动阅读进度时不宜每一次滚动都立即落盘。可以在滚动稳定后的短时间内合并写入,在页面失活或用户明确点击保存时再执行一次保存;合并期间的最新候选位置保存在内存中即可。每次写入的 contentRevision 必须来自完成内容提交后的页面状态。若仍处于“读取中”,不应让空白段落覆盖此前有效书签。

三、恢复之前先判定格式和正文版本

持久化的数据一旦写出,就有可能被下一个版本继续读取。早期工程可能只存了文章标识和 offsetPx,现在已经改用段落身份。如果把旧数字当成新版本里的段落进度,恢复结果就会出现跳跃。迁移需要显式区分旧格式和新格式,并优先选择安全的章节起点。

这里为三篇文章维护一份当前内容修订表。文章 metro 的修订号为七,cloud 为五,canyon 为三。新格式且修订相同、段落存在的记录直接精确恢复;旧内容修订号对应的段落可能已经移动,回到文章开头;旧版像素书签转换为文章首段;完全未知的文章标识或损坏数据则跳过。下段函数只依赖业务数据,可以放在构建前的独立检查脚本里验证状态决策。

const catalog = new Map([
  ['metro', { revision: 7, blocks: ['lead', 'route', 'summary'] }],
  ['cloud', { revision: 5, blocks: ['lead', 'chapter2', 'summary'] }],
  ['canyon', { revision: 3, blocks: ['lead', 'route', 'summary'] }]
]);

function resolveBookmark(raw) {
  const current = catalog.get(raw?.articleId);
  if (!current) return { status: 'DROP' };
  if (raw.schema === 1 && Number.isFinite(raw.offsetPx)) {
    return { status: 'MIGRATE', articleId: raw.articleId,
      blockId: 'lead', progress: 0 };
  }
  if (raw.schema !== 2 || !Number.isInteger(raw.contentRevision) ||
      !Number.isFinite(raw.progress) || raw.progress < 0 || raw.progress > 1 ||
      typeof raw.blockId !== 'string') return { status: 'DROP' };
  if (raw.contentRevision !== current.revision ||
      !current.blocks.includes(raw.blockId)) {
    return { status: 'FALLBACK', articleId: raw.articleId,
      blockId: 'lead', progress: 0 };
  }
  return { status: 'EXACT', articleId: raw.articleId,
    blockId: raw.blockId, progress: raw.progress };
}

这段逻辑故意不根据当前窗口宽度决定是否接受书签。窗口只是排版条件,内容修订和段落身份才决定是否能够恢复。如果用户更新了“峡谷行记”的正文,在修订号三的页面里读到修订号二的书签,结果是 FALLBACK:保留文章选择,但回到 lead,不会把旧的 route 高度误用到新版段落中。

更复杂的编辑器可以维护段落映射表,在两个修订版本间把旧段落键转换为新段落键。阅读应用初期不必贸然引入这种映射,因为一旦对应关系错误,恢复到另一段文字比回到首段更难发现。要先保证状态可靠,再根据内容管理方式增加映射规则。

开发环境中的书签读取与迁移逻辑

四、五条数据覆盖四种恢复分支

检查样本应把状态决策完整列出来,避免只拿一条正常书签证明整个恢复流程。下面五条输入分别涉及同版本、旧版本、旧结构和未知文章。样本中的百分比表示段落内相对位置,和 Scroller 的绝对偏移不是同一类数据。

const cases = [
  ['城市光谱', { schema: 2, articleId: 'metro', blockId: 'route',
    progress: 0.42, contentRevision: 7 }],
  ['云端书签', { schema: 2, articleId: 'cloud', blockId: 'chapter2',
    progress: 0.60, contentRevision: 5 }],
  ['峡谷行记', { schema: 2, articleId: 'canyon', blockId: 'intro',
    progress: 0.28, contentRevision: 2 }],
  ['旧版像素书签', { schema: 1, articleId: 'canyon', offsetPx: 920 }],
  ['未知文章', { schema: 2, articleId: 'outside', blockId: 'lead',
    progress: 0.1, contentRevision: 1 }]
];
const counts = { EXACT: 0, FALLBACK: 0, MIGRATE: 0, DROP: 0 };
for (const [name, raw] of cases) {
  const result = resolveBookmark(raw);
  counts[result.status] += 1;
  console.log(`${name} ${result.status} ${result.blockId ?? '-'}`);
}
console.log(JSON.stringify(counts));

根据规则,城市光谱和云端书签均为 EXACT;旧版峡谷书签因正文修订变化进入 FALLBACK;旧像素记录进入 MIGRATE;未知文章进入 DROP。统计对应 EXACT 2 / FALLBACK 1 / MIGRATE 1 / DROP 1。页面上的“恢复阅读位置”入口只对可接受的记录生效,无法定位的记录可以提示用户从目录重新选择。

恢复顺序也有讲究。先读取并检查书签,再将目标文章路由装入 NavPathStack;等详情内容与布局就绪后,才把 blockId 映射到实际滚动位置。若在内容加载前就滚动,块尚不存在,首次定位可能失败;若连续点击恢复按钮,则应沿用已建立的重复导航去重规则,避免生成两层相同详情。

阅读位置恢复与异常记录列表

五、写入时序与异常处理决定长期可靠性

首选项读取失败、JSON 字段缺失、内容版本更新和应用进程意外退出,是四类不同情况。读取失败不应自动触发覆盖写入,否则原有有效书签可能被空数据替代。字段缺失由恢复决策返回 DROP,界面回到未选择状态;内容修订变化使用 FALLBACK,保留可识别的文章身份;进程退出则由最近一次完成 flush 的记录决定恢复点。

保存任务还需要防止乱序。假设读者先停在“峡谷行记”百分之七十的位置,马上又切到“城市光谱”百分之四十二。如果两次写入异步排队并反向完成,最终存储可能回退到更早的文章。可以用单一写入队列串行执行,每次接收新的候选书签时更新队尾,并在提交之前复核当前文章代次。这样仍保留上一个阶段处理旧回执的规则,不会让持久化机制破坏界面一致性。

长文章的正文与缩略图不适合塞进 Preferences,键值文件也不能充当多文章协作数据库。只保存当前书签或少量最近阅读位置即可。若未来引入多个账户、跨设备同步以及冲突合并,就要另建可表达账户归属、修改时间与冲突策略的数据层。本阶段的价值在于把可靠的恢复起点固定下来,为下一步多窗口同步和离线内容管理提供稳定基础。

相关接口可参考华为开发者文档中的 NavDestination 生命周期、Navigation 参数传递 与 Preferences 数据持久化开发案例。工程上最重要的不是把页面状态保存得越多越好,而是让持久记录能够在内容修订、路由变化和窗口重排后仍给出可信的阅读起点。

Logo

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

更多推荐