图稿只有十几张时,校对台把所有作品一次性放进数组,页面似乎没有什么问题。到了几百张,问题却悄悄变了:切换筛选条件要重新准备一批数据,滚动过程中又可能不断追加作品,审阅者正在核对的 sk-01 不能因为目录更新而被清空。上一节处理的是单幅作品内部的批注长列表,这次讨论的则是全部图稿的目录列表。二者不是同一个数据源,不能因为都用 LazyForEach 就共享更新逻辑。

我们保留此前建立的 SketchWork{id,title,phase,comment}、SketchRevision{revisionId,workId,sequence,label} 和 ReviewMark{markId,workId,revisionId,xPermille,yPermille,note}。它们的身份关系不变:作品用 workId 定位,版本用 revisionId 定位,批注的归属必须同时检查二者。新增的目录加载只负责把更多 SketchWork 送入列表,不应改写右侧预览的审阅会话。

图稿目录分批加载封面

先算数据流,再谈性能

把所有作品一次性渲染出来,与把所有作品一次性读取到内存,是两件不同的事。LazyForEach 能减少非可见条目的组件创建,但如果数据层先构造了上万条复杂模型,它并不能自动解决数据准备的成本。因此目录层应该先定义分页协议,UI 层再决定何时申请下一页。简单说,按需构建组件和按需取得数据需要分别设计。

假设目录有 480 张图稿,页面每次请求 32 条。完整遍历对应 15 次请求;首次数据进入目录源的是 32 条,而不是 480 条。这是由样本规模和分页参数计算出来的数量关系,不是设备测试结论。实际渲染多少个条目,还与可视高度、缓存数量、组件结构和滚动行为有关。发布前应在相同设备、相同构建模式下分别记录页面进入时间、每页加载的耗时及滚动帧数据,不能把理论比例写成已获得的性能提升百分比。

一次性准备和分批准备的对照

在工程评审中,我更关心三个可检查的问题:是否出现重复记录;加载后是否改变选中的 workId;过滤条件变化后,上一轮请求的旧结果会不会混入新的列表。第一项交给稳定 ID 与数据源约束,第二项交给状态隔离,第三项交给请求代次。代码没有解决这些问题之前,讨论滑动是否流畅没有太大意义。

让目录数据源只负责增量

沿用上篇 IDataSource 的监听机制,但把目录源单独命名为 SketchCatalogSource,避免与 ReviewListDataSource 混在一起。下面代码可以放在 entry/src/main/ets/model/SketchCatalogSource.ets。onDataAdd 是数据源通知,不需要重建整个 List;只有切换筛选条件这种语义上重置目录的操作,才调用 onDataReloaded。

// entry/src/main/ets/model/SketchCatalogSource.ets
export interface SketchWork {
  id: string;
  title: string;
  phase: string;
  comment: string;
}

export class SketchCatalogSource implements IDataSource {
  private rows: SketchWork[] = [];
  private listeners: DataChangeListener[] = [];
  totalCount(): number { return this.rows.length; }
  getData(index: number): SketchWork { return this.rows[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); }
  }
  clear(): void {
    this.rows = [];
    this.listeners.forEach((l: DataChangeListener) => l.onDataReloaded());
  }
  append(page: SketchWork[]): number {
    let added: number = 0;
    for (const item of page) {
      if (this.rows.some((row: SketchWork) => row.id === item.id)) { continue; }
      const index: number = this.rows.length;
      this.rows.push(item);
      this.listeners.forEach((l: DataChangeListener) => l.onDataAdd(index));
      added++;
    }
    return added;
  }
}

clear 与 append 是两种不同的通知语义。列表已经展示了 32 项,再加载 32 项时不需要通知全部重载;只需要对新增位置发出 onDataAdd。这不仅减少了无关组件重建的可能,也为排查问题留下了可追踪的边界。注意这里使用的是数据源里唯一的作品 ID,不是当前数组索引。若作品顺序变化,索引可能从 5 变为 12,但 sk-016 仍然指向同一张作品。

