阅读工作台已经具备目录与详情分栏、重复点击去重、返回后的选中态同步、段落锚点、异步回写保护和书签存储。随着文章列表支持修订与下架,这些机制又会遇到一个共同问题:目录已经更新,路由栈里仍保留旧文章,书签指向被移除的内容,预取缓存可能装着上一版正文。三个对象各自看着正常,组合起来却会出现左栏无法选中、右栏仍显示旧稿的情况。

最容易被忽略的时机是进入阅读工作台时恢复上次会话。假设用户上次打开“城市光谱”,后来这篇文章退出当前目录;此时如果按原来的本地书签还原页面,列表中已经找不到它。另有“云端书签”内容从修订号五变成六,旧缓存却可能继续参与内容展示。解决办法不是在每个页面分别判断,而是在目录版本变化时完成一次业务状态对账,并将结果有顺序地交给界面层。

目录变更后的阅读状态总览

一、把目录作为身份与内容修订的共同基准

这里继续使用同一套阅读工作台资料:“峡谷行记”的稳定身份是 canyon,“城市光谱”是 metro,“云端书签”是 cloud。稳定身份解决的是“这篇是谁”,修订号解决的是“现在展示的是否仍是这一版”。它们各自承担不同职责,不能用标题、列表位置或图片地址作为长期标识。标题允许调整,列表可能重新排序,资源链接也可能变更。

对账的输入分为两部分。新目录保存文章身份和正文修订号;旧状态保存当前选择、最近阅读的身份顺序、书签集合与内容缓存。真正需要删除的对象由新目录决定,保留哪些书签和缓存也取决于是否仍存在对应文章。发生版本变化时,书签可以保留文章身份,再根据段落版本安排锚点降级;正文缓存必须按修订号严格匹配,避免旧文本覆盖新文本。

示例中,新目录保留 canyon 修订三和 cloud 修订六,移除 metro。旧状态还选着 metro,历史阅读次序为 canyon → metro,书签为 metro 与 canyon,缓存分别保存 metro 修订七、cloud 修订五、canyon 修订三。这组数据足以把重选、清除和失效处理拆开观察,避免只测试列表顺序正常的一条路径。

二、写一段可复用的目录对账逻辑

页面组件不应该掌管缓存淘汰与书签规则。把这一段放在业务模型中,输入输出都采用明确字段,之后宽屏、窄屏和恢复入口都复用相同决策。下面使用 ArkTS 风格的数据结构,返回的是下一份完整状态及四项计数。最近阅读历史采用从早到晚的顺序,逆序查找仍存在的文章;全部失效时退回目录首项,空目录则返回空选中态。

interface ArticleEntry { id: string; revision: number; title: string }
interface BookmarkEntry { articleId: string; paragraphId: string }
interface CachedEntry { articleId: string; revision: number; bytes: number }
interface ReaderState {
  selectedId: string;
  recentIds: string[];
  bookmarks: BookmarkEntry[];
  cache: CachedEntry[];
}
interface ReconcileResult {
  next: ReaderState;
  reselect: number;
  dropBookmark: number;
  evictCache: number;
  keepCache: number;
}

function reconcileCatalog(old: ReaderState, articles: ArticleEntry[]): ReconcileResult {
  const revisions: Map<string, number> = new Map();
  articles.forEach((item: ArticleEntry) => revisions.set(item.id, item.revision));
  let selected: string = old.selectedId;
  let reselect: number = 0;
  if (!revisions.has(selected)) {
    const validHistory: string[] = old.recentIds.filter((id: string) => revisions.has(id));
    selected = validHistory.length > 0 ? validHistory[validHistory.length - 1] :
      (articles.length > 0 ? articles[0].id : '');
    reselect = old.selectedId === selected ? 0 : 1;
  }
  const bookmarks: BookmarkEntry[] = old.bookmarks.filter((x: BookmarkEntry) =>
    revisions.has(x.articleId));
  const cache: CachedEntry[] = old.cache.filter((x: CachedEntry) =>
    revisions.get(x.articleId) === x.revision);
  return {
    next: {
      selectedId: selected,
      recentIds: old.recentIds.filter((id: string) => revisions.has(id)),
      bookmarks: bookmarks,
      cache: cache
    },
    reselect: reselect,
    dropBookmark: old.bookmarks.length - bookmarks.length,
    evictCache: old.cache.length - cache.length,
    keepCache: cache.length
  };
}

