ArkUI + HarmonyOS 图稿校对台:分批加载与列表重建成本(连载05/06)【共创稿事节】
图稿只有十几张时,校对台把所有作品一次性放进数组,页面似乎没有什么问题。到了几百张,问题却悄悄变了:切换筛选条件要重新准备一批数据,滚动过程中又可能不断追加作品,审阅者正在核对的 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 参数和数据源检查、二维数据的懒加载实践。
更多推荐

所有评论(0)