HarmonyOS 展馆导览工程·第5篇:拆解 LazyForEach 的身份、版本与增量通知【共创稿事节】
列表里的展品还是同一件,但“尚未查看”已经变成“已看”。这次变化究竟应该保留组件,还是重建组件?只回答“给每一项一个唯一键”还不够,因为唯一性解决的是不同条目之间的区分,更新策略还要回答同一条目改变之后如何让界面读到新内容。GalleryGuide 的第五步就围绕这个问题,将静态 ForEach 改为有明确通知协议的数据源。

业务身份和渲染身份并不完全相同
第1篇使用 pottery-01 标识彩陶条目,这个身份继续用于导航和仓库查询,不因列表状态变化而改变。本篇在它外面增加一个展示行:展品对象、查看标记、渲染版本。查看标记只属于本次导览会话,不修改展品说明;渲染版本用于告诉本例的懒加载列表,这一行已经产生了需要重新构建的展示内容。
这不是要求所有列表都在键后拼版本号。若条目组件已经通过适合的可观察数据模型精确更新内部属性,保持业务键并由状态系统刷新内容可以是另一种设计。本篇选择普通行对象与替换式更新,明确使用 onDataChange 通知,并让变更行生成新键。为了使取舍清晰,行内没有输入框、临时选区或必须保留的复杂局部状态。
未变化的行继续保持原键,只有实际修改的行增加版本。这样既不会因为一次按钮点击使全列表换身份,也不会让已变化对象沿用旧构建结果。随机数和当前时间都不适合作为键:即使业务数据没变,每次取键也可能产生新身份,框架就失去了稳定比较的依据。键应该由数据事实决定,不能由执行时机决定。
本篇面向 API 12 及以上,使用 IDataSource 的基础监听接口,不混用批量 onDatasetChange。后者有自己的一组索引和操作约束,迁移时应整套选择更新协议,不能看到有一个新接口就与已有 onDataAdd 随意穿插。当前先实现单条状态变更与尾部追加,两种操作足以观察更新路径。
懒加载指的是按需要创建界面节点,并不自动把仓库变成分页接口。EXHIBITS 仍然完整保存在内存,totalCount 返回现有条目数量;如果将来数据来自远端分页,还需要独立处理下一页请求、重复页、失败重试和结束标记。把网络请求写进 getData,会让滚动读取隐式触发副作用,也很难控制重复调用,本例坚持让读取保持简单。
查看标记也不是系统判断用户真正看过展品的证据。这里由显式按钮切换,目的是提供确定的数据变更输入;真实产品若要自动记录,需要定义点击、停留或完成讲解等具体规则。将可视区域出现过一次等同于已经参观,会把渲染机制误当成业务事实。展示字段的来源必须先被定义,再讨论如何高效更新它。
数据源先提交数据,再发出对应通知
新增 entry/src/main/ets/common/ExhibitSource.ets,引用第1篇的数据模块。追加时同时更新 EXHIBITS,因此第1篇的 findExhibit 仍能查询新条目;查看标记只存放在行中,不进入馆藏模型。这个示例只有 Index 拥有一份数据源,不负责多仓库实例之间的同步。
import { Exhibit, EXHIBITS, findExhibit } from './Exhibit';
export interface ExhibitRow {
item: Exhibit;
note: string;
revision: number;
}
export class ExhibitSource implements IDataSource {
private rows: ExhibitRow[] = EXHIBITS.map((item: Exhibit): ExhibitRow => {
return { item: item, note: '尚未查看', revision: 0 };
});
private listeners: DataChangeListener[] = [];
totalCount(): number {
return this.rows.length;
}
getData(index: number): ExhibitRow {
return this.rows[index];
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const index = this.listeners.indexOf(listener);
if (index >= 0) {
this.listeners.splice(index, 1);
}
}
toggleViewed(id: string): boolean {
const index = this.rows.findIndex((row: ExhibitRow) => row.item.id === id);
if (index < 0) {
return false;
}
const old = this.rows[index];
this.rows[index] = {
item: old.item,
note: old.note === '已看' ? '尚未查看' : '已看',
revision: old.revision + 1
};
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataChange(index);
});
return true;
}
append(item: Exhibit): boolean {
if (findExhibit(item.id) !== undefined) {
return false;
}
EXHIBITS.push(item);
const index = this.rows.length;
this.rows.push({ item: item, note: '尚未查看', revision: 0 });
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataAdd(index);
});
return true;
}
}
通知表达已经完成的数据变化,而不是请求框架替我们修改数组。toggleViewed 先找到当前索引,再替换行,最后发送 onDataChange;append 先写入实体与展示行,再发送新增通知。顺序颠倒时,框架收到通知后可能读取到旧数据或不存在的位置。对增量列表来说,索引是某次操作的位置,业务标识才是跨操作的身份。
无效标识和重复追加都返回 false,不发送虚假通知。把 findIndex 的负数结果直接交给监听器,可能导致错误行被更新;只要发生了按钮点击就广播“数据改变”,同样会让通知失去可信含义。通知应该由成功的数据操作产生,而不是由用户动作的数量产生,这也是后来接入服务端增量消息时需要保留的约束。
监听器的注册与注销是 IDataSource 协议的一部分,不能只实现读取方法。注册去重防止同一监听器被重复通知,注销按对象身份移除对应项。这里不手动调用框架的注册流程,只提供实现给 LazyForEach 使用。把监听器列表当成普通事件总线向外暴露,会增加重复订阅与生命周期耦合,本例不提供这种出口。
getData 只做按索引访问,totalCount 只读取长度,二者不排序、不格式化长文本,也不写日志。它们是列表获取内容的基础路径,额外工作会随着访问次数被放大。需要展示价格、时间或复杂富文本时,可以在数据进入展示模型时完成转换,并保存轻量结果;不要每一次读取都重新解析同一段原始数据。
toggleViewed 使用业务标识查找当前索引,而不是把首次绘制时的下标捕获进按钮。即使本篇没有删除操作,这个习惯仍然有价值:将来列表插入或排序后,旧下标不再代表原来的实体。把身份传给数据源,再由数据源在当前数据中定位,可以把索引有效性的判断集中到操作发生时,而不是依赖某次过去的渲染。
保持数据源实例,替换列表的渲染方式
在第4篇 pages/Index.ets 顶部增加数据源导入。Index 内新增两个字段,原有 stack 与 destination 保留,ExhibitDetail 继续使用上一版;以下字段属于同一个 Index,不创建第二个入口组件。
import { ExhibitRow, ExhibitSource } from '../common/ExhibitSource';
private source: ExhibitSource = new ExhibitSource();
@State notice: string = '查看标记仅保留在本次会话';
将 Index 的 build 替换为以下完整方法。为了聚焦更新机制,两个操作按钮用于可复现地改变样例数据,不代表真实展馆内容管理后台。它们不访问远端服务,也没有任何测量数据。
build() {
Navigation(this.stack) {
Column({ space: 12 }) {
Row({ space: 8 }) {
Button('切换首件标记').fontSize(14).layoutWeight(1)
.padding({ left: 8, right: 8 }).onClick(() => {
const changed = this.source.toggleViewed('pottery-01');
this.notice = changed ? '已切换彩陶条目的查看标记' : '未找到目标展品';
})
Button('追加展品').fontSize(14).layoutWeight(1)
.padding({ left: 8, right: 8 }).onClick(() => {
const added = this.source.append({
id: 'stone-04', title: '刻纹石片', hall: '石器厅',
summary: '观察刻线的走向与交叉关系。'
});
this.notice = added ? '已追加刻纹石片' : '该展品已经存在';
})
}.width('100%').padding({ top: 12 })
Text(this.notice).fontSize(14).fontColor('#52616B')
List({ space: 12 }) {
LazyForEach(this.source, (row: ExhibitRow) => {
ListItem() {
Column({ space: 8 }) {
Text(row.item.title).fontSize(22).fontWeight(FontWeight.Medium)
Text(row.item.hall).fontSize(16).fontColor('#52616B')
Text(row.note).fontSize(15).fontColor('#147D92')
}.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}.backgroundColor('#FFFFFF').borderRadius(16)
.onClick(() => {
this.stack.pushPath({ name: 'ExhibitDetail', param: row.item.id });
})
}, (row: ExhibitRow) => `${row.item.id}:${row.revision}`)
}.width('100%').layoutWeight(1)
}.padding(16).width('100%').height('100%')
}
.title($r('app.string.guide_home_title'))
.mode(NavigationMode.Auto)
.navBarWidth(280).navBarWidthRange([240, 360]).minContentWidth(360)
.navDestination(this.destination)
.backgroundColor('#F2F5F7').width('100%').height('100%')
}

