工单从三条增加到三千条,通常先暴露的不是数据库问题,而是列表管理方式的问题。上一阶段的响应式工单台用 ForEach(this.visibleOrders()) 显示筛选结果;数据量少时,这种写法直观,视图与业务选择也容易对应。一旦叠加搜索、频繁状态变化和窄宽屏切换,就需要认真回答:哪些列表项有必要参与当前绘制?筛选后的索引是否还能表示原来的工单?选中的详情究竟依赖“第几项”,还是依赖稳定业务 ID?

这里继续保留此前的 WorkOrder { id, title, status, description } 与 workOrderSelectedId,保留主从布局的 GridRow/GridCol。真正调整的是列表与数据之间的适配层:把数据项获取、通知、筛选映射独立出来,让 LazyForEach 只面向一个稳定的数据源。这个改造针对的是长列表的创建范围与更新边界,不将“换成惰性渲染”直接等同于任何设备上的 FPS 提升。

HarmonyOS 响应式工单台长列表技术封面

先把三个容易混淆的索引分开

假设完整数据依次是工单 1、2、3、4;筛选“待处理”后只有工单 1、3,此时显示索引 1 对应业务 ID 3,已不再对应原数组的第二项。若列表项以索引作 Key,筛选或插入后,框架可能复用一个原本代表其他工单的组件。高亮、输入中的文本、临时展开状态都容易跟错对象。Key 必须来自稳定的 id,而非当前可见位置。

另一方面,selectedId 不是选中下标。上篇规定:筛选后原选择还在可见结果中则保留,否则落到第一条可见工单;可见集合为空时赋值 -1。这条规则在长列表里仍然适用,但还要补充一个限制:本篇筛选针对内存中完整的本地示例集合,不是只拿当前屏幕渲染的几行去判断。LazyForEach 的渲染窗口只决定创建哪些子组件,不改变全部业务数据的成员关系。

数据发生改变时还有第三种身份:组件的渲染缓存。LazyForEach 使用 IDataSource 取数据,并通过 DataChangeListener 接收变更。数据源对象应尽量保持稳定,不要在每次 build() 时新建一个数据源;修改内部数组之后必须通知监听者。直接给内部数组 push() 然后指望 @State 自动发现,不是这一套数据源契约的可靠写法。

业务ID、过滤索引与惰性渲染窗口的关系

构造独立数据源:先用全量刷新保证正确

在 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 查询详情,不能把“当前页缺失”解释成“记录不存在”。

LazyForEach的数据源调用、通知与视图更新路径

三类刷新事件,分别验证而不是一起猜

第一类是条件改变。输入“工单 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 组件。

Logo

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

更多推荐