施工进度时间轴的最后一步,是让同一份项目计划在手机与平板之间保持可以理解的阅读位置。工地管理人员可能早晨用平板查看东区一层主体结构的整周施工安排,中午拿手机继续看ACT-02混凝土浇筑的进度。设备尺寸改变不困难,真正麻烦的是日期窗口、分组展开状态与当前任务在切换后发生错位,更麻烦的是页面关闭以后仍有增量订阅占着资源。这次把阅读快照、版本核对和清理放到一起收尾。

施工进度跨设备阅读状态封面

前七篇已经形成稳定的计划模型:ACT-01基础准备在10月27日,ACT-02混凝土浇筑从10月28日至30日,ACT-03钢筋安装从10月29日至31日,ACT-04隐蔽验收安排在10月31日。业务日期均为YYYY-MM-DD格式,进度及修订号由项目数据提供;日历日序数承担范围求交、周起点和裁剪计算。设备差异首先影响显示密度,不应该反过来改写施工开始和结束的日期。

哪些东西值得继续阅读

我把需要恢复的内容称为阅读状态,而不是把整个组件对象序列化。它只包含项目ID、当前选中的活动ID、周窗口开始日期、分组展开ID、首个可见活动ID、用户选择的视图模式和状态版本。图片缓存、临时滚动句柄、窗口焦点、计时器、订阅函数都不适合进入跨设备数据包;这些资源依赖当前页面实例,另一台设备拿到后也没有意义。

阅读状态还要严格区分业务时间与记录时间。windowStart代表10月26日这个自然日,不随着设备时区漂移;savedAt则是快照记录的实际时刻,通常用带时区信息的时间戳。实际同步时,设备可能临时离线,保存较晚的本地状态不意味着一定比服务端记录新,因此不能只比较客户端时钟。这与第七篇的增量事件游标是两套概念:一个描述活动数据版本,一个描述阅读意图版本。

跨设备恢复尤其需要核对项目归属。同一名称的施工阶段在不同项目里可能有相同显示文本,不能依赖“主体施工”这几个字定位;应使用稳定的projectId、activityId与groupId。若同步来的ACT-02已经从可访问项目中移出,就应清除该选中态,并让视口停留在用户仍有权限浏览的周窗口,不要跳向不存在的详情页。所有权限判断由业务服务或本地授权数据负责,页面只是根据结果恢复展示。

快照格式先做版本化处理

第一步不是写网络同步,而是定义可审核的数据结构。Schema版本用来辨别快照字段含义;revision在本工程里由同一项目的阅读状态服务递增发放,本地离线变更保留草稿版本,不能自行把某台设备的本地计数当作全局顺序。原始快照中可能包含已经不用的字段,恢复前只提取允许列表里的键。当前项目列表尚未加载完成时,应先保留待核验快照,避免空列表被误认为任务已删除。

下面的JavaScript业务代码对输入做了边界收敛;validIds由已经完成权限校验的活动列表产生。它只负责内容结构,不承担系统跨设备传输。

function readSnapshot(raw, projectId, validIds) {
  if (!raw || raw.schema !== 2 || raw.projectId !== projectId) return null;
  const allowed = new Set(validIds);
  const selectedId = typeof raw.selectedId === 'string' &&
    allowed.has(raw.selectedId) ? raw.selectedId : null;
  const day = /^\d{4}-\d{2}-\d{2}$/.test(raw.windowStart || '')
    ? raw.windowStart : '2026-10-26';
  const view = ['compact', 'split', 'overview'].includes(raw.view)
    ? raw.view : 'compact';
  return {
    schema: 2, projectId, selectedId, windowStart: day, view,
    expandedGroups: [...new Set((raw.expandedGroups || []).filter(
      x => typeof x === 'string' && x.length <= 64
    ))].slice(0, 30),
    anchorId: allowed.has(raw.anchorId) ? raw.anchorId : selectedId,
    revision: Number.isSafeInteger(raw.revision) && raw.revision >= 0
      ? raw.revision : 0
  };
}

这里校验了选择项和锚点是否仍属于当前有效活动集合,但日期正则只能检查格式,完整的日历有效性校验还要交给前面日期领域函数。恢复时不允许在活动列表尚未齐全时立即认定选中项失效。页面进入时读取快照,加载项目活动,再执行合法性核对,最后更新组件所需的轻量状态;顺序错了会出现“先闪到首页再跳回任务”的多次布局。

开发环境中的阅读快照与状态同步

多设备更新不能只看时间戳

以平板停留在ACT-02为例,服务器已保存阅读版本9,手机离线时改成了ACT-03,但尚未提交。本地草稿和远端版本9来自不同决策,系统不能在联网一瞬间静默丢掉手机刚刚选中的任务。我的做法是把服务端快照与本地待提交草稿分开:已确认的阅读状态可以按服务端递增修订号合并;存在待提交草稿时,页面标识“存在本地阅读位置”,让用户选择使用当前设备位置或读取已同步位置。

如果服务端修订号低于本地已经确认的版本,返回包可能是缓存或乱序结果,应忽略;修订号相等且内容相同,不必再次渲染;修订号相等但内容不同,则属于服务端契约异常,需要记录冲突而不是自作主张按照设备名称排序。这个判断比使用毫秒时间戳更稳定,也可以复用前一篇处理ACT-02进度增量的版本比较经验,但两个revision不能混在同一字段。

要解决的问题是区分已确认阅读状态、远端落后数据和当前设备仍在编辑的阅读草稿。下面的纯函数只返回业务决策,不会自动改写用户的手机视口。

