页面没崩,功能也都能用,但滑动列表时就是“不跟手”:快速滑几下会突然顿住,点收藏偶尔慢半拍,图片出现的一瞬间还会抖一下。

这种问题最难受的地方,不是不会改,而是不知道该改哪里。是 ArkUI 布局太复杂?图片太大?接口太慢?还是设备性能不够?

这篇文章复盘一次典型的 HarmonyOS 页面卡顿排查。我们不靠“感觉优化”,而是从稳定复现开始,用帧时间、CPU 时间线和自定义 Trace 找到真正的耗时点,再一项一项验证修复效果。

本文示例使用 ArkTS 和 ArkUI。为了方便公开,业务名称和测试数据做了简化;不同 DevEco Studio、HarmonyOS SDK 和设备版本的性能工具界面可能略有差异,请以当前版本为准。


一、优化之前,先把“卡”说清楚

“页面有点卡”不是一个可以直接交给开发的有效问题。

它可能表示下面几件完全不同的事:

用户感受常见问题优先检查
点击后很久才有反应主线程阻塞、同步任务过重点击回调、CPU 时间线、同步 I/O
列表滑动不连贯帧耗时超预算、频繁构建Frame、UI 主线程、渲染线程
页面打开慢首屏做了太多工作路由到首帧的关键路径
动画抖动每帧触发布局或绘制动画属性、布局和重绘范围
图片出现时顿一下大图解码、资源尺寸不匹配图片尺寸、解码和缓存
用一会儿越来越卡内存压力、泄漏、任务堆积内存趋势、GC、监听和定时器

这篇文章重点解决第二类:页面能够正常打开,但列表滑动时出现长帧和掉帧。

最终我们发现,不是某一个“神秘方法”特别慢,而是四个问题叠在了一起:

使用 ForEach 一次创建全部卡片
        +
在组件构建链路中重复解析和格式化数据
        +
父组件高频状态变化,引起大范围更新
        +
列表展示小图,却加载接近原图尺寸的资源

单独看每一项都不一定把页面拖死,叠在一起就足够让滑动体验明显变差。


二、掉帧到底是什么意思?先理解“帧预算”

屏幕上的动画和滑动,本质上是一张张画面连续显示。

如果设备以 60 Hz 刷新,一帧大约只有:

1000 ms ÷ 60 ≈ 16.67 ms

如果是 120 Hz,一帧预算更紧:

1000 ms ÷ 120 ≈ 8.33 ms

这不代表应用可以独占全部时间。系统调度、输入处理、组件更新、布局、绘制和合成都要消耗时间。只要某一帧的工作没能及时完成,用户就可能看到画面不连续。

可以把一帧粗略理解为:

处理状态变化
  ↓
组件构建与更新
  ↓
测量和布局
  ↓
绘制与提交
  ↓
系统合成并显示

其中任何一步突然变长,都可能形成长帧。

所以性能调优的核心问题不是“代码能不能运行”,而是:

这段工作有没有挤进当前帧的时间预算?


三、问题现场:一个看起来很普通的资讯列表

出问题的页面是一个资讯流,包含大约 300 条数据。每个卡片有:

  • 标题和摘要;
  • 一张封面图;
  • 作者、发布时间和阅读数;
  • 收藏按钮;
  • 下载进度或同步状态。

测试同事的反馈是:

1. 第一次打开页面有短暂白屏;
2. 快速滑动时偶尔连续掉帧;
3. 后台同步进度变化时,列表更容易卡;
4. 关闭图片显示后,情况会改善,但没有完全解决。

我们先固定了一条复现路径:

冷启动应用
  → 进入资讯页
  → 等首屏图片出现
  → 快速向下滑动 5 秒
  → 停止 2 秒
  → 快速向上滑回顶部
  → 点击第一个卡片的收藏按钮

为什么一定要写得这么具体?

因为“随便滑一滑”无法比较修复前后。优化一次,操作方式就换一次,很容易得到一个自己想看到的结果。

固定场景后,我们记录了第一轮基线。下面的数据只用于展示分析方式,不同设备不能直接横向对比:

