文章配图: 在 、、、 四个容器

页面预览

前言

在前两篇中,我们用 LazyForEach 实现了按需渲染,用 @Reusable 实现了组件复用。但快速滑动长列表时,你可能遇到过白块闪烁——新数据来不及创建组件,用户看到空白区域一闪而过。HarmonyOS 提供了 cachedCount 属性,让开发者可以设置列表项/网格项的预加载数量,在滑动到达之前提前创建好组件,彻底消除白块。

本文以「猫猫大作战」排行榜列表为锚点,深入讲解 cachedCountListGridSwiperWaterFlow 四个容器中的使用方式,以及如何结合 @ReusableLazyForEach 找到缓存数量与内存开销的最优平衡点

提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–68 篇。本篇是阶段二第 69 篇,列表性能优化三部曲的第三篇。

一、什么是 cachedCount

1.1 问题场景

不设置 cachedCount 时,LazyForEach 的行为如下:

滚动方向 →  ↓
┌─────────────┐
│  可见区 ①   │  ← 已渲染
├─────────────┤
│  可见区 ②   │  ← 已渲染
├─────────────┤
│  可见区 ③   │  ← 已渲染
├─────────────┤
│  (空白)   │  ← 未渲染,手指划到此处才开始创建 → ⚠️ 白块!
├─────────────┤
│  (空白)   │  ← 未渲染
└─────────────┘

设置 cachedCount(3) 后:

滚动方向 →  ↓
┌─────────────┐
│  可见区 ①   │  ← 已渲染
├─────────────┤
│  可见区 ②   │  ← 已渲染
├─────────────┤
│  可见区 ③   │  ← 已渲染
├─────────────┤
│  缓存区 ④   │  ← ⭐ 预加载!手指到达时瞬显
├─────────────┤
│  缓存区 ⑤   │  ← ⭐ 预加载!
├─────────────┤
│  缓存区 ⑥   │  ← ⭐ 预加载!
└─────────────┘

1.2 原理示意

cachedCount 会指示滚动容器在当前可视区域前后各额外创建 N 个不可见组件,放入缓存池中等待用户滑动到达:

                                          cachedCount(3)
        ┌─── 预加载(上方 3 个,超出释放) ←──┐
        │                                    │
        │  ┌──── 可视区  ────┐               │
        │  │  [Item-1]      │               │
        │  │  [Item-2]      │               │
        │  │  [Item-3]      │               │
        │  └────────────────┘               │
        │  ┌──── 缓存区  ────┐               │
        │  │  [Item-4] ⭐   │  ←── 预加载   │
        │  │  [Item-5] ⭐   │               │
        │  │  [Item-6] ⭐   │               │
        │  └────────────────┘               │
        └───────────────────────────────────┘

cachedCount 只在 LazyForEach 中生效,对 ForEach 无效(因为 ForEach 已经全量加载了)。

二、各容器中的 cachedCount

|cachedCount | List | Grid | Swiper | WaterFlow |
|在可见区域前后各缓存的数量 | 在可见区域前后各缓存的数量,Grid 还需要乘以列数 | 在可见区域前后各缓存的数量 | 在可见区域前后各缓存的数量 |

2.1 List 中的 cachedCount

List({ space: 8 }) {
  LazyForEach(this.records, (item: GameRecord) => {
    ListItem() {
      RecordCard({ record: item })  // @Reusable 组件
    }
  }, (item: GameRecord) => item.id.toString())
}
.cachedCount(5)  // 上下各预加载 5 个 ListItem
.width('100%')
.height('100%')

cachedCount(5) 时,List 会:

  • 可视区 10 条 + 上方缓存 5 条 + 下方缓存 5 条 = 最多 20 个组件同时存在
  • 滑动出上/下缓存区的组件会被回收或进入 @Reusable 复用池

2.2 Grid 中的 cachedCount

Grid 的缓存算法略有不同:实际缓存数 = cachedCount × 列数