样本数据层用一个确定性的内存仓库说明分页契约。真实工程可以将这个仓库换成数据库查询或网络服务;页面只依赖返回的 rows 和 hasMore,无需关心取数位置。以下代码可放在 SketchCatalogGateway.ets。

import { SketchWork } from './SketchCatalogSource';

export interface CatalogPage {
  rows: SketchWork[];
  hasMore: boolean;
}

export class SketchCatalogGateway {
  private all: SketchWork[] = [];
  constructor() {
    for (let i: number = 0; i < 480; i++) {
      const serial: string = (i + 1).toString().padStart(3, '0');
      this.all.push({ id: `sk-${serial}`, title: `待校图稿 ${serial}`,
        phase: i % 4 === 0 ? '已校对' : '待校对', comment: '请核查审阅记录' });
    }
  }
  async page(offset: number, limit: number, onlyPending: boolean): Promise<CatalogPage> {
    const filtered: SketchWork[] = onlyPending ?
      this.all.filter((w: SketchWork) => w.phase === '待校对') : this.all;
    const safeOffset: number = Math.max(0, offset);
    const end: number = Math.min(filtered.length, safeOffset + limit);
    return { rows: filtered.slice(safeOffset, end), hasMore: end < filtered.length };
  }
}

真实接口应额外返回服务端游标;当数据持续插入或删除时,简单的 offset 分页可能跳过某条记录或重复记录。这里的内存样本不会在遍历中插入数据,因此可用 offset 展示核心交互。若要支持多人协同、频繁更新的目录,下一阶段需要采用按稳定排序键和 ID 排序的游标协议,而不是将示例假设照搬进生产服务。

用请求代次隔离旧数据,选中态不跟着重置

下列页面是最小可读的加载示例。@State selectedId 继续作为图稿选择的唯一入口,source 只记录当前目录。新的筛选条件创建新的请求代次 generation,较早的异步请求即使稍后返回,也不再写入数据源。busy 防止列表触底事件重复发起相同页;请求失败后保留已加载项目,用户可再次触底重试。

// entry/src/main/ets/pages/CatalogPage.ets
import { SketchCatalogSource, SketchWork } from '../model/SketchCatalogSource';
import { SketchCatalogGateway, CatalogPage } from '../model/SketchCatalogGateway';

@Entry
@Component
struct CatalogPage {
  @State selectedId: string = 'sk-001';
  @State onlyPending: boolean = false;
  @State busy: boolean = false;
  @State hasMore: boolean = true;
  @State message: string = '目录准备中';
  private source: SketchCatalogSource = new SketchCatalogSource();
  private gateway: SketchCatalogGateway = new SketchCatalogGateway();
  private offset: number = 0;
  private generation: number = 0;
  private readonly pageSize: number = 32;

  aboutToAppear(): void { this.reload(); }

  private reload(): void {
    this.generation += 1;
    this.offset = 0;
    this.busy = false;
    this.hasMore = true;
    this.source.clear();
    this.loadMore();
  }

  private async loadMore(): Promise<void> {
    if (this.busy || !this.hasMore) { return; }
    const ticket: number = this.generation;
    const currentOffset: number = this.offset;
    const currentFilter: boolean = this.onlyPending;
    this.busy = true;
    try {
      const data: CatalogPage = await this.gateway.page(
        currentOffset, this.pageSize, currentFilter);
      if (ticket !== this.generation) { return; }
      this.source.append(data.rows);
      this.offset = currentOffset + data.rows.length;
      this.hasMore = data.hasMore;
      this.message = this.hasMore ? '继续上滑加载更多' : '已显示全部图稿';
    } catch (e) {
      if (ticket === this.generation) { this.message = '目录加载失败,请重试'; }
    } finally {
      if (ticket === this.generation) { this.busy = false; }
    }
  }