指标优化前
进入页面到首屏可操作约 780 ms
快速滑动期间长帧占比约 21%
最长单帧约 91 ms
滑动期间主线程峰值接近满载
首屏一次创建的卡片300 个

“一次创建 300 个卡片”已经很可疑,但我们没有立刻改。先采集证据,确认耗时究竟发生在哪里。


四、排查前先排除测试误差

性能数据很容易受环境影响。正式抓取前,我们先固定测试条件。

1. 不用纯 Debug 体验代表正式包

Debug 构建、调试器连接、额外日志和检查逻辑都会影响性能。日常定位可以使用可分析构建,但最终结论必须在接近发布配置的包上再次验证。

2. 固定设备状态

  • 使用同一台设备;
  • 电量和温度保持在合理范围;
  • 避免边充电边测试带来温度变化;
  • 关闭与测试无关的高负载应用;
  • 每轮测试前让设备短暂恢复稳定。

设备发热后可能降频。同一份代码,第一轮和第十轮的结果可能完全不同。

3. 固定数据和网络

列表条数、图片数量、图片尺寸和接口响应要尽量一致。否则这一轮加载 30 条,下一轮加载 300 条,数据没有可比性。

为了区分网络慢和渲染慢,我们还准备了本地固定数据。这样接口发生波动时,也能单独验证 UI 侧问题。

4. 减少高频调试日志

在滚动回调或组件构建路径中大量打印,本身就可能制造卡顿:

ListItem() {
  console.info(`building item: ${JSON.stringify(item)}`)
  ArticleCard({ data: item })
}

除了打印次数多,JSON.stringify() 也会做额外计算。调试性能问题时,不要让调试代码成为新的性能问题。


五、第一步:从 Frame 时间线确认“哪一段真的卡”

打开 DevEco Studio 的 Profiler,连接设备和目标进程,开始记录后执行刚才的固定操作。

具体面板名称可能随版本变化,但通常要重点观察:

  • Frame 或帧相关时间线;
  • CPU 活动;
  • ArkTS/应用主线程;
  • UI 构建、布局和渲染相关事件;
  • GC、图片和系统调度信息;
  • 自定义 Trace 区间。

不要一开始就盯着整段十几秒的波形看。先根据触摸和页面行为找到掉帧最明显的几百毫秒,再放大时间轴。

我们的时间线上出现了两个明显特征:

  1. 快速滑动开始后,主线程连续出现多个超过帧预算的任务;
  2. 即使手指停止,只要下载进度更新,仍会出现一组规律性的耗时尖峰。

第二个特征很关键。它说明卡顿不只来自滑动,还有某个周期性状态在触发大范围工作。


六、第二步:给可疑业务代码加自定义 Trace

Profiler 能告诉我们“这一段主线程很忙”,但业务方法较多时,还需要知道究竟忙在哪里。

可以使用 HiTraceMeter 给关键代码加标记。下面以数据转换为例:

import { hiTraceMeter } from '@kit.PerformanceAnalysisKit'

const TRACE_TASK_ID = 1001

function buildArticleViewData(
  source: RawArticle[]
): ArticleViewData[] {
  hiTraceMeter.startTrace('Feed.buildArticleViewData', TRACE_TASK_ID)

  try {
    return source.map((item: RawArticle) => convertToViewData(item))
  } finally {
    hiTraceMeter.finishTrace('Feed.buildArticleViewData', TRACE_TASK_ID)
  }
}

Trace 名称要具体。下面这些名字没有太大帮助:

test
doWork
handle
task1

推荐直接体现业务和阶段:

Feed.parseResponse
Feed.buildViewData
Feed.updateProgress
Feed.loadFirstScreen
ArticleCard.formatPublishTime

也不要给每一行都加 Trace。标记太多会让时间线难读,还会带来额外开销。先围绕可疑的大阶段,再逐步缩小范围。

hiTraceMeter 的具体导入方式和接口能力可能随 SDK 版本变化,请根据当前工程的 API 文档和类型提示调整。


七、定位结果一:ForEach 一次创建了全部卡片

页面最初的写法大致如下:

@Component
struct FeedPage {
  @State private articles: ArticleViewData[] = []

