本文将带您从零基础开始,系统地学习如何在 HarmonyOS NEXT(API 24)中使用 ArkTS 实现高性能、优雅的列表分页加载功能。通过完整的代码示例、架构设计分析和性能优化技巧,让您彻底掌握 List + onReachEnd 的核心应用。


项目演示

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

目录

  1. 引言:为什么需要上拉加载更多
  2. 核心概念解析
  3. 环境准备与项目结构
  4. List 组件深度剖析
  5. onReachEnd 事件机制
  6. 分页加载架构设计
  7. 完整实战代码
  8. 状态管理与数据流
  9. 性能优化实战
  10. 常见问题与解决方案
  11. 进阶技巧与最佳实践
  12. 测试与调试
  13. 常见面试题
  14. 完整项目源码
  15. 最佳实践总结
  16. 参考资料

1. 引言:为什么需要上拉加载更多

在移动应用开发中,列表是最常见的UI组件之一。无论是电商商品列表、社交动态流、还是资讯文章列表,用户都期望能够流畅地浏览大量内容。然而,一次性加载所有数据会导致严重的性能问题:

  • 内存占用过高:数百万条数据会让应用瞬间崩溃
  • 加载速度缓慢:网络请求时间过长,用户体验差
  • 流量浪费:用户可能只看前几条,却下载了全部数据

上拉加载更多(Infinite Scroll / Load More) 正是解决这些问题的最佳方案。它的核心思想很简单:初始只加载第一页数据,当用户滚动到列表底部时,自动加载下一页,以此类推。

1.1 上拉加载更多的优势

优势 说明
按需加载 只加载用户需要的数据,节省资源
流畅体验 页面首次加载速度快,用户感知更好
内存友好 控制单次加载的数据量,避免OOM
流量节省 只请求展示的数据,降低用户流量消耗

1.2 典型应用场景

  • 电商App:商品列表、订单列表、购物车
  • 社交应用:动态流、消息列表、好友列表
  • 资讯阅读:文章列表、评论列表、搜索结果
  • 工具类App:文件管理、历史记录、联系人

1.3 HarmonyOS 的独特优势

HarmonyOS NEXT 在列表滚动体验上进行了深度优化:

// HarmonyOS NEXT 支持的 List 特性
List() {
  // 组件虚拟化自动开启
  // 只渲染可视区域内的 ListItem
  // 滑动性能接近原生
}

相比传统的 Android/iOS 方案,HarmonyOS 的 List 组件有以下优势:

  • 声明式UI:数据驱动视图,开发效率高
  • 虚拟化渲染:自动回收不可见项,内存占用低
  • 跨端适配:一套代码适配手机、平板、折叠屏
  • 流畅动画:60fps滑动,丝般顺滑

2. 核心概念解析

在深入代码之前,我们需要理解几个关键概念。

2.1 List 组件架构

HarmonyOS 的 List 组件采用了经典的 MVC 分离 架构:

┌──────────────────────────────────────────────────────────────┐
│                        List                                   │
│  ┌──────────────────────────────────────────────────────────┐ │
│  │  Scroll + ListItem 容器                                  │ │
│  │  ┌────────────────────────────────────────────────────┐  │ │
│  │  │  ListItem (可见项)                                  │  │ │
│  │  │  ┌──────────────────────────────────────────────┐ │  │ │
│  │  │  │  自定义内容 @Builder                           │ │  │ │
│  │  │  │  (图片+文字+其他组件)                          │ │  │ │
│  │  │  └──────────────────────────────────────────────┘ │  │ │
│  │  ├────────────────────────────────────────────────────┤  │ │
│  │  │  ListItem (可见项)                                  │  │ │
│  │  │  ┌──────────────────────────────────────────────┐ │  │ │
│  │  │  │  自定义内容 @Builder                           │ │  │ │
│  │  │  └──────────────────────────────────────────────┘ │  │ │
│  │  ├────────────────────────────────────────────────────┤  │ │
│  │  │  ListItem (加载更多指示器)                          │  │ │
│  │  │  ┌──────────────────────────────────────────────┐ │  │ │
│  │  │  │  LoadingProgress + Text                        │ │  │ │
│  │  │  └──────────────────────────────────────────────┘ │  │ │
│  │  └────────────────────────────────────────────────────┘  │ │
│  └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

2.2 分页加载核心流程

用户首次打开页面
        ↓
┌─────────────┐
│ 加载第一页   │ ← 10条数据
│ (page=1)    │
└─────────────┘
        ↓
┌─────────────┐
│ 显示列表     │
│ 用户浏览     │
└─────────────┘
        ↓
┌─────────────┐
│ 滚动到底部   │ ← onReachEnd 触发
└─────────────┘
        ↓
┌─────────────┐
│ 加载下一页   │ ← page++
│ (page=2)    │
└─────────────┘
        ↓
      ...循环

2.3 状态机模型

分页加载本质上是一个状态机:

