HarmonyOS Grid 滑动卡顿怎么排查:中式美食菜谱封面墙 cachedCount 怎么取值

Grid 做图片墙时,最容易被忽略的不是布局本身,而是滑动时“提前准备多少个 GridItem”。我在中式美食的菜谱封面墙里遇到过一个很典型的现象:缓存设得太保守,快速滑动时边缘会短暂空一下;缓存设得太激进,页面还没滑过去,图片解码和节点准备已经把主线程拖慢了。

所以 cachedCount 不能按感觉填,也不是越大越好。它更像一个缓存窗口:可视区附近准备一点,让快速滑动能接住;但不要把很远的图片也提前拉进来,不然页面卡顿只是从“滑动时卡”变成了“还没滑就先卡”。

先把几个职责说清楚

这几个词放在一起时,很容易混着用:

名称 负责什么 判断口径
Grid 负责网格布局,比如三列菜谱封面墙 先看列数、间距、单项高度是否稳定
virtualScroll 负责按需渲染,不一次性创建所有项 数据量大、图片多、滑动长时优先考虑
cachedCount 负责可视区外提前准备多少项 太小容易露白,太大容易多做解码和布局
图片兜底 负责图片还没回来或失败时页面不闪 占位图、失败图、加载态要提前设计

官方文档里 Grid 的 cachedCount 说明比较直接:设置缓存后,会在 Grid 显示区域上下缓存一定数量的 GridItem;LazyForEach 或开启 virtualScroll 的 Repeat 超出显示和缓存范围后会被回收。这个规则放到图片墙里,就能转成一个更容易判断的工程问题:我到底要为“下一段滑动”提前准备多少内容。

问题是怎么发生的

假设菜谱封面墙一次显示 3 列、4 行,也就是可视区里有 12 个封面。现在用户快速往下滑,页面需要马上接住下一屏内容。

如果 cachedCount 太小,Grid 只准备了很近的一点内容。用户手势一快,图片还没加载完,边缘就会露出空白占位。

如果 cachedCount 太大,Grid 会提前准备很多屏外内容。看起来很稳,但图片墙的问题是每个 GridItem 都带封面图,节点创建、图片解码、圆角裁剪、阴影这些成本会被一起提前触发。

我一般先用下面这个规则判断:

  • 文字列表可以稍微大胆一点,因为单项成本低;
  • 图片墙先保守一点,因为图片解码、缓存和裁剪成本高;
  • 如果首屏加载已经慢,先别急着加 cachedCount,应该先查图片大小、占位和缓存策略;
  • 如果只有快速滑动露白,才考虑适当增加 cachedCount。

案例一:缓存太小,快速滑动容易露白

先看一个容易出问题的写法:

Grid() {
  Repeat(this.recipeCards)
    .each((item: RecipeCard) => {
      GridItem() {
        RecipeCoverCard({ item })
      }
    })
    .key((item: RecipeCard) => item.id)
    .virtualScroll()
}
.columnsTemplate('1fr 1fr 1fr')
.cachedCount(1)

这个写法看起来没有明显错误。它确实启用了按需渲染,也给了缓存数量。但放到图片墙里,cachedCount(1) 可能只够接住很轻的滑动。只要图片较大,或者用户从首页快速滑到分类结果页,边缘就可能出现短暂空白。

我本地用一个简化模型算了一下:可视区 12 项,缓存 1 行时,准备范围是 18 项。也就是说,除了可视区,只多准备了 6 项。对于三列图片墙来说,这只是上下各一行,手势稍微快一点就会顶到边界。

case-a-fast-fling
visibleCount: 12
preparedCount: 18
extraCount: 6
decision: too-small-cache-risk

这种情况不应该只怪 Grid,也不能简单说“图片加载慢”。更准确的判断是:缓存窗口太窄,图片兜底也没有把视觉断层接住。

案例二:缓存太大,页面还没滑就先做了太多事

再看另一个极端:

Grid() {
  Repeat(this.recipeCards)
    .each((item: RecipeCard) => {
      GridItem() {
        RecipeCoverCard({ item })
      }
    })
    .key((item: RecipeCard) => item.id)
    .virtualScroll()
}
.columnsTemplate('1fr 1fr 1fr')
.cachedCount(6)

这个写法通常是从“不要露白”的角度出发,但它会把问题推到另一边。可视区仍然只有 12 项,缓存 6 行时,准备范围可能变成 48 项。多出来的 36 个 GridItem 如果都有封面图,就会提前触发大量图片相关工作。

case-b-heavy-images
visibleCount: 12
preparedCount: 48
extraCount: 36
decision: too-large-cache-risk

