施工进度工作台的第四篇已经将阶段折叠和可视行加载拆开。项目负责人将“主体施工”展开,屏幕里同时出现ACT-02混凝土浇筑和ACT-03钢筋安装,当前位置停在ACT-02附近。此时后台收到一条进度变更:ACT-02由40%调整为45%。如果界面用整张新列表替换所有分组节点,折叠状态、滚动位置和选中对象都可能被重建,用户会觉得自己被突然带回页面顶部。

仍然沿用东区一层主体结构工程,固定周区间为2026年10月26日周一至11月1日周日。ACT-01基础准备安排在10月27日,ACT-02混凝土浇筑从10月28日至30日,ACT-03钢筋安装从10月29日至31日,ACT-04隐蔽验收位于10月31日。前四篇已经定义自然日模型、分组键和可视窗口,本篇增加活动更新事件、修订号与稳定渲染键,让单条数据变化尽量停留在它所属的行。

施工时间轴局部刷新与稳定任务键工程封面

更新事件先匹配身份,再比较修订号

时间轴里至少存在三个彼此不同的标识:活动ID识别业务任务,分组ID识别施工阶段,行键识别渲染节点。ACT-02不因为进度由40%变为45%就更换ID;阶段仍是主体施工;渲染键也仍是task:ACT-02。如果把当前百分比或数组下标拼进键,每一次进度更新都会产生新节点,列表组件可能丢失本来可以复用的状态。

服务端推送或定时拉取的进度更新应有单调递增的修订号。假设ACT-02当前revision为7,收到revision为8、percent为45的事件,业务层可以接受;紧跟着又收到旧的revision为7、percent为40,应拒绝回退。这种防乱序规则比单纯比较事件到达时间更可靠,也无需假定网络请求会严格保持发送顺序。其他活动的对象引用尽可能保持原样,方便上层状态管理判断哪个节点真正发生变化。

百分比字段要约束在0至100之间,revision必须为非负整数;无效数据进入异常记录,不参与列表覆盖。若活动已经完成,还需要业务确认是否允许回改,不能因为字段是合法数字就任意覆盖历史。下面的纯Node.js函数实施这些边界条件,并用对象同一性断言说明只有ACT-02换成新对象,其余行仍持有原来的数据。

const assert = require('node:assert/strict');
const activities = [
  {id:'ACT-01',title:'基础准备',percent:100,revision:3},
  {id:'ACT-02',title:'混凝土浇筑',percent:40,revision:7},
  {id:'ACT-03',title:'钢筋安装',percent:20,revision:2},
  {id:'ACT-04',title:'隐蔽验收',percent:0,revision:1}
];
function patchById(rows, event) {
  if (!Number.isInteger(event.revision) || !Number.isFinite(event.percent) ||
      event.percent < 0 || event.percent > 100) throw new Error('进度事件格式不正确');
  return rows.map(row => row.id === event.id && event.revision > row.revision
    ? {...row, percent:event.percent, revision:event.revision} : row);
}
const next=patchById(activities,{id:'ACT-02',percent:45,revision:8});
assert.equal(next[1].percent,45);
assert.strictEqual(next[0],activities[0]);
assert.strictEqual(next[2],activities[2]);
assert.equal(patchById(next,{id:'ACT-02',percent:40,revision:7})[1].percent,45);

键值跟随业务实体,不跟随展示位置

分组折叠之后,ACT-02在可视数组中的位置可能发生变化。展开基础阶段,它会向后移动;收起验收阶段,它又可能前移。无论行索引如何变化,task:ACT-02这个键始终相同。分组标题则使用group:structure之类的键。给两类节点加不同前缀很有必要:将来某个活动ID与阶段ID字符串相同,也不会发生渲染键冲突。

华为开发者关于Repeat的官方资料指出,键值应具备唯一性与稳定性,且不推荐把位置索引作为身份来源。官方还区分了非虚拟列表和virtualScroll模式下的节点更新、复用与销毁。业务层的稳定键并非保证所有状态自动保存:页面需要继续维护selectedActivityId和可视锚点,若当前项目过滤条件使ACT-02不在窗口里,选中业务ID可以保留,滚动锚点则按可见行寻找最近的阶段标题。

每次数据更新后不必重造全部分组。按组ID把活动合并回原结构,再生成轻量可视行投影即可;视图层根据同一个键更新对应卡片。这里的“局部刷新”指业务数据只替换发生变化的实体,实际创建多少组件仍受ArkUI所选渲染方案、状态管理装饰器、模板和可见范围影响,不能把业务对象复用等同于某个固定的帧耗时或节点数量。

下面用固定键构造投影,并在排序变化时用key重新找回滚动锚点。折叠集合由用户操作维护,后台收到的进度事件不应该把它改回默认值。示例保留上一节的阶段命名和ACT-02选中语义。

const assert = require('node:assert/strict');
const phases = [
  {id:'foundation',name:'基础阶段',items:[{id:'ACT-01'}]},
  {id:'structure',name:'主体施工',items:[{id:'ACT-02'},{id:'ACT-03'}]},
  {id:'acceptance',name:'验收阶段',items:[{id:'ACT-04'}]}
];
function renderRows(phases,collapsed) {
  const rows=[];
  for(const group of phases) {
    rows.push({key:'group:'+group.id,type:'group',name:group.name});
    if(!collapsed.has(group.id)) for(const item of group.items)
      rows.push({key:'task:'+item.id,type:'task',id:item.id});
  }
  return rows;
}
function anchorIndex(rows,selectedId) {
  const pos=rows.findIndex(row=>row.key==='task:'+selectedId);
  return pos<0 ? rows.findIndex(row=>row.key==='group:structure') : pos;
}
const rows=renderRows(phases,new Set(['foundation','acceptance']));
assert.equal(new Set(rows.map(x=>x.key)).size,rows.length);
assert.equal(rows[anchorIndex(rows,'ACT-02')].key,'task:ACT-02');
assert.equal(renderRows(phases,new Set(['structure']))[anchorIndex(renderRows(phases,new Set(['structure'])),'ACT-02')].key,'group:structure');

