文档开发者中心
我的
|

HarmonyOS 6

华为鸿蒙开发高级篇03-高性能列表深入:懒加载与组件复用

华为鸿蒙开发高级篇03-高性能列表深入:懒加载与组件复用(1 万条消息列表案例)

高级系列第 3 篇 · 案例:1 万条聊天消息列表的流畅滚动与快速定位。 目标:讲透 LazyForEach 的工作机制,掌握 cachedCount、组件复用(可复用)、滚动定位等生产级优化手段,并给出一份"优化前后"的完整对比代码。

cke_144.png

一、案例背景

聊天、日志、排行榜这类列表动辄上万条。如果还用入门篇的 ForEach:

  • 一次性创建上万个子组件,首屏卡顿数秒;
  • 每条消息的文本、头像、图片都要常驻内存,内存持续增长;
  • 滚到底部要加载"看不见的"几千个节点。

生产级方案是 LazyForEach + 组件复用 + 分页加载三件套。本文用消息列表把它们全部落地。

二、核心原理:LazyForEach 到底做了什么

LazyForEach 的本质是按需创建 + 窗口回收:

可视区 ── cachedCount 缓冲区 ── 回收区
│ 只创建可视窗口内的 item │ 滚动离开后销毁子组件(回收内存)│
  • 系统维护一个"渲染窗口":可视区 + 前后各 cachedCount 个缓冲项。
  • 滚出窗口的 item 被销毁,滚入的 item 才创建。
  • 数据源(IDataSource)负责告诉框架:总共多少条、第 N 条是什么、以及增删改发生在哪个位置(差量通知)。

结论:LazyForEach 解决的是"节点创建"问题;组件复用解决的是"节点重建"成本问题——两者配合才丝滑。

cke_145.png

cke_146.png

三、实际开发代码:1 万条消息列表

3.1 数据源:IDataSource 完整实现

// data/MessageDataSource.ets
import { IDataSource, DataChangeListener } from '@kit.ArkUI'

export interface Message {
  id: number
  sender: string
  text: string
  time: string
}

export class MessageDataSource implements IDataSource {
  private messages: Message[] = []
  private listeners: DataChangeListener[] = []

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

  getData(index: number): Message {
    return this.messages[index]
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (!this.listeners.includes(listener)) {
      this.listeners.push(listener)
    }
  }

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

  // 模拟批量加载
  pushMessages(batch: Message[]) {
    const start = this.messages.length
    this.messages.push(...batch)
    // 差量通知:告诉框架"从 start 开始新增了 batch.length 条"
    this.listeners.forEach((l) => l.onDataAdd(start, batch.length))
  }

  // 单条插入
  insert(index: number, msg: Message) {
    this.messages.splice(index, 0, msg)
    this.listeners.forEach((l) => l.onDataAdd(index, 1))
  }

  // 删除
  remove(index: number) {
    this.messages.splice(index, 1)
    this.listeners.forEach((l) => l.onDataDelete(index))
  }
}

3.2 列表项组件:开启复用(可复用)

@Reusable 让 item 在离开窗口后不销毁、进入窗口时复用,避免重复创建组件对象:

// components/MessageItem.ets
@Component
@Reusable
export struct MessageItem {
  @ObjectLink? 不适合——可复用组件建议用普通参数 + 手动刷新
  // 可复用组件推荐:内部用状态接收数据,aboutToReuse 里更新
  @State sender: string = ''
  @State text: string = ''
  @State time: string = ''

  // 框架回调:复用该组件实例展示新数据时调用
  aboutToReuse(params: Record<string, Object>) {
    this.sender = params.sender as string
    this.text = params.text as string
    this.time = params.time as string
  }

  build() {
    Column({ space: 6 }) {
      Row({ space: 8 }) {
        Text(this.sender).fontSize(14).fontWeight(FontWeight.Medium)
        Text(this.time).fontSize(12).fontColor('#86909C')
      }
      .width('100%')

      Text(this.text)
        .fontSize(16)
        .width('100%')
        .textAlign(TextAlign.Start)
    }
    .alignItems(HorizontalAlign.Start)
    .padding(12)
    .width('100%')
    .backgroundColor(Color.White)
    .borderRadius(10)
  }
}

说明:@Reusable 与 aboutToReuse 的写法以所装 SDK 为准;核心机制是"实例复用 + 参数回填"。

3.3 页面:LazyForEach + cachedCount + 定位

// pages/MessageListPage.ets
import { MessageDataSource, Message } from '../data/MessageDataSource'
import { MessageItem } from '../components/MessageItem'

@Entry
@Component
struct MessageListPage {
  private data: MessageDataSource = new MessageDataSource()
  private scroller: Scroller = new Scroller()
  @State totalText: string = ''