这种页面的表现通常不是露白,而是滑动前几屏突然变沉,或者从搜索结果切到图片墙时有明显停顿。这个时候继续加 cachedCount 只会更糟,应该反过来查三个点:

  • 封面图是不是已经做了缩略图,不要拿详情页大图直接塞进 Grid;
  • GridItem 里有没有太多跟图片无关的复杂布局;
  • 图片失败或加载中是否有稳定占位,避免反复改变单项高度。

我会怎么取值

我的做法是先从 cachedCount(2)cachedCount(3) 起步,再按页面表现调。

Grid() {
  Repeat(this.recipeCards)
    .each((item: RecipeCard) => {
      GridItem() {
        RecipeCoverCard({
          item,
          placeholder: $r('app.media.recipe_placeholder'),
          failed: $r('app.media.recipe_failed')
        })
      }
    })
    .key((item: RecipeCard) => item.id)
    .virtualScroll()
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(12)
.columnsGap(12)
.cachedCount(2)

这个取值不是固定答案,但它有一个比较稳的出发点:可视区 12 项时,缓存 2 行大约准备 24 项,多准备 12 项。对三列图片墙来说,通常可以接住一次正常快速滑动,同时不会提前准备太多远处图片。

case-c-balanced
visibleCount: 12
preparedCount: 24
extraCount: 12
decision: balanced

如果设备性能较强、图片已经做了缩略图、封面卡片结构很轻,可以再往上调一点;如果列表里还有渐变、阴影、标签、收藏按钮、评分等复杂元素,就不要轻易把 cachedCount 拉太高。

RecipeCoverCard 也要配合

cachedCount 只能控制节点准备范围,不能替你解决图片本身的问题。图片卡片要尽量稳定,至少做到三件事:

@Component
struct RecipeCoverCard {
  @Prop item: RecipeCard
  @Prop placeholder: Resource
  @Prop failed: Resource

  build() {
    Column() {
      Image(this.item.cover || this.placeholder)
        .width('100%')
        .aspectRatio(1.2)
        .objectFit(ImageFit.Cover)
        .borderRadius(12)
        .alt(this.failed)

      Text(this.item.name)
        .fontSize(14)
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('100%')
  }
}

这里重点不是代码有多复杂,而是职责边界:

  • Grid 负责布局和可视区;
  • cachedCount 负责附近节点准备;
  • RecipeCoverCard 负责图片占位、失败兜底和单项高度稳定;
  • 数据层负责给封面缩略图,不要让 UI 层直接背大图压力。

这几个边界分开以后,排查就清楚了。露白先看缓存窗口和占位,卡顿先看图片大小和单项结构,不要所有问题都往 Grid 身上堆。

怎么验证

我本地用一个小脚本模拟了三列 Grid 的缓存窗口,核心输入是总数、列数、可视行数、首个可见项和 cachedRows。

const cases = [
  { name: 'case-a-fast-fling', columns: 3, visibleRows: 4, firstVisibleIndex: 36, cachedRows: 1 },
  { name: 'case-b-heavy-images', columns: 3, visibleRows: 4, firstVisibleIndex: 36, cachedRows: 6 },
  { name: 'case-c-balanced', columns: 3, visibleRows: 4, firstVisibleIndex: 36, cachedRows: 2 },
]

验证结果是:

cachedRows = 1  -> 准备 18 项,风险是快速滑动露白
cachedRows = 6  -> 准备 48 项,风险是图片解码压力过早上来
cachedRows = 2  -> 准备 24 项,比较适合作为图片墙起步值

这个脚本不能替代真机性能测试,但它能帮我先把判断口径算清楚。真机上还要继续看两类现象:

  • 快速滑动是否出现空白、错位、旧图闪一下;
  • 首屏进入、搜索结果切换、分类切换时是否明显卡顿。

如果出现第一类问题,先加一点缓存或加强占位;如果出现第二类问题,先降缓存、压缩封面图、拆轻 GridItem。

最后沉淀成一条规则

Grid 图片墙不要只盯着 cachedCount 一个数。更稳的检查顺序是:

  1. 先确定 GridItem 高度稳定,不要图片回来后把行高撑变;
  2. 再确认图片使用缩略图,不要把大图直接丢进列表;
  3. 然后从 cachedCount(2)cachedCount(3) 起步;
  4. 快速滑动露白就微调缓存或占位;
  5. 进入页面卡顿就先查图片解码和单项结构,不要继续加缓存。

对菜谱封面墙这种图片密集页面来说,cachedCount 的价值不是“越多越稳”,而是把可视区附近准备好,把远处的成本留到真正需要的时候再做。这样滑动体验和内存压力才比较容易平衡。

Logo

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

更多推荐