  build() {
    List() {
      ForEach(
        this.articles,
        (item: ArticleViewData) => {
          ListItem() {
            ArticleCard({ data: item })
          }
        },
        (item: ArticleViewData) => item.id
      )
    }
    .width('100%')
    .height('100%')
  }
}

ForEach 适合数据量较小、需要一次性构建的场景。这里一次给它 300 条数据,页面首次出现时就创建了大量卡片。屏幕上明明只能看到几条,屏幕外内容也参与了构建。

首屏阶段同时发生:

创建大量组件
  + 计算卡片布局
  + 准备图片请求
  + 格式化文本
  + 建立响应式依赖

第一项修复是改用 LazyForEach,按需创建可见区域及缓存范围附近的组件。

1. 准备数据源

class BasicDataSource implements IDataSource {
  private listeners: DataChangeListener[] = []

  totalCount(): number {
    return 0
  }

  getData(index: number): Object {
    return new Object()
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener)
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const index = this.listeners.indexOf(listener)
    if (index >= 0) {
      this.listeners.splice(index, 1)
    }
  }

  notifyDataReload(): void {
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataReloaded()
    })
  }

  notifyDataChange(index: number): void {
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataChange(index)
    })
  }
}

class ArticleDataSource extends BasicDataSource {
  private data: ArticleViewData[] = []

  totalCount(): number {
    return this.data.length
  }

  getData(index: number): ArticleViewData {
    return this.data[index]
  }

  replaceAll(data: ArticleViewData[]): void {
    this.data = data
    this.notifyDataReload()
  }

  updateAt(index: number, data: ArticleViewData): void {
    if (index < 0 || index >= this.data.length) {
      return
    }

    this.data[index] = data
    this.notifyDataChange(index)
  }
}

2. 改用 LazyForEach

@Component
struct FeedPage {
  private dataSource: ArticleDataSource = new ArticleDataSource()

  build() {
    List({ space: 12 }) {
      LazyForEach(
        this.dataSource,
        (item: ArticleViewData) => {
          ListItem() {
            ArticleCard({ data: item })
          }
        },
        (item: ArticleViewData) => item.id
      )
    }
    .cachedCount(4)
    .width('100%')
    .height('100%')
  }
}

这里的 cachedCount(4) 只是示例,不是越大越好:

  • 太小:快速滑动时可能来不及准备;
  • 太大:提前构建过多内容,增加 CPU 和内存开销;
  • 合适值:要结合卡片复杂度和目标设备实测。

改用懒加载后,首屏和首次滑动明显改善,但持续滑动仍有规律性尖峰。说明我们解决了一个问题,但还没结束。


八、定位结果二:在构建链路里做了太多重复计算

继续查看 Trace,发现日期格式化和 JSON 解析被调用得非常频繁。

原来的卡片代码类似这样:

interface RawArticle {
  id: string
  title: string
  summary: string
  publishTime: number
  extraJson: string
  coverUrl: string
}

@Component
struct ArticleCard {
  @Prop data: RawArticle

  private formatPublishTime(timestamp: number): string {
    const date = new Date(timestamp)
    return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
  }

  private readAuthorName(extraJson: string): string {
    const extra = JSON.parse(extraJson) as Record<string, string>
    return extra['authorName'] ?? '匿名作者'
  }

  build() {
    Row({ space: 12 }) {
      Image(this.data.coverUrl)
        .width(112)
        .height(84)

      Column({ space: 8 }) {
        Text(this.data.title.trim())
        Text(this.data.summary.replace(/\s+/g, ' '))
        Text(
          `${this.readAuthorName(this.data.extraJson)} · ` +
          `${this.formatPublishTime(this.data.publishTime)}`
        )
      }
    }
  }
}

功能没有错,但多项工作被塞进了构建链路:

  • JSON.parse()
  • 日期对象创建和格式化;
  • 正则替换;
  • 字符串拼接;
  • 文本清洗。

一次执行可能不慢,但列表滚动、状态变化和组件复用期间会反复触发。卡片一多,累计成本就很可观。

修复:原始数据和展示数据分开

接口数据适合传输,不一定适合直接渲染。可以先转换成展示模型:

interface ArticleViewData {
  id: string
  title: string
  summary: string
  authorAndTime: string
  thumbnailUrl: string
  favorite: boolean
  progress: number
}

