版本和验证环境

验证环境先写清楚:HarmonyOS 5.0.2(API 14)及以上,DevEco Studio 6.0 Release,ArkTS 声明式 UI,Stage 模型应用。页面代码按 List / ListItem / ForEach 的常见写法组织,事件记录按 HiAppEvent 接入思路封装。本文的小脚本已经在本地 Node 环境跑过,用来验证“图片布局抖动”和“滚动中同步统计”两类场景的掉帧差异;真正落到项目里时,再把同样的字段接到 HiAppEvent 或统一性能事件上报里。

官方文档里性能体验建议会把时延、帧率、内容显示、内存和 CPU 都放在一起看;滑动丢帧不能只看一个日志点。这里的处理目标很明确:先稳定布局和状态刷新,再记录掉帧事件,最后用同一批数据对比修复前后。

问题先说清楚

很多 HarmonyOS 页面并不是一打开就慢,而是滑动一会儿才开始不稳。最麻烦的是,开发时看起来只是“偶尔卡一下”,日志里又没有直接报错,最后排查就容易变成猜:是不是图片太多、是不是状态刷新太频繁、是不是列表复用没写好。

我现在更倾向于先把问题拆成证据,再决定改哪里。滑动卡顿这种问题,不能只看一两次手感,要把页面、数据规模、图片状态、刷新动作和掉帧事件放到同一条记录里。这样后面改了代码,才能知道是确实变好了,还是只是这次滑动刚好没复现。

本文按 HarmonyOS 5.0.2(API 14)及以上的应用性能优化思路来写,重点不在堆概念,而是把一个列表页面的掉帧排查流程说清楚:怎么发生、怎么复现、怎么记录、怎么修、怎么确认修复有效。

先看两个容易复现的场景

我把问题拆成两个场景。第一个是图片列表,第二个是滚动时统计刷新。它们看起来都是“滑动卡”,但根因不一样,修法也不一样。

场景 表面现象 常见根因 适合记录什么
图片列表滑动卡 首屏正常,快速滑动时突然抖一下 图片没有稳定占位,解码和布局一起发生 列表数量、图片是否已缓存、当前页面
滚动中统计刷新 滑动时底部统计或标题数字跟着变 同步计算抢主线程,状态更新太密 刷新来源、耗时、触发次数

这两种问题都不适合只在控制台打印一句“卡顿了”。如果没有上下文,后面看到日志也不知道当时页面上有多少数据、图片是否命中缓存、是不是刚好触发了一次全量统计。

Case A:图片列表为什么会把滑动拖慢

先看一个简化版的坏写法。列表里每一项都有封面图,但没有给图片区域稳定高度,也没有准备占位。图片回来以后,行高变化、布局重新计算、图片解码都挤在滑动过程中,用户感知就是滑动中突然顿一下。

interface RecipeCard {
  id: string
  title: string
  cover: string
  loaded: boolean
}

@Entry
@Component
struct JankImageListPage {
  @State recipes: RecipeCard[] = []

  aboutToAppear() {
    this.recipes = mockRecipeCards(120)
  }

  build() {
    List() {
      ForEach(this.recipes, (item: RecipeCard) => {
        ListItem() {
          Column() {
            // 坏点:图片回来之前没有稳定尺寸,滑动中会反复影响布局。
            Image(item.cover)
              .objectFit(ImageFit.Cover)
              .borderRadius(12)

            Text(item.title)
              .fontSize(16)
              .margin({ top: 8 })
          }
          .padding(12)
        }
      }, (item: RecipeCard) => item.id)
    }
  }
}

这个问题的修复不是一句“缓存图片”就完了。缓存能减少网络和解码压力,但如果图片区域本身不稳定,布局还是会在滑动中被拉扯。我的处理会分三步:固定图片区域、失败兜底、把列表规模写进事件上下文。

@Component
struct StableImageCard {
  @Prop item: RecipeCard

  build() {
    Column() {
      Stack() {
        Rect()
          .fill('#F3F6F8')
          .width('100%')
          .height(132)
          .borderRadius(12)

        Image(this.item.cover)
          .width('100%')
          .height(132)
          .objectFit(ImageFit.Cover)
          .borderRadius(12)
          .alt('/resources/base/media/recipe_cover_fallback.png')
      }

      Text(this.item.title)
        .fontSize(16)
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ top: 8 })
    }
    .padding(12)
  }
}

