鸿蒙学习实战之路-ArkTS循环渲染_ForEach使用指南

鸿蒙学习实战之路-ArkTS循环渲染_ForEach使用指南

害,上篇我们聊了条件渲染if else,有朋友私信问我:"西兰花啊,如果我有个数组要渲染成列表咋整?总不能一个个写Text组件吧?"

害,这问题问得对!想象一下,你要显示100个商品列表,难道要写100个Text组件?那不得写到天荒地老~

今天这篇,我就手把手带你搞定ArkTS的循环渲染ForEach,让你告别重复写组件的痛苦!全程不超过10分钟~

什么是ForEach循环渲染?

ArkUI通过自定义组件的build()函数和@Builder装饰器中的声明式UI描述语句构建相应的UI。在声明式描述语句中开发者除了使用系统组件外,还可以使用渲染控制语句来辅助UI的构建,这些渲染控制语句包括控制组件是否显示的条件渲染语句,基于数组数据快速生成组件的循环渲染语句,针对大数据量场景的数据懒加载语句,针对混合模式开发的组件渲染语句。

简单说,ForEach就是帮你把数组里的每个元素变成UI组件的神器!这就像Vue的v-for和React的map(),一个函数搞定整个列表渲染~

适用场景:

  • 商品列表展示
  • 消息列表
  • 菜单项渲染
  • 表格数据展示

🥦 西兰花小贴士

ForEach就像你家的"模板机",给一个模板和数据源,就能批量生产各种UI组件,省时省力还不会出错~

ForEach的核心原理:键值生成规则

在ForEach循环渲染过程中,系统会给每个数组元素生成一个唯一且持久的键值,用于标识对应的组件。当键值变化时,ArkUI框架会认为该数组元素已被替换或修改,并基于新的键值创建新的组件。

键值生成规则详解

ForEach提供了一个keyGenerator参数,这是个函数,开发者可以通过它自定义键值的生成规则。如果没定义的话,ArkUI框架会使用默认的键值生成函数:

typescript 复制代码
(item: Object, index: number) => { 
  return index + '__' + JSON.stringify(item); 
}

图1 ForEach键值生成规则

🥦 西兰花警告

  • 框架会对重复的键值发出运行时警告,UI更新时如果出现重复键值,框架可能无法正常工作
  • 不建议在键值中包含数据项索引index,这可能导致渲染结果非预期和渲染性能降低
  • 如果在itemGenerator函数中声明了index参数,但未在keyGenerator函数中声明index参数,框架会在keyGenerator函数返回值基础上拼接index,这会引发性能问题

键值生成实战示例

typescript 复制代码
interface 菜单项类型 { 
  菜品名称: string;
  价格: number;
} 

@Entry 
@Component 
struct 菜单列表 { 
  @State 菜单数据: Array<菜单项类型> = [
    { 菜品名称: '西兰花炒蛋', 价格: 18 },
    { 菜品名称: '花菜炒肉', 价格: 22 },
    { 菜品名称: '西红柿鸡蛋', 价格: 15 }
  ];
  