// 状态枚举
enum LoadState {
  IDLE = 'idle',           // 空闲状态
  LOADING = 'loading',     // 加载中
  SUCCESS = 'success',     // 加载成功
  ERROR = 'error',         // 加载失败
  NO_MORE = 'no_more'      // 没有更多数据
}

状态转换图:

         ┌──────────────────────────────────────────────────┐
         │                                                  │
         ▼                                                  │
┌─────────────┐    LOAD    ┌──────────────┐                │
│    IDLE     │───────────►│   LOADING    │                │
└─────────────┘            └──────┬───────┘                │
         ▲                        │                         │
         │                        │                         │
         │            ┌───────────┼───────────┐            │
         │            ▼           ▼           ▼            │
         │     ┌──────────┐ ┌───────┐ ┌────────┐           │
         │     │ SUCCESS  │ │ ERROR │ │NO_MORE │           │
         │     └────┬─────┘ └───┬───┘ └────────┘           │
         │          │           │                           │
         │          │           │ 重试                      │
         └──────────┴───────────┴                           │
                    │                                       │
                    └───────────────────────────────────────┘

3. 环境准备与项目结构

3.1 开发环境要求

  • DevEco Studio:4.0 Release 或更高版本
  • SDK 版本:API 24(HarmonyOS NEXT)
  • Node.js:18.x 或更高版本
  • 设备模拟器:Phone / Tablet / Foldable

3.2 项目创建步骤

打开 DevEco Studio:
1. File → New → Create Project
2. 选择 Application → Empty Ability
3. 填写项目信息:
   - Project name: ListLoadMore
   - Bundle name: com.example.listloadmore
   - Save location: D:\Projects\
   - Compile SDK: HarmonyOS NEXT (API 24)
4. 点击 Finish

3.3 目录结构

ListLoadMore/
├── entry/
│   ├── src/
│   │   └── main/
│   │       ├── ets/
│   │       │   ├── pages/
│   │       │   │   └── Index.ets          # 主页面
│   │       │   ├── model/
│   │       │   │   └── Product.ets         # 数据模型
│   │       │   ├── service/
│   │       │   │   └── DataService.ets     # 数据服务
│   │       │   └── common/
│   │       │       └── Constants.ets       # 常量定义
│   │       ├── resources/
│   │       └── module.json5
│   └── build-profile.json5
├── AppScope/
│   └── app.json5
└── build-profile.json5

3.4 配置文件说明

module.json5 - 如果需要网络请求,添加权限:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

4. List 组件深度剖析

4.1 List 基础用法

List 是一个容器组件,用于展示垂直或水平滚动的列表。

@Entry
@Component
struct ListBasicDemo {
  private items: string[] = ['Item 1', 'Item 2', 'Item 3'];

  build() {
    List() {
      ForEach(this.items, (item: string) => {
        ListItem() {
          Text(item)
            .fontSize(18)
            .padding(16)
        }
      }, (item: string, index: number) => index.toString())
    }
    .width('100%')
    .height('100%')
  }
}

4.2 List 核心属性

属性 类型 说明 默认值
space number | string ListItem 之间的间距 0
divider DividerStyle 分割线样式
scrollBar BarState 滚动条状态 Auto
scrollBarColor ResourceColor 滚动条颜色 -
scrollBarWidth number 滚动条宽度 -
nestedScroll NestedScrollMode 嵌套滚动模式 -
sticky StickyStyle 吸顶/吸底效果 -
lanes number 多列布局 1

4.3 List 事件一览

事件 触发时机 回调参数
onScroll 滚动中 (offset: number, state: ScrollState)
onReachStart 滚动到顶部 () => void
onReachEnd 滚动到底部 () => void
onScrollIndex 索引变化 (first: number, last: number)
onViewportChange 可见区域变化 (value: ViewportChangeInfo)

4.4 ListItem 详解

ListItem 是 List 的子组件,每个 ListItem 代表列表中的一行或一项。

ListItem() {
  Row() {
    Image($r('app.media.thumb'))
      .width(60)
      .height(60)
      .borderRadius(8)
    
    Column() {
      Text('商品名称')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
      
      Text('商品描述')
        .fontSize(14)
        .fontColor('#999999')
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%')
  .padding(12)
}

ListItem 注意事项:

  • ListItem 必须且只能作为 List 的直接子组件
  • ListItem 的高度可以自适应或固定
  • 大数据量推荐使用 LazyForEach

4.5 性能优化:LazyForEach

当数据量较大时,使用 LazyForEach 代替 ForEach 可以显著提升性能:

import { IDataSource } from '@kit.ArkUI';

class ProductDataSource implements IDataSource {
  private products: Product[] = [];
  private listeners: DataChangeListener[] = [];

  totalCount(): number {
    return this.products.length;
  }

  getData(index: number): Product {
    return this.products[index];
  }

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

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

  addData(index: number, data: Product[]): void {
    this.products.splice(index, 0, ...data);
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataReloaded();
    });
  }
}

使用 LazyForEach:

@State dataSource: ProductDataSource = new ProductDataSource();