修完以后,图片加载慢最多只是“图片晚一点出来”,不会再把整行高度和列表布局带着抖。这时再去看滑动事件,掉帧次数才有比较意义。

Case B:滚动中同步统计为什么更隐蔽

第二个场景更隐蔽:页面底部有“已选 N 项 / 共 M 项”,或者顶部有分类命中数量。开发时为了省事,可能每次列表状态变化都同步扫一遍数组。

@State selectedIds: string[] = []
@State recipes: RecipeCard[] = []

private countSelected(): number {
  // 坏点:滚动过程中频繁触发时,这类同步计算会抢主线程。
  return this.recipes.filter(item => this.selectedIds.includes(item.id)).length
}

build() {
  Column() {
    Text(`已选 ${this.countSelected()} 项 / 共 ${this.recipes.length} 项`)
    List() {
      ForEach(this.recipes, (item: RecipeCard) => {
        ListItem() {
          StableImageCard({ item })
        }
      }, (item: RecipeCard) => item.id)
    }
  }
}

如果数据只有十几条,这么写没什么感觉。一旦列表变长,或者选中状态、搜索词、分类切换一起变化,滚动中就会出现一段一段的不稳。更稳的写法是把统计结果从渲染过程里拆出来,状态变化时更新一次,页面只读结果。

@State selectedIds: string[] = []
@State selectedCount: number = 0
@State recipes: RecipeCard[] = []

private refreshSelectedCount() {
  const selected = new Set(this.selectedIds)
  let count = 0
  for (const item of this.recipes) {
    if (selected.has(item.id)) {
      count++
    }
  }
  this.selectedCount = count
}

private toggleSelected(id: string) {
  if (this.selectedIds.includes(id)) {
    this.selectedIds = this.selectedIds.filter(item => item !== id)
  } else {
    this.selectedIds = [...this.selectedIds, id]
  }
  this.refreshSelectedCount()
}

build() {
  Column() {
    Text(`已选 ${this.selectedCount} 项 / 共 ${this.recipes.length} 项`)
    List() {
      ForEach(this.recipes, (item: RecipeCard) => {
        ListItem() {
          StableImageCard({ item })
        }
      }, (item: RecipeCard) => item.id)
    }
  }
}

这个改法的重点不是“少写一行 filter”,而是把计算时机固定下来。渲染阶段只拿状态结果,不在每次 UI 构建时重新扫数据。后面再配合 HiAppEvent 记录,就能看到掉帧次数有没有下降。

HiAppEvent 该记录哪些字段

我的记录习惯是宁愿字段少一点,也要能支撑后面的判断。滑动丢帧事件至少要带上页面、列表规模、图片状态和本轮是否触发同步统计。

type ScrollJankScene = 'image_list' | 'sync_statistic'

interface ScrollJankPayload {
  pageName: string
  scene: ScrollJankScene
  listSize: number
  cachedImageCount: number
  hasStablePlaceholder: boolean
  syncStatisticTriggered: boolean
  droppedFrameCount: number
}

function reportScrollJank(payload: ScrollJankPayload) {
  // 实际项目里这里接入 HiAppEvent 或统一性能事件上报。
  // 重点是字段要能解释“为什么这次卡”,不要只写一个 jank=true。
  console.info('[scroll_jank]', JSON.stringify(payload))
}

如果是图片列表,就记录图片是否有稳定占位、缓存命中数量。如果是统计刷新,就记录这次是否触发了同步统计。字段不用贪多,但要能回答一个问题:这次掉帧到底跟什么动作挨得最近。

我用一个小脚本先验证排查逻辑

为了避免只是凭感觉说,我把两个坏场景和一个修复场景跑了一遍。模拟规则很简单:一帧预算按 16ms 算,超过就记为一次掉帧。

const events: Array<Record<string, number | string | boolean>> = []

function report(name: string, payload: Record<string, number | string | boolean>) {
  events.push({ name, ...payload })
}

