大屏阅读工作台已经把目录和详情拆开处理:目录以稳定文章标识决定高亮,Navigation 管理可返回的详情历史,窗口尺寸变化时则利用段落锚点找回阅读位置。至此,读者即使在宽窄窗口间切换,也有机会回到原先阅读的段落。但详情数据如果需要异步加载,还会出现一种更隐蔽的错位:前一篇文章的读取结果晚到,覆盖了用户后来打开的文章。

例如从“峡谷行记”切换到“城市光谱”,前者内容较大,读取过程较长;后者很快返回并完成显示。此时若旧回调仅执行 this.content = result,右侧标题可能仍是“城市光谱”,正文却换成峡谷内容。更麻烦的是,详情失去可操作状态以后,后台请求仍可能继续完成。界面显示和资源生命周期不能只依赖哪个 Promise 最后结束,必须有可检查的提交条件。

阅读工作台内容回写关系

一、页面切换会改变回执的有效身份

之前的阅读目录仍保持三条记录:canyon 对应“峡谷行记”,metro 对应“城市光谱”,cloud 对应“云端书签”。详情路由仍使用 ReaderDetail,目录选中态继续由 selectedId 表示,阅读锚点由文章标识和段落标识组合保存。这次扩展的是内容读取,不重做页面入栈,也不改写已经建立的锚点结构。

一次读取需要带上三个信息:请求发起时的文章标识、详情激活代次,以及内容修订号。文章标识防止不同文章互相覆盖;激活代次区分同一详情反复进入前台的过程;修订号处理同一批请求里重复送达或较旧内容晚到的情况。单靠时间戳不够稳妥,因为不同来源的时钟并不一定一致,而且完成时间只是回调到达顺序,并非内容新旧顺序。

NavDestination 具有 onActive、onInactive 等生命周期事件。页面成为可操作页面时,可以开启当前代次的内容读取;失活后,可以停止将结果写入可见状态。这里“失活”与“销毁”不是一回事:页面可能仍保留在路由栈中,只是被后面的详情遮住。若在失活时把文章锚点和缓存一起删除,返回上一页反而会丢失有价值的阅读上下文。

因此需要区分可见提交资格与长期缓存资格。缓存层可以继续保存经过类型及版本检查的内容,当前页面的 @State 则必须服从激活代次。用户切换到另一篇文章后,原请求可以结束,但它的结果不能重新夺走当前详情的位置。

二、用一份提交门禁代替零散的回调判断

在业务层,使用 ReaderCommitGate 保存当前文章、激活代次、激活标志和已接纳回执集合。新文章打开或当前详情重新激活时,生成新的代次票据;读取完成时将票据与当前状态比较。这个纯函数式入口适合单元检查,也避免多个页面组件各自维护不同的取消条件。

下面的 JavaScript 代码可用于 Node.js 的业务状态检查。activate 返回与此次读取绑定的票据;accept 负责决定结果是否有资格成为当前内容。对同一个代次、同一个修订号的重复返回,只记录一次。示例中的 revision 由内容服务或本地索引提供,不使用网络到达顺序代替数据版本。

class ReaderCommitGate {
  constructor() {
    this.articleId = '';
    this.epoch = 0;
    this.active = false;
    this.revision = 0;
    this.content = '';
    this.accepted = new Set();
  }
  activate(articleId) {
    this.articleId = articleId;
    this.epoch += 1;
    this.active = true;
    this.revision = 0;
    return { articleId, epoch: this.epoch };
  }
  deactivate() {
    this.active = false;
  }
  accept(ticket, payload) {
    if (!this.active) return 'INACTIVE';
    if (ticket.epoch !== this.epoch || ticket.articleId !== this.articleId) {
      return 'STALE';
    }
    const key = `${ticket.articleId}:${ticket.epoch}:${payload.revision}`;
    if (this.accepted.has(key)) return 'DUP';
    if (payload.revision < this.revision) return 'STALE';
    this.accepted.add(key);
    this.revision = payload.revision;
    this.content = payload.text;
    return 'APPLY';
  }
}

这里故意没有在 deactivate 时清空 accepted。已经接纳过的回执属于请求历史,切换回来时仍可用于诊断重复处理;下一次激活会取得新代次,旧键不会与新票据冲突。实际项目可以根据内存预算淘汰较早代次的集合,但淘汰前要确认没有相应请求仍可能回调。对长时间阅读会话,直接保留无限增长的集合会积累内存,通常只需要最近若干代次或针对业务操作键设置有效期限。

当新的读取结果到达时,先验证票据,再检查修订号,最后更新 content。顺序不能调换:如果先把内容写到视图,再发现票据过期,即使立即回退也可能出现一帧错误内容。失败状态统一返回 STALE、DUP 或 INACTIVE,方便页面统计,而不会破坏已有 selectedId 和段落锚点。

三、用乱序输入覆盖几个容易忽略的分支

请求返回顺序必须由输入顺序控制,不能靠等待一个固定毫秒数来猜测异步竞态。示例按“打开峡谷、切换城市、城市先返回、峡谷晚返回、城市重复返回、详情失活后旧结果返回、城市重新激活并读到新修订”组织事件。每条事件都明确对应哪一个票据,因此检查结果可稳定复核。

以下代码与上一段放在同一个 JavaScript 文件即可运行,既能得到每次处理的状态,也能汇总最终应显示的正文身份。它刻意使用同步递交构造事件的方法,让异步问题的判断脱离线程调度和机器速度。

