ArkUI + HarmonyOS 响应式工单台:LazyForEach增量更新(连载04/06)【共创稿事节】
工单从三条增加到三千条,通常先暴露的不是数据库问题,而是列表管理方式的问题。上一阶段的响应式工单台用 ForEach(this.visibleOrders()) 显示筛选结果;数据量少时,这种写法直观,视图与业务选择也容易对应。一旦叠加搜索、频繁状态变化和窄宽屏切换,就需要认真回答:哪些列表项有必要参与当前绘制?筛选后的索引是否还能表示原来的工单?选中的详情究竟依赖“第几项”,还是依赖稳定业务 ID?
这里继续保留此前的 WorkOrder { id, title, status, description } 与 workOrderSelectedId,保留主从布局的 GridRow/GridCol。真正调整的是列表与数据之间的适配层:把数据项获取、通知、筛选映射独立出来,让 LazyForEach 只面向一个稳定的数据源。这个改造针对的是长列表的创建范围与更新边界,不将“换成惰性渲染”直接等同于任何设备上的 FPS 提升。

先把三个容易混淆的索引分开
假设完整数据依次是工单 1、2、3、4;筛选“待处理”后只有工单 1、3,此时显示索引 1 对应业务 ID 3,已不再对应原数组的第二项。若列表项以索引作 Key,筛选或插入后,框架可能复用一个原本代表其他工单的组件。高亮、输入中的文本、临时展开状态都容易跟错对象。Key 必须来自稳定的 id,而非当前可见位置。
另一方面,selectedId 不是选中下标。上篇规定:筛选后原选择还在可见结果中则保留,否则落到第一条可见工单;可见集合为空时赋值 -1。这条规则在长列表里仍然适用,但还要补充一个限制:本篇筛选针对内存中完整的本地示例集合,不是只拿当前屏幕渲染的几行去判断。LazyForEach 的渲染窗口只决定创建哪些子组件,不改变全部业务数据的成员关系。
数据发生改变时还有第三种身份:组件的渲染缓存。LazyForEach 使用 IDataSource 取数据,并通过 DataChangeListener 接收变更。数据源对象应尽量保持稳定,不要在每次 build() 时新建一个数据源;修改内部数组之后必须通知监听者。直接给内部数组 push() 然后指望 @State 自动发现,不是这一套数据源契约的可靠写法。