function simulate(listSize: number, imagePlaceholder: boolean, syncCalcCost: boolean): number {
  let dropped = 0
  for (let frame = 0; frame < 120; frame++) {
    const layoutCost = imagePlaceholder ? 4 : (frame % 17 === 0 ? 26 : 7)
    const calcCost = syncCalcCost && frame % 9 === 0 ? 18 : 2
    const total = layoutCost + calcCost
    if (total > 16) {
      dropped++
      report('scroll_jank', { frame, total, listSize, imagePlaceholder, syncCalcCost })
    }
  }
  return dropped
}

const imageLayoutJank = simulate(120, false, false)
const statisticJank = simulate(120, true, true)
const fixed = simulate(120, true, false)

console.info({ imageLayoutJank, statisticJank, fixed, eventCount: events.length })

本地验证结果是:图片布局抖动场景记录到 8 次掉帧,滚动中同步统计场景记录到 14 次掉帧,固定图片占位并把统计从渲染过程拆出去后,模拟掉帧为 0。这个数字不是要替代真机性能测试,而是用来确认排查思路没跑偏:先把问题拆出来,再去真机上看真实事件和耗时。

几种处理方式怎么选

处理方式 能解决什么 风险 我会怎么用
只加日志 能知道大概哪里发生过 没有上下文,很难复盘 只作为临时排查
固定图片占位 减少滑动中布局抖动 需要设计好默认图和比例 图片列表默认要做
统计结果前置 减少渲染阶段同步计算 状态更新链路要清楚 数据量变大时必须做
HiAppEvent 统一记录 能做发布后对比 字段设计太乱会污染数据 只保留能解释问题的字段

我的选择是:页面结构先修稳,再接事件记录。不要反过来。因为结构不稳时,事件会很多,但每一条都像噪音;结构先稳住以后,剩下的事件才更接近真正需要处理的问题。

可以封装成一个小工具

如果项目里有多个长列表,不建议每个页面都手写一套字段。可以封装一个很薄的工具,只要求调用方传页面名、场景和列表状态。

class ScrollPerformanceReporter {
  static reportImageListJank(pageName: string, listSize: number, cachedImageCount: number, droppedFrameCount: number) {
    reportScrollJank({
      pageName,
      scene: 'image_list',
      listSize,
      cachedImageCount,
      hasStablePlaceholder: true,
      syncStatisticTriggered: false,
      droppedFrameCount
    })
  }

  static reportStatisticJank(pageName: string, listSize: number, droppedFrameCount: number) {
    reportScrollJank({
      pageName,
      scene: 'sync_statistic',
      listSize,
      cachedImageCount: 0,
      hasStablePlaceholder: true,
      syncStatisticTriggered: true,
      droppedFrameCount
    })
  }
}

封装时不要把工具做成“大而全性能中心”。先把最常见的滑动问题记录准:页面、场景、数据规模、图片状态、掉帧数量。后面如果要扩展启动耗时、页面切换耗时、网络等待,也可以继续加独立方法,不要把所有问题塞进一个字段里。

真机验证时我会看哪些结果

真机验证不要只看一次滑动手感。我会固定三组条件:同一台设备、同一批 120 条列表数据、同一组图片缓存状态。修复前先跑 3 次,记录掉帧次数、触发页面、列表长度、图片缓存命中数量;修复后再跑 3 次,用同样字段对比。

如果修复后掉帧次数下降,但偶尔还有尖刺,就继续看是不是网络图片首次解码、统计刷新、页面切换恢复或后台回前台一起发生。如果掉帧次数没有下降,就说明前面的判断不成立,不能继续在图片占位上浪费时间,要改查主线程任务、长同步计算或组件复用边界。

最后总结

滑动丢帧排查最怕一句“感觉卡”。感觉只能说明问题存在,不能说明问题怎么发生。更稳的做法是:先把列表结构修到不抖,再把高频计算从渲染过程拆出去,最后用 HiAppEvent 或统一性能事件记录,把掉帧和页面上下文放在一起看。

这样做的收益很直接:修复前后能对比,线上问题能复盘,下一次再遇到类似页面,也不用重新靠猜。对 HarmonyOS 5.0+ 的应用性能优化来说,这种证据链比单点技巧更可靠。

Logo

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

更多推荐