function convertToViewData(raw: RawArticle): ArticleViewData {
  let authorName = '匿名作者'

  try {
    const extra = JSON.parse(raw.extraJson) as Record<string, string>
    authorName = extra['authorName'] ?? authorName
  } catch (_) {
    // 使用默认值;可在数据层记录一次异常,不要在构建链路反复打印
  }

  const date = new Date(raw.publishTime)
  const timeText =
    `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`

  return {
    id: raw.id,
    title: raw.title.trim(),
    summary: raw.summary.replace(/\s+/g, ' '),
    authorAndTime: `${authorName} · ${timeText}`,
    thumbnailUrl: buildThumbnailUrl(raw.coverUrl, 224, 168),
    favorite: false,
    progress: 0
  }
}

卡片只负责展示:

@Component
struct ArticleCard {
  @Prop data: ArticleViewData

  build() {
    Row({ space: 12 }) {
      Image(this.data.thumbnailUrl)
        .width(112)
        .height(84)
        .objectFit(ImageFit.Cover)

      Column({ space: 8 }) {
        Text(this.data.title)
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Text(this.data.summary)
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Text(this.data.authorAndTime)
          .fontSize(12)
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
  }
}

重点是改变计算时机:

优化前:每次组件构建或更新时重复转换
优化后:数据进入页面时转换一次,渲染阶段直接读取

大批量转换要不要放进 TaskPool?

如果只是 20 条简单数据,并发调度成本可能比计算本身还高。只有数据量大、转换确实耗时时,才考虑移出 UI 主线程。

import { taskpool } from '@kit.ArkTS'

@Concurrent
function convertArticleList(
  source: RawArticle[]
): ArticleViewData[] {
  return source.map((item: RawArticle) => convertToViewData(item))
}

async function prepareArticleList(
  source: RawArticle[]
): Promise<ArticleViewData[]> {
  const task = new taskpool.Task(convertArticleList, source)
  return await taskpool.execute(task) as ArticleViewData[]
}

使用 TaskPool 时,要注意参数和返回值的可传递性,并遵守当前 SDK 对并发函数的限制。不要把操作 UI、依赖页面实例或不可序列化资源的逻辑直接塞进并发任务。


九、定位结果三:一个进度值,让整个列表跟着忙

修完前两项后,时间线上每隔一段时间仍会出现尖峰,频率和下载进度更新完全一致。

原来的页面把所有进度放进父组件状态:

@Component
struct FeedPage {
  @State private articles: ArticleViewData[] = []

  onProgressChanged(articleId: string, progress: number): void {
    this.articles = this.articles.map((item: ArticleViewData) => {
      if (item.id !== articleId) {
        return item
      }

      return { ...item, progress: progress }
    })
  }

  build() {
    // 构建列表
  }
}

每收到一次进度,就创建一个新数组。虽然只有一个卡片变化,但父级列表状态整体替换,会触发更大范围的比较、通知和更新。

下载进度每秒可能变化很多次。单次更新即使只有几毫秒,也会不断抢占主线程时间。

修复一:只通知变化的数据项

class ProgressArticleDataSource extends BasicDataSource {
  private data: ArticleViewData[] = []
  private indexById: Map<string, number> = new Map()

  totalCount(): number {
    return this.data.length
  }

  getData(index: number): ArticleViewData {
    return this.data[index]
  }

  replaceAll(data: ArticleViewData[]): void {
    this.data = data
    this.indexById.clear()

    data.forEach((item: ArticleViewData, index: number) => {
      this.indexById.set(item.id, index)
    })

    this.notifyDataReload()
  }

  updateProgress(articleId: string, progress: number): void {
    const index = this.indexById.get(articleId)
    if (index === undefined) {
      return
    }

    this.data[index] = {
      ...this.data[index],
      progress: progress
    }

    this.notifyDataChange(index)
  }
}

修复二:限制 UI 刷新频率

下载层产生 1% 变化,不代表 UI 必须立即绘制一次。100 ms 或 200 ms 更新一次,对用户通常已经足够平滑。

class ProgressDispatcher {
  private lastDispatchTime: Map<string, number> = new Map()
  private readonly minInterval: number = 150

  shouldDispatch(id: string, progress: number): boolean {
    if (progress >= 100) {
      this.lastDispatchTime.set(id, Date.now())
      return true
    }

    const now = Date.now()
    const last = this.lastDispatchTime.get(id) ?? 0

    if (now - last < this.minInterval) {
      return false
    }

    this.lastDispatchTime.set(id, now)
    return true
  }
}

这里有一个很实用的性能思想:

数据变化频率,不必等于界面刷新频率。

传感器、下载、播放进度、拖动坐标都可能高频变化。UI 只需以用户能感知、业务能接受的频率更新,同时确保完成、失败等最终状态不会被节流丢掉。


十、定位结果四:112×84 的位置,加载了超大图片

关闭图片后情况会改善,说明图片链路也有问题。

检查后发现,卡片显示区域只有 112 × 84 vp 左右,请求的却是接近原图尺寸的封面。有些图片宽度超过 3000 像素。

这会产生多余成本:

下载更多字节
  ↓
解码更大的像素数据
  ↓
占用更多内存
  ↓
上传和绘制成本增加
  ↓
快速滑动时 GC 与内存压力变大

修复思路

  1. 服务端或图片服务提供缩略图;
  2. 根据展示尺寸和设备像素密度请求合适资源;
  3. 列表使用缩略图,进入详情后再加载大图;
  4. 设置固定占位尺寸,避免加载后重新顶开布局;
  5. 控制内存缓存上限;
  6. 请求失败时使用占位图,不要无限快速重试。

缩略图地址可在数据转换阶段生成:

function buildThumbnailUrl(
  sourceUrl: string,
  pixelWidth: number,
  pixelHeight: number
): string {
  const separator = sourceUrl.includes('?') ? '&' : '?'
  return `${sourceUrl}${separator}width=${pixelWidth}&height=${pixelHeight}`
}

这只是示意,参数格式取决于项目使用的图片服务。不要直接照搬到不支持这些参数的地址上。

另外,“压缩图片”不只是降低 JPEG 质量。真正影响运行时开销的还有像素尺寸。一张 4000×3000 的低质量图片,解码后的像素缓冲仍然可能很大。


十一、动画卡顿:优先考虑合成友好的属性

列表问题解决后,顶部筛选面板的展开动画仍偶尔抖动。原实现不断修改高度:

Column() {
  FilterPanel()
}
.height(this.expanded ? 240 : 0)
.animation({ duration: 250 })

高度变化会影响测量和布局,周围组件也可能跟着重新排布。复杂页面中,每一帧重新布局的成本可能很高。

如果视觉设计允许,可以评估透明度和平移:

Column() {
  FilterPanel()
}
.opacity(this.expanded ? 1 : 0)
.translate({ y: this.expanded ? 0 : -12 })
.animation({ duration: 250, curve: Curve.EaseOut })

这不是说宽高永远不能做动画。如果需求就是让其他内容随面板展开,布局变化是合理的。关键是知道它触发了什么,再根据页面复杂度实测。

动画调优可以依次问:

能否只动当前组件?
能否缩小重排和重绘范围?
能否用 transform / opacity 达到相近效果?
每帧是否夹杂同步计算、日志或高频状态更新?

视觉上相同的两段动画,底层工作量可能完全不同。


十二、布局层级是不是越少越好?

“减少布局嵌套”经常出现在优化清单里,但容易被误解成看到一层 RowColumn 就删。

真正需要关注的是:

  • 有没有大量重复且无实际作用的容器;
  • 测量规则是否复杂;
  • 一次状态变化会影响多大范围;
  • 是否存在多次测量;
  • 是否叠加大量裁剪、模糊、阴影和透明层;
  • 屏幕外组件是否也参与构建。

下面这种无意义包装可以简化:

Column() {
  Row() {
    Column() {
      Text(this.title)
    }
  }
}

但不要为了少一层布局,把清晰结构改成难以维护的大段代码。如果时间线上布局耗时很低,盯着删 Column 几乎没有收益。


十三、首屏慢和滑动卡,不要混在一起优化

虽然两者都叫“卡”,但关键路径不一样。

首屏慢主要看

  • 页面出现前做了多少同步初始化;
  • 是否等待了非首屏接口;
  • 是否一次转换全部数据;
  • 首屏组件是否创建过多;
  • 图片和字体是否阻塞关键展示;
  • 能否先展示骨架或基础内容。

滑动卡主要看

  • 每帧是否重复创建和计算;
  • 列表是否懒加载与复用;
  • 状态更新范围是否过大;
  • 图片尺寸、解码和缓存是否合理;
  • 是否在滚动期间做同步任务;
  • 绘制和布局是否过重。

我们对这个页面采用了分阶段策略:

第一阶段:标题栏、骨架和首屏必要数据
第二阶段:当前可见卡片图片
第三阶段:非首屏数据和低优先级预取
第四阶段:不影响使用的统计与预加载

不是所有事情都要在用户看到页面前完成。


十四、优化结果:每次只改一类问题

四轮修改后,我们再次使用相同设备、数据和操作脚本采样:

阶段首屏可操作滑动长帧占比最长帧
优化前约 780 ms约 21%约 91 ms
改用 LazyForEach约 430 ms约 10%约 58 ms
展示数据预计算约 350 ms约 6%约 39 ms
单项更新并节流约 340 ms约 3%约 27 ms
缩略图与动画调整后约 330 ms约 1%~2%约 20 ms

最值得关注的不是某个绝对数字,而是每次只修改一类问题,再用同一场景验证。

如果一次同时改懒加载、图片、线程、动画和缓存,即使最终变快了,也不知道真正有效的是哪一项;一旦出现新问题,回退也很困难。

我们的优化顺序是:

先砍掉不应该做的工作
  ↓
再减少重复工作
  ↓
再缩小状态更新范围
  ↓
最后调整资源尺寸和动画细节

“不做”通常比“把同一件事做得更快”收益更大。


十五、排查过程中最容易走的五个弯路

1. 一卡就怪图片

图片确实经常是热点,但关闭图片后仍然卡,就要继续看组件构建、状态更新和布局。不要因为图片最直观,就把所有问题都推给图片库。

2. 只看平均 FPS

平均值会掩盖短时间的严重卡顿。大部分时间 60 FPS,某一瞬间卡住 200 ms,平均值可能仍然不难看,但用户一定能感知。

除了平均值,还要关注长帧分布、最长帧、卡顿发生的交互,以及连续掉帧还是偶发尖峰。

3. 哪里慢就立刻放进 TaskPool

并发不是免费午餐。任务调度、参数传递和结果回传都有成本。小计算频繁丢进 TaskPool,可能更慢。

正确顺序通常是:

删除不必要计算
  → 避免重复计算
  → 确实很重时再考虑移出主线程

4. 为了性能把缓存无限调大

缓存太小会重复加载,太大会制造内存压力和频繁 GC。缓存必须有边界,并根据命中率、对象大小和设备情况实测。

5. 优化完只在旗舰设备看一眼

旗舰设备会掩盖问题。至少要覆盖真实用户中有代表性的设备档位、刷新率和系统版本。


十六、常见代码热点检查表

代码或设计可能问题优化方向
大列表使用 ForEach首屏创建过多组件使用 LazyForEach 并实测缓存范围
构建链路中 JSON.parse更新时反复解析提前转换展示模型
高频创建 Date、正则和大字符串主线程重复计算预计算、缓存稳定结果
替换整个数组只为更新一项更新范围过大数据源单项通知、拆分状态
进度每变化一次就刷新 UI刷新频率过高节流、合并更新
小卡片加载原始大图解码和内存成本高请求匹配尺寸的缩略图
动画持续修改宽高每帧测量和布局评估平移、缩放、透明度
滚动回调中打印大量日志I/O 和格式化开销降低频率或移除
点击事件里同步读写大文件阻塞交互响应异步化并立即反馈
单例缓存无上限内存压力持续上升容量限制和淘汰策略
父组件保存全部细粒度状态小变化引起大更新状态下沉、组件隔离
页面出现时初始化所有服务首屏关键路径过长分阶段、按需初始化

这张表是排查入口,不是机械规则。是否真的有问题,要以 Trace 和实际测量为准。


十七、一套可以直接照着走的卡顿排查流程

第 1 步:把问题描述具体

明确是首屏慢、点击迟钝、滑动掉帧、动画抖动,还是长时间使用后变卡。

第 2 步:建立稳定复现

固定设备、数据、操作、停留时间和构建类型。让任何人照步骤都能看到相近问题。

第 3 步:记录优化前基线

至少记录首屏耗时、长帧、最长帧、CPU 热点和卡顿区间。没有基线,就无法证明优化有效。

第 4 步:抓取时间线

在 Profiler 中找到掉帧对应的时间段,判断压力来自应用主线程、UI 构建布局、渲染、图片、GC 还是其他事件。

第 5 步:用 Trace 缩小范围

给可疑业务阶段加少量、明确的自定义标记,不要无差别埋点。

第 6 步:一次只解决一类问题

先处理耗时最大、证据最明确的热点。修改越集中,因果关系越清楚。

第 7 步:按同一场景回归

相同设备、数据和操作重新采样,与基线对比。

第 8 步:检查副作用

性能提升后还要确认:

  • 数据有没有显示错误;
  • 懒加载有没有白块;
  • 节流有没有漏掉最终状态;
  • 图片是否过度模糊;
  • 缓存调整是否增加网络流量;
  • 动画效果是否仍符合设计。

性能优化的目标不是跑分,而是让用户体验更稳定,同时保持功能正确。


十八、上线前性能自查清单

列表与组件

  • 长列表使用了适合的懒加载方案;
  • 列表 Key 稳定、唯一,没有使用随机值;
  • 屏幕外缓存数量经过实际测试;
  • 构建链路中没有重复的大量解析和格式化;
  • 单个状态变化不会无故刷新整个页面;
  • 高频状态已经节流或合并。

图片与资源

  • 列表加载合适尺寸的缩略图;
  • 图片区域有稳定尺寸和占位;
  • 图片缓存有容量边界;
  • 失败请求不会无限快速重试;
  • 不再使用的媒体和系统资源能够释放。

动画与布局

  • 动画期间没有执行重计算或大量日志;
  • 能使用平移、缩放、透明度的场景已经评估;
  • 没有大量无意义布局嵌套;
  • 阴影、模糊、裁剪和透明层经过真机测试;
  • 大字体、横竖屏和大屏状态不会异常重排。

数据与任务

  • 首屏只加载和处理必要数据;
  • 大计算不会长时间阻塞 UI 主线程;
  • 并发任务的收益大于调度成本;
  • 页面退出后长任务、监听和定时器能够停止;
  • 同步文件或数据库操作不在关键交互路径。

验证

  • 使用接近发布配置的安装包测试;
  • 记录了优化前后数据;
  • 使用固定场景重复采样;
  • 覆盖了有代表性的设备和刷新率;
  • 检查了发热和长时间运行后的表现;
  • 性能优化没有破坏业务正确性。

十九、写在最后:性能优化不是玄学,是证据链

回头看这次问题,真正有效的不是“把所有代码重写一遍”,而是建立了一条完整证据链:

稳定复现滑动卡顿
  ↓
Frame 时间线确认长帧
  ↓
CPU 与自定义 Trace 找到主线程热点
  ↓
确认全量构建、重复计算和高频更新
  ↓
逐项修改并使用原场景回归
  ↓
长帧减少,最长帧下降,体验恢复稳定

做 HarmonyOS 性能调优时,最值得养成的习惯有三个:

  1. **先测量,再修改。**不要只凭代码长相判断性能。
  2. **先减少工作,再加速工作。**不创建、不重复、不刷新,永远比努力把无用工作做快更划算。
  3. **一次改一类问题。**只有这样,才知道收益从哪里来。

最后送给大家一句很朴素的话:

页面卡顿不是因为“代码太多”,而是某一帧里做了太多不该在这一帧做的事。

把那一帧放大,找到它到底在忙什么,性能问题通常就没有看起来那么神秘。

如果这篇文章帮你找到了页面掉帧的方向,欢迎点赞、收藏。也欢迎把你遇到的 Trace 或卡顿场景留在评论区,我们一起看看那一帧到底被谁占满了。

Logo

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

更多推荐