图稿校对台做到第三阶段,选中作品、版次与批注定位都能在窗口改变后保留下来。然而,当一张图稿从两条批注积累到几百条,新的矛盾就出现了:用户只是把“屋檐透视”改成“已处理”,整个列表却看起来重新闪了一下;拖到第八十条意见再补上一条,滚动位置也不够稳定。此时应该处理的是数据通知和渲染边界,而不是继续叠加保存按钮。

批注长列表与精准刷新封面

上一阶段已经明确了三类身份:SketchWork.id 表示作品,SketchRevision.revisionId 表示一次修订,ReviewMark.markId 表示一条批注;xPermille、yPermille 仍以千分比描述图片内容区域中的位置。本篇保留 sk-01、sk-01-r2、m-001 这些演示身份,页面仍通过 selectedId + revisionId 筛选意见。不同的是,原来直接用 ForEach 逐项展示的小型数组,将演进成有变更通知的数据源。批注多了以后,正确的刷新范围比反复重建页面更重要。

列表慢,不意味着要把所有状态都搬出去

先区分三种完全不同的成本。加载一张超大原图要花图片解码和纹理资源;同时构建几百个批注行要花组件创建和布局时间;每次改动一个状态却让所有行都重新计算,还会造成多余的更新。三者的定位工具和优化手段不一样。仅把图片换成缩略图,未必解决行组件更新;只换成懒加载列表,也不会自动改善原图资源占用。

在现有图稿校对台中,画布与批注列表是并列的功能区。选中作品或版次属于页面级事件,确实需要切换整个查询结果;修改一条批注的状态只属于某个列表项,不应该由此改变作品 ID、版次 ID 或已经保存的审阅位置。页面中用 @State selectedId、@State revisionId 和 @State focusMarkId 保存当前会话指针,批注仓储保存实体,列表数据源维护当前过滤后的行。这三层不互相冒充,才能谈可预测的局部更新。

官方 ArkUI 的 List 配合 LazyForEach 可以按数据源创建列表项,并通过 IDataSource 和 DataChangeListener 通知新增、删除、改变或重排。它不是数据库,也不负责图片异步加载;它解决的是“列表希望知道哪里变了”的问题。普通 ForEach 对三五条固定意见十分直接,不值得为了展示两个标签引入数据源。而在批注可能不断增长、行项目又包含状态图标和操作按钮的情况下,使用数据源就有实际工程意义。

作品筛选、列表数据源和绘制区域的职责边界

特别要注意 ID:列表项的稳定键应取 markId,不能取下标,也不能把“已处理/待处理”写进键。下标会因为插入、排序改变含义;状态如果拼进键,点击按钮后该条记录就变成了另一个 UI 身份,框架可能把原行移除再新增一行。连续审阅时,人眼看到的闪动,有时正来自这种不稳定的键。

把每次修改变成一条可说明的通知

为了让技术链路完整,下面保留前篇的 ReviewMark 结构,并给演示批注补充独立的处理状态。旧版 m-001 与归一化坐标保持不动;状态与正文属于新的展示字段。这里的 PreviewMark 仅代表列表显示模型,不代替前几篇的 ReviewMark 存储与校验规则。实际工程中可在模型映射层从业务记录构建它。

// entry/src/main/ets/model/ReviewListDataSource.ets
export interface PreviewMark {
  markId: string;
  workId: string;
  revisionId: string;
  note: string;
  resolved: boolean;
}

export class ReviewListDataSource implements IDataSource {
  private rows: PreviewMark[] = [];
  private listeners: DataChangeListener[] = [];

