公交换乘界面的退出与重新进入,表面上只是页面的打开和关闭,实际涉及路线选择、离线资料版本、筛选条件以及异步任务的重新组织。前七篇逐步建立了公交站点图、计划班次、候选路径、宽屏布局、离线资料合并、读屏顺序和异常恢复。此时如果直接保存整个页面对象,下次进入可能继续显示旧候选的“可用”标签;如果每次都清空,又会丢失用户正在查看的换乘方案。更稳妥的方式,是把需要长期保留的业务事实与短暂存在的页面资源彻底区分。

在当前线路表中,市民中心 ST-A 到图书馆 ST-E 的已选候选键为 L1-0835>L2-0848,两段本地计划时刻分别是08:35—08:42与08:48—09:05。第七篇指出,科技馆 L1:ST-B 的无台阶条件由本地 v4 到导入 v5 出现分歧,因此这条候选保留身份,却处于“待复核”状态。重新进入页面时,应保留这一状态和用户的无障碍偏好,同时根据当前有效资料重建候选列表。显示了同一个键,并不等于旧的可行性结论继续有效。

公交退出恢复与路线状态资源清理封面

保存事实,而非保存组件对象

退出快照需要尽量小而明确。这里保留快照结构版本、当前线路资料版本、起终点、选中的稳定候选键、无台阶偏好、待复核站点以及列表焦点键。倒计时秒数、当前设备的屏幕宽度、渲染组件引用、定时器句柄和事件订阅函数都不应进入快照。倒计时以计划出发绝对时间和重新获取的当前时间为依据;分栏布局由当前视口宽度决定;列表焦点则由候选稳定键匹配后再恢复。将这些临时量写入文件,会把设备和页面的偶然状态误认为业务事实。

快照中的 graphVersion 指向创建快照时使用的资料版本,并非指示恢复时必须采用旧数据。读取时要同时取得当前有效的资料版本,比较两者是否一致。如果已经换成 v5,应先对选中路线执行完整约束检查,待复核条目必须继续标出,不能借恢复动作跳过核查。无障碍偏好属于用户选择,但某个站点是否满足无台阶通行应由版本化资料和已确认的复核记录决定,两者不可混在一个布尔字段里。下面的函数把快照限定为可序列化的普通数据,并附加内容摘要以便发现存储损坏。

const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
function checksum(text) {
  return createHash('sha256').update(text, 'utf8').digest('hex');
}
function createCheckpoint(state) {
  if (!state || !['v4', 'v5'].includes(state.graphVersion)) {
    throw new Error('invalid graph version');
  }
  if (typeof state.selectedKey !== 'string') throw new Error('invalid key');
  const payload = {
    schemaVersion: 1,
    graphVersion: state.graphVersion,
    origin: state.origin,
    destination: state.destination,
    selectedKey: state.selectedKey,
    stepFreeOnly: state.stepFreeOnly === true,
    pendingStopKeys: [...new Set(state.pendingStopKeys || [])].sort(),
    focusKey: state.focusKey || state.selectedKey
  };
  const serialized = JSON.stringify(payload);
  return { payload, sha256: checksum(serialized) };
}
const saved = createCheckpoint({
  graphVersion: 'v5', origin: 'ST-A', destination: 'ST-E',
  selectedKey: 'L1-0835>L2-0848', stepFreeOnly: true,
  pendingStopKeys: ['L1:ST-B', 'L1:ST-B']
});
assert.equal(saved.payload.pendingStopKeys.length, 1);
assert.equal(saved.payload.selectedKey, saved.payload.focusKey);
assert.equal(saved.sha256.length, 64);
console.log(saved.payload);

这里把去重和排序放在快照形成阶段,是为了让重复提交同样的用户状态产生一致的序列化结果。SHA-256校验用于发现内容不一致,不承担身份认证作用;如果存储位置可能受到不可信输入影响,还需在外部设计来源验证和访问控制。快照结构应维护 schemaVersion,而不是让恢复逻辑猜测哪个字段可能缺失。未来增加多个候选收藏时,可以通过显式迁移函数把旧结构提升到新结构,不要直接改变已经落盘的字段含义。