List() {
  LazyForEach(this.dataSource, (item: Product) => {
    ListItem() {
      this.ProductCard(item)
    }
  }, (item: Product) => item.id.toString())
}

5. onReachEnd 事件机制

5.1 事件触发条件

onReachEnd 是 List 组件最核心的事件,当列表滚动到末尾时触发:

List() {
  // ...
}
.onReachEnd(() => {
  // 滚动到底部,触发加载更多
  console.log('onReachEnd triggered!');
})

触发机制图解:

┌─────────────────────────────┐
│  List 可视区域               │
│  ┌───────────────────────┐  │
│  │  ListItem 1           │  │
│  ├───────────────────────┤  │
│  │  ListItem 2           │  │
│  ├───────────────────────┤  │
│  │  ListItem 3 (最后一项) │  │  ← 当滚动到这里时
│  │  [加载更多...]         │  │      onReachEnd 触发
│  └───────────────────────┘  │
└─────────────────────────────┘

5.2 关键特性

  1. 触发时机:当列表内容的底部边缘与可视区域的底部边缘对齐时
  2. 触发频率:一次滚动到底部只触发一次(除非用户再次滚动)
  3. 防抖处理:需要手动处理重复触发的问题
  4. 兼容性:支持水平和垂直滚动的 List

5.3 与其他事件的对比

List() {
  // ...
}
.onReachStart(() => {
  // 滚动到顶部时触发(下拉刷新)
})
.onReachEnd(() => {
  // 滚动到底部时触发(加载更多)
})
.onScroll((offset: number, state: ScrollState) => {
  // 滚动过程中持续触发
  // offset: 当前滚动偏移量
  // state: Idle/Auto/Edge/Fling
})
.onScrollIndex((first: number, last: number) => {
  // 可见区域的首尾索引变化时触发
  console.log(`Visible: ${first} - ${last}`);
})

5.4 自定义触发阈值

有时候我们希望在距离底部还有一定距离时就提前加载:

@State lastIndex: number = 0;
@State totalItems: number = 100;
private preloadThreshold: number = 5; // 距离底部5项时开始加载

build() {
  List() {
    ForEach(this.items, (item: Product) => {
      ListItem() {
        this.ProductCard(item)
      }
    }, (item: Product) => item.id.toString())
  }
  .onScrollIndex((first: number, last: number) => {
    this.lastIndex = last;
    
    // 提前加载:距离底部还有 preloadThreshold 项时触发
    if (last >= this.totalItems - this.preloadThreshold) {
      this.loadMoreData();
    }
  })
}

5.5 水平 List 的 onReachEnd

onReachEnd 同样适用于水平滚动的 List:

List() {
  ForEach(this.hItems, (item: string) => {
    ListItem() {
      Text(item)
        .fontSize(18)
        .padding(16)
    }
  }, (item: string, index: number) => index.toString())
}
.listDirection(Axis.Horizontal)  // 设置为水平方向
.onReachEnd(() => {
  console.log('Horizontal list reached end');
})

6. 分页加载架构设计

6.1 整体架构图

┌────────────────────────────────────────────────────────────┐
│                       UI Layer                              │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  Index.ets (Page Component)                            │ │
│  │  ├── @State: productList, currentPage, loadState       │ │
│  │  ├── List + onReachEnd                                  │ │
│  │  └── @Builder: ProductCard, LoadingFooter              │ │
│  └────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
                          │
                          ▼
┌────────────────────────────────────────────────────────────┐
│                    Service Layer                            │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  DataService.ets                                        │ │
│  │  ├── fetchProducts(page, pageSize): Promise<Result>    │ │
│  │  ├── 缓存管理 (Cache)                                   │ │
│  │  ├── 请求去重 (Request Deduplication)                   │ │
│  │  └── 重试机制 (Retry Logic)                             │ │
│  └────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
                          │
                          ▼
┌────────────────────────────────────────────────────────────┐
│                   Network Layer                             │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  HTTP/HTTPS 请求                                        │ │
│  │  ├── URL 构建                                           │ │
│  │  ├── 参数序列化                                         │ │
│  │  ├── 超时处理                                           │ │
│  │  └── 错误处理                                           │ │
│  └────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘

6.2 数据模型设计

// Product.ets - 商品数据模型
export interface Product {
  id: number;
  name: string;
  price: number;
  originalPrice: number;
  imageUrl: string;
  category: string;
  salesCount: number;
  rating: number;
  description: string;
  isNew: boolean;
  isHot: boolean;
}

// 分页请求参数
export interface PageRequest {
  page: number;          // 当前页码(从1开始)
  pageSize: number;      // 每页数量
  keyword?: string;      // 搜索关键词
  category?: string;     // 分类筛选
  sortBy?: SortType;     // 排序方式
}

export enum SortType {
  DEFAULT = 'default',
  PRICE_ASC = 'price_asc',
  PRICE_DESC = 'price_desc',
  SALES_DESC = 'sales_desc',
  NEWEST = 'newest'
}