  build() {
    Column({ space: 12 }) {
      Text('图稿目录').fontSize(24).fontWeight(FontWeight.Bold)
      Row({ space: 12 }) {
        Button(this.onlyPending ? '显示全部' : '只看待校')
          .onClick(() => { this.onlyPending = !this.onlyPending; this.reload(); })
        Text(`当前作品:${this.selectedId}`).fontSize(14)
      }
      List({ space: 8 }) {
        LazyForEach(this.source, (item: SketchWork) => {
          ListItem() {
            Row() {
              Text(item.title).fontSize(17).layoutWeight(1)
              Text(item.phase).fontSize(13)
            }
            .padding(14).width('100%')
            .backgroundColor(this.selectedId === item.id ? '#E8F3FF' : '#FFFFFF')
            .onClick(() => { this.selectedId = item.id; })
          }
        }, (item: SketchWork) => item.id)
      }
      .layoutWeight(1)
      .onReachEnd(() => { this.loadMore(); })
      Text(this.busy ? '正在加载…' : this.message).fontSize(13)
    }.padding(16).height('100%')
  }
}

上面的 generation 特别容易被忽略。用户快速点击“只看待校”和“显示全部”,如果两条异步请求先后返回顺序颠倒,没有代次判断,列表就可能混进不同筛选条件的结果。需要注意:这个示例在改变筛选时不会直接清空 selectedId,因此即使选中的作品暂时不在目录里,右侧的原有审阅内容也仍有合法身份。产品上可以显示“当前作品不在筛选结果中”的提示,而不应该偷偷将审阅对象切换为第一行。

请求代次与列表稳定身份的关键路径

该怎样记录成本,而不是编造一组好看的数字

性能结论要从可复现的操作和同等环境开始。建议固定设备、SDK、窗口宽度、图片分辨率、每页条数、是否预热缓存,再分别执行“打开目录”“连续滚动”“切换筛选”“从图稿返回目录”。记录每一步的数据请求次数、数据源通知次数、重复 ID 数量及列表首个稳定可交互时间。ArkUI 的组件布局和绘制指标可使用 DevEco Studio Profiler 与实际设备工具进行核对;本文给出的 480、32、15 是示例配置与计算结果,并非上述工具的采样值。

对于这个系列,最小检查集其实很明确:分页边界前后不重复;筛选快速切换不会混入旧页;任意新页加载都不改写 selectedId、revisionId、focusMarkId;批注仍然同时按 workId 和 revisionId 校验。若这些成立,再对比整批重载和增量通知的耗时数据,才有评估优化的基础。

图稿目录的验证检查面板

这里还有一个实际工程细节:目录条目最好不要包含整张原始图像数据,至少应将缩略图标识、业务字段与高分辨率预览资源分开管理。作品列表只展示标题、状态和轻量预览;用户点击条目后,再由审阅区按 workId、revisionId 加载对应的高清图稿与批注。否则即便每页只返回32条,单个条目携带的大图也可能把取数优势抵消。若使用缓存,缓存键至少应包含作品ID与版本ID,避免新版本覆盖旧图而列表仍显示旧缩略图。这条原则不是让项目增加一套新的身份系统,而是继续使用此前已经稳定下来的两级标识。

对分页更新还有一项容易漏掉的验证:当筛选结果发生变化时,旧目录里已选中的作品可能暂时不可见。此时页面不应该将其标记为“已删除”。不可见只代表当前筛选不匹配,删除则意味着业务实体不存在。处理这两种情况,需要先确认仓储是否还能按照 workId 查询到作品;只有确实不存在,才能清理关联的版本和批注选择。把筛选状态误当成业务存在性判断,会让用户在切换条件后无故失去编辑上下文。

如果后续接入服务端分页,还应约定稳定的排序方式。例如先按更新时间降序,再按不可重复的作品ID排序,分页游标保存这两个字段。只用更新时间排序可能因为同一秒更新了多幅图而导致边界模糊。游标结构必须由服务端生成、客户端原样携带,并且与筛选参数绑定。这样在目录数据变化较大时,增量加载才能保持清晰的连续性。

本节把“批注内部更新”和“图稿目录增长”拆成两个独立数据通道,既延续了前四篇的稳定身份规则,又给大量作品提供可扩展的入口。真正需要优先优化的不是某个动画参数,而是明确数据加载边界、通知范围和会话归属。下一篇将把图稿校对台的异常恢复、发布验收与边界回归完整收尾。

可参考华为开发者文档:LazyForEach 参数和数据源检查、二维数据的懒加载实践。

Logo

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

更多推荐