同一批阅读条目,手机上浏览顺畅,放到双栏窗口却可能出现两个问题:列表滑动时偶尔停顿,选中某篇文章后,右侧详情更新带动左侧大量行重新计算。肉眼看起来都是“卡”,但这两个现象并不一定属于同一层故障。前者可能与列表节点创建、图片解码或预加载有关;后者则可能来自状态依赖范围过大。这一篇不急于给出某个所谓万能参数,而是把实验变量拆开,让优化能被验证,也能被推翻。

长列表实验技术封面

此前四篇已经建立 ReadingItem、ReadingMark、selectedId 和阅读快照的基本约定:条目的身份由稳定的 id 决定;被选中的文章在窄窗中参与页面导航,在宽窗中控制详情面板;阅读锚点用于恢复浏览位置。现在这些约定要面对更大的数据量。假设本地阅读列表逐步增长至数百项,折叠屏展开以后左侧目录与右侧正文并行存在。性能优化不能以牺牲导航结果正确性为代价,尤其不能把索引号误当成文章身份。

一、先定义可以被反复执行的实验

要判断哪一种列表策略有效,必须先约定比较对象。把同一批固定顺序的数据分成两份实验方案:基线方案以 ForEach 生成列表项;候选方案以 Repeat(...).virtualScroll() 在有效加载范围内生成节点。官方 List 文档指出,ForEach 会一次性创建所有子组件,而 Repeat 的虚拟滚动模式可以按可见区与预加载范围管理节点。两者适合不同数据规模,因此对比时不可混入不同条目高度、不同媒体资源或不同过滤算法。这里所有项目统一用纯文本行,先隔离图片解码的影响,之后再作为第二个变量单独加入缩略图。

实验不把“列表长度越长就一定越慢”当结论。要观察四种行为:首次进入目录页的构建过程;连续上下滚动时是否出现明显帧间停顿;选择条目后详情切换是否导致左侧行状态错误;窗口由单栏改为双栏时能否保持 selectedId 与滚动锚点。测试应在相同设备、系统版本、构建模式、屏幕方向和冷暖启动条件下重复进行,数据的采样窗口也要相同。对照的目标不是造一个漂亮的平均值,而是区分可稳定复现的差异与偶发现象。

全量创建与虚拟滚动的节点管理对照

这里还需明确一个容易被忽略的条件:Repeat 的虚拟滚动模式属于状态管理 V2 使用路径,不应直接混入前文使用的 V1 @State 列表属性。项目可以在局部列表组件采用 @ComponentV2 和 @Local,再通过清晰的只读入参或适配层连接既有阅读仓库。迁移的关键不在于换几个装饰器,而是保证同一条目在两套状态模型之间不会产生两个相互冲突的选中真值。

二、实现独立的可见区列表组件

下面的组件采用 V2 局部状态,输入条目在初始化时填充。真实工程应由上层仓库提供数据,并确保在进入页面前完成必要的读取。代码中的 ReadingRow 保留前四篇的稳定 id,同时把选择事件收敛到一个入口。对于独立复现实验,可以把下列内容放到一个新的 ArkTS 页面中进行对比。

class ReadingRow {
  readonly id: string;
  readonly title: string;
  constructor(id: string, title: string) {
    this.id = id;
    this.title = title;
  }
}

@Entry
@ComponentV2
struct ReadingListProbe {
  @Local rows: ReadingRow[] = [];
  @Local selectedId: string = '';
  @Local firstVisibleIndex: number = 0;

  aboutToAppear(): void {
    const seed: ReadingRow[] = [];
    for (let i: number = 0; i < 500; i++) {
      seed.push(new ReadingRow(`article_${i}`, `阅读条目 ${i + 1}`));
    }
    this.rows = seed;
  }

