本文以 TileQueue / TQ-1505 这套离线构造的候选帧数据讲解 UI 状态管理。12 张图、分页日志、截图均是为了说明工程边界而设置的示意数据,不是对真实 3DGS 重建、设备性能或 DevEco 编译的测试结论。本文讨论的是候选帧展示,不涉及 3DGS 算法接口。

一、卡片没加载出来,问题未必在图片解码

我最近整理一个 3DGS 采集流程的候选帧审阅页,最开始觉得十二张缩略图放进 Grid 很简单。真正容易出问题的,其实是数据不在同一个时刻到齐:第一页和第二页先返回,每页四项;用户已经滚到列表底部,第三页还在排队。与此同时,界面提前告诉渲染层总共应该有十二项。这个数字本身没错,但如果后面的四项始终没有补进数组,滚动范围和实际数据长度就会脱节。

这里先把三个量拆开。totalCount 是业务最终预期条数,loadedCount 是此刻已经拿到的有效条数,renderedCount 则是当前渲染窗口真正需要创建的节点数。它们不会天然相等。把网络预期总数直接当成已经加载的条数去展示,用户就会看到滚动还没结束、内容却已经空了的尴尬画面。开发时盯着图片组件反复调整宽高,通常排不到真正原因。

我把 Demo 命名为 TileQueue,任务号固定 TQ-1505,数据全放在本地模拟分页器里。它先准备 G-001 到 G-008 八项,任务清单总数为 12;接近末尾时追加 G-009 到 G-012 四项。屏幕最终显示 12/12、进度 100%、状态“就绪”。这种安排的好处是排查足够直观:没有网络波动和模型计算参与,不需要假称自己测到了任何设备数据。

二、先整理加载合同,再讨论虚拟滚动

华为 Repeat 的开发文档写得很清楚:Repeat 的 virtualScroll 可以和 Grid 等滚动容器结合,按有效加载范围创建节点;totalCount 超过数组长度时,开发者要在接近末尾前及时把数据补齐。它不是“只填 totalCount,组件自动去服务器取数据”的神奇开关。这里保留最终总量 12,同时设计一个只允许一次分页进行中的应用层门禁,不把后端请求时序交给组件猜测。

业务模型需要有稳定主键。即便本篇主要讨论缺页,key 仍然不能写数组下标:以后候选帧重新打分或筛掉模糊帧,行号会移动,用下标作为 key 容易让节点身份和业务身份错开。每个帧使用固定的 G-xxx,分数只是展示属性,不参与主键。下面这段模型和本地分页数据用于说明这一点。

export class CandidateFrame {
  readonly id: string;
  readonly score: number;
  constructor(id: string, score: number) {
    this.id = id;
    this.score = score;
  }
}

const DEMO_SCORES: number[] = [91, 89, 88, 86, 85, 83, 82, 80, 79, 77, 76, 74];
export function mockPage(page: number, size: number = 4): CandidateFrame[] {
  const start = (page - 1) * size;
  return DEMO_SCORES.slice(start, start + size).map(
    (score: number, offset: number) =>
      new CandidateFrame(`G-${String(start + offset + 1).padStart(3, '0')}`, score)
  );
}

这一层只产生不可变的业务标识,没有文件扫描、照片读写或 3DGS 参数推理。模拟页码从 1 开始,前两页会产生八项,第三页补充四项。生产项目应该把数据来源替换为真正的分页服务或本地清单读取,并明确数据端的总量是否会变化。假如服务端重新计算了总数,界面应该同步更新预期条数,而不是把旧的 12 永远写死。

分页器和 UI 状态不要揉成一个回调。我更愿意给加载操作设置“任务代际”:一次筛选重置后,把 generation 增加,前一次加载即便稍后完成,也不能偷偷把旧列表接回新列表。本篇不是在讲 WebSocket 乱序帧,而是处理UI 滚动请求对当前数据集的归属。两者都可能用版本号,但工程对象完全不同。

下面是用于组件里的应用层分页方法。isLoading 阻止 Grid 在同一帧内重复触发;先将结果放在临时变量,再根据代际决定是否发布;finally 只回收本代加载态,避免旧请求误关掉新任务的按钮。真实网络请求可在这里替换 mockPage,但成功与失败都要走相同的收口路径。

// 以下成员放在 TileQueuePage 组件内
@Local frames: CandidateFrame[] = [...mockPage(1), ...mockPage(2)];
@Local isLoading: boolean = false;
@Local stateLabel: string = '待补齐';
@Local progress: number = 67;
private readonly totalCount: number = 12;
private generation: number = 1;
private disposed: boolean = false;

async loadNextPage(): Promise<void> {
  if (this.isLoading || this.frames.length >= this.totalCount) return;
  const mine = this.generation;
  this.isLoading = true;
  const page = Math.floor(this.frames.length / 4) + 1;
  try {
    const incoming = mockPage(page);
    if (this.disposed || mine !== this.generation) return;
    const existing = new Set(this.frames.map((x: CandidateFrame) => x.id));
    this.frames = [...this.frames, ...incoming.filter((x: CandidateFrame) => !existing.has(x.id))];
    this.progress = Math.round(this.frames.length / this.totalCount * 100);
    this.stateLabel = this.frames.length === this.totalCount ? '就绪' : '待补齐';
  } catch (_) {
    if (mine === this.generation) this.stateLabel = '可重试';
  } finally {
    if (mine === this.generation) this.isLoading = false;
  }
}