构造独立数据源:先用全量刷新保证正确
在 entry/src/main/ets/model/WorkOrderSource.ets 中创建数据源。下面接口名称沿用 HarmonyOS ArkUI 的 IDataSource、DataChangeListener 约定。getData(index) 只读取当前可见集合,totalCount() 反映筛选后的总项数。数据源的 replace() 是业务方法,不是系统 API;它复制结果后通知框架重新计算范围。
export interface WorkOrder {
id: number;
title: string;
status: string;
description: string;
}
export class WorkOrderSource implements IDataSource {
private visible: WorkOrder[] = [];
private listeners: DataChangeListener[] = [];
constructor(initial: WorkOrder[]) {
this.visible = initial.slice();
}
totalCount(): number {
return this.visible.length;
}
getData(index: number): WorkOrder {
return this.visible[index];
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const index: number = this.listeners.indexOf(listener);
if (index >= 0) this.listeners.splice(index, 1);
}
replace(next: WorkOrder[]): void {
this.visible = next.slice();
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataReloaded();
});
}
snapshot(): WorkOrder[] {
return this.visible.slice();
}
}
这里故意先使用 onDataReloaded(),因为筛选与排序会让多个位置同时变化。仅凭前后数组长度相同就调用单项更新通知,可能把旧位置与新身份错配。等业务确认只改动了某个位置的内容、且其它位置和稳定 Key 都未变化时,才适合研究更精确的增删改通知。先确定通知语义,再谈减少重建。 对于需要极高频更新的看板,可以把变化计算封装为独立的差量算法,而不必让页面点击处理器直接拼一堆通知。
把筛选、导航与长列表放回同一页面
下面的 Index.ets 可以作为独立的示例入口;使用的是纯内存数据,以避免把数据库分页与列表按需渲染混成两个问题。为方便观察,构造 1200 条不同 ID 的工单;这个数字是测试数据规模,不是性能测试结论。LazyForEach 在 List 容器内部工作,列表有确定高度,不再放进外层无限高度的 Scroll。页面仍使用上篇的共享选择键和窄屏路由规则。
import { WorkOrder, WorkOrderSource } from '../model/WorkOrderSource';
function makeOrders(): WorkOrder[] {
const result: WorkOrder[] = [];
for (let id: number = 1; id <= 1200; id++) {
result.push({
id,
title: `设备巡检工单 ${id}`,
status: id % 3 === 0 ? '处理中' : '待处理',
description: `检查编号 ${id} 对应的巡检执行记录。`
});
}
return result;
}
@Entry
@Component
struct Index {
@StorageLink('workOrderSelectedId') selectedId: number = 1;
@State private keyword: string = '';
@State private filterStatus: string = '全部';
@State private compact: boolean = true;
@State private visibleCount: number = 0;
private orders: WorkOrder[] = makeOrders();
private source: WorkOrderSource = new WorkOrderSource([]);
private pathStack: NavPathStack = new NavPathStack();
aboutToAppear(): void {
this.applyFilter();
}
private currentOrder(): WorkOrder | undefined {
return this.orders.find((item: WorkOrder): boolean =>
item.id === this.selectedId);
}
private applyFilter(): void {
const word: string = this.keyword.trim().toLowerCase();
const next: WorkOrder[] = this.orders.filter((item: WorkOrder): boolean => {
const statusOK: boolean = this.filterStatus === '全部' ||
item.status === this.filterStatus;
const textOK: boolean = word.length === 0 ||
item.title.toLowerCase().includes(word) ||
item.description.toLowerCase().includes(word);
return statusOK && textOK;
});
// 先校正业务选择,再通知列表重新加载。
if (!next.some((item: WorkOrder): boolean => item.id === this.selectedId)) {
this.selectedId = next.length > 0 ? next[0].id : -1;
}
if (this.selectedId === -1) this.pathStack.clear();
this.visibleCount = next.length;
this.source.replace(next);
}
private choose(item: WorkOrder): void {
this.selectedId = item.id;
if (this.compact) {
this.pathStack.pushPath({ name: 'WorkOrderDetail' });
}
}
@Builder
private listPane() {
Column({ space: 10 }) {
TextInput({ placeholder: '按标题或描述筛选', text: this.keyword })
.onChange((value: string) => {
this.keyword = value;
this.applyFilter();
})
Row({ space: 8 }) {
ForEach(['全部', '待处理', '处理中'], (status: string) => {
Button(status)
.onClick(() => {
this.filterStatus = status;
this.applyFilter();
})
}, (status: string) => status)
}
Text(`可见工单 ${this.visibleCount} 条`).fontSize(12)
if (this.visibleCount === 0) {
Text('没有匹配工单').fontColor('#64748B')
} else {
List({ space: 6 }) {
LazyForEach(this.source, (item: WorkOrder) => {
ListItem() {
Text(`${item.id} ${item.title}`)
.width('100%').padding(12)
.backgroundColor(item.id === this.selectedId ? '#BFDBFE' : '#F1F5F9')
.onClick(() => this.choose(item))
}
}, (item: WorkOrder) => item.id.toString())
}
.height(450)
}
}.padding(12).width('100%')
}
@Builder
private detailPane() {
Column({ space: 12 }) {
Text('工单详情').fontSize(22)
if (this.selectedId >= 0 && this.currentOrder()) {
Text(this.currentOrder()!.title)
Text(this.currentOrder()!.status)
Text(this.currentOrder()!.description)
} else {
Text('当前筛选条件下无可查看工单')
}
}.padding(18).width('100%')
}
@Builder
private destination(name: string) {
if (name === 'WorkOrderDetail') {
NavDestination() { this.detailPane() }.title('工单详情')
}
}
build() {
Navigation(this.pathStack) {
Column({ space: 10 }) {
Text('响应式工单台').fontSize(24).fontWeight(FontWeight.Bold)
GridRow({ columns: { sm: 4, md: 8, lg: 12 },
gutter: { x: 12, y: 12 } }) {
GridCol({ span: { sm: 4, md: 3, lg: 4 } }) {
this.listPane()
}
if (!this.compact) {
GridCol({ span: { sm: 4, md: 5, lg: 8 } }) {
this.detailPane()
}
}
}
.onBreakpointChange((value: string) => {
this.compact = value === 'sm';
if (!this.compact) this.pathStack.clear();
})
.width('100%')
}.padding(12).width('100%')
}
.navDestination(this.destination)
.mode(NavigationMode.Stack)
.width('100%').height('100%')
}
}
这个结构有两个刻意保留的限定。第一,示例只用 onDataReloaded(),方便对筛选正确性做基线检查;它并不承诺频繁输入时没有额外计算。第二,currentOrder() 依旧从完整业务集合按 ID 查询。因为我们已经在每次筛选变化时校正选择,所以它与可见集合一致;真实数据来自服务器分页时,必须改成以 ID 查询详情,不能把“当前页缺失”解释成“记录不存在”。