  build() {
    Column({ space: 8 }) {
      Text(`当前选中:${this.selectedId || '无'}`)
        .fontSize(16)
      Text(`首个可见位置:${this.firstVisibleIndex}`)
        .fontSize(14)
      List({ space: 4 }) {
        Repeat<ReadingRow>(this.rows)
          .each((ri: RepeatItem<ReadingRow>) => {
            ListItem() {
              Row() {
                Text(ri.item.title)
                  .fontSize(16)
                  .fontColor(this.selectedId === ri.item.id ? Color.Blue : Color.Black)
              }
              .height(52)
              .padding({ left: 16, right: 16 })
              .width('100%')
              .onClick(() => { this.selectedId = ri.item.id; })
            }
          })
          .key((item: ReadingRow): string => item.id)
          .virtualScroll({ totalCount: this.rows.length })
      }
      .onScrollIndex((start: number, end: number, center: number) => {
        this.firstVisibleIndex = start;
      })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
  }
}

代码把数据量固定为一个实验输入,不把 500 写成最佳性能值。.key() 使用稳定条目身份而不是数组下标,是为了在排序、插入和删除以后仍能判断哪些节点属于同一篇文章。.virtualScroll() 管理有效加载范围,.onScrollIndex() 则提供列表当前进入可见区的索引观察点。注意事件返回的是索引,不是跨排序永久有效的阅读锚点。把它存到 ReadingMark 之前,应该通过 rows[start].id 转换成业务标识,并检查边界;恢复时反向查找当前列表中的位置。

selectedId 在此页作为示例状态;接入此前的阅读工程时,应该把点击操作转给单一状态协调器。列表只是显示和分发事件的组件,详情页、导航栈和 Preferences 不能再维护各自独立的“当前选中”。这样既降低依赖面,又能避免窗口切换时出现左侧高亮与右侧文章不一致的问题。

三、对照实验真正应该记录什么

将候选方案与基线方案放在两个可切换构建入口,使用同一数据生成器,按固定操作脚本完成多次试验。首先进入列表,等待静止;其次以相同手势从顶部滑至中部,再返回顶部;接着连续选中三篇不同文章;最后缩放窗口切换一栏与两栏,并观察右侧文章是否与左侧高亮一致。采样工具使用 DevEco Studio Profiler 的帧分析能力记录时间分布,再人工标记显著停顿发生的操作;对于不具备可复现证据的数字,实验报告保留空值而不是填写猜测的 FPS。

控制变量与采样记录设计

建议记录每组的设备型号、系统与 SDK、应用构建模式、数据条目数、条目平均高度、预加载配置、图片是否开启、操作路径、采样轮数和观察结果。要关注中位数以及分布尾部,而不是选择“最顺畅的一次”当代表。若两组差异很小,不能推出虚拟化没有价值;可能是数据量不够大、CPU 与内存压力不足,或者当前瓶颈不在节点数量。若只有添加缩略图时出现异常,再把图片分辨率、加载策略与缓存作为新一轮独立变量。

另一个误区是用 Date.now() 在某个事件回调前后相减,再宣称得到了完整的 UI 帧耗时。这个值至多反映某段 JavaScript/ArkTS 同步逻辑的墙钟时间,无法单独代表 GPU 呈现、布局排队和系统调度。浏览行为的流畅度应优先结合真实帧时间工具与实际的交互记录。没有采样环境时可以给出方法和判定标准,但不能倒填“改善了多少毫秒”的结果。

四、把多栏状态与性能问题拆开

当前工程中,窗口宽度变化会修改布局模式,但 selectedId、阅读快照及 ReadingMark 的归属保持不变。列表性能优化应遵循“数据身份不变、渲染方式可替换”的原则:同一阅读仓库可以供单栏与双栏复用,具体 List 组件可以独立切换全量创建或虚拟化策略。不要把窗口宽度直接拼入 .key();否则同一条目从单栏进入双栏后可能被识别成新的元素,造成额外节点重建和焦点丢失。

跨布局切换时保持稳定键与阅读状态

做宽屏测试时,还要控制右侧详情的复杂度。若选中条目时右侧同时加载大型图片、构建富文本与滚动定位,帧分析中的峰值不能简单归咎于左侧列表。可以暂时用固定高度的详情占位内容隔离变量,分别比较“只有列表滚动”“只有详情更新”“两者同时执行”三种操作。可见区变化事件要承担记录职责,不宜在每次触发时进行大规模检索、持久化磁盘写入或者重新构造整个数据数组。阅读锚点可按生命周期与交互边界合并保存,避免把高频滚动事件转化为频繁 I/O。

当数据源发生过滤与重排时,恢复逻辑需要先确认 id 是否仍存在,再决定滚动目标。如果旧文章已被删除,合理策略是清除无效选择或回退到列表首项,而不是复用原来的数值索引。对于动态高度条目,initialIndex 与绝对偏移可能存在估算差异;因此锚点恢复应优先依赖业务 id,并在布局准备完成后按需要调整细粒度位置。这些约束看起来与性能没有关系,却是把优化真正放到多设备项目中的必要条件。

五、在可测量的范围内做工程取舍

经过上述拆分,优化选择可以按证据进行。如果主要问题是长列表首次构建量大,考虑 Repeat 虚拟化、稳定键和合理的预加载;如果问题只发生在大图进入可见区,首先检查缩略图及解码;如果选中后两栏同时刷新,关注状态归属和组件拆分;如果窗口切换后位置错乱,则追查业务键与恢复顺序,而不是继续调整缓存数量。

下一篇将以这套阅读工作区为对象进行多设备与无障碍验收。性能数据只是质量的一面:当读屏焦点、触控目标和导航返回行为不正确时,即使列表看起来很顺,也还不能称为完整的多端体验。

官方参考:

Logo

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

更多推荐