  aboutToAppear() {
    // 模拟 1 万条数据
    const batch: Message[] = []
    for (let i = 0; i < 10000; i++) {
      batch.push({
        id: i,
        sender: i % 3 === 0 ? '产品经理' : '开发同事',
        text: `这是第 ${i + 1} 条消息,内容用于演示长列表性能优化。`,
        time: `${String(9 + (i % 8)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}`
      })
    }
    this.data.pushMessages(batch)
    this.totalText = `共 ${this.data.totalCount()} 条`
  }

  build() {
    Column() {
      Text(this.totalText).fontSize(14).fontColor('#86909C').padding(8)

      List({ scroller: this.scroller }) {
        LazyForEach(this.data, (msg: Message, index: number) => {
          ListItem() {
            MessageItem({ sender: msg.sender, text: msg.text, time: msg.time })
          }
          // 键必须唯一且稳定:用消息 id
        }, (msg: Message) => `${msg.id}`)
      }
      // cachedCount:可视区前后各预建 15 个缓冲项,滚动手感更平滑
      .cachedCount(15)
      .width('100%')
      .layoutWeight(1)

      Row({ space: 16 }) {
        Button('滚到底部').onClick(() => this.scroller.scrollEdge(Edge.Bottom))
        Button('回到顶部').onClick(() => this.scroller.scrollEdge(Edge.Top))
        Button('定位到 5000 条').onClick(() => {
          this.scroller.scrollToIndex(4999, true)   // 平滑定位
        })
      }
      .padding(12)
    }
    .width('100%').height('100%')
  }
}

3.4 优化前后对比(验证思路)

指标 ForEach(优化前) LazyForEach + 复用(优化后)
首屏创建节点 10000 个 可视区约 10~30 个
滑动掉帧 明显(频繁 GC) 平稳(窗口回收 + 实例复用)
内存占用 随数据量线性增长 稳定在窗口大小附近
定位到 5000 条 滚动到才创建,中间全建 scrollToIndex 直达,只建沿途

验证方式:DevEco Profiler 抓帧对比滑动 FPS;用 HiLog 打点统计 build() 调用次数(LazyForEach 下远小于总条数)。

四、进阶:分页加载 + 双向加载(聊天场景)

真实聊天列表是"先看最新的,向上翻历史":

  // 滚到顶部附近时加载更早的消息(聊天室场景)
  onScrollIndex(first: number) {
    if (first <= 3) {
      this.loadEarlierMessages()   // 插入到数组头部,注意 onDataAdd 用头部索引
    }
  }

  private loadEarlierMessages() {
    // 模拟加载更早 50 条
    const earlier: Message[] = []
    for (let i = 0; i < 50; i++) {
      earlier.push({ id: this.data.totalCount() + i, sender: '历史消息', text: '更早的消息内容', time: '昨日' })
    }
    // 头部插入:IDataSource 提供 insert(0, ...) 差量通知
    earlier.forEach((m) => this.data.insert(0, m))
  }

注意:头部插入会改变所有已有 index,务必用差量通知(onDataAdd(0, n))并保证键不变,否则列表会错位。

五、优化与踩坑

问题 处理
键重复导致错位/崩溃 键生成函数返回唯一稳定值(用业务 id,别用 index 或可变字段)
滚到顶部加载历史后跳动 头部插入前记录当前首项 id,插入后用 scrollToIndex 恢复
复用组件状态串台 所有展示数据必须在 aboutToReuse 里回填,勿残留旧值
图片列表仍卡 图片走懒加载 + 缩略图;必要时 item 内 Image 设置合理尺寸与占位
cachedCount 太大反而卡 平衡内存与预建:经验值 5~20,按 item 复杂度调整

六、小结与延伸

  • 三件套:LazyForEach(按需创建)+ cachedCount(预建缓冲)+ @Reusable(实例复用),外加唯一稳定键这一条铁律。
  • 案例沉淀:MessageDataSource(IDataSource 完整实现)+ MessageItem(可复用组件)+ 定位/分页代码,可直接改造为通用长列表模板。
  • 延伸:多类型 item(不同消息类型用不同组件 + 复用池)、onScrollIndex 做滚动触发统计埋点、接入图片懒加载库。

下一篇预告:多线程与并发——TaskPool/Worker 实战(图片批量压缩案例)。

点赞
收藏
回复
1
分享
举报
浏览44 发布于2026-09-10 05:10上海
全部评论
最多点赞
最新发布
最早发布
写回答
新增插入模板功能
一键使用模板,快速填写内容,轻松发帖~
知道了
  • 为了保障您的信息安全,请勿上传您的敏感个人信息(如您的密码等信息)和您的敏感资产信息(如关键源代码、签名私钥、调试安装包、业务日志等信息),且您需自行承担由此产生的信息泄露等安全风险。
  • 如您发布的内容为转载内容,请注明内容来源。

我要发帖子

了解社区公约,与您携手共创和谐专业的开发者社区。