三类刷新事件,分别验证而不是一起猜
第一类是条件改变。输入“工单 11”,可见集合明显缩小,replace() 触发重新装配;如果旧 selectedId 不在结果中,会改成第一个可见 ID。清空输入后不应凭空恢复旧 ID,因为上一阶段约定的策略是保持当前有效选择。第二类是纯布局变化:从手机宽度调整到平板宽度,切换的只有左右布局和路由呈现,不能让 applyFilter() 被断点事件额外调用,也不能由此改变 selectedId。
第三类是数据增减。可以在本地示例的业务入口里替换 orders 数组,然后明确调用 applyFilter();对真实服务端结果则应在数据合并后生成新的可见集合,再通知数据源。要特别观察删除当前选中项、列表为空、连续输入关键词,以及把数据从 1200 条缩到 5 条后快速拖动滚动条的行为。每次验证要分别记录业务 ID、可见集合长度和当前滚动范围,不要仅凭界面上出现了多少行来判断 totalCount()。

还有一个常被遗漏的排障点:列表项是否有自己的临时编辑状态。如果将“输入草稿”只存放在按索引复用的行组件里,滑出窗口回收后再进入,草稿可能恢复到错误项。稳妥做法是让业务可持久状态由 id 索引的上层模型拥有,行组件负责呈现和发出事件。LazyForEach 不是数据库缓存,也不是所有状态的自动容器。
若计划进一步减少刷新范围,下一阶段可尝试稳定顺序下的 onDataChange() 等精确通知,并用开发工具记录节点创建、布局和绘制情况;但实验必须固定设备、系统版本、列表项复杂度、窗口大小和采样方式,才能讨论收益。过滤本身如果需要遍历完整内存集合,其时间复杂度不会因为用了惰性渲染而消失。可视区域之外的组件少建一些,与筛选计算是否足够快,是两个需要分开优化的问题。
对于滚动位置还有一项容易误判的需求:从工单列表进入详情、返回时,用户希望仍看到刚才的条目。selectedId 可以保证业务记录没有换人,却不会自动记录列表滚动偏移。当前代码保留了稳定的数据源和导航组件,但没有承诺页面销毁后恢复精确像素位置。要实现这一能力,应在页面生命周期中单独维护与筛选条件绑定的滚动锚点,优先记录最近可见工单的业务 ID,再在数据重新加载、筛选条件一致且目标 ID 仍存在的情况下定位。不要把一个只在旧列表有效的绝对下标直接应用到新的排序结果上。
另外,onDataReloaded() 对一次筛选变化是一种保守而明确的通知策略。它不等同于“所有数据都会同时渲染”,也不保证滚动中的每个节点都被原样复用。长列表性能应结合 List 的真实可视区、item 的布局复杂度、图片处理及文本更新频率综合评估。对于筛选输入非常密集的业务,还可以考虑输入节流或只在用户确认后执行搜索,但这属于交互策略,应由产品允许的响应延迟来决定,不能在缺乏交互要求时擅自增加延时。
本篇把数据所有权、列表渲染和业务选择正式拆开:orders 是完整数据,WorkOrderSource 是当前可见投影,selectedId 是业务身份,GridRow 只决定主从呈现。接下来的多窗口与折叠态篇章,将继续沿用这一分工,专门讨论窗口形态突然变化时导航状态、列表位置和用户操作上下文如何合理降级。
参考资料:华为开发者:LazyForEach 数据懒加载、ArkUI 长列表渲染与 Repeat 的比较、List 组件、GridCol 组件。
更多推荐

所有评论(0)