Grid() {
  LazyForEach(this.catsGrid, (cat: CatItem) => {
    GridItem() {
      CatCard({ cat: cat })
    }
  }, (cat: CatItem) => cat.id.toString())
}
.columnsTemplate('1fr 1fr 1fr')  // 3 列
.cachedCount(3)                   // 实际缓存 = 3 × 3 = 9 个 GridItem
cachedCount 值 3 列 Grid 实际缓存数 说明
1 3 最少缓存,低内存消耗但快速滑动可能有白块
3 9 推荐值,较流畅且内存可控
5 15 极流畅,但内存开销较大
10 30 仅在低端机+大列表快速滑动时使用

2.3 Swiper 中的 cachedCount

Swiper(this.swiperController) {
  LazyForEach(this.heroImages, (img: ImageData) => {
    Image(img.url)
      .width('100%')
      .height('100%')
  }, (img: ImageData) => img.id.toString())
}
.cachedCount(2)  // 前后各预加载 2 页
.loop(true)
.autoPlay(false)

Swiper 的 cachedCount 常用于短视频滑动场景——预加载前后视频的 AVPlayer 资源到 prepared 状态,实现无缝切换。

2.4 WaterFlow 中的 cachedCount

WaterFlow() {
  LazyForEach(this.flowData, (item: MediaItem) => {
    FlowItem() {
      VideoCard({ video: item })
    }
  }, (item: MediaItem) => item.id.toString())
}
.columnsTemplate('1fr 1fr')
.cachedCount(4)  // 上下各缓存 4 行

三、cachedCount 与 @Reusable 的配合

3.1 缓存池与复用池的关系

设置 cachedCount 后,组件一共经历三个状态:

          ┌─────────────┐
  创建 → │  可视区节点   │ ← 实际显示在屏幕上
          └──────┬──────┘
                 │ 滑出可视区
                 ↓
          ┌─────────────┐
          │  缓存区节点  │ ← 被 cachedCount 保留,不显示但存活
          └──────┬──────┘
                 │ 超出缓存区
                 ↓
          ┌─────────────┐
          │  @Reusable  │ ← 进入复用池,等待被复用
          │   复用池     │
          └─────────────┘

@Reusable + cachedCount 同时使用时,组件滑出缓存区后不会销毁,而是进入 @Reusable 的复用池,等下一个新组件需要创建时直接取出复用。

3.2 内存占用计算

组件总数 = 可视区数量 + cachedCount × 2(上下)

实际占用内存 ≈ 组件总数 × 单组件内存

示例:
- 可视区 = 10 条
- cachedCount = 5
- 单组件 ≈ 50KB(含文字、阴影、图片)

组件总数 = 10 + 5×2 = 20 个
总内存   = 20 × 50KB = 1MB ✅ 完全可以接受

极端情况(Grid + 大图):
- 可视区 = 6 列 × 4 行 = 24
- cachedCount = 5 → 实际 5×6 = 30
- 单组件 ≈ 200KB(大图)
总内存 = (24 + 30×2) × 200KB = 84 × 200KB = 16.8MB ⚠️ 需要关注

3.3 推荐配置表

容器 列表项复杂度 cachedCount 推荐值 单组件内存 预期内存
List(纯文本) 简单 5 ~10KB ~0.2MB
List(图片) 中等 3 ~100KB ~1.6MB
Grid(3 列) 中等 3 ~80KB ~1.9MB
Grid(大图) 复杂 2 ~300KB ~3.6MB
Swiper(视频) 极复杂 2 ~5MB(AVPlayer) ~20MB
WaterFlow 中等 4 ~100KB ~2.4MB

经验法则:从 cachedCount(3) 起步,快速滑动测试,如出现白块则 +1,直到流畅为止。

四、项目实战:排行榜缓存策略调优

4.1 场景说明

「猫猫大作战」的排行榜列表,列表项 RecordCard 包含排名、玩家名、得分、排名变化箭头,单个组件约 50KB。需要在 3 秒内滚动 500 条记录时保持 60fps。

4.2 基准测试

@Entry
@Component
struct LeaderboardPage {
  private dataSource: LeaderboardDataSource = new LeaderboardDataSource(10000);

  @State cachedCountValue: number = 3;     // 可调节
  @State visibleItems: number = 0;
  @State totalNodes: number = 0;