// 分页响应结果
export interface PageResult<T> {
  list: T[];             // 数据列表
  total: number;         // 总数量
  page: number;          // 当前页码
  pageSize: number;      // 每页数量
  totalPages: number;    // 总页数
  hasMore: boolean;      // 是否有更多数据
}

6.3 常量配置

// Constants.ets
export class AppConstants {
  // 分页配置
  static readonly PAGE_SIZE: number = 10;
  static readonly PRELOAD_THRESHOLD: number = 3;
  
  // 超时配置
  static readonly REQUEST_TIMEOUT: number = 10000;
  static readonly MAX_RETRIES: number = 3;
  
  // 缓存配置
  static readonly CACHE_EXPIRE_TIME: number = 5 * 60 * 1000;
  
  // 颜色配置
  static readonly COLOR_PRIMARY: string = '#007DFF';
  static readonly COLOR_BACKGROUND: string = '#F5F5F5';
  static readonly COLOR_TEXT_PRIMARY: string = '#333333';
  static readonly COLOR_TEXT_SECONDARY: string = '#999999';
  
  // 字体大小
  static readonly FONT_SIZE_TITLE: number = 18;
  static readonly FONT_SIZE_CONTENT: number = 14;
  static readonly FONT_SIZE_HINT: number = 12;
}

6.4 数据服务设计

// DataService.ets
import { Product, PageRequest, PageResult, SortType } from '../model/Product';

export class DataService {
  private static instance: DataService;
  
  // 配置
  private maxRetries: number = 3;
  private cacheExpireTime: number = 5 * 60 * 1000;
  
  // 缓存和队列
  private requestQueue: Map<string, Promise<PageResult<Product>>> = new Map();
  private cache: Map<string, { data: PageResult<Product>; expireTime: number }> = new Map();
  
  private constructor() {}
  
  static getInstance(): DataService {
    if (!DataService.instance) {
      DataService.instance = new DataService();
    }
    return DataService.instance;
  }
  
  async fetchProducts(request: PageRequest): Promise<PageResult<Product>> {
    const cacheKey: string = this.buildCacheKey(request);
    
    // 1. 检查缓存
    const cached = this.cache.get(cacheKey);
    if (cached && Date.now() < cached.expireTime) {
      return cached.data;
    }
    
    // 2. 请求去重
    const existingRequest = this.requestQueue.get(cacheKey);
    if (existingRequest) {
      return existingRequest;
    }
    
    // 3. 执行请求
    const newRequest = this.doFetchProducts(request)
      .then((result: PageResult<Product>) => {
        this.cache.set(cacheKey, {
          data: result,
          expireTime: Date.now() + this.cacheExpireTime
        });
        return result;
      })
      .finally(() => {
        this.requestQueue.delete(cacheKey);
      });
    
    this.requestQueue.set(cacheKey, newRequest);
    return newRequest;
  }
  
  private async doFetchProducts(request: PageRequest): Promise<PageResult<Product>> {
    let lastError: Error | null = null;
    
    for (let retry: number = 0; retry < this.maxRetries; retry++) {
      try {
        return await this.mockApi(request);
      } catch (error) {
        lastError = error as Error;
        await this.delay(Math.pow(2, retry) * 1000);
      }
    }
    
    throw lastError || new Error('Failed to fetch');
  }
  
  private async mockApi(request: PageRequest): Promise<PageResult<Product>> {
    await this.delay(1500);
    
    const totalItems: number = 100;
    const totalPages: number = Math.ceil(totalItems / request.pageSize);
    const startIndex: number = (request.page - 1) * request.pageSize;
    
    const list: Product[] = [];
    const categories: string[] = ['电子产品', '服装', '食品', '家居', '运动'];
    const names: string[] = [
      '智能手机 Pro', '笔记本电脑 Air', '无线蓝牙耳机', '智能手表', '平板电脑',
      '运动跑鞋', '休闲外套', '牛仔裤', '纯棉T恤', '羽绒服'
    ];
    
    for (let i: number = 0; i < request.pageSize; i++) {
      const index: number = startIndex + i;
      if (index >= totalItems) break;
      
      list.push({
        id: index + 1,
        name: names[index % names.length],
        price: Math.floor(Math.random() * 900 + 100),
        originalPrice: Math.floor(Math.random() * 1200 + 200),
        imageUrl: '',
        category: categories[index % categories.length],
        salesCount: Math.floor(Math.random() * 10000),
        rating: Math.round((Math.random() * 2 + 3) * 10) / 10,
        description: `商品编号 ${index + 1}`,
        isNew: index < 20,
        isHot: index % 5 === 0
      });
    }
    
    return {
      list: list,
      total: totalItems,
      page: request.page,
      pageSize: request.pageSize,
      totalPages: totalPages,
      hasMore: request.page < totalPages
    };
  }
  
  private buildCacheKey(request: PageRequest): string {
    return `products_${request.page}_${request.pageSize}_${request.keyword || ''}`;
  }
  
  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  
  clearCache(): void {
    this.cache.clear();
  }
}