开发环境中的稳定键与活动修订合并

数据修订与列表显示的事件顺序

若有两个进度事件连续到达,较新的修订号并不保证一定先于旧事件被处理。为了减少主线程上重复计算,页面可以将短时间内积累的事件按活动ID合并,每个活动保留最高revision的那一条,再一次提交新的数据数组。这个队列仅聚合尚未写入的变化;不能跨越不同项目或不同施工周随意合并,因为窗口条件可能不同。消息中应携带项目范围、活动ID与版本,不能仅有百分比。

项目切换时,上一项目的更新事件还可能留在队列里。接收端要先比较projectId,再比较版本。对ACT-02这种任务,不可把其他项目中同名活动覆盖到东区一层的数据。对于时间区间、任务负责人等会改变图表位置的更新,除了重绘任务条,还应重新计算当前周裁剪范围;进度百分比变化则通常不影响日期轴。把字段变更分类,既能避免重复求解周起点,也方便分析为什么某条任务突然改变位置。

合并队列还涉及生命周期。页面离开后,原有定时器和监听应解除;新页面构造自己的队列。因为事件最终还可能参与审计或数据库保存,这里只清理页面接收器,不删除已经持久记录的业务修订。下方代码用Map按活动键聚合,拒绝旧项目的更新,输出按版本合并后的事件,并在销毁后丢弃后续事件。

const assert = require('node:assert/strict');
class RevisionInbox {
  constructor(projectId) { this.projectId=projectId; this.pending=new Map(); this.closed=false; }
  accept(event) {
    if(this.closed || event.projectId!==this.projectId) return false;
    if(!Number.isInteger(event.revision)) return false;
    const old=this.pending.get(event.id);
    if(!old || event.revision>old.revision) this.pending.set(event.id,event);
    return true;
  }
  drain() { const events=[...this.pending.values()]; this.pending.clear(); return events; }
  dispose() { this.closed=true; this.pending.clear(); }
}
const inbox=new RevisionInbox('east-floor-1');
inbox.accept({projectId:'east-floor-1',id:'ACT-02',revision:8,percent:45});
inbox.accept({projectId:'east-floor-1',id:'ACT-02',revision:7,percent:40});
inbox.accept({projectId:'other-project',id:'ACT-02',revision:9,percent:70});
assert.deepEqual(inbox.drain().map(e=>[e.id,e.revision]),[['ACT-02',8]]);
inbox.dispose();
assert.equal(inbox.accept({projectId:'east-floor-1',id:'ACT-03',revision:3}),false);

对照上一个可见快照,减少无关行的更新

工程里还有一个容易忽略的细节:事件修订成功不意味着当前可视窗口必须重新滚动。若ACT-02的进度发生变化,日期边界、阶段归属和任务排序仍相同,那么新的可视行投影应该延续之前的anchorKey,并让显示层仅更新那张任务卡的进度条与修订文字。只有开始时间、结束时间、阶段归属或过滤条件改变时,才需要重算行次序和锚点落点。把这两类更新分开,可以减少无关组件的状态变化,也能给现场人员稳定的阅读位置。

对于进入后台后集中到达的多个事件,可先以项目ID、任务ID和版本号建立更新摘要,再由页面恢复时统一应用。若页面处于窄屏紧凑模式,应更新任务卡而不是为了同步宽屏图表而强行跳到另一种布局;若页面当前只显示主体施工组,基础阶段的合法更新应留在源数据中,等用户展开时自然呈现。这样既不会漏掉后台变更,也不用在折叠状态下创建大量看不见的任务行。

诊断时区分数据更新、可视行变化与滚动位移

发现时间轴跳回顶部时,先检查收到的是内容修订、时间区间修订还是阶段结构修订。随后核对稳定键是否从task:ACT-02意外变成了task:1,再观察collapsedGroupIds是否被整包列表覆盖。还需要核对可视锚点是否仍能在折叠后的行数组中找到,以及容器宽度是否触发了第三篇的布局切换。不同触发来源应分别记录,避免把所有变化归为“刷新导致”。

可在业务日志中保留projectId、activityId、oldRevision、newRevision、changedFields、anchorKey和visibleRowCount等有限字段。字段足以判断哪条事件被接纳,以及为什么触发可视区域重排,不需要记录每个施工任务的完整文本描述。更新操作失败时保留上一个稳定数组,再在界面提供重试入口;数据校验失败的记录单独写入异常列表,不能覆盖合法的旧修订。

当列表使用Repeat或LazyForEach时,选型应基于官方组件生命周期、数据规模和状态管理方式,不宜凭经验给出固定的性能提升百分比。对本工作台最重要的原则是,后台进度更新、用户分组折叠、滚动位置与跨设备布局属于不同的状态维度。ACT-02从40%变为45%,既不该重置10月26日至11月1日的周范围,也不该改变用户已展开的主体施工组。下一篇将进一步处理时间轴视口与时间窗口过滤,这里固定下来的稳定键和revision合并逻辑会成为基础。

手机时间轴中ACT-02进度局部更新界面

参考资料

Logo

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

更多推荐