可靠写入与受控恢复

页面消失时发起一次异步写入,并不保证写入总能完成。关闭页面可能遇到进程终止,多个快速切换也可能让较早的任务晚于较新的任务结束。因此持久化层应串行处理同一个快照键,按状态版本或递增修订号判断新旧,并对写入失败提供反馈。在Node.js本地工程脚本中,可以通过同目录临时文件和重命名降低读到半截JSON的可能性。下方代码采用临时目录完整演练写入、读取与摘要比对,文件名由应用控制,不接受任意外部路径。

const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { createHash } = require('node:crypto');
const digest = text => createHash('sha256').update(text).digest('hex');
async function writeCheckpoint(file, payload) {
  const body = JSON.stringify(payload);
  const envelope = JSON.stringify({ payload, sha256: digest(body) });
  const temporary = file + '.pending';
  try {
    await fs.writeFile(temporary, envelope, { encoding: 'utf8', flag: 'w' });
    await fs.rename(temporary, file);
  } catch (error) {
    await fs.rm(temporary, { force: true });
    throw error;
  }
}
async function readCheckpoint(file) {
  const data = JSON.parse(await fs.readFile(file, 'utf8'));
  if (digest(JSON.stringify(data.payload)) !== data.sha256) {
    throw new Error('checkpoint digest mismatch');
  }
  if (data.payload.schemaVersion !== 1) throw new Error('unknown schema');
  return data.payload;
}
(async () => {
  const folder = await fs.mkdtemp(path.join(os.tmpdir(), 'route-state-'));
  try {
    const file = path.join(folder, 'checkpoint.json');
    await writeCheckpoint(file, { schemaVersion: 1, graphVersion: 'v5',
      selectedKey: 'L1-0835>L2-0848', stepFreeOnly: true });
    assert.equal((await readCheckpoint(file)).graphVersion, 'v5');
    await fs.writeFile(file, '{"payload":{},"sha256":"bad"}');
    await assert.rejects(readCheckpoint(file), /digest mismatch/);
  } finally {
    await fs.rm(folder, { recursive: true, force: true });
  }
})().catch(error => { console.error(error); process.exitCode = 1; });

实际HarmonyOS应用可根据数据量选择Preferences或面向记录的持久化方案。Preferences适合保存少量选择键、筛选配置和快照元信息;使用其写入接口后,应依据所选存储模式执行相应持久化处理。多进程同时操作同一偏好文件存在一致性风险,需要将更新入口收敛,不能照搬Node.js的文件路径与重命名方法当作ArkTS平台接口。文件型离线线路资料、异常审计记录和用户偏好也不宜混写成一个无限增长的键值对象。当前小规模路线快照可以采用一个独立键,但版本表仍需自身的校验与更新流程。

公交恢复页面的开发环境与数据流

恢复顺序比“成功读取JSON”更重要。页面重新显示时,先建立新的会话标识并读取当前资料版本;随后读取快照、验证结构及来源,在新线路图上重新筛选路径;再按稳定候选键查找项目。如果该键仍存在但被 L1:ST-B 的待复核状态阻断,界面应选中它并同时显示“不可推荐”,而不是回退到第一条看似可用的路线。如果键已消失,可以保留原键供用户查看失效原因,将焦点移动到仍存在的候选,并明确提示选择发生变化。这样既不会误恢复旧结论,也不会让读屏焦点跳到不相干的行。

页面退出时清理短期资源

界面资源不是业务快照的一部分。公交候车倒计时可能注册周期刷新,线路资料更新可能绑定事件监听,路线查询还可能存在正在进行的异步请求。如果页面离开后这些回调继续持有组件引用,下一次进入时可能发生重复刷新、重复订阅甚至旧查询结果覆盖新页面。资源清理应设计为可重复调用:定时器可以多次清除,订阅只移除属于当前页面的监听,查询应取消或使用会话代号使过期结果失效。