  totalCount(): number { return this.rows.length; }
  getData(index: number): PreviewMark { 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); }
  }

  replaceAll(next: PreviewMark[]): void {
    this.rows = next.slice();
    this.listeners.forEach((listener: DataChangeListener) =>
      listener.onDataReloaded());
  }

  updateResolved(markId: string, resolved: boolean): boolean {
    const index: number = this.rows.findIndex(
      (row: PreviewMark) => row.markId === markId);
    if (index < 0) { return false; }
    const before: PreviewMark = this.rows[index];
    this.rows[index] = { ...before, resolved: resolved };
    this.listeners.forEach((listener: DataChangeListener) =>
      listener.onDataChange(index));
    return true;
  }

  append(mark: PreviewMark): void {
    const index: number = this.rows.length;
    this.rows.push(mark);
    this.listeners.forEach((listener: DataChangeListener) =>
      listener.onDataAdd(index));
  }
}

代码刻意把 replaceAll 与 updateResolved 分开。切换作品或版次时,全部过滤结果变了,onDataReloaded 是诚实的通知;一条意见的处理状态变化时,先替换对应行对象,再调用 onDataChange(index)。不要为了避免一次重载,就在数据源内部偷偷把十个新元素冒充十次局部状态改变。通知必须与真实操作匹配,才不会留下索引错位、旧行残留等问题。

append 的前提是当前列表采用追加顺序,且新增记录确实位于末尾。如果用户使用“待处理优先”排序,新增项可能应插在顶部,必须先计算目标索引,再执行插入通知;不能为保留旧代码而把数据丢进错误位置。编辑已有批注同样要遵守先验证业务身份、再更新模型、最后通知视图的顺序。找不到 markId 就返回失败,不去更新“刚好在相同下标”的其他意见。

接着在页面中放入能运行的使用路径。注意这里调用的 prepareRows() 是演示数据生成函数,它只产生当前作品与版次的列表,不负责写入审阅会话;之前的 ReviewSessionStore 继续单独处理 workId/revisionId/markId 的恢复。

// entry/src/main/ets/pages/ReviewLongListPage.ets
import { PreviewMark, ReviewListDataSource } from '../model/ReviewListDataSource';

function prepareRows(workId: string, revisionId: string): PreviewMark[] {
  const result: PreviewMark[] = [];
  for (let i: number = 0; i < 240; i++) {
    result.push({
      markId: `${workId}-${revisionId}-comment-${i}`,
      workId: workId,
      revisionId: revisionId,
      note: `第 ${i + 1} 条图稿细节复核意见`,
      resolved: i % 5 === 0
    });
  }
  return result;
}

@Entry
@Component
struct ReviewLongListPage {
  @State selectedId: string = 'sk-01';
  @State revisionId: string = 'sk-01-r2';
  @State focusMarkId: string = '';
  @State widthVp: number = 390;
  private source: ReviewListDataSource = new ReviewListDataSource();

  aboutToAppear(): void {
    this.source.replaceAll(prepareRows(this.selectedId, this.revisionId));
  }

  private selectWork(id: string, revisionId: string): void {
    if (this.selectedId === id && this.revisionId === revisionId) { return; }
    this.selectedId = id;
    this.revisionId = revisionId;
    this.focusMarkId = '';
    this.source.replaceAll(prepareRows(id, revisionId));
  }

  build() {
    Column({ space: 12 }) {
      Text('图稿校对台 · 批注').fontSize(24).fontWeight(FontWeight.Bold)
      Row({ space: 10 }) {
        Button('古塔 R2').onClick(() => this.selectWork('sk-01', 'sk-01-r2'))
        Button('山川 R0').onClick(() => this.selectWork('sk-02', 'sk-02-r0'))
      }
      Text(`当前作品:${this.selectedId} | ${this.revisionId}`)
      List({ space: 6 }) {
        LazyForEach(this.source, (mark: PreviewMark) => {
          ListItem() {
            Row({ space: 10 }) {
              Text(mark.note).layoutWeight(1).maxLines(2)
              Button(mark.resolved ? '已处理' : '待处理')
                .onClick(() => {
                  this.source.updateResolved(mark.markId, !mark.resolved);
                })
            }.padding(12).backgroundColor('#F1F5F9').borderRadius(10)
          }
        }, (mark: PreviewMark) => mark.markId)
      }.width('100%').layoutWeight(1).cachedCount(2)
    }
    .padding(16).width('100%').height('100%')
    .onAreaChange((_oldArea, newArea) => {
      const width: number = Number(newArea.width);
      if (Number.isFinite(width) && width > 0) { this.widthVp = width; }
    })
  }
}