  build() {
    Column() {
      // 调试面板
      Row() {
        Text(`cachedCount: ${this.cachedCountValue}`)
        Button('-').onClick(() => {
          if (this.cachedCountValue > 0) this.cachedCountValue--;
        })
        Button('+').onClick(() => {
          if (this.cachedCountValue < 20) this.cachedCountValue++;
        })
      }
      .padding(8)
      .width('100%')

      // 排行榜列表
      List({ space: 8 }) {
        LazyForEach(this.dataSource, (item: GameRecord) => {
          ListItem() {
            RecordCard({ record: item })
          }
        }, (item: GameRecord) => item.id.toString())
      }
      .cachedCount(this.cachedCountValue)   // 可动态调节
      .width('100%')
      .layoutWeight(1)
      .onScrollIndex((start, end) => {
        this.visibleItems = end - start + 1;
        // 估算总节点数 = 可视 + cachedCount×2
        this.totalNodes = this.visibleItems + this.cachedCountValue * 2;
      })
    }
    .height('100%')
  }
}

4.3 调优过程

cachedCount 白块 帧率(低端机) 内存 结论
0 🔴 频繁 28fps 3.2MB 太差
1 🟡 偶尔 35fps 3.5MB 一般
3 🟢 很少 52fps 4.1MB 推荐
5 🟢 无 57fps 5.0MB 较优
10 🟢 无 58fps 6.8MB 过度

推荐配置cachedCount(5) 是"白块消除"与"内存控制"的最佳平衡点。

4.4 动态缓存适配

// 根据设备性能动态设置 cachedCount
function getRecommendedCachedCount(): number {
  const memory = Number(deviceInfo.deviceMemory); // MB
  const isLowEnd = deviceInfo.deviceType === 'phone' && memory < 4000;

  if (isLowEnd) {
    return 2;   // 低端机:节省内存
  } else if (memory > 8000) {
    return 8;   // 高端机:极致流畅
  } else {
    return 5;   // 中端机:平衡
  }
}

List()
  .cachedCount(getRecommendedCachedCount())

五、cachedCount 与其他缓存策略对比

策略 作用域 机制 数据源 适用场景
cachedCount 容器级 预创建组件 LazyForEach 消除滑动白块
@Reusable 组件级 复用滑出节点 任意 减少创建开销
组件冻结 组件级 不可见时不刷新 LazyForEach 减少非可见区域更新
onVisibleAreaChange 组件级 感知可见比例 任意 按需加载资源

四者组合使用

List({ space: 8 }) {
  LazyForEach(this.dataSource, (item: GameRecord) => {
    ListItem() {
      RecordCard({ record: item })
        .reuseId(item.rank <= 3 ? 'top3' : 'normal')  // @Reusable 复用
    }
  }, (item: GameRecord) => item.id.toString())
}
.cachedCount(5)                              // 缓存 5 个

六、cachedCount 与组件冻结

从 API 17 开始,ArkUI 支持组件冻结功能,当组件处于非可视区域(含 cachedCount 缓存区)时,状态变量的变化不会触发组件刷新,进一步节省性能:

List({ space: 8 }) {
  LazyForEach(this.dataSource, (item: GameRecord) => {
    ListItem() {
      RecordCard({ record: item })
    }
  }, (item: GameRecord) => item.id.toString())
}
.cachedCount(5)
// 组件冻结默认开启(API 17+),无需额外配置

冻结 + cachedCount 的协同效果:缓存区中的组件虽然存活,但不会响应状态变化刷新 UI,CPU/GPU 零开销。

七、常见踩坑

7.1 坑一:cachedCount 与 ForEach 不生效

// 🚫 错误:cachedCount 对 ForEach 无效
<List> {
  ForEach(this.records, (item) => {
    ListItem() { Text(item.name) }
  })
}
.cachedCount(5)  // ❌ ForEach 下此属性无效

解决:搭配 LazyForEach 使用。

7.2 坑二:cachedCount 与 @Reusable 的 aboutToReuse 不触发

cachedCount 预创建的组件不会触发 aboutToReuse,因为组件是全新创建而非从复用池取出:

@Reusable
@Component
struct RecordCard {
  aboutToReuse(params: Record<string, Object>) {
    console.info('从复用池取出'); // 缓存区新建组件时不触发!
  }

  aboutToAppear() {
    console.info('全新创建');    // 缓存区新建组件时触发!
  }
}