source 是普通的组件字段,整个页面使用同一个实例。不要在 build 里调用一个每次返回新对象的工厂,也不要在点击后重新赋值一个新的数据源来试图触发更新。LazyForEach 的数据变化由监听协议表达,source 外面再包一层状态装饰器并不能替代这份协议;换对象和通知变化是两件不同的事。
List 使用 layoutWeight 接收按钮与说明之外的剩余高度。懒加载仍需要合理的滚动容器约束,不能把一个需要按可见范围工作的大列表放进无限伸展的布局,再期待它自然获得正确的加载范围。这里仅有少量样例用于讲清机制,数据量增大后的收益应另行测量,不能从组件名称直接推导帧率提升数字。
数据修改和通知在同一个同步方法中完成,方法内部没有等待操作。后续接入异步请求时,应先取得结果、完成有效性检查,再在界面线程提交这一组变化;不能把“先改一半、等待网络、再发通知”作为常态。多个响应乱序回来时,还需要按业务版本决定是否接受结果,渲染版本只描述已经接受的本地展示变化,不承担远端冲突解决。
两个样例按钮和提示条用于观察当前会话的操作结果,因此放在列表之外。某一行被重建时,提示条不会随它一起销毁;某一行滚出可见范围,也不会让操作结果失去承载位置。这个组件边界将“发生了什么操作”与“某个条目现在怎样显示”分开,对排查增量刷新尤其有帮助,避免把所有诊断信息塞进正在变化的行内。