  build() { 
    Row() {
      Column() {
        ForEach(this.菜单数据, 
          (item: 菜单项类型, index: number) => {
            菜品卡片({ 菜名: item.菜品名称, 序号: index }) // 组件生成函数中使用index参数
          }, 
          (item: 菜单项类型, index: number) => {
            return item.菜品名称; // 建议在键值生成函数中使用与UI界面相关的数据属性
          }
        )
      }
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
} 

@Component 
struct 菜品卡片 { 
  @Prop 菜名: string = '';
  @Prop 序号: number = 0;
  
  build() { 
    Text(this.菜名)
      .fontSize(20)
      .margin(10)
  }
} 

在上述示例中,当组件生成函数声明index时,建议键值生成函数也声明index参数,避免渲染性能降低和渲染结果非预期。同时建议在键值生成函数实现中使用与UI相关的数据属性。

组件创建规则:首次渲染 vs 非首次渲染

ForEach的第二个参数itemGenerator函数会根据键值生成规则为数据源的每个数组项创建组件。组件创建包括两种情况:首次渲染和非首次渲染。

首次渲染

在ForEach首次渲染时,会根据键值生成规则为数据源的每个数组项生成唯一键值,并创建相应的组件。

typescript 复制代码
@Entry 
@Component 
struct 基础示例 { 
  @State 字符串数组: Array<string> = ['西兰花', '花菜', '西红柿'];
  
  build() { 
    Row() {
      Column() {
        ForEach(this.字符串数组, 
          (item: string) => 蔬菜组件({ 蔬菜: item }),
          (item: string) => item
        )
      }
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
} 

@Component 
struct 蔬菜组件 { 
  @Prop 蔬菜: string;
  
  build() { 
    Text(this.蔬菜)
      .fontSize(20)
      .margin(10)
  }
} 

图2 ForEach数据项不存在相同键值案例首次渲染运行效果图

在上述代码中,keyGenerator函数的返回值是item。在ForEach渲染循环时,为数组项依次生成键值"西兰花"、"花菜"和"西红柿",并创建对应的蔬菜组件渲染到界面上。

当不同数组项生成的键值相同时,框架的行为是未定义的。比如:

typescript 复制代码
@Entry 
@Component 
struct 重复值示例 { 
  @State 字符串数组: Array<string> = ['西兰花', '花菜', '花菜', '西红柿'];
  
  build() { 
    Row() {
      Column() {
        ForEach(this.字符串数组, 
          (item: string) => 蔬菜组件({ 蔬菜: item }),
          (item: string) => item
        )
      }
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
} 

@Component 
struct 蔬菜组件 { 
  @Prop 蔬菜: string;
  
  build() { 
    Text(this.蔬菜)
      .fontSize(20)
      .margin(10)
  }
} 

图3 ForEach数据源存在相同值案例首次渲染运行效果图

在该示例中,当ForEach遍历到索引为1的"花菜"时,创建键值为"花菜"的组件并记录。当遍历到索引为2的"花菜"时,当前项的键值也为"花菜",此时不再创建新的组件。

非首次渲染

在ForEach组件进行非首次渲染时,它会检查新生成的键值是否在上次渲染中已经存在。如果键值不存在,则创建新组件;如果键值存在,则复用对应的组件。

typescript 复制代码
@Entry 
@Component 
struct 动态更新示例 { 
  @State 字符串数组: Array<string> = ['西兰花', '花菜', '西红柿'];
  
  build() { 
    Row() {
      Column() {
        Text('点击修改第3个蔬菜名称')
          .fontSize(18)
          .fontColor(Color.Red)
          .onClick(() => {
            this.字符串数组[2] = '新的西红柿';
          })
          
        ForEach(this.字符串数组, 
          (item: string) => 蔬菜组件({ 蔬菜: item })
            .margin({ top: 20 }),
          (item: string) => item
        )
      }
      .justifyContent(FlexAlign.Center)
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
} 

@Component 
struct 蔬菜组件 { 
  @Prop 蔬菜: string;
  
  build() { 
    Text(this.蔬菜)
      .fontSize(20)
  }
} 

图4 ForEach非首次渲染案例运行效果图

从本例可以看出@State能够监听到简单数据类型数组字符串数组数组项的变化。

  • 当字符串数组数组项发生变化时,会触发ForEach重新渲染
  • ForEach遍历新的数据源['西兰花', '花菜', '新的西红柿'],并生成对应的键值
  • 其中,键值"西兰花"和"花菜"在上次渲染中已经存在,所以ForEach复用了对应的组件并进行了渲染。对于第三个数组项"新的西红柿",由于其键值在上次渲染中不存在,因此ForEach为该数组项创建了一个新的组件

ForEach实战应用场景

场景1:数据源不变 - 骨架屏加载

在数据源保持不变的场景中,数据源可以直接采用基本数据类型。比如页面加载状态时,可以使用骨架屏列表进行渲染展示。

typescript 复制代码
@Entry 
@Component 
struct 文章列表骨架 { 
  @State 占位数组: Array<number> = [1, 2, 3, 4, 5];
  
  build() { 
    Column() {
      ForEach(this.占位数组, 
        (item: number) => 文章骨架卡片()
          .margin({ top: 20 }),
        (item: number) => item.toString()
      )
    }
    .padding(20)
    .width('100%')
    .height('100%')
  }
} 

@Builder 
function 矩形占位(宽度: number | Resource | string = '100%', 高度: number | Resource | string = '100%') { 
  Row()
    .width(宽度)
    .height(高度)
    .backgroundColor('#FFF2F3F4')
} 

@Component 
struct 文章骨架卡片 { 
  build() { 
    Row() {
      Column() {
        矩形占位(80, 80)
      }
      .margin({ right: 20 })
      
      Column() {
        矩形占位('60%', 20)
        矩形占位('50%', 20)
      }
      .alignItems(HorizontalAlign.Start)
      .justifyContent(FlexAlign.SpaceAround)
      .height('100%')
    }
    .padding(20)
    .borderRadius(12)
    .backgroundColor('#FFECECEC')
    .height(120)
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
  }
} 

图5 骨架屏运行效果图

在本示例中,采用数据项item作为键值生成规则,由于数据源占位数组的数组项各不相同,因此能够保证键值的唯一性。

场景2:数据源数组项变化 - 动态增删改

在数据源数组项发生变化的场景下,如数组插入、删除操作或者数组项索引位置交换时,数据源应为对象数组类型,并使用对象的唯一ID作为键值。

typescript 复制代码
class 文章 { 
  id: string;
  标题: string;
  简介: string;
  
  constructor(id: string, 标题: string, 简介: string) {
    this.id = id;
    this.标题 = 标题;
    this.简介 = 简介;
  }
} 

@Entry 
@Component 
struct 文章列表视图 { 
  是否触底: boolean = false;
  @State 文章列表: Array<文章> = [
    new 文章('001', '第1篇文章', '文章简介内容'),
    new 文章('002', '第2篇文章', '文章简介内容'),
    new 文章('003', '第3篇文章', '文章简介内容'),
    new 文章('004', '第4篇文章', '文章简介内容'),
    new 文章('005', '第5篇文章', '文章简介内容'),
    new 文章('006', '第6篇文章', '文章简介内容')
  ];
  
  加载更多文章() { 
    this.文章列表.push(new 文章('007', '加载的新文章', '文章简介内容'));
  }
  
  build() { 
    Column({ space: 5 }) {
      List() {
        ForEach(this.文章列表, 
          (item: 文章) => {
            ListItem() {
              文章卡片({ 文章项: item })
                .margin({ top: 20 })
            }
          }, 
          (item: 文章) => item.id
        )
      }
      .onReachEnd(() => {
        this.是否触底 = true;
      })
      .parallelGesture(
        PanGesture({ direction: PanDirection.Up, distance: 80 })
          .onActionStart(() => {
            if (this.是否触底) {
              this.加载更多文章();
              this.是否触底 = false;
            }
          })
      )
      .padding(20)
      .scrollBar(BarState.Off)
    }
    .width('100%')
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
} 

@Component 
struct 文章卡片 { 
  @Prop 文章项: 文章;
  
  build() { 
    Row() {
      Image($r('app.media.icon'))
        .width(80)
        .height(80)
        .margin({ right: 20 })
        
      Column() {
        Text(this.文章项.标题)
          .fontSize(20)
          .margin({ bottom: 8 })
          
        Text(this.文章项.简介)
          .fontSize(16)
          .fontColor(Color.Gray)
          .margin({ bottom: 8 })
      }
      .alignItems(HorizontalAlign.Start)
      .width('80%')
      .height('100%')
    }
    .padding(20)
    .borderRadius(12)
    .backgroundColor('#FFECECEC')
    .height(120)
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
  }
} 

图6 数据源数组项变化案例运行效果图

在本示例中,文章卡片组件作为文章列表视图组件的子组件,通过@Prop装饰器接收一个文章对象,用于渲染文章卡片。

  • 当列表滚动到底部且手势滑动距离超过80vp时,触发加载更多文章()函数。此函数在文章列表数据源尾部添加新数据项,增加数据源长度
  • 数据源被@State装饰器修饰,ArkUI框架能够感知数据源长度的变化并触发ForEach进行重新渲染

当数据源的数组项为对象数据类型,并且只修改某个数组项的属性值时,由于数据源为复杂数据类型,ArkUI框架无法监听到@State装饰器修饰的数据源数组项的属性变化,从而无法触发ForEach的重新渲染。为实现ForEach子组件重新渲染,需要结合@Observed和@ObjectLink装饰器使用。

typescript 复制代码
@Observed 
class 文章 { 
  id: string;
  标题: string;
  简介: string;
  是否点赞: boolean;
  点赞数: number;
  
  constructor(id: string, 标题: string, 简介: string, 是否点赞: boolean, 点赞数: number) {
    this.id = id;
    this.标题 = 标题;
    this.简介 = 简介;
    this.是否点赞 = 是否点赞;
    this.点赞数 = 点赞数;
  }
} 

@Entry 
@Component 
struct 文章列表视图 { 
  @State 文章列表: Array<文章> = [
    new 文章('001', '第0篇文章', '文章简介内容', false, 100),
    new 文章('002', '第1篇文章', '文章简介内容', false, 100),
    new 文章('003', '第2篇文章', '文章简介内容', false, 100),
    new 文章('004', '第4篇文章', '文章简介内容', false, 100),
    new 文章('005', '第5篇文章', '文章简介内容', false, 100),
    new 文章('006', '第6篇文章', '文章简介内容', false, 100),
  ];
  
  build() { 
    List() {
      ForEach(this.文章列表, 
        (item: 文章) => {
          ListItem() {
            文章卡片({
              文章项: item
            })
              .margin({ top: 20 })
          }
        }, 
        (item: 文章) => item.id
      )
    }
    .padding(20)
    .scrollBar(BarState.Off)
    .backgroundColor(0xF1F3F5)
  }
} 

@Component 
struct 文章卡片 { 
  @ObjectLink 文章项: 文章;
  
  处理点赞() { 
    this.文章项.是否点赞 = !this.文章项.是否点赞;
    this.文章项.点赞数 += this.文章项.是否点赞 ? 1 : -1;
  }
  
  build() { 
    Row() {
      Column() {
        Text(this.文章项.标题)
          .fontSize(18)
          .margin({ bottom: 8 })
          
        Text(this.文章项.简介)
          .fontSize(14)
          .fontColor(Color.Gray)
          .margin({ bottom: 8 })
      }
      .alignItems(HorizontalAlign.Start)
      .width('60%')
      .height('100%')
      
      Column() {
        Button(this.文章项.是否点赞 ? '❤️' : '🤍')
          .fontSize(16)
          .onClick(() => this.处理点赞())
          .margin({ bottom: 5 })
          
        Text(`${this.文章项.点赞数}`)
          .fontSize(14)
          .fontColor(Color.Gray)
      }
      .width('40%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
    }
    .padding(15)
    .borderRadius(8)
    .backgroundColor('#FFF8F9FA')
    .width('100%')
    .height(100)
    .justifyContent(FlexAlign.SpaceBetween)
  }
} 

在这个示例中,当用户点击点赞按钮时,只修改了文章对象的属性值。由于使用了@Observed和@ObjectLink装饰器,ArkUI框架能够监听到对象属性的变化并触发相关组件的重新渲染。

场景4:拖拽排序功能

在拖拽排序场景中,需要实现数组项位置的交换。这也需要使用对象的唯一ID作为键值,确保拖拽过程中组件的正确复用和更新。

typescript 复制代码
@Observed 
class 菜单项 { 
  id: string;
  菜品名称: string;
  价格: number;
  图标: Resource;
  
  constructor(id: string, 菜品名称: string, 价格: number, 图标: Resource) {
    this.id = id;
    this.菜品名称 = 菜品名称;
    this.价格 = 价格;
    this.图标 = 图标;
  }
} 

@Entry 
@Component 
struct 菜单排序视图 { 
  @State 菜单列表: Array<菜单项> = [
    new 菜单项('001', '西兰花炒蛋', 18, $r('app.media.dish1')),
    new 菜单项('002', '花菜炒肉', 22, $r('app.media.dish2')),
    new 菜单项('003', '西红柿鸡蛋', 15, $r('app.media.dish3')),
    new 菜单项('004', '青椒土豆丝', 16, $r('app.media.dish4')),
    new 菜单项('005', '蒜蓉菠菜', 14, $r('app.media.dish5')),
  ];
  
  // 交换两个菜单项的位置
  交换位置(from: number, to: number) { 
    const temp = this.菜单列表[from];
    this.菜单列表[from] = this.菜单列表[to];
    this.菜单列表[to] = temp;
  }
  
  build() { 
    List() {
      ForEach(this.菜单列表, 
        (item: 菜单项, index: number) => {
          ListItem() {
            菜单项卡片({ 
              菜单项数据: item,
              当前索引: index,
              交换回调: (fromIndex: number, toIndex: number) => this.交换位置(fromIndex, toIndex)
            })
          }
        }, 
        (item: 菜单项) => item.id
      )
    }
    .padding(20)
    .scrollBar(BarState.Off)
    .backgroundColor(0xF1F3F5)
  }
} 

@Component 
struct 菜单项卡片 { 
  @ObjectLink 菜单项数据: 菜单项;
  @Prop 当前索引: number;
  交换回调: (from: number, to: number) => void = () => {};
  
  build() { 
    Row() {
      Image(this.菜单项数据.图标)
        .width(60)
        .height(60)
        .margin({ right: 15 })
        
      Column() {
        Text(this.菜单项数据.菜品名称)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 5 })
          
        Text(`¥${this.菜单项数据.价格}`)
          .fontSize(16)
          .fontColor('#FF666666')
      }
      .alignItems(HorizontalAlign.Start)
      .width('70%')
      .height('100%')
      
      Column() {
        Text('⋮⋮')
          .fontSize(20)
          .fontColor(Color.Gray)
      }
      .width('30%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.End)
    }
    .padding(15)
    .borderRadius(10)
    .backgroundColor(Color.White)
    .width('100%')
    .height(90)
    .justifyContent(FlexAlign.SpaceBetween)
    .gesture(
      PanGesture({ direction: PanDirection.Vertical })
        .onActionEnd((event) => {
          // 这里可以添加拖拽排序逻辑
          // 根据拖拽距离和方向确定新位置
          console.log(`菜品 ${this.菜单项数据.菜品名称} 从位置 ${this.currentIndex} 被拖拽`);
        })
    )
  }
} 

🥦 西兰花小贴士

拖拽排序功能虽然看起来复杂,但核心还是键值生成规则。只要保证每个菜单项有唯一ID,ArkUI就能正确处理组件的复用和更新~

性能优化最佳实践

键值选择策略

数据类型 推荐键值 适用场景
对象数组 item.id 或 item.uniqueKey 动态增删改场景
基础类型数组 item 本身 数据源不变场景
复杂对象 与UI显示相关的属性 列表展示场景

避免不必要的组件重建

  1. 使用唯一标识符:优先使用数据项的唯一ID作为键值
  2. 避免使用索引:不要使用数组索引作为键值,容易导致渲染问题
  3. 合理使用@Observed和@ObjectLink:只在对对象属性变化需要响应时使用

大数据集处理

对于大量数据的场景,建议:

  1. 使用分页加载
  2. 结合LazyForEach实现懒加载
  3. 合理设置列表的可见区域

常见坑与解决方案

坑1:渲染结果非预期

问题现象:列表显示不正确,元素顺序错乱或重复显示

解决方案

  • 检查键值生成函数,确保返回唯一且稳定的键值
  • 避免使用数组索引作为键值
  • 确保数据项的键值在数据变化时保持一致性

坑2:数据变化不触发渲染

问题现象:修改数组项属性后UI没有更新

解决方案

  • 对于对象数组,使用@Observed和@ObjectLink装饰器
  • 对于基础类型数组,直接修改数组项即可触发更新
  • 检查@State装饰器是否正确应用到数据源

坑3:渲染性能降低

问题现象:列表操作卡顿,渲染速度慢

解决方案

  • 优化键值生成函数,避免复杂的计算
  • 使用唯一ID而不是索引作为键值
  • 对于大数据集,考虑使用LazyForEach
  • 合理使用组件的memoization

坑4:内存消耗过大

问题现象:长时间使用后内存占用过高

解决方案

  • 及时清理不需要的数据
  • 避免在组件中保存大量状态
  • 使用对象池复用组件

🥦 西兰花警告

ForEach的键值生成是个技术活,千万别小看!键值选择不当就像做菜时调料放错,整个味道都变了~血的教训啊朋友们!

总结与最佳实践

  1. 键值选择:唯一ID > 数据项本身 > 索引(强烈不推荐)
  2. 数据结构:简单数据用@State,复杂对象用@Observed + @ObjectLink
  3. 性能优化:避免不必要的组件重建,合理使用懒加载
  4. 错误处理:添加键值重复检测,提供降级方案
  5. 测试覆盖:测试各种数据变化场景,确保渲染正确性

ArkTS的ForEach循环渲染功能真的很强大,掌握了它,你就能轻松应对各种列表渲染需求。记住:实操 > 理论,多写代码才能真正理解~

下一步行动

👉 预告:《ArkTS数据懒加载_LazyForEach使用指南》

下期我们将深入学习LazyForEach,了解如何处理大数据量场景,避免一次性渲染过多组件导致的性能问题。让你的应用在处理千条、万条数据时依然流畅如丝~

推荐资源

📚 官方文档:

📖 进阶学习:


我是盐焗西兰花,

不教理论,只给你能跑的代码和避坑指南。

下期见!🥦

相关推荐
巧克力味的桃子2 小时前
单链表 - 有序插入并输出学习笔记
笔记·学习
坚持学习前端日记3 小时前
软件开发完整流程详解
学习·程序人生·职场和发展·创业创新
Wokoo73 小时前
开发者AI大模型学习与接入指南
java·人工智能·学习·架构
小猪佩奇TONY4 小时前
OpenCL 学习(3)---- OpenCL 第一个程序
学习
守护安静星空4 小时前
live555学习笔记
笔记·学习
航Hang*5 小时前
第1章:初识Linux系统——第13节:总复习②
linux·笔记·学习·centos
俩毛豆5 小时前
【鸿蒙生态共建】意图框架的使用-通过小艺调起京东发起搜索《精通HarmonyOS NEXT :鸿蒙App开发入门与项目化实战》读者福利
华为·harmonyos·小艺
YJlio5 小时前
PsPing 学习笔记(14.1):ICMP Ping 进阶——替代系统 ping 的正确姿势
windows·笔记·学习
BMS小旭6 小时前
CubeMx-GPIO学习
单片机·学习