注意 Set 去重是二次防线,不能代替服务端的分页协议。假如分页接口可以返回不足四条,下一页不能简单地根据当前长度除以四推断:这段脚手架只针对明确“四条一页、无空页、总数 12”的演示合同。实际项目应保存后端游标或下一页 token,遇到空页也要能停下并给出重试入口。否则,迟早会在末页附近形成无限重复请求。

progress 表示应用层已纳入清单的比例,不是渲染帧率,也不是 3DGS 重建进度。本示例 8/12 算出来约为 67%,补齐后是 100%。为了让配图与代码同一口径,最终画面和示例日志都采用 loaded=12/12、progress=100、state=就绪。这些数字能通过纯业务模型推导,不需要编造 HiLog 实测。

三、真正容易踩到的,是滚动回调的调用频率

Grid 的 onScrollIndex 只告诉你当前可见范围发生了什么,不会替你判断后台是不是还有未完成的分页。特别是预加载触发、屏幕旋转、窗口尺寸变化时,回调可能连续发生。这里的阈值设为“可见末端距当前已加载尾部不超过两项”,目的不是性能调优结论,而是为请求留一个提前量。是否合适仍需用目标设备和真实图片负载验证。

另一个细节是控制虚拟节点的生命周期。Repeat 节点会按需创建、复用和下树;不能依赖单个 GridItem 的创建回调来执行一次性计费或提交,因为它不代表用户真的确认了某一帧。业务状态应该由模型驱动,节点只是投影。为了让这层边界清晰,我把组件简化成下面的可理解版本,02 图里的目录、中心代码和日志也围绕这个接口展开。

@Entry
@ComponentV2
struct TileQueuePage {
  // 此处合入上一段的 @Local 字段和 loadNextPage 方法
  aboutToDisappear(): void {
    this.disposed = true;
    this.generation++;
  }

  build() {
    Column({ space: 12 }) {
      Text('TileQueue  ·  TQ-1505').fontSize(22).fontWeight(FontWeight.Bold)
      Text(`候选帧 ${this.frames.length}/${this.totalCount} · ${this.stateLabel}`)
      Grid() {
        Repeat<CandidateFrame>(this.frames)
          .each((ri: RepeatItem<CandidateFrame>) => {
            GridItem() {
              Column({ space: 6 }) {
                Text(ri.item.id).fontSize(18)
                Text(`示例分数 ${ri.item.score}`).fontSize(13)
              }.padding(12).backgroundColor('#EDF5F7')
            }
          })
          .key((frame: CandidateFrame) => frame.id)
          .virtualScroll({ totalCount: this.totalCount })
      }
      .columnsTemplate('1fr 1fr')
      .cachedCount(2)
      .onScrollIndex((_first: number, last: number) => {
        if (last >= this.frames.length - 3) this.loadNextPage();
      })
      .layoutWeight(1)
      Text(`进度 ${this.progress}%`)
    }.height('100%').padding(16)
  }
}

这段组件代码与上一段成员声明需要合并到同一个 TileQueuePage 中,并非可以单独粘贴的完整项目。完整工程还要补上窗口生命周期回到前台时的刷新策略,以及加载失败后的重新触发按钮。aboutToDisappear 仅阻止返回后继续发布 UI 状态,并不意味着底层网络或解码工作已经被系统取消;若使用真实网络 API,还应释放请求资源。这里之所以没有取消句柄,是因为 mockPage 根本没有发起网络请求。

四、如何验收一份不造假的分页方案

示意工作台中的帧清单是十二项,顺序是 G-001 到 G-012。前两页加载后,我们预期状态为 8/12 和 67%;临近尾部才触发第三页,完成后是 12/12、100%、“就绪”。如果要做自动化单测,应断言 mockPage(3) 返回四个唯一 ID,且两次重复合并不会让清单超出十二项;如果要谈真实运行性能,则必须另备设备型号、SDK、资源尺寸、缓存策略和可重复的采样日志。

我特别建议检查三类边界。第一类是源数据变化,例如服务端删掉两帧,旧 totalCount 就不成立;第二类是请求失败,页面不能永远呈现无法解释的空白尾部;第三类是页面退出后回调返回,不得改写已经销毁的 UI。对演示数据来说,这三条分别对应“重建分页合同”“保留可重试态”“代际检查”。它们可以在单元级验证,不能替代 DevEco 的构建和真实终端测试。

从一张“缩略图列表”往下挖,真正值得写下来的并不是 Grid 的两列排版,而是滚动窗口、业务清单与异步分页之间的协作条件。Repeat 帮我们减少不必要的节点创建,却不会替应用兜底缺失的数据。把这个边界守住,再谈大图预览、GPU 内存、缓存淘汰才不容易跑偏。

五、参考资料与边界声明

华为官方参考:Repeat 可复用循环渲染;List 容器及懒加载背景。本文仅核对组件公开用法与约束,未做 DevEco 编译、真机滑动分析、3DGS重建,也未宣称官方保证某个预加载阈值。全部图片为独立生成的示意图,模拟器与 HiLog 均是示例排版,而非真实录屏。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