更新一行时,究竟发生了什么
点击切换按钮后,数据源找到彩陶当前的位置,构建一条新展示行,将 note 改为已看并增加 revision。监听器收到该位置的变更通知,重新取出数据和键;新键与旧键不同,本例对应的行组件重新构建。其他两行没有改变版本,因此没有必要改变它们的身份。业务路由仍传 pottery-01,不把渲染版本误当作展品身份。

如果行内以后加入文本输入,直接重建可能导致未提交输入丢失。那时应先把输入草稿移到按业务标识管理的模型,或采用可观察条目状态,使内容更新与组件身份更精确地配合。当前版本号方案有明确前提:展示行是轻量、只读的内容投影。技术方案是否适合,取决于局部状态的保存要求,不取决于哪一种写法行数更少。
追加按钮还提供一个重要验证:重复点击只保留一条刻纹石片。第一次追加成功后,仓库查询能找到它,详情可以打开;第二次发现标识已经存在,既不写入数组也不发送新增通知。若列表多出两行而详情总指向同一件实体,问题首先是身份约束被破坏,不应靠把键拼上数组下标来掩盖重复数据。
最后检查状态变更后的滚动与返回。先切换标记,再打开彩陶详情,返回列表后标记仍应保留在这份 source 中;完整进程重启后恢复默认标记,则符合本篇会话范围。需要跨会话保存时,应设计持久化的数据协议,不能把渲染版本和组件键原样存到业务库中,作为用户行为记录使用。
准备性能实验时,应该另外生成足够规模且标识互不重复的数据集,固定条目高度、内容复杂度与操作步骤,再观察主线程工作和节点情况。不要只把三条样例复制成大量相同标识,这会先破坏键约束,所得现象无法代表正常列表。性能结论的前提是业务数据与通知正确;本篇给出的正是进入那一步之前必须满足的结构条件。
列表优化的起点是正确表达变化。可见范围、节点创建和状态观察都依赖稳定的输入约定;当源数据、通知位置和键彼此矛盾时,再增加缓存数量只会扩大问题范围。下一篇将把这个数据源与前面的导航、资源和生命周期串起来,通过边界用例收尾,确保每项增量没有破坏先前建立的协议。
官方参考
更多推荐

所有评论(0)