7. 完整实战代码

7.1 基础版(ForEach)

这是最简单易用的版本,适合入门学习:

// Index.ets
import { promptAction } from '@kit.ArkUI';
import { Product, PageRequest, PageResult } from '../model/Product';
import { DataService } from '../service/DataService';
import { AppConstants } from '../common/Constants';

@Entry
@Component
struct Index {
  private dataService: DataService = DataService.getInstance();
  
  @State productList: Product[] = [];
  @State currentPage: number = 1;
  @State loadState: string = 'idle';
  
  private pageSize: number = AppConstants.PAGE_SIZE;
  
  aboutToAppear(): void {
    this.loadMoreData();
  }
  
  async loadMoreData(): Promise<void> {
    if (this.loadState === 'loading' || this.loadState === 'no_more') {
      return;
    }
    
    this.loadState = 'loading';
    
    try {
      const request: PageRequest = {
        page: this.currentPage,
        pageSize: this.pageSize
      };
      
      const result: PageResult<Product> = await this.dataService.fetchProducts(request);
      this.productList = [...this.productList, ...result.list];
      
      if (result.hasMore) {
        this.currentPage++;
        this.loadState = 'success';
      } else {
        this.loadState = 'no_more';
        promptAction.showToast({ message: '已加载全部商品' });
      }
    } catch (error) {
      this.loadState = 'error';
      promptAction.showToast({ message: '加载失败,请重试' });
    }
  }
  