function reconcileReadState(confirmed, incoming, draft) {
  if (!incoming || incoming.projectId !== confirmed.projectId)
    return { kind: 'reject-project', state: confirmed };
  if (draft && draft.dirty)
    return { kind: 'need-user-choice', state: confirmed, draft };
  if (incoming.revision < confirmed.revision)
    return { kind: 'stale', state: confirmed };
  if (incoming.revision === confirmed.revision) {
    const left = JSON.stringify(confirmed);
    const right = JSON.stringify(incoming);
    return { kind: left === right ? 'unchanged' : 'conflict', state: confirmed };
  }
  return { kind: 'accepted', state: { ...incoming } };
}

数据进入界面后还有布局投影问题。平板可以采用整周七日视图,手机窄屏可能只显示任务卡;二者应从同一个自然日窗口和活动集合衍生出不同的行列表。不要把平板的像素滚动值直接传给手机,那样会落到完全不同的任务。用首个可见的活动ID作为阅读锚点,在新设备重建行列表以后定位对应行,才能抵抗分组展开、屏幕密度和列表顺序的小幅变化。若锚点不在当前可见列表,可退回被选任务,之后才使用周起点。

页面退出时把同步会话一起结束

跨设备阅读状态最容易遗忘的是订阅寿命。页面打开时,数据服务可能推送活动进度和阅读位置两种消息;页面被隐藏或离开时,两套订阅不一定都需要继续。应用级同步与页面级显示通知应分开管理。页面关闭后应解绑自己的阅读通知、取消不再需要的拉取任务、丢弃大尺寸图表投影数组;稳定的本地快照由存储层维护,不应由于视图销毁而被清空。

跨屏布局中的订阅更应按页面实例登记。如果同一个工程同时在两个窗口打开,关闭其中一个窗口只能释放对应的订阅回调,不能关闭另一个窗口仍在使用的共享数据连接。对于共享连接,通常要在服务层维护引用计数或单独的订阅句柄;页面层拿到的解除函数只能解除自己的注册。无论采用何种存储实现,持久化完成与游标推进必须保证次序,以免崩溃后错误地跳过还没有写入的状态。

第三段代码把订阅入口抽象为可注入函数,适用于本地事件总线或项目自己的消息适配器。它避免对跨设备系统SDK作额外假设,订阅接口必须返回一个可调用的解除函数。

function createReadingSession(subscribe, publish) {
  let active = true;
  let version = 0;
  const remove = subscribe(packet => {
    const received = ++version;
    if (!active || !packet || packet.type !== 'reading') return;
    if (received !== version) return;
    publish(packet.payload);
  });
  if (typeof remove !== 'function') throw new TypeError('缺少解除订阅函数');
  return {
    close() {
      if (!active) return;
      active = false;
      ++version;
      remove();
    },
    isActive: () => active
  };
}
const assert = require('node:assert/strict');
const ids = ['ACT-01', 'ACT-02', 'ACT-03', 'ACT-04'];
const state = readSnapshot({ schema: 2, projectId: 'EAST-L1',
  selectedId: 'ACT-02', anchorId: 'ACT-02',
  windowStart: '2026-10-26', view: 'split', revision: 8
}, 'EAST-L1', ids);
assert.equal(state.selectedId, 'ACT-02');
assert.equal(reconcileReadState(state, { ...state, revision: 9 }, null).kind, 'accepted');
let handler; let removed = 0; const displayed = [];
const session = createReadingSession(
  fn => { handler = fn; return () => { removed++; }; },
  value => displayed.push(value)
);
handler({ type: 'reading', payload: { selectedId: 'ACT-02' } });
session.close(); session.close();
handler({ type: 'reading', payload: { selectedId: 'ACT-03' } });
assert.equal(removed, 1);
assert.equal(displayed.length, 1);

跨设备阅读位置与退出清理

从问题现象反推哪个层次出了错

如果手机打开后跳到10月25日而平板停留在10月26日,先核对快照存储的是自然日字符串还是不带时区的毫秒数;若任务名称正确却选中错误,检查是否把数组下标当成了稳定ID;如果返回页面后收到重复的订阅通知,检查页面退出时是否解除了自己的句柄。如果离线恢复后显示旧进度,则要查看活动数据revision和阅读状态revision有没有混用,以及增量游标是否在持久写入之前就被推进。

我会分别检查两个设备读取同一已确认快照时的项目ID、窗口起点、活动锚点和展开组集合,并以任务业务ID核对导航。检查不是要求两个设备具有同样的像素滚动位置,而是确认它们表达相同的阅读意图。对于网络不稳定导致的重复增量,应利用服务端单调水位和幂等合并降低重放影响;对于权限变化,必须重新取得有效任务集合再恢复选中内容,不能让本地旧记录扩大用户可查看范围。

这套时间轴最终保留什么

八篇工程从自然日模型、跨月周视图、断点布局一直延续到局部刷新、窗口裁剪、离线增量,最终把读取状态和业务数据拆成两条独立的演进轨道:活动事实以服务器修订号为准,阅读位置承载用户想继续查看的位置;临时界面资源随页面生命周期创建和释放。这样的结构使手机的紧凑任务卡与平板的整周图表可以保持同一个ACT-02,而不会将设备差异带进施工计划本身。后续扩展多项目导航时,优先保证项目归属与回退行为清楚,再添加同步频率和渲染优化。

华为开发者参考:ContainerReader响应式容器断点、List列表与懒加载生命周期、UIContext多实例适配、NavDestination生命周期。

Logo

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

更多推荐