理解cachedCount 预创建 = 新创建(aboutToAppear),@Reusable 复用 = 取出旧节点(aboutToReuse)。

7.3 坑三:缓存区太大导致内存警告

// 🚫 错误:无上限的 cachedCount
LazyForEach(this.dataSource, (item) => {
  ListItem() { HeavyImageCard({ data: item }) }
})
.cachedCount(50)  // ❌ 50×2=100 个重图组件 → 内存直接爆炸

合理做法

组件复杂度 最大 cachedCount 建议
纯文本/轻量 20
含小图(< 50KB) 10
含大图(> 200KB) 5
含视频/Canvas 等重型组件 2

7.4 坑四:Swiper 中 cachedCount 预创建资源重复消耗

// ✅ Swiper 中 cachedCount 与资源缓存配合
Swiper() {
  LazyForEach(this.videoData, (item: VideoData) => {
    VideoPlayerView({ src: item.url })
  }, (item: VideoData) => item.id.toString())
}
.cachedCount(2)

Swiper 的 cachedCount(2) 会前后各多创建 2 个 VideoPlayerView 实例,每个实例都包含一个 AVPlayer。如果前后页的视频已经 prepared,需要确保只有缓存的 AVPlayer 进入 prepared 状态,而不是全部同时播放。

八、性能验证方法

8.1 使用 SmartPerf 验证

# 抓取帧率数据
hyperfunk --app-pid $(pidof com.catbattle) --frame-record -o /data/output

# 查看关键指标
# - Frame Time: 平均 < 16ms (60fps)
# - Jank Frames: < 1%
# - 无白块(通过录屏回放验证)

8.2 使用 HiLog 验证缓存命中

import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG = 'CachedCountDemo';
const DOMAIN = 0xFF00;

@Reusable
@Component
struct RecordCard {
  @Prop record: GameRecord = new GameRecord();
  private createTime: number = 0;

  aboutToAppear() {
    this.createTime = performance.now();
    const totalNodes = performance.getTotalNodeCount?.() ?? 0;
    hilog.info(DOMAIN, TAG, `🆕 新建组件: ${this.record.id}, 节点数: ${totalNodes}`);
  }

  aboutToReuse() {
    const now = performance.now();
    const reuseCost = now - this.createTime;
    hilog.info(DOMAIN, TAG, `🔄 复用组件: ${this.record.id}, 复用耗时: ${reuseCost.toFixed(1)}ms < 0.1ms`);
  }

  aboutToDisappear() {
    hilog.info(DOMAIN, TAG, `🗑️ 销毁组件: ${this.record.id}`);
  }
}

运行后观察日志:

// cachedCount(5) + @Reusable 的典型日志
🆕 新建组件: 0, 节点数: 12     ← 首次创建 12 个组件(可见区+缓存)
🆕 新建组件: 1, 节点数: 12
...
🔄 复用组件: 11, 复用耗时: 0.02ms  ← 后续全从复用池取
🔄 复用组件: 12, 复用耗时: 0.01ms
...

九、最佳实践速查表

场景 cachedCount @Reusable 说明
消息列表(纯文本) 5 推荐
排行榜(文本+小图) 5 推荐
商品网格(3 列) 3 缓存数 = 3×3=9
视频 Swiper 2 大资源需谨慎
图片瀑布流 4 WaterFlow
低端机优化 2 内存优先
长列表快速滑动 8 流畅优先

十、总结

cachedCountLazyForEach 的核心搭档,通过在可视区前后预创建组件完美消除滑动白块。与 @Reusable 搭配使用时,缓存区组件滑出后进入复用池,内存和创建效率两不误。

核心要点

  • cachedCount(N) = 向上预加载 N 个 + 向下预加载 N 个
  • 只在 LazyForEach 中生效,对 ForEach 无效
  • Grid 的实际缓存数 = cachedCount × 列数
  • 3 起步调整,白块消失即停
  • 重型组件(视频/大图)谨慎设大 ≤ 5
  • @Reusable + 组件冻结配合使用效果最佳

下一篇预告:第 70 篇将深入 IDataSource — 自定义数据源的进阶用法,包括分页加载、服务端同步、数据合并等高级话题。本篇也是阶段二的收官之作!

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

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

更多推荐