以下Node.js代码用一个资源作用域集中管理定时器和事件监听。作用域关闭后不能再注册新资源,重复关闭也不会重复执行清理动作。它体现的是应用层资源所有权机制;在ArkUI页面中要根据具体生命周期安排关闭时机,不能把后台隐藏与组件最终销毁看成同一事件。需要继续保持候车提醒的业务任务,也应由独立的服务或适合的系统机制持有,而不是让不可见页面的普通定时器长期悬挂。

const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
class ResourceScope {
  constructor() { this.cleanups = []; this.closed = false; }
  own(cleanup) {
    if (this.closed) throw new Error('scope is closed');
    this.cleanups.push(cleanup);
  }
  interval(callback, milliseconds) {
    const handle = setInterval(callback, milliseconds);
    this.own(() => clearInterval(handle));
  }
  listen(emitter, event, callback) {
    emitter.on(event, callback);
    this.own(() => emitter.off(event, callback));
  }
  close() {
    if (this.closed) return;
    this.closed = true;
    for (const cleanup of this.cleanups.reverse()) cleanup();
    this.cleanups = [];
  }
}
const stream = new EventEmitter();
let refreshes = 0;
const page = new ResourceScope();
page.listen(stream, 'version', () => { refreshes += 1; });
page.interval(() => { refreshes += 1; }, 60000);
stream.emit('version', 'v5');
assert.equal(refreshes, 1);
page.close();
page.close();
stream.emit('version', 'v5');
assert.equal(stream.listenerCount('version'), 0);
assert.equal(refreshes, 1);
assert.throws(() => page.own(() => {}), /closed/);
console.log('资源已释放');

同一个会话的异步任务还应携带请求代号。页面重新打开后,即使旧请求未能被即时取消,其结果也不应提交到新的状态容器。尤其是在离线数据切换时,v4 发起的计算可能晚于 v5 查询返回;对结果进行资料版本和会话双重比对,能避免界面回到上一份资料。对于长列表,应在数据渲染准备好之后再用稳定键确定索引,必要时通过List绑定的Scroller定位;不要把上一屏的行号当作唯一恢复依据。

公交线路状态恢复与资源清理界面

从异常状态闭环到工程收尾

这一系列的公交工程以本地班次和站点资料为基础,提供路线候选及换乘条件的计算。结束时要确认几个互不替代的事实:有效线路图版本可辨识,选中候选键能够回溯,待复核站点没有被自动转为可用,页面重复进入不会留下额外订阅,重新筛选结果与当前资料相符。无台阶信息需要以明确的复核记录为依据;它不能因为页面完成初始化就视为确认。对于计划到发时刻,恢复后的倒计时还要按当前时间重新计算,不能使用退出之前剩余的秒数。

接口使用时还需要区分页面生命周期。华为开发者的Navigation文档给出了NavDestination的 onHidden、onActive、onWillDisappear 等回调,它们对应隐藏、重新激活和即将销毁等不同阶段。隐藏时适合暂停与显示有关的短期刷新;销毁时清理页面持有的资源;真正的业务快照写入应在状态发生重要变化时就安排,而不是赌最后一个销毁回调一定来得及完成异步操作。ArkUI列表文档说明,可以通过Scroller定位条目,但恢复焦点应当与最新列表数据一致。

相关技术资料:华为开发者Navigation子页面生命周期、Preferences持久化接口、ArkUI List及滚动控制。

路线退出恢复的核心并不是把屏幕停在原来的位置,而是在资料更新后依然能够解释用户选中了什么、这一选择为何受到限制,以及哪些刷新任务已经结束。把业务事实存进可迁移的快照,把布局和候选重新计算,把定时器与监听统一归属到生命周期,公交换乘工程就形成了从建图、选路、无障碍约束到收尾的一条完整闭环。

Logo

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

更多推荐