这个页面直接展示的是数据源通知与长列表。原有图稿图片预览、版本切换和批注锚点已经在前三篇建立,接入时让预览区与这份列表并列即可,不需要把 ReviewSessionStore 复制成第二份。为保持示例紧凑,prepareRows 使用有规律的本地演示数据;正式项目应从批注仓储获取当前版次的真实记录,再映射成 PreviewMark[]。初始化时调用一次重载是合理的,但绝不应在 build() 中反复执行生成或重载,否则每次状态变化都可能触发额外工作。

单行状态切换与错误的全量刷新对比

性能分析要从可复现步骤开始

讨论体验流畅之前,应该先把变量关起来。可以准备三组不同容量的同类型批注:二十、两百和一千条;内容长度保持接近,主图固定为一张相同资源。然后分别完成冷进入、连续滚动、点击某条批注改变状态、切换版次、从宽屏转窄屏五种操作。每项先记录行为是否正确,再通过 DevEco Studio 的性能分析能力观察帧耗时或组件更新热点。这里不直接提供所谓优化后提升了百分之多少,因为数值必须来自同一设备、同一构建与同一输入条件下的实际采样。

最值得关注的不是“点击完后看起来没有动”,而是被修改的行是不是仍对应同一个 markId。例如当第八十条意见改为已处理,检查第七十九条和第八十一条的数据有没有发生变化;在有筛选和重排时,则要验证正在聚焦的 focusMarkId 是否仍存在。对一个“仅显示未处理”的视图,把第八十条标记为已处理后,它理应从当前结果中消失,这是一项集合过滤变化,不能强行当成原位文本更新。业务含义决定通知方式,不应为了追求局部刷新而牺牲正确性。

图像相关优化则放在另外一条观察线上。列表里的小缩略图优先使用与展示尺寸匹配的资源,不把 6000 像素的原画作为每一行的预览。原图的批注锚点仍按前文约定从千分比换算到图片真实内容矩形,不能拿 ListItem 的宽高去换算。缩略图缓存要有容量与失效策略;当 revisionId 改变时,旧版图像缓存不能被当作新版文件。布局改变时,要先保留当前的业务选中 ID,再做界面布局重新计算,而不是清空整个批注数据源来强制刷新。

在长列表场景还有一个容易让人误会的现象:LazyForEach 不意味着数据源中的 1000 条业务记录不存在,它只是避免不必要的 UI 项创建。数据仍由业务层管理。如果仅有四条批注却每次切换作品都感觉卡顿,优先检查同步图片解码、复杂 JSON 解析或布局嵌套,而不是因为听说懒加载“更快”就引入更多状态。相反,如果行里包含图片、时间、状态、操作按钮,且列表数据量确实上升,稳定 ID 和精确变更通知可以让后续排查更有把握。

长列表回归场景、稳定键与滚动位置检查

把优化结果与系列后续工作接起来

校对台现在具备了分栏展示、修订批注、审阅恢复与长列表更新四层能力。到下一阶段,页面很可能不再只有一个人操作:审阅意见需要和网络请求、断网重试、重复提交结合。此时 markId、workId、revisionId 的稳定关系以及“写入成功后更新可见状态”的原则会继续发挥作用。但长列表数据源只是显示层适配器,它不提供冲突检测,也无法替代正式的批注存储服务。

值得保留的工程判断是:小列表用简单方式;大列表用可通知的数据源;状态变化只更新该变的位置;作品切换才替换查询结果。可复现的检查步骤要和具体数据 ID 绑定,图片性能与列表刷新分开测。把这些边界处理好,后续增加远端评论数量或多端审阅时,开发团队才不会被一个“偶尔闪一下”的问题拖进整页重写。

相关官方资料:

Logo

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

更多推荐