这一段刻意不触及 Navigation 组件,也不负责磁盘上的文件清理。业务状态应该先确定,再安排界面和存储更新。这样,目录更新失败时保留上一份可用状态;目录成功更新而缓存回收失败时,也能把后者安排到下一次巡检,不会为了删除一项旧缓存导致整个目录不可用。数据量很大时可把查找结构集中构建为 Map,避免在每次循环中扫描整个目录。

三、把对账结果交给 Navigation 与书签存储

NavPathStack 负责页面历史,不等同于文章目录。对账返回的新选中文章还需要同步到路由,否则左栏可能已经高亮“峡谷行记”,右栏依然停留在“城市光谱”。对当前示例,最容易理解的处理是先冻结过期回执,替换当前有效详情,再统一刷新高亮。对于包含多个历史详情的产品,应考虑保存合法历史并逐项过滤,不能无差别清空用户所有导航记录。

以下是主页面中的关键方法,沿用此前建立的 ReaderDetail 页面名称,参数仍只传文章身份。clear() 与 pushPathByName() 用于重建当前阅读详情;不再显示任何文章时回到目录占位区域。generation 让旧版内容请求的回执自动失去提交资格,书签和缓存的持续化由各自仓储在下一阶段完成。

// 位于已有的阅读工作台页面组件内部
@State selectedId: string = '';
private pathStack: NavPathStack = new NavPathStack();
private generation: number = 0;

applyCatalogResult(result: ReconcileResult): void {
  this.generation += 1;
  const nextId: string = result.next.selectedId;
  this.selectedId = nextId;
  this.pathStack.clear();
  if (nextId.length > 0) {
    this.pathStack.pushPathByName('ReaderDetail', { articleId: nextId }, false);
  }
  this.bookmarkStore.replace(result.next.bookmarks);
  this.contentCache.replace(result.next.cache);
}

canApplyReply(articleId: string, requestGeneration: number): boolean {
  return this.selectedId === articleId && this.generation === requestGeneration;
}

bookmarkStore 和 contentCache 是此前文章定义的业务仓储对象,分别负责书签写入与带预算的内存缓存;代码中的 replace 是项目方法,不是系统接口。特别需要保证保存顺序:先确认有效目录,再保存新的书签集合,写入结束后通知界面刷新。持久化仍使用前一篇引入的 ArkData Preferences,业务层写入完成后调用 flush(),并捕获可恢复的失败。下次启动如果发现目录修订号不一致,还要重新走对账流程,而不能相信一个孤立的保存成功标记。

阅读工作台的目录对账开发结构

四、用一组连续输入检查修复是否闭合

下面把示例数据放在正文里,减少附加资源依赖。它考察四个结果:选中项从 metro 回退至 canyon;metro 书签退出有效集合;两份缓存因文章移除和修订不一致而失效;canyon 修订三的缓存可以继续使用。每个动作都能从输入直接推导,不需要把界面上的提示数量当成业务事实。