  build() {
    Column() {
      this.TitleBar();
      
      List() {
        if (this.loadState === 'idle') {
          // 首次加载骨架屏
          ListItem() {
            this.SkeletonView();
          }
        } else {
          // 商品列表
          ForEach(this.productList, (item: Product) => {
            ListItem() {
              this.ProductCard(item);
            }
          }, (item: Product) => item.id.toString())
          
          // 加载状态指示器
          ListItem() {
            this.LoadingFooter();
          }
        }
      }
      .layoutWeight(1)
      .divider({ strokeWidth: 1, color: '#EEEEEE', startMargin: 16, endMargin: 16 })
      .onReachEnd(() => {
        this.loadMoreData();
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(AppConstants.COLOR_BACKGROUND)
  }
  
  @Builder
  TitleBar() {
    Row() {
      Text('商品列表')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
    }
    .width('100%')
    .height(56)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(Color.White)
    .shadow({ radius: 2, color: '#1A000000', offsetY: 1 })
  }
  
  @Builder
  ProductCard(product: Product) {
    Row() {
      // 商品图片占位
      Column() {
        Text('📦')
          .fontSize(32)
      }
      .width(90)
      .height(90)
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#E8E8E8')
      .borderRadius(8)
      .margin({ right: 12 })
      
      // 商品信息
      Column() {
        Row() {
          Text(product.name)
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .layoutWeight(1)
          
          if (product.isHot) {
            Text('HOT')
              .fontSize(10)
              .fontColor(Color.White)
              .backgroundColor('#FF4444')
              .padding({ left: 4, right: 4, top: 2, bottom: 2 })
              .borderRadius(4)
              .margin({ left: 8 })
          }
        }
        .width('100%')
        
        Text(`销量 ${product.salesCount}`)
          .fontSize(12)
          .fontColor('#999999')
          .margin({ top: 6 })
        
        Row() {
          Text(`¥${product.price}`)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF4444')
          
          Text(`¥${product.originalPrice}`)
            .fontSize(12)
            .fontColor('#CCCCCC')
            .decoration({ type: TextDecorationType.LineThrough })
            .margin({ left: 8 })
        }
        .margin({ top: 8 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(Color.White)
  }
  
  @Builder
  LoadingFooter() {
    Row() {
      if (this.loadState === 'loading') {
        LoadingProgress()
          .width(20)
          .height(20)
          .color('#007DFF')
          .margin({ right: 8 })
        Text('正在加载...').fontSize(14).fontColor('#999')
      } else if (this.loadState === 'error') {
        Text('加载失败,点击重试')
          .fontSize(14)
          .fontColor('#FF4444')
          .onClick(() => {
            this.loadMoreData();
          })
      } else if (this.loadState === 'no_more') {
        Text('— 已加载全部 —').fontSize(14).fontColor('#CCC')
      } else {
        Text('上拉加载更多').fontSize(14).fontColor('#999')
      }
    }
    .width('100%')
    .height(50)
    .justifyContent(FlexAlign.Center)
  }
  
  @Builder
  SkeletonView() {
    Column() {
      ForEach([1, 2, 3, 4, 5], () => {
        Row() {
          Column()
            .width(90)
            .height(90)
            .backgroundColor('#E0E0E0')
            .borderRadius(8)
            .margin({ right: 12 })
          
          Column() {
            Column().width('70%').height(16).backgroundColor('#E0E0E0').borderRadius(4)
            Column().width('40%').height(12).backgroundColor('#E0E0E0').borderRadius(4).margin({ top: 8 })
            Column().width('30%').height(14).backgroundColor('#E0E0E0').borderRadius(4).margin({ top: 12 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(Color.White)
      }, index => index.toString())
    }
    .width('100%')
  }
}

8. 状态管理与数据流

8.1 ArkTS 状态装饰器

装饰器 适用场景 生命周期
@State 组件内部私有状态 组件销毁时回收
@Prop 父组件向子组件传递 父组件更新时同步
@Link 父子组件双向绑定 双向同步
@Provide 跨层级依赖注入 全局可用
@Consume 接收 @Provide 的数据 跟随 Provider

8.2 状态更新最佳实践

// ✅ 正确 - 创建新数组触发更新
this.productList = [...this.productList, ...newItems];

// ✅ 正确 - 使用 concat
this.productList = this.productList.concat(newItems);

// ❌ 错误 - 直接修改不会触发 UI 更新
this.productList.push(...newItems);

8.3 数据流图

┌──────────────┐   fetchProducts()    ┌──────────────┐
│  UI Layer    │ ───────────────────► │ Service Layer│
│              │                      │              │
│  @State      │ ◄─────────────────── │ async/await  │
│  自动更新     │      返回数据          │ Promise      │
└──────────────┘                      └──────────────┘
       │                                     │
       ▼                                     ▼
┌──────────────┐                      ┌──────────────┐
│  View Render  │                      │  Network API │
│  声明式渲染   │                      │  HTTP Request│
└──────────────┘                      └──────────────┘

9. 性能优化实战

9.1 虚拟化渲染

HarmonyOS List 自动开启虚拟化,只渲染可视区域内的 ListItem:

┌─────────────────────────────┐
│  可视区域                     │
│  ┌─────────────────────────┐ │
│  │  ListItem 3 (渲染中)     │ │
│  ├─────────────────────────┤ │
│  │  ListItem 4 (渲染中)     │ │ ← 只渲染这3-5项
│  ├─────────────────────────┤ │
│  │  ListItem 5 (渲染中)     │ │
│  └─────────────────────────┘ │
└─────────────────────────────┘
      ↑              ↑
      │              │
   ListItem 1     ListItem 100
   (已回收)       (未渲染)

9.2 LazyForEach vs ForEach

特性 ForEach LazyForEach
渲染方式 一次性渲染全部 按需渲染
内存占用
适用数据量 < 50 条 > 50 条
更新方式 重新遍历 增量更新
数据源 数组 IDataSource

9.3 图片加载优化

// 使用 AsyncImage 组件
AsyncImage({ url: product.imageUrl })
  .width(90)
  .height(90)
  .borderRadius(8)
  .placeholder('placeholder.jpg')
  .error('error.jpg')
  .cachePolicy(CachePolicy.All)
  .objectFit(ImageFit.Cover)

9.4 内存泄漏预防

@Entry
@Component
struct Index {
  private timer: number | null = null;
  
  aboutToAppear(): void {
    this.timer = setTimeout(() => {}, 1000);
  }
  
  aboutToDisappear(): void {
    // 清理资源!
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
  }
}

9.5 性能监控

import hilog from '@ohos.hilog';

const TAG: string = 'ListPerf';
const DOMAIN: number = 0x0001;

async loadMoreData(): Promise<void> {
  const startTime: number = Date.now();
  
  try {
    const result = await this.fetchData();
    const duration: number = Date.now() - startTime;
    
    hilog.info(DOMAIN, TAG, `Load ${result.list.length} items in ${duration}ms`);
    
    if (duration > 2000) {
      hilog.warn(DOMAIN, TAG, 'Slow load detected!');
    }
  } catch (error) {
    hilog.error(DOMAIN, TAG, 'Load failed: %{public}s', error.message);
  }
}

10. 常见问题与解决方案

Q1: onReachEnd 触发太频繁?

解决方案: 添加状态锁 + 防抖

@State loadState: string = 'idle';
private lastTriggerTime: number = 0;
private debounceTime: number = 500;

async loadMoreData(): Promise<void> {
  const now: number = Date.now();
  
  // 防抖检查
  if (now - this.lastTriggerTime < this.debounceTime) {
    return;
  }
  
  // 状态检查
  if (this.loadState === 'loading' || this.loadState === 'no_more') {
    return;
  }
  
  this.lastTriggerTime = now;
  this.loadState = 'loading';
  
  // ... 加载逻辑
}

Q2: 数据重复加载?

解决方案: 请求去重

private pendingRequests: Map<string, Promise<PageResult>> = new Map();

async fetchData(page: number): Promise<PageResult> {
  const requestKey: string = `page_${page}`;
  
  // 如果已有相同请求在进行,直接返回
  const existing = this.pendingRequests.get(requestKey);
  if (existing) {
    return existing;
  }
  
  // 创建新请求
  const request = this.doFetch(page).finally(() => {
    this.pendingRequests.delete(requestKey);
  });
  
  this.pendingRequests.set(requestKey, request);
  return request;
}

Q3: 列表不更新?

解决方案: 使用不可变数据更新

// ❌ 错误
this.productList.push(...newItems);

// ✅ 正确
this.productList = this.productList.concat(newItems);
this.productList = [...this.productList, ...newItems];

Q4: 首屏加载慢?

解决方案: 骨架屏 + 并发请求

build() {
  List() {
    if (this.loadState === 'idle') {
      // 显示骨架屏
      ForEach([1, 2, 3, 4, 5], () => {
        ListItem() {
          this.SkeletonItem();
        }
      })
    } else {
      // 真实数据
      ForEach(this.productList, (item: Product) => {
        ListItem() {
          this.ProductCard(item);
        }
      })
    }
  }
}

Q5: 内存持续增长?

解决方案: 实现数据回收策略

private maxCacheSize: number = 200;

async loadMoreData(): Promise<void> {
  // ... 加载新数据
  
  // 超过限制,回收旧数据
  if (this.productList.length > this.maxCacheSize) {
    this.productList = this.productList.slice(-150);
    this.currentPage = Math.floor(this.productList.length / this.pageSize) + 1;
  }
}

11. 进阶技巧与最佳实践

11.1 下拉刷新 + 上拉加载组合

@Entry
@Component
class RefreshListPage {
  @State isRefreshing: boolean = false;
  @State productList: Product[] = [];
  @State loadState: string = 'idle';
  
  async handleRefresh(): Promise<void> {
    this.isRefreshing = true;
    this.currentPage = 1;
    this.productList = [];
    await this.loadMoreData();
    this.isRefreshing = false;
    promptAction.showToast({ message: '刷新成功' });
  }
  
  build() {
    List() {
      // 刷新指示器
      ListItem() {
        if (this.isRefreshing) {
          Row() {
            LoadingProgress().width(20).height(20).margin({ right: 8 })
            Text('正在刷新...').fontSize(14).fontColor('#999')
          }
          .width('100%').height(40).justifyContent(FlexAlign.Center)
        }
      }
      
      // 商品列表
      ForEach(this.productList, (item: Product) => {
        ListItem() {
          this.ProductCard(item);
        }
      }, (item: Product) => item.id.toString())
      
      // 加载指示器
      ListItem() {
        this.LoadingFooter();
      }
    }
    .onReachEnd(() => {
      if (!this.isRefreshing) {
        this.loadMoreData();
      }
    })
    // 下拉刷新手势
    .gesture(
      PanGesture({ direction: PanDirection.Vertical })
        .onActionEnd((event: GestureEvent) => {
          if (event.offsetY > 100 && !this.isRefreshing) {
            this.handleRefresh();
          }
        })
    )
  }
}

11.2 多类型列表项

enum ListItemType {
  PRODUCT = 'product',
  AD = 'ad',
  BANNER = 'banner',
  TITLE = 'title'
}

interface ListItemData {
  type: ListItemType;
  data: Product | Ad | Banner | Title;
}

@Builder
renderListItem(item: ListItemData) {
  switch (item.type) {
    case ListItemType.PRODUCT:
      this.ProductCard(item.data as Product);
      break;
    case ListItemType.AD:
      this.AdCard(item.data as Ad);
      break;
    case ListItemType.BANNER:
      this.BannerCard(item.data as Banner);
      break;
    case ListItemType.TITLE:
      this.TitleCard(item.data as Title);
      break;
    default:
      break;
  }
}

11.3 搜索+筛选+分页组合

@Entry
@Component
struct SearchListPage {
  @State keyword: string = '';
  @State category: string = '';
  @State productList: Product[] = [];
  @State currentPage: number = 1;
  @State loadState: string = 'idle';
  
  async search(): Promise<void> {
    this.currentPage = 1;
    this.productList = [];
    await this.loadMoreData();
  }
  
  async loadMoreData(): Promise<void> {
    if (this.loadState === 'loading' || this.loadState === 'no_more') {
      return;
    }
    
    this.loadState = 'loading';
    
    try {
      const result = await this.dataService.fetchProducts({
        page: this.currentPage,
        pageSize: 10,
        keyword: this.keyword,
        category: this.category
      });
      
      this.productList = [...this.productList, ...result.list];
      this.currentPage++;
      this.loadState = result.hasMore ? 'success' : 'no_more';
    } catch (error) {
      this.loadState = 'error';
    }
  }
  
  build() {
    Column() {
      // 搜索栏
      Row() {
        Search({ value: this.keyword, placeholder: '搜索商品' })
          .searchButton('搜索')
          .onSubmit((value: string) => {
            this.keyword = value;
            this.search();
          })
          .layoutWeight(1)
      }
      .width('100%')
      .padding(12)
      
      // 筛选标签
      Scroll() {
        Row() {
          this.FilterTag('全部', '')
          this.FilterTag('数码', 'digital')
          this.FilterTag('服饰', 'clothes')
          this.FilterTag('美食', 'food')
        }
        .padding({ left: 12, right: 12 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .width('100%')
      .height(40)
      
      // 列表
      List() {
        ForEach(this.productList, (item: Product) => {
          ListItem() {
            this.ProductCard(item);
          }
        }, (item: Product) => item.id.toString())
      }
      .layoutWeight(1)
      .onReachEnd(() => {
        this.loadMoreData();
      })
    }
  }
  
  @Builder
  FilterTag(label: string, value: string) {
    Text(label)
      .fontSize(14)
      .fontColor(this.category === value ? '#007DFF' : '#666')
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .backgroundColor(this.category === value ? '#E6F4FF' : '#F5F5F5')
      .borderRadius(16)
      .margin({ right: 8 })
      .onClick(() => {
        this.category = value;
        this.search();
      })
  }
}

12. 测试与调试

12.1 使用 HiLog 调试

import hilog from '@ohos.hilog';

const TAG: string = 'ListDebug';
const DOMAIN: number = 0x0001;

async loadMoreData(): Promise<void> {
  hilog.info(DOMAIN, TAG, 
    'loadMoreData called. page: %{public}d, loadState: %{public}s',
    this.currentPage, this.loadState);
  
  const startTime = Date.now();
  
  try {
    const result = await this.fetchData();
    const duration = Date.now() - startTime;
    
    hilog.info(DOMAIN, TAG,
      'Success! Loaded %{public}d items in %{public}dms',
      result.list.length, duration);
      
  } catch (error) {
    hilog.error(DOMAIN, TAG,
      'Failed: %{public}s', error.message);
  }
}

12.2 DevEco Studio 调试工具

  1. Layout Inspector - 查看组件树和布局属性
  2. Performance Profiler - 分析渲染性能
  3. Memory Analyzer - 检测内存泄漏
  4. Network Monitor - 监控网络请求

13. 常见面试题

Q1: 如何防止 List 重复请求?

答: 三重保护机制:

  1. 状态锁(isLoading 标记)
  2. 请求去重(Request Map)
  3. 防抖节流(时间戳检查)

Q2: List 和 Scroll 的区别?

特性 List Scroll
虚拟化 自动开启 不支持
性能 高(大数据量) 低(一次性渲染)
适用场景 长列表 短内容

Q3: LazyForEach 和 ForEach 的区别?

答: ForEach 立即渲染全部,LazyForEach 按需渲染。LazyForEach 需要实现 IDataSource,适合 >50 条数据。

Q4: List 的 onReachEnd 不触发怎么办?

可能原因:

  1. List 没有设置明确高度
  2. 内容不足以滚动
  3. isLoading 状态未正确处理
  4. 嵌套滚动冲突

Q5: 如何实现真正的无限滚动?

答: 分页加载 + 虚拟化 + 动态数据源 + 内存回收 + 图片懒加载。


14. 最佳实践总结

✅ 必做清单

  1. 状态管理

    • 使用 @State 管理组件状态
    • 不可变数据更新
  2. 性能优化

    • 大数据量用 LazyForEach
    • 图片懒加载
    • 及时清理资源
  3. 错误处理

    • 完整的 try-catch
    • 用户友好提示
    • 重试机制
  4. 代码质量

    • TypeScript 类型安全
    • @Builder 复用组件
    • 详细注释

⚠️ 避坑指南

  1. 不要在异步回调忘记更新 UI
  2. 不要直接修改数组,要用不可变更新
  3. 不要忽略内存泄漏
  4. 不要在 @Builder 中声明 @State
  5. 不要滥用 console.log,用 HiLog

📊 性能指标

指标 优秀 良好 需优化
首屏加载 < 500ms 500ms-1s > 1s
滚动帧率 55-60fps 45-55fps < 45fps
内存占用 < 100MB 100-200MB > 200MB

15. 参考资料

官方文档

推荐学习路径

  1. 入门 - ArkTS 基础语法、声明式 UI、List 基础
  2. 进阶 - 状态管理、LazyForEach、性能优化
  3. 实战 - 完整分页列表、下拉刷新、边界处理

工具推荐

  • DevEco Studio - 官方 IDE
  • ArkUI Inspector - UI 调试
  • Performance Profiler - 性能分析
  • HiLog - 日志工具

结语

通过本文的学习,您已经掌握了 HarmonyOS List 上拉加载更多的核心技术。从基础的 List + onReachEnd 到高级的虚拟化滚动、智能预加载,我们一步步深入,理解了每一个细节。

关键要点回顾:

  1. List 组件自带虚拟化,长列表最佳选择
  2. onReachEnd 是触发加载的核心事件
  3. 状态管理是关键,注意防重复加载
  4. LazyForEach 适合大数据量,性能更好
  5. 错误处理和边界情况不能忽视

下一步建议:

  • 动手实践,运行示例代码
  • 尝试添加下拉刷新
  • 学习更多 ArkUI 组件
  • 关注 HarmonyOS 更新
Logo

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

更多推荐