const gate = new ReaderCommitGate();
const count = { APPLY: 0, STALE: 0, DUP: 0, INACTIVE: 0 };
const record = (name, ticket, payload) => {
  const status = gate.accept(ticket, payload);
  count[status] += 1;
  console.log(`${name} ${status}`);
};
const canyon = gate.activate('canyon');
const metro = gate.activate('metro');
record('metro rev5', metro, { revision: 5, text: '城市光谱' });
record('canyon rev2', canyon, { revision: 2, text: '峡谷行记' });
record('metro rev5 repeat', metro, { revision: 5, text: '城市光谱' });
gate.deactivate();
record('metro inactive', metro, { revision: 6, text: '城市更新' });
const metroAgain = gate.activate('metro');
record('metro rev6', metroAgain, { revision: 6, text: '城市光谱·新版' });
console.log(JSON.stringify({ count, selected: gate.articleId, revision: gate.revision }));

按代码的数据顺序,结果分别是 APPLY、STALE、DUP、INACTIVE、APPLY,汇总为 APPLY 2 / STALE 1 / DUP 1 / INACTIVE 1,当前文章仍是 metro,内容修订号为 6。这些判断只针对应用的业务提交逻辑。具体数据从网络、数据库或本地文件读出时,还需要为超时、权限变化和内容解析错误返回独立错误码,避免将真正的读取故障误计为过期回执。

开发环境中内容回执的状态检查

四、ArkUI 页面把生命周期映射到业务票据

页面层不需要复制一套复杂的内容状态机,它只在生命周期和用户操作处维护激活资格,并在提交前再做一次身份检查。下一段 ArkTS 保留 NavDestination 的基本组织方式,集中表达代次与页面是否可操作之间的关系;外层 Navigation 仍使用之前建立的 ReaderDetail 路由配置。

@Component
struct ReaderDetailPane {
  @Prop articleId: string = 'metro';
  @State contentStatus: string = '等待读取';
  private active: boolean = false;
  private epoch: number = 0;

  private startRead(): void {
    const requestEpoch: number = ++this.epoch;
    const requestArticle: string = this.articleId;
    this.contentStatus = '读取中';
    Promise.resolve('内容已就绪').then((value: string) => {
      if (!this.active || requestEpoch !== this.epoch ||
        requestArticle !== this.articleId) return;
      this.contentStatus = value;
    });
  }

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        Text('阅读工作台').fontSize(24).fontWeight(FontWeight.Bold)
        Text(this.articleId).fontSize(16)
        Text(this.contentStatus)
        Button('重新读取').onClick(() => this.startRead())
      }.padding(20)
    }
    .onActive(() => {
      this.active = true;
      this.startRead();
    })
    .onInactive(() => {
      this.active = false;
      this.epoch += 1;
    })
  }
}

这里的 Promise.resolve 只是页面层的最小数据返回入口,工程接入时应替换为已有的文章读取服务。更重要的是更新条件:回执必须匹配当前组件的代次和文章标识,同时页面处于可操作状态。onInactive 递增代次,使较早的回调即使在同一文章重新激活之后到达,也不会满足本次资格。这比在回调中仅比较 articleId 更严格。

如果读取服务支持取消,可以保存当前请求句柄,在失活时调用其释放方法;即使请求能够取消,提交前的票据检查仍应保留,因为取消和完成之间存在时间差。页面销毁时,需要解除数据订阅、定时器与事件监听,不能寄希望于组件不再显示就让外部订阅自动消失。本文的 ArkUI 状态仅负责界面,长周期缓存应留在数据层,避免把单个组件的生命周期误用为全局内容仓库的生命周期。

五、让图片排版和内容回写遵守同一顺序

上一阶段保存的阅读锚点由文章标识、内容块标识和块内进度组成。异步正文完成提交后,图片与富文本的尺寸可能还要经历布局计算,因此“正文已接纳”和“锚点能够定位”也不能视为同一时刻。可在数据接纳后触发布局更新,再在相关内容块完成布局后恢复段落位置,并核对当时的 articleId 和 epoch。

若布局准备过程中又发生了切换,上一条恢复指令应终止,不应把当前文章滚动到别人的段落。对于目录返回,高亮仍以 NavPathStack 当前可见的文章身份为准;对于滚动恢复,锚点仅在内容修订号匹配时生效。这样目录身份、内容回写身份与阅读位置身份形成连续检查链路,而不会互相覆盖。

城市光谱详情的业务状态

页面中应保留可读的状态反馈,例如“读取中”“内容已就绪”“内容已更新”,避免把内部 STALE 错误码直接展示成读者无法理解的提示。开发时可在日志中记录文章标识、回执代次、内容版本与门禁结果,但不要输出正文全文;含个人内容的文章还需对标题和外部路径做脱敏处理。

发生读取失败时,当前详情可以继续保留上一份有效内容,同时显示可重试操作;如果内容版本已失效,则应停止锚点恢复,提示刷新后重新定位。如果某篇文章被删除,结果即使对应正确代次,也需要经过目录索引存在性检查,才能成为可见正文。业务身份校验优先于 UI 更新,错误处理才不会让当前页面忽然跳向陌生内容。

六、把提交资格收敛为一种规则

大屏阅读工作台现在已有稳定文章路由、返回后的目录高亮、尺寸变化后的段落锚点,以及异步内容回写的代次检查。四个部分使用相同文章主键,却承担不同职责:Navigation 管理路径,目录管理选择,阅读模块管理位置,内容门禁管理回执资格。以后加入搜索结果直接打开详情、离线缓存刷新或多个详情的快速切换,可以沿用这套模型,而不必再让一个回调同时修改所有状态。

关键原则是回执返回不等于可以提交。每次写入可见状态之前,都要确认当前页面、当前文章与当前代次;同一修订号的重复回执只保留一次。系统生命周期资料可参阅华为开发者文档 NavDestination 页面生命周期、Navigation 分栏开发 和 组件导航概述。

Logo

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

更多推荐