const catalog = [{id:'canyon', revision:3}, {id:'cloud', revision:6}];
const old = {
  selectedId:'metro', recentIds:['canyon','metro'],
  bookmarks:[{articleId:'metro',paragraphId:'intro'},
             {articleId:'canyon',paragraphId:'river'}],
  cache:[{articleId:'metro',revision:7,bytes:120},
         {articleId:'cloud',revision:5,bytes:92},
         {articleId:'canyon',revision:3,bytes:168}]
};
const rev = new Map(catalog.map(x => [x.id,x.revision]));
const recent = old.recentIds.filter(id => rev.has(id));
const selected = rev.has(old.selectedId) ? old.selectedId : (recent.at(-1) || catalog[0]?.id || '');
const bookmarks = old.bookmarks.filter(x => rev.has(x.articleId));
const cache = old.cache.filter(x => rev.get(x.articleId) === x.revision);
const outcome = {
  RESELECT: Number(selected !== old.selectedId),
  DROP_BOOKMARK: old.bookmarks.length - bookmarks.length,
  EVICT_CACHE: old.cache.length - cache.length,
  KEEP_CACHE: cache.length
};
console.log(selected, outcome);

这段 JavaScript 可直接在 Node.js 中运行,输出应是 canyon 和 RESELECT: 1、DROP_BOOKMARK: 1、EVICT_CACHE: 2、KEEP_CACHE: 1。数据中的缓存大小单位为 KB,原有三项合计三百八十,筛选后仅留下百六十八;后续邻篇预取仍遵循此前定义的三百四十 KB 总预算。要注意,预算校验是对有效缓存的第二道检查,并不能被目录身份筛选替代。

如果目录变成空集合,选中项和路径都应退出详情状态;如果删除的正好是历史列表最后一篇,还应继续向前查找。如果文章仅更改标题而修订号不变,书签与正文缓存依旧有效;如果修订号增加,缓存应立即失效,书签锚点则需再检查段落是否存在。这些分支比“删除后默认打开第一篇”的简单写法更接近实际阅读场景。

目录修订后的阅读状态

五、异步更新、并发返回与资源释放要同一节拍

大屏上两栏同时显示时,目录更新与用户点击可能几乎同时发生。如果目录版本更新后才关闭旧请求回写,较慢的正文结果可能在新路径已经就绪时覆盖右栏内容。更稳妥的顺序是:收到新的目录修订号即增加请求代次;执行纯数据对账;更新路由和选中项;最后安排正文加载、书签落盘和缓存回收。原先的 NavDestination 活跃态检查仍保留,它与当前文章身份、请求代次三者共同决定回执能否入场。

同时要注意 NavPathStack 的历史语义。上面的清栈方法适合“目录主版本变更后重建当前阅读”的入口,若应用还有编辑页、设置页、外部深链等其他页面,就应该限定清理范围,不能把所有业务页面都当成阅读详情。交互层可以先将无效页面重定向到合法文章,再恢复可保留的导航历史。这样既维持了菜单高亮的确定性,也避免用户在返回时又看见被撤下的内容。

目录修订并不会自动删除实际存储文件。缓存索引清理与磁盘删除应分离:先从可读取索引中撤销无效项目,再按文件是否被其他位置引用决定是否删除。此前的预取队列也要在目录更新后失效旧代次,阻止已被移除的文章重新占用缓存预算。所有失败状态应留下可再次处理的标记,不应让异常处理反过来破坏用户正在阅读的有效文章。

六、把整个系列的状态交汇到一个确定结果

经过这一轮,阅读工作台的目录、路由、书签、缓存和异步请求不再各自维护互相矛盾的当前文章。它们共享文章稳定身份与修订号,目录更新先得到一个确定的新状态,再由页面与仓储分别落实。此前的分栏布局、返回高亮、段落锚点、持久化和邻篇预取也因此有了共同的收束点。

实际项目中最值得长期保留的是纯函数对账和清晰的输入集合。测试时可以很方便地加入空目录、重复文章身份、错误修订号、请求晚到、书签损坏以及存储失败等组合,而不需要每次从界面交互开始构造完整路径。读者看到的是一篇平稳切换的文章,工程内部则是一组可以解释、可以恢复且有明确责任归属的数据决策。

相关资料:Navigation 子页面与生命周期、Navigation 参数与返回处理、HarmonyOS 开发文档中心。

Logo

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

更多推荐