1# Repeat:可复用的循环渲染 2 3> **说明:** 4> 5> Repeat从API version 12开始支持。 6> 7> 本文档仅为开发者指南。API参数说明见:[Repeat API参数说明](../../reference/apis-arkui/arkui-ts/ts-rendering-control-repeat.md)。 8 9## 概述 10 11Repeat基于数组类型数据来进行循环渲染,一般与容器组件配合使用。Repeat组件包含两种模式:**non-virtualScroll模式**和**virtualScroll模式**。 12 13- **non-virtualScroll模式**:Repeat在初始化页面时加载列表中的所有子组件,适合**短数据列表/组件全部加载**的场景。详细描述见[non-virtualScroll模式](#non-virtualscroll模式)。 14- **virtualScroll模式**(开启[virtualScroll](../../reference/apis-arkui/arkui-ts/ts-rendering-control-repeat.md#virtualscroll)开关):Repeat根据容器组件的**有效加载范围(可视区域+预加载区域)** 加载子组件。当容器滑动/数组改变时,Repeat会根据父容器组件传递的参数重新计算有效加载范围,实时管理列表节点的创建与销毁。 15<br/>该模式适合**需要懒加载的长数据列表/通过组件复用优化性能表现**的场景。详细描述见[virtualScroll模式](#virtualscroll模式)。 16 17> **说明:** 18> 19> Repeat与ForEach、LazyForEach的区别: 20> 21> - 相较于[ForEach](arkts-rendering-control-foreach.md)组件,non-virtualScroll模式在以下两个维度实现了优化升级:首先,针对特定数组更新场景的渲染性能进行了优化;其次,将子组件的内容/索引管理职责转移至框架层面。 22> - 相较于[LazyForEach](arkts-rendering-control-lazyforeach.md)组件,virtualScroll模式直接监听状态变量的变化,而LazyForEach需要开发者实现[IDataSource](../../reference/apis-arkui/arkui-ts/ts-rendering-control-lazyforeach.md#idatasource10)接口,手动管理子组件内容/索引的修改。除此之外,Repeat还增强了节点复用能力,提高了长列表滑动和数据更新的渲染性能。Repeat增加了模板(template)的能力,在同一个数组中,根据开发者自定义的模板类型(template type)渲染不同的子组件。 23 24下面的示例代码使用Repeat组件的virtualScroll模式进行循环渲染。 25 26```ts 27// 在List容器组件中使用Repeat virtualScroll模式 28@Entry 29@ComponentV2 // 推荐使用V2装饰器 30struct RepeatExample { 31 @Local dataArr: Array<string> = []; // 数据源 32 33 aboutToAppear(): void { 34 for (let i = 0; i < 50; i++) { 35 this.dataArr.push(`data_${i}`); // 为数组添加一些数据 36 } 37 } 38 39 build() { 40 Column() { 41 List() { 42 Repeat<string>(this.dataArr) 43 .each((ri: RepeatItem<string>) => { // 默认模板 44 ListItem() { 45 Text('each_A_' + ri.item).fontSize(30).fontColor(Color.Red) // 文本颜色为红色 46 } 47 }) 48 .key((item: string, index: number): string => item) // 键值生成函数 49 .virtualScroll({ totalCount: this.dataArr.length }) // 打开virtualScroll模式,totalCount为期望加载的数据长度 50 .templateId((item: string, index: number): string => { // 根据返回值寻找对应的模板子组件进行渲染 51 return index <= 4 ? 'A' : (index <= 10 ? 'B' : ''); // 前5个节点模板为A,接下来的5个为B,其余为默认模板 52 }) 53 .template('A', (ri: RepeatItem<string>) => { // 'A'模板 54 ListItem() { 55 Text('ttype_A_' + ri.item).fontSize(30).fontColor(Color.Green) // 文本颜色为绿色 56 } 57 }, { cachedCount: 3 }) // 'A'模板的缓存列表容量为3 58 .template('B', (ri: RepeatItem<string>) => { // 'B'模板 59 ListItem() { 60 Text('ttype_B_' + ri.item).fontSize(30).fontColor(Color.Blue) // 文本颜色为蓝色 61 } 62 }, { cachedCount: 4 }) // 'B'模板的缓存列表容量为4 63 } 64 .cachedCount(2) // 容器组件的预加载区域大小 65 .height('70%') 66 .border({ width: 1 }) // 边框 67 } 68 } 69} 70``` 71 72运行后界面如下图所示。 73 74 75 76## 使用限制 77 78- Repeat一般与容器组件配合使用,子组件应当是允许包含在容器组件中的子组件,例如,Repeat与[List](../../reference/apis-arkui/arkui-ts/ts-container-list.md)组件配合使用时,子组件必须为[ListItem](../../reference/apis-arkui/arkui-ts/ts-container-listitem.md)组件。 79- 当Repeat与自定义组件或[@Builder函数](./arkts-builder.md)混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递`RepeatItem.item`或`RepeatItem.index`,将会出现UI渲染异常。详细见[Repeat与@Builder混用的限制](#repeat与builder混用的限制)。 80 81Repeat virtualScroll模式使用限制: 82 83- 必须在滚动类容器组件内使用,仅有[List](../../reference/apis-arkui/arkui-ts/ts-container-list.md)、[Grid](../../reference/apis-arkui/arkui-ts/ts-container-grid.md)、[Swiper](../../reference/apis-arkui/arkui-ts/ts-container-swiper.md)以及[WaterFlow](../../reference/apis-arkui/arkui-ts/ts-container-waterflow.md)组件支持Repeat virtualScroll模式。 84- virtualScroll模式不支持V1装饰器,混用V1装饰器会导致渲染异常,不建议开发者同时使用。 85- 必须创建且只允许创建一个子组件,生成的子组件必须是允许包含在Repeat父容器组件中的子组件。 86- 滚动容器组件内只能包含一个Repeat。以List为例,同时包含ListItem、ForEach、LazyForEach的场景是不推荐的;同时包含多个Repeat也是不推荐的。 87- totalCount值大于数组长度时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。解决方案见[totalCount值大于数据源长度](#totalcount值大于数据源长度)。 88 89Repeat通过键值识别数组如何改变:增加了哪些数据、删除了哪些数据,以及哪些数据改变了位置(索引)。键值生成函数`.key()`的使用建议如下: 90 91- 即使数组发生变化,开发者也必须保证键值key唯一。 92- 每次执行`.key()`函数时,使用相同的数据项作为输入,输出必须是一致的。 93- 允许在`.key()`中使用index,但不建议开发者这样做。因为在数据项移动时索引index发生变化,key值因此改变,导致Repeat认为数据发生了变化,从而触发子组件重新渲染,降低性能表现。 94- 推荐将简单类型数组转换为类对象数组,并添加一个`readonly id`属性,在构造函数中给它赋一个唯一的值。 95 96从API version 18开始,不建议开发者使用`.key()`函数。如果开发者按照上面的建议使用`.key()`,那么Repeat仍能保持兼容性,不会有性能影响。 97 98> **说明:** 99> 100> Repeat子组件节点的操作分为四种:节点创建、节点更新、节点复用、节点销毁。其中,节点更新和节点复用的区别为: 101> 102> - **节点更新**:组件节点不下树,只有状态变量刷新。 103> - **节点复用**:旧的节点下树,但不会销毁,存储在空闲节点缓存池;新节点从缓存池中获取可复用的节点,重新上树。 104 105## non-virtualScroll模式 106 107### 键值生成规则 108 109`.key()`方法为每一项数据生成一个键值。请注意键值(key)与索引(index)的区别:键值是数据项的唯一标识符,Repeat根据键值是否发生变化判断数据项是否更新;索引则只标识数据项在数据源中的位置。 110 111`.key()`的逻辑如下图所示。 112 113当`.key()`缺省时,Repeat会生成新的随机键值。当发现有重复key时,Repeat会在已有键值的基础上递归生成新的键值,直到没有重复键值。 114 115 116 117### 子组件渲染逻辑 118 119在Repeat首次渲染时,子组件全部创建。数组发生改变后,Repeat的处理分为以下几个步骤: 120 121首先,遍历旧数组键值,如果新数组中没有该键值,将其加入键值集合deletedKeys。 122 123其次,遍历新数组键值,依次判断以下条件,符合条件时进行对应的操作: 124 1251. 若在旧数组中能找到相同键值,直接使用对应的子组件节点,并更新索引index; 1262. 若deletedKeys非空,按照先进后出的顺序,更新该集合中的键值所对应的节点; 1273. 若deletedKeys为空,则表示没有可以更新的节点,需要创建新节点。 128 129最后,如果新数组键值遍历结束后,deletedKeys非空,则销毁集合中的键值所对应的节点。 130 131 132 133以下图中的数组变化为例。 134 135 136 137根据上述判断逻辑,`item_0`没有变化,`item_1`和`item_2`只更新了索引,`item_n1`和`item_n2`分别由`item_4`和`item_3`进行节点更新获得,`item_n3`为新创建的节点。 138 139## virtualScroll模式 140 141### 键值生成规则 142 143`.key()`方法为每一项数据生成一个键值。请注意键值(key)与索引(index)的区别:键值是数据项的唯一标识符,Repeat根据键值是否发生变化判断数据项是否更新;索引则只标识数据项在数据源中的位置。 144 145当开发者未定义`.key()`时,Repeat会直接对比数组数据的变化来判断Repeat子节点是否改变(节点发生改变会触发页面刷新逻辑)。当存在重复key时,Repeat会重新生成随机key作为当前数据项的键值。需要注意,当每次刷新页面后,`.key()`会被重新计算(即再次出现重复key),进而生成新的随机key。随机key的构成:`___${index}_+_${key}_+_${Math.random()}`,其中的变量依次为:索引、旧键值、随机数。 146 147### 子组件渲染逻辑 148 149在Repeat首次渲染时,根据容器组件的有效加载范围(可视区域+预加载区域)创建当前需要的子组件。 150 151在容器滑动/数组改变时,将失效的子组件节点(离开有效加载范围)加入空闲节点缓存列表中(断开与组件树的关系,但不销毁),在需要生成新的组件时,对缓存里的组件进行复用(更新被复用子组件的变量值,重新上树)。从API version 18开始,Repeat支持[VirtualScroll模式缓存池自定义组件冻结](./arkts-custom-components-freezeV2.md#repeat-virtualscroll)。 152 153Repeat组件在virtualScroll模式下默认启用复用功能。从API version 18开始,可以通过配置`reusable`字段选择是否启用复用功能。为提高渲染性能,建议启用复用功能。代码示例见[VirtualScrollOptions对象说明](../../reference/apis-arkui/arkui-ts/ts-rendering-control-repeat.md#virtualscrolloptions对象说明)。 154 155通过典型的<!--Del-->[<!--DelEnd-->滑动场景<!--Del-->](#滑动场景)<!--DelEnd-->和<!--Del-->[<!--DelEnd-->数据更新场景<!--Del-->](#数据更新场景)<!--DelEnd-->示例来展示virtualScroll模式下子组件的渲染逻辑。定义长度为20的数组,数组前5项的template type为`aa`,其余项为`bb`。`aa`缓存池容量为3,`bb`缓存池容量为4。容器组件的预加载区域大小为2。为了便于理解,在`aa`和`bb`缓存池中分别加入一个和两个空闲节点。 156 157首次渲染,列表的节点状态如下图所示。 158 159 160 161#### 滑动场景 162 163将屏幕向右滑动(屏幕内容右移)一个节点的距离,Repeat将开始复用缓存池中的节点。index=10的节点进入有效加载范围,计算出其template type为`bb`。由于`bb`缓存池非空,Repeat会从`bb`缓存池中取出一个空闲节点进行复用,更新其节点属性,该子组件中涉及数据item和索引index的其他孙子组件会根据V2状态管理的规则做同步更新。其他节点仍在有效加载范围,均只更新索引index。 164 165index=0的节点滑出了有效加载范围。当UI主线程空闲时,会检查`aa`缓存池是否已满,此时`aa`缓存池未满,将该节点加入到对应的缓存池中。 166 167如果此时对应template type的缓存池已满,Repeat会销毁掉多余的节点。 168 169 170 171#### 数据更新场景 172 173在上一小节的基础上做如下的数组更新操作,删除index=4的节点,修改节点数据`item_7`为`new_7`。 174 175首先,删除index=4的节点后,失效节点加入`aa`缓存池。后面的列表节点前移,新进入有效加载区域的节点`item_11`会复用`bb`缓存池中的空闲节点,其他节点均只更新索引index。如下图所示。 176 177 178 179其次,节点`item_5`前移,索引index更新为4。根据template type的计算规则,节点`item_5`的template type变为`aa`,需要从`aa`缓存池中复用空闲节点,并且将旧节点加入`bb`缓存池。如下图所示。 180 181 182 183### template:子组件渲染模板 184 185template模板目前只支持在virtualScroll模式下使用。 186 187- 每个节点会根据`.templateId()`得到template type,从而渲染对应的`.template()`中的子组件。 188- 当多个template type相同时,Repeat会覆盖先定义的`.template()`函数,仅生效最后定义的`.template()`。 189- 如果找不到对应的template type,Repeat会优先渲染type为空的`.template()`中的子组件,如果没有,则渲染`.each()`中的子组件。 190 191### cachedCount:空闲节点缓存列表大小 192 193cachedCount是相应的template type的缓存池中可缓存子组件节点的最大数量,仅在virtualScroll模式下生效。 194 195> **说明:** 196> 197> 滚动容器组件属性`.cachedCount()`和Repeat组件属性`.template()`的参数`cachedCount`都是为了平衡性能和内存,但是含义是不同的。 198> - 滚动类容器组件`.cachedCount()`:是指在可见范围外预加载的节点,这些节点会位于组件树上,但不是可见范围内,List/Grid等容器组件会额外渲染这些可见范围外的节点,从而达到其性能收益。Repeat会将这些节点视为“可见”的。 199> - `.template()`中的`cachedCount`: 是指Repeat视为“不可见”的节点,这些节点是空闲的,框架会暂时保存,在需要使用的时候更新这些节点,从而实现复用。 200 201将cachedCount设置为当前模板的节点在屏上可能出现的最大数量时,Repeat可以做到尽可能多的复用。但后果是当屏上没有当前模板的节点时,缓存池也不会释放,应用内存会增大。需要开发者根据具体情况自行把控。 202 203- cachedCount缺省时,框架会分别对不同template,根据屏上节点+预加载的节点个数来计算cachedCount。当屏上节点+预加载的节点个数变多时,cachedCount也会对应增长。需要注意cachedCount数量不会减少。 204- 显式指定cachedCount,推荐设置成和屏幕上节点个数一致。需要注意,不推荐设置cachedCount小于2,因为这会导致在快速滑动场景下创建新的节点,从而导致性能劣化。 205 206### totalCount:期望加载的数据长度 207 208totalCount表示期望加载的数据长度,默认为原数组长度,可以大于已加载数据项的数量。令arr.length表示数据源长度,以下为totalCount的处理规则: 209 210- totalCount缺省/非自然数时,totalCount默认为arr.length,列表正常滚动。 211- 0 <= totalCount < arr.length时,界面中只渲染区间[0, totalCount - 1]范围内的数据。 212- totalCount > arr.length时,代表Repeat将渲染区间[0, totalCount - 1]范围内的数据,滚动条样式根据totalCount值变化。 213 214> **注意:** 215> 216> 当totalCount > arr.length时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,开发者需要对数据请求的错误场景(如网络延迟)进行保护操作,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。 217 218### onTotalCount:计算期望的数据长度 219 220onTotalCount?(): number; 221 222onTotalCount从API version 18开始支持,需在virtualScroll模式下使用。开发者可设置自定义方法,用于计算期望的数组长度。其返回值应当为自然数,可以不等于实际数据源长度arr.length。以下为onTotalCount的处理规则: 223 224- onTotalCount返回值为非自然数时,使用arr.length代替返回值,列表正常滚动。 225- 0 <= onTotalCount返回值 < arr.length时,界面中只渲染区间[0, onTotalCount返回值 - 1]范围内的数据。 226- onTotalCount返回值 > arr.length时,代表Repeat将渲染区间[0, onTotalCount返回值 - 1]范围内的数据,滚动条样式根据onTotalCount返回值变化。 227 228> **注意:** 229> 230> - 相较于totalCount,Repeat可在需要时主动调用onTotalCount方法,更新期望数据长度。 231> - totalCount与onTotalCount至多设置一个。均未设置,则采用默认值arr.length;同时设置,则忽略totalCount。 232> - 当onTotalCount返回值 > arr.length时,建议配合使用onLazyLoading实现数据懒加载。 233 234### onLazyLoading:数据精准懒加载 235 236onLazyLoading?(index: number): void; 237 238onLazyLoading从API version 18开始支持,需在virtualScroll模式下使用。开发者可设置自定义方法,用于向指定的数据源index中写入数据。以下为onLazyLoading的处理规则: 239 240- Repeat读取数据源中某一index处对应数据前,会先检查此index处是否存在数据。 241- 若不存在数据,且开发者提供了onLazyLoading方法,则Repeat将调用此方法。 242- 在onLazyLoading方法中,开发者需要向Repeat指定的index中写入数据,方式如下:`arr[index] = ...`。在onLazyLoading方法中,不允许使用除`[]`以外的数组操作,且不允许写入指定index以外的元素,否则系统将抛出异常。 243- onLazyLoading方法执行完成后,若指定index中仍无数据,将导致渲染异常。 244 245> **注意:** 246> 247> - 当使用onLazyLoading时,建议与onTotalCount配合使用,而非totalCount。 248> - 若期望数据源长度大于实际数据源长度,推荐使用onLazyLoading。 249> - onLazyLoading方法中应避免高耗时操作。若数据加载耗时较长,可在onLazyLoading方法中先为此数据创建占位符,再创建异步任务加载数据。 250> - 当使用onLazyLoading,并设置onTotalCount为`arr.length + 1`时,可实现数据的无限加载。需要注意,在此场景下,开发者需要提供首屏显示所需的初始数据,并建议设置父容器组件`cachedCount > 0`,否则将会导致渲染异常。若与Swiper-Loop模式同时使用,停留在`index = 0`处时将导致onLazyLoading方法被持续触发,建议避免与Swiper-Loop模式同时使用。此外,开发者需要关注内存消耗情况,避免因数据持续加载而导致内存过量消耗。 251 252## 使用场景 253 254### non-virtualScroll数据展示&操作 255 256#### 数据源变化 257 258```ts 259@Entry 260@ComponentV2 261struct Parent { 262 @Local simpleList: Array<string> = ['one', 'two', 'three']; 263 264 build() { 265 Row() { 266 Column() { 267 Text('点击修改第3个数组项的值') 268 .fontSize(24) 269 .fontColor(Color.Red) 270 .onClick(() => { 271 this.simpleList[2] = 'new three'; 272 }) 273 274 Repeat<string>(this.simpleList) 275 .each((obj: RepeatItem<string>)=>{ 276 ChildItem({ item: obj.item }) 277 .margin({top: 20}) 278 }) 279 .key((item: string) => item) 280 } 281 .justifyContent(FlexAlign.Center) 282 .width('100%') 283 .height('100%') 284 } 285 .height('100%') 286 .backgroundColor(0xF1F3F5) 287 } 288} 289 290@ComponentV2 291struct ChildItem { 292 @Param @Require item: string; 293 294 build() { 295 Text(this.item) 296 .fontSize(30) 297 } 298} 299``` 300 301 302 303第三个数组项重新渲染时会复用之前的第三项的组件,仅对数据做了刷新。 304 305#### 索引值变化 306 307下方例子当交换数组项1和2时,若键值和上次保持一致,Repeat会复用之前的组件,仅对使用了index索引值的组件做数据刷新。 308 309```ts 310@Entry 311@ComponentV2 312struct Parent { 313 @Local simpleList: Array<string> = ['one', 'two', 'three']; 314 315 build() { 316 Row() { 317 Column() { 318 Text('交换数组项1,2') 319 .fontSize(24) 320 .fontColor(Color.Red) 321 .onClick(() => { 322 let temp: string = this.simpleList[2]; 323 this.simpleList[2] = this.simpleList[1]; 324 this.simpleList[1] = temp; 325 }) 326 .margin({bottom: 20}) 327 328 Repeat<string>(this.simpleList) 329 .each((obj: RepeatItem<string>)=>{ 330 Text("index: " + obj.index) 331 .fontSize(30) 332 ChildItem({ item: obj.item }) 333 .margin({bottom: 20}) 334 }) 335 .key((item: string) => item) 336 } 337 .justifyContent(FlexAlign.Center) 338 .width('100%') 339 .height('100%') 340 } 341 .height('100%') 342 .backgroundColor(0xF1F3F5) 343 } 344} 345 346@ComponentV2 347struct ChildItem { 348 @Param @Require item: string; 349 350 build() { 351 Text(this.item) 352 .fontSize(30) 353 } 354} 355``` 356 357 358 359### virtualScroll数据展示&操作 360 361本小节将展示virtualScroll模式下,Repeat的实际使用场景和组件节点的复用情况。根据复用规则可以衍生出大量的测试场景,篇幅原因,只对典型的数据变化进行解释。 362 363#### 一个template 364 365下面的代码示例展示了Repeat virtualScroll模式下修改数组的常见操作,包括**插入数据、修改数据、删除数据、交换数据**。点击下拉框选择索引index值,点击相应的按钮即可进行数据修改操作。依次点击数据项可以交换被点击的两个数据项。 366 367```ts 368@ObservedV2 369class Repeat005Clazz { 370 @Trace message: string = ''; 371 372 constructor(message: string) { 373 this.message = message; 374 } 375} 376 377@Entry 378@ComponentV2 379struct RepeatVirtualScroll { 380 @Local simpleList: Array<Repeat005Clazz> = []; 381 private exchange: number[] = []; 382 private counter: number = 0; 383 @Local selectOptions: SelectOption[] = []; 384 @Local selectIdx: number = 0; 385 386 @Monitor("simpleList") 387 reloadSelectOptions(): void { 388 this.selectOptions = []; 389 for (let i = 0; i < this.simpleList.length; ++i) { 390 this.selectOptions.push({ value: i.toString() }); 391 } 392 if (this.selectIdx >= this.simpleList.length) { 393 this.selectIdx = this.simpleList.length - 1; 394 } 395 } 396 397 aboutToAppear(): void { 398 for (let i = 0; i < 100; i++) { 399 this.simpleList.push(new Repeat005Clazz(`item_${i}`)); 400 } 401 this.reloadSelectOptions(); 402 } 403 404 handleExchange(idx: number): void { // 点击交换子组件 405 this.exchange.push(idx); 406 if (this.exchange.length === 2) { 407 let _a = this.exchange[0]; 408 let _b = this.exchange[1]; 409 let temp: Repeat005Clazz = this.simpleList[_a]; 410 this.simpleList[_a] = this.simpleList[_b]; 411 this.simpleList[_b] = temp; 412 this.exchange = []; 413 } 414 } 415 416 build() { 417 Column({ space: 10 }) { 418 Text('virtualScroll each()&template() 1t') 419 .fontSize(15) 420 .fontColor(Color.Gray) 421 Text('Select an index and press the button to update data.') 422 .fontSize(15) 423 .fontColor(Color.Gray) 424 425 Select(this.selectOptions) 426 .selected(this.selectIdx) 427 .value(this.selectIdx.toString()) 428 .key('selectIdx') 429 .onSelect((index: number) => { 430 this.selectIdx = index; 431 }) 432 Row({ space: 5 }) { 433 Button('Add No.' + this.selectIdx) 434 .onClick(() => { 435 this.simpleList.splice(this.selectIdx, 0, new Repeat005Clazz(`${this.counter++}_add_item`)); 436 this.reloadSelectOptions(); 437 }) 438 Button('Modify No.' + this.selectIdx) 439 .onClick(() => { 440 this.simpleList.splice(this.selectIdx, 1, new Repeat005Clazz(`${this.counter++}_modify_item`)); 441 }) 442 Button('Del No.' + this.selectIdx) 443 .onClick(() => { 444 this.simpleList.splice(this.selectIdx, 1); 445 this.reloadSelectOptions(); 446 }) 447 } 448 Button('Update array length to 5.') 449 .onClick(() => { 450 this.simpleList = this.simpleList.slice(0, 5); 451 this.reloadSelectOptions(); 452 }) 453 454 Text('Click on two items to exchange.') 455 .fontSize(15) 456 .fontColor(Color.Gray) 457 458 List({ space: 10 }) { 459 Repeat<Repeat005Clazz>(this.simpleList) 460 .each((obj: RepeatItem<Repeat005Clazz>) => { 461 ListItem() { 462 Text(`[each] index${obj.index}: ${obj.item.message}`) 463 .fontSize(25) 464 .onClick(() => { 465 this.handleExchange(obj.index); 466 }) 467 } 468 }) 469 .key((item: Repeat005Clazz, index: number) => { 470 return item.message; 471 }) 472 .virtualScroll({ totalCount: this.simpleList.length }) 473 .templateId(() => "a") 474 .template('a', (ri) => { 475 Text(`[a] index${ri.index}: ${ri.item.message}`) 476 .fontSize(25) 477 .onClick(() => { 478 this.handleExchange(ri.index); 479 }) 480 }, { cachedCount: 3 }) 481 } 482 .cachedCount(2) 483 .border({ width: 1 }) 484 .width('95%') 485 .height('40%') 486 } 487 .justifyContent(FlexAlign.Center) 488 .width('100%') 489 .height('100%') 490 } 491} 492``` 493该应用列表内容为100项自定义类`RepeatClazz`的`message`字符串属性,List组件的cachedCount设为2,模板'a'的缓存池大小设为3。应用界面如下图所示: 494 495 496 497#### 多个template 498 499```ts 500@ObservedV2 501class Repeat006Clazz { 502 @Trace message: string = ''; 503 504 constructor(message: string) { 505 this.message = message; 506 } 507} 508 509@Entry 510@ComponentV2 511struct RepeatVirtualScroll2T { 512 @Local simpleList: Array<Repeat006Clazz> = []; 513 private exchange: number[] = []; 514 private counter: number = 0; 515 @Local selectOptions: SelectOption[] = []; 516 @Local selectIdx: number = 0; 517 518 @Monitor("simpleList") 519 reloadSelectOptions(): void { 520 this.selectOptions = []; 521 for (let i = 0; i < this.simpleList.length; ++i) { 522 this.selectOptions.push({ value: i.toString() }); 523 } 524 if (this.selectIdx >= this.simpleList.length) { 525 this.selectIdx = this.simpleList.length - 1; 526 } 527 } 528 529 aboutToAppear(): void { 530 for (let i = 0; i < 100; i++) { 531 this.simpleList.push(new Repeat006Clazz(`item_${i}`)); 532 } 533 this.reloadSelectOptions(); 534 } 535 536 handleExchange(idx: number): void { // 点击交换子组件 537 this.exchange.push(idx); 538 if (this.exchange.length === 2) { 539 let _a = this.exchange[0]; 540 let _b = this.exchange[1]; 541 let temp: Repeat006Clazz = this.simpleList[_a]; 542 this.simpleList[_a] = this.simpleList[_b]; 543 this.simpleList[_b] = temp; 544 this.exchange = []; 545 } 546 } 547 548 build() { 549 Column({ space: 10 }) { 550 Text('virtualScroll each()&template() 2t') 551 .fontSize(15) 552 .fontColor(Color.Gray) 553 Text('Select an index and press the button to update data.') 554 .fontSize(15) 555 .fontColor(Color.Gray) 556 557 Select(this.selectOptions) 558 .selected(this.selectIdx) 559 .value(this.selectIdx.toString()) 560 .key('selectIdx') 561 .onSelect((index: number) => { 562 this.selectIdx = index; 563 }) 564 Row({ space: 5 }) { 565 Button('Add No.' + this.selectIdx) 566 .onClick(() => { 567 this.simpleList.splice(this.selectIdx, 0, new Repeat006Clazz(`${this.counter++}_add_item`)); 568 this.reloadSelectOptions(); 569 }) 570 Button('Modify No.' + this.selectIdx) 571 .onClick(() => { 572 this.simpleList.splice(this.selectIdx, 1, new Repeat006Clazz(`${this.counter++}_modify_item`)); 573 }) 574 Button('Del No.' + this.selectIdx) 575 .onClick(() => { 576 this.simpleList.splice(this.selectIdx, 1); 577 this.reloadSelectOptions(); 578 }) 579 } 580 Button('Update array length to 5.') 581 .onClick(() => { 582 this.simpleList = this.simpleList.slice(0, 5); 583 this.reloadSelectOptions(); 584 }) 585 586 Text('Click on two items to exchange.') 587 .fontSize(15) 588 .fontColor(Color.Gray) 589 590 List({ space: 10 }) { 591 Repeat<Repeat006Clazz>(this.simpleList) 592 .each((obj: RepeatItem<Repeat006Clazz>) => { 593 ListItem() { 594 Text(`[each] index${obj.index}: ${obj.item.message}`) 595 .fontSize(25) 596 .onClick(() => { 597 this.handleExchange(obj.index); 598 }) 599 } 600 }) 601 .key((item: Repeat006Clazz, index: number) => { 602 return item.message; 603 }) 604 .virtualScroll({ totalCount: this.simpleList.length }) 605 .templateId((item: Repeat006Clazz, index: number) => { 606 return (index % 2 === 0) ? 'odd' : 'even'; 607 }) 608 .template('odd', (ri) => { 609 Text(`[odd] index${ri.index}: ${ri.item.message}`) 610 .fontSize(25) 611 .fontColor(Color.Blue) 612 .onClick(() => { 613 this.handleExchange(ri.index); 614 }) 615 }, { cachedCount: 3 }) 616 .template('even', (ri) => { 617 Text(`[even] index${ri.index}: ${ri.item.message}`) 618 .fontSize(25) 619 .fontColor(Color.Green) 620 .onClick(() => { 621 this.handleExchange(ri.index); 622 }) 623 }, { cachedCount: 1 }) 624 } 625 .cachedCount(2) 626 .border({ width: 1 }) 627 .width('95%') 628 .height('40%') 629 } 630 .justifyContent(FlexAlign.Center) 631 .width('100%') 632 .height('100%') 633 } 634} 635``` 636 637 638 639#### 数据精准懒加载 640 641当数据源总长度较长,或数据项加载耗时较长时,可使用数据懒加载功能,避免在初始化时加载所有数据。 642 643**示例一** 644 645数据源总长度较长,在首次渲染、滑动屏幕、跳转显示区域时,动态加载对应区域内的数据。 646 647```ts 648@Entry 649@ComponentV2 650struct RepeatLazyLoading { 651 // 假设数据源总长度较长,为1000。初始数组未提供数据。 652 @Local arr: Array<string> = []; 653 scroller: Scroller = new Scroller(); 654 build() { 655 Column({ space: 5 }) { 656 // 初始显示位置为index = 100, 数据可通过懒加载自动获取。 657 List({ scroller: this.scroller, space: 5, initialIndex: 100 }) { 658 Repeat(this.arr) 659 .virtualScroll({ 660 // 期望的数据源总长度为1000。 661 onTotalCount: () => { return 1000; }, 662 // 实现数据懒加载。 663 onLazyLoading: (index: number) => { this.arr[index] = index.toString(); } 664 }) 665 .each((obj: RepeatItem<string>) => { 666 ListItem() { 667 Row({ space: 5 }) { 668 Text(`${obj.index}: Item_${obj.item}`) 669 } 670 } 671 .height(50) 672 }) 673 } 674 .height('80%') 675 .border({ width: 1}) 676 // 显示位置跳转至index = 500, 数据可通过懒加载自动获取。 677 Button('ScrollToIndex 500') 678 .onClick(() => { this.scroller.scrollToIndex(500); }) 679 } 680 } 681} 682``` 683 684运行效果: 685 686 687 688**示例二** 689 690数据加载耗时长,在onLazyLoading方法中,首先为数据项创建占位符,再通过异步任务加载数据。 691 692```ts 693@Entry 694@ComponentV2 695struct RepeatLazyLoading { 696 @Local arr: Array<string> = []; 697 build() { 698 Column({ space: 5 }) { 699 List({ space: 5 }) { 700 Repeat(this.arr) 701 .virtualScroll({ 702 onTotalCount: () => { return 100; }, 703 // 实现数据懒加载。 704 onLazyLoading: (index: number) => { 705 // 创建占位符。 706 this.arr[index] = ''; 707 // 模拟高耗时加载过程,通过异步任务加载数据。 708 setTimeout(() => { this.arr[index] = index.toString(); }, 1000); 709 } 710 }) 711 .each((obj: RepeatItem<string>) => { 712 ListItem() { 713 Row({ space: 5 }) { 714 Text(`${obj.index}: Item_${obj.item}`) 715 } 716 } 717 .height(50) 718 }) 719 } 720 .height('100%') 721 .border({ width: 1}) 722 } 723 } 724} 725``` 726 727运行效果: 728 729 730 731**示例三** 732 733使用数据懒加载,并配合设置`onTotalCount: () => { return this.arr.length + 1; }`,可实现数据无限懒加载。 734 735> **注意:** 736> 737> - 此场景下,开发者需要提供首屏显示所需的初始数据,并建议设置父容器组件`cachedCount > 0`,否则将会导致渲染异常。 738> - 若与Swiper-Loop模式同时使用,停留在`index = 0`处时将导致onLazyLoading方法被持续触发,建议避免与Swiper-Loop模式同时使用。 739> - 开发者需要关注内存消耗情况,避免因数据持续加载而导致内存过量消耗。 740 741```ts 742@Entry 743@ComponentV2 744struct RepeatLazyLoading { 745 @Local arr: Array<string> = []; 746 // 提供首屏显示所需的初始数据。 747 aboutToAppear(): void { 748 for (let i = 0; i < 15; i++) { 749 this.arr.push(i.toString()); 750 } 751 } 752 build() { 753 Column({ space: 5 }) { 754 List({ space: 5 }) { 755 Repeat(this.arr) 756 .virtualScroll({ 757 // 数据无限懒加载。 758 onTotalCount: () => { return this.arr.length + 1; }, 759 onLazyLoading: (index: number) => { this.arr[index] = index.toString(); } 760 }) 761 .each((obj: RepeatItem<string>) => { 762 ListItem() { 763 Row({ space: 5 }) { 764 Text(`${obj.index}: Item_${obj.item}`) 765 } 766 } 767 .height(50) 768 }) 769 } 770 .height('100%') 771 .border({ width: 1}) 772 // 建议设置cachedCount > 0 773 .cachedCount(1) 774 } 775 } 776} 777``` 778 779运行效果: 780 781 782 783### Repeat嵌套 784 785Repeat支持嵌套使用。下面是使用virtualScroll模式进行嵌套的示例代码: 786 787```ts 788// Repeat嵌套 789@Entry 790@ComponentV2 791struct RepeatNest { 792 @Local outerList: string[] = []; 793 @Local innerList: number[] = []; 794 795 aboutToAppear(): void { 796 for (let i = 0; i < 20; i++) { 797 this.outerList.push(i.toString()); 798 this.innerList.push(i); 799 } 800 } 801 802 build() { 803 Column({ space: 20 }) { 804 Text('Repeat virtualScroll嵌套') 805 .fontSize(15) 806 .fontColor(Color.Gray) 807 List() { 808 Repeat<string>(this.outerList) 809 .each((obj) => { 810 ListItem() { 811 Column() { 812 Text('outerList item: ' + obj.item) 813 .fontSize(30) 814 List() { 815 Repeat<number>(this.innerList) 816 .each((subObj) => { 817 ListItem() { 818 Text('innerList item: ' + subObj.item) 819 .fontSize(20) 820 } 821 }) 822 .key((item) => "innerList_" + item) 823 .virtualScroll() 824 } 825 .width('80%') 826 .border({ width: 1 }) 827 .backgroundColor(Color.Orange) 828 } 829 .height('30%') 830 .backgroundColor(Color.Pink) 831 } 832 .border({ width: 1 }) 833 }) 834 .key((item) => "outerList_" + item) 835 .virtualScroll() 836 } 837 .width('80%') 838 .border({ width: 1 }) 839 } 840 .justifyContent(FlexAlign.Center) 841 .width('90%') 842 .height('80%') 843 } 844} 845``` 846 847运行效果: 848 849 850 851### 父容器组件应用场景 852 853本节展示Repeat virtualScroll模式与容器组件的常见应用场景。 854 855#### 与List组合使用 856 857在List容器组件中使用Repeat的virtualScroll模式,示例如下: 858 859```ts 860class DemoListItemInfo { 861 name: string; 862 icon: Resource; 863 864 constructor(name: string, icon: Resource) { 865 this.name = name; 866 this.icon = icon; 867 } 868} 869 870@Entry 871@ComponentV2 872struct DemoList { 873 @Local videoList: Array<DemoListItemInfo> = []; 874 875 aboutToAppear(): void { 876 for (let i = 0; i < 10; i++) { 877 // 此处app.media.listItem0、app.media.listItem1、app.media.listItem2仅作示例,请开发者自行替换 878 this.videoList.push(new DemoListItemInfo('视频' + i, 879 i % 3 == 0 ? $r("app.media.listItem0") : 880 i % 3 == 1 ? $r("app.media.listItem1") : $r("app.media.listItem2"))); 881 } 882 } 883 884 @Builder 885 itemEnd(index: number) { 886 Button('删除') 887 .backgroundColor(Color.Red) 888 .onClick(() => { 889 this.videoList.splice(index, 1); 890 }) 891 } 892 893 build() { 894 Column({ space: 10 }) { 895 Text('List容器组件中包含Repeat组件') 896 .fontSize(15) 897 .fontColor(Color.Gray) 898 899 List({ space: 5 }) { 900 Repeat<DemoListItemInfo>(this.videoList) 901 .each((obj: RepeatItem<DemoListItemInfo>) => { 902 ListItem() { 903 Column() { 904 Image(obj.item.icon) 905 .width('80%') 906 .margin(10) 907 Text(obj.item.name) 908 .fontSize(20) 909 } 910 } 911 .swipeAction({ 912 end: { 913 builder: () => { 914 this.itemEnd(obj.index); 915 } 916 } 917 }) 918 .onAppear(() => { 919 console.info('AceTag', obj.item.name); 920 }) 921 }) 922 .key((item: DemoListItemInfo) => item.name) 923 .virtualScroll() 924 } 925 .cachedCount(2) 926 .height('90%') 927 .border({ width: 1 }) 928 .listDirection(Axis.Vertical) 929 .alignListItem(ListItemAlign.Center) 930 .divider({ 931 strokeWidth: 1, 932 startMargin: 60, 933 endMargin: 60, 934 color: '#ffe9f0f0' 935 }) 936 937 Row({ space: 10 }) { 938 Button('删除第1项') 939 .onClick(() => { 940 this.videoList.splice(0, 1); 941 }) 942 Button('删除第5项') 943 .onClick(() => { 944 this.videoList.splice(4, 1); 945 }) 946 } 947 } 948 .width('100%') 949 .height('100%') 950 .justifyContent(FlexAlign.Center) 951 } 952} 953``` 954 955右滑并点击按钮,或点击底部按钮,可删除视频卡片: 956 957 958 959#### 与Grid组合使用 960 961在Grid容器组件中使用Repeat的virtualScroll模式,示例如下: 962 963```ts 964class DemoGridItemInfo { 965 name: string; 966 icon: Resource; 967 968 constructor(name: string, icon: Resource) { 969 this.name = name; 970 this.icon = icon; 971 } 972} 973 974@Entry 975@ComponentV2 976struct DemoGrid { 977 @Local itemList: Array<DemoGridItemInfo> = []; 978 @Local isRefreshing: boolean = false; 979 private layoutOptions: GridLayoutOptions = { 980 regularSize: [1, 1], 981 irregularIndexes: [10] 982 } 983 private GridScroller: Scroller = new Scroller(); 984 private num: number = 0; 985 986 aboutToAppear(): void { 987 for (let i = 0; i < 10; i++) { 988 // 此处app.media.gridItem0、app.media.gridItem1、app.media.gridItem2仅作示例,请开发者自行替换 989 this.itemList.push(new DemoGridItemInfo('视频' + i, 990 i % 3 == 0 ? $r("app.media.gridItem0") : 991 i % 3 == 1 ? $r("app.media.gridItem1") : $r("app.media.gridItem2"))); 992 } 993 } 994 995 build() { 996 Column({ space: 10 }) { 997 Text('Grid容器组件中包含Repeat组件') 998 .fontSize(15) 999 .fontColor(Color.Gray) 1000 1001 Refresh({ refreshing: $$this.isRefreshing }) { 1002 Grid(this.GridScroller, this.layoutOptions) { 1003 Repeat<DemoGridItemInfo>(this.itemList) 1004 .each((obj: RepeatItem<DemoGridItemInfo>) => { 1005 if (obj.index === 10 ) { 1006 GridItem() { 1007 Text('先前浏览至此,点击刷新') 1008 .fontSize(20) 1009 } 1010 .height(30) 1011 .border({ width: 1 }) 1012 .onClick(() => { 1013 this.GridScroller.scrollToIndex(0); 1014 this.isRefreshing = true; 1015 }) 1016 .onAppear(() => { 1017 console.info('AceTag', obj.item.name); 1018 }) 1019 } else { 1020 GridItem() { 1021 Column() { 1022 Image(obj.item.icon) 1023 .width('100%') 1024 .height(80) 1025 .objectFit(ImageFit.Cover) 1026 .borderRadius({ topLeft: 16, topRight: 16 }) 1027 Text(obj.item.name) 1028 .fontSize(15) 1029 .height(20) 1030 } 1031 } 1032 .height(100) 1033 .borderRadius(16) 1034 .backgroundColor(Color.White) 1035 .onAppear(() => { 1036 console.info('AceTag', obj.item.name); 1037 }) 1038 } 1039 }) 1040 .key((item: DemoGridItemInfo) => item.name) 1041 .virtualScroll() 1042 } 1043 .columnsTemplate('repeat(auto-fit, 150)') 1044 .cachedCount(4) 1045 .rowsGap(15) 1046 .columnsGap(10) 1047 .height('100%') 1048 .padding(10) 1049 .backgroundColor('#F1F3F5') 1050 } 1051 .onRefreshing(() => { 1052 setTimeout(() => { 1053 this.itemList.splice(10, 1); 1054 this.itemList.unshift(new DemoGridItemInfo('refresh', $r('app.media.gridItem0'))); // 此处app.media.gridItem0仅作示例,请开发者自行替换 1055 for (let i = 0; i < 10; i++) { 1056 // 此处app.media.gridItem0、app.media.gridItem1、app.media.gridItem2仅作示例,请开发者自行替换 1057 this.itemList.unshift(new DemoGridItemInfo('新视频' + this.num, 1058 i % 3 == 0 ? $r("app.media.gridItem0") : 1059 i % 3 == 1 ? $r("app.media.gridItem1") : $r("app.media.gridItem2"))); 1060 this.num++; 1061 } 1062 this.isRefreshing = false; 1063 }, 1000); 1064 console.info('AceTag', 'onRefreshing'); 1065 }) 1066 .refreshOffset(64) 1067 .pullToRefresh(true) 1068 .width('100%') 1069 .height('85%') 1070 1071 Button('刷新') 1072 .onClick(() => { 1073 this.GridScroller.scrollToIndex(0); 1074 this.isRefreshing = true; 1075 }) 1076 } 1077 .width('100%') 1078 .height('100%') 1079 .justifyContent(FlexAlign.Center) 1080 } 1081} 1082``` 1083 1084下拉屏幕,或点击刷新按钮,或点击“先前浏览至此,点击刷新”,可加载新的视频内容: 1085 1086 1087 1088#### 与Swiper组合使用 1089 1090在Swiper容器组件中使用Repeat的virtualScroll模式,示例如下: 1091 1092```ts 1093const remotePictures: Array<string> = [ 1094 'https://www.example.com/xxx/0001.jpg', // 请填写具体的网络图片地址 1095 'https://www.example.com/xxx/0002.jpg', 1096 'https://www.example.com/xxx/0003.jpg', 1097 'https://www.example.com/xxx/0004.jpg', 1098 'https://www.example.com/xxx/0005.jpg', 1099 'https://www.example.com/xxx/0006.jpg', 1100 'https://www.example.com/xxx/0007.jpg', 1101 'https://www.example.com/xxx/0008.jpg', 1102 'https://www.example.com/xxx/0009.jpg' 1103]; 1104 1105@ObservedV2 1106class DemoSwiperItemInfo { 1107 id: string; 1108 @Trace url: string = 'default'; 1109 1110 constructor(id: string) { 1111 this.id = id; 1112 } 1113} 1114 1115@Entry 1116@ComponentV2 1117struct DemoSwiper { 1118 @Local pics: Array<DemoSwiperItemInfo> = []; 1119 1120 aboutToAppear(): void { 1121 for (let i = 0; i < 9; i++) { 1122 this.pics.push(new DemoSwiperItemInfo('pic' + i)); 1123 } 1124 setTimeout(() => { 1125 this.pics[0].url = remotePictures[0]; 1126 }, 1000); 1127 } 1128 1129 build() { 1130 Column() { 1131 Text('Swiper容器组件中包含Repeat组件') 1132 .fontSize(15) 1133 .fontColor(Color.Gray) 1134 1135 Stack() { 1136 Text('图片加载中') 1137 .fontSize(15) 1138 .fontColor(Color.Gray) 1139 Swiper() { 1140 Repeat(this.pics) 1141 .each((obj: RepeatItem<DemoSwiperItemInfo>) => { 1142 Image(obj.item.url) 1143 .onAppear(() => { 1144 console.info('AceTag', obj.item.id); 1145 }) 1146 }) 1147 .key((item: DemoSwiperItemInfo) => item.id) 1148 .virtualScroll() 1149 } 1150 .cachedCount(9) 1151 .height('50%') 1152 .loop(false) 1153 .indicator(true) 1154 .onChange((index) => { 1155 setTimeout(() => { 1156 this.pics[index].url = remotePictures[index]; 1157 }, 1000); 1158 }) 1159 } 1160 .width('100%') 1161 .height('100%') 1162 .backgroundColor(Color.Black) 1163 } 1164 } 1165} 1166``` 1167 1168定时1秒后加载图片,模拟网络延迟: 1169 1170 1171 1172### 拖拽排序 1173 1174当Repeat在List组件下使用,并且设置了onMove事件,Repeat每次迭代都生成一个ListItem时,可以使能拖拽排序。non-virtualScroll模式和virtualScroll模式都支持设置拖拽排序。 1175 1176#### 使用限制 1177- 拖拽排序离手后,如果数据位置发生变化,则会触发onMove事件,上报数据移动原始索引号和目标索引号。在onMove事件中,需要根据上报的起始索引号和目标索引号修改数据源。数据源修改前后,要保持每个数据的键值不变,只是顺序发生变化,才能保证落位动画正常执行。 1178- 拖拽排序过程中,在离手之前,不允许修改数据源。 1179 1180#### 示例代码 1181```ts 1182@Entry 1183@ComponentV2 1184struct RepeatVirtualScrollOnMove { 1185 @Local simpleList: Array<string> = []; 1186 1187 aboutToAppear(): void { 1188 for (let i = 0; i < 100; i++) { 1189 this.simpleList.push(`${i}`); 1190 } 1191 } 1192 1193 build() { 1194 Column() { 1195 List() { 1196 Repeat<string>(this.simpleList) 1197 // 通过设置onMove,使能拖拽排序。 1198 .onMove((from: number, to: number) => { 1199 let temp = this.simpleList.splice(from, 1); 1200 this.simpleList.splice(to, 0, temp[0]); 1201 }) 1202 .each((obj: RepeatItem<string>) => { 1203 ListItem() { 1204 Text(obj.item) 1205 .fontSize(16) 1206 .textAlign(TextAlign.Center) 1207 .size({height: 100, width: "100%"}) 1208 }.margin(10) 1209 .borderRadius(10) 1210 .backgroundColor("#FFFFFFFF") 1211 }) 1212 .key((item: string, index: number) => { 1213 return item; 1214 }) 1215 .virtualScroll({ totalCount: this.simpleList.length }) 1216 } 1217 .border({ width: 1 }) 1218 .backgroundColor("#FFDCDCDC") 1219 .width('100%') 1220 .height('100%') 1221 } 1222 } 1223} 1224``` 1225 1226运行效果: 1227 1228 1229 1230### 通过`.key()`函数控制列表节点刷新范围 1231 1232从API version 18开始,开发者自定义`.key()`函数时,Repeat子节点会根据key的变化来判断节点是否需要更新。修改数组后,1)如果key发生改变,则页面立即刷新,数据更新为修改后的值;2)key没有改变,页面不会立即刷新。 1233 1234前置条件:使用Repeat virtualScroll模式渲染数组,数据项为被`@ObservedV2`装饰的类,两个类属性均被`@Trace`装饰。其中`msg`属性值作为列表渲染的节点内容,点击“click”按钮修改列表第一个节点的内容。 1235 1236第一种预期场景:当列表节点数据的属性值发生改变时,触发页面刷新,第一个列表节点数据更新为修改后的值。 1237 1238这种场景有两种实现方式:1)定义`.key()`函数,并且对应节点的键值发生变化;2)不定义`.key()`函数,Repeat会直接对比数据对象是否改变。代码示例如下: 1239 1240```ts 1241@ObservedV2 1242class RepeatData { 1243 @Trace id: string; 1244 @Trace msg: string; 1245 1246 constructor(id: string, msg: string) { 1247 this.id = id; 1248 this.msg = msg; 1249 } 1250} 1251 1252@Entry 1253@ComponentV2 1254struct RepeatRerender { 1255 @Local dataArr: Array<RepeatData> = []; 1256 1257 aboutToAppear(): void { 1258 for (let i = 0; i < 10; i++) { 1259 this.dataArr.push(new RepeatData(`key${i}`, `data${i}`)); 1260 } 1261 } 1262 1263 build() { 1264 Column({ space: 20 }) { 1265 List() { 1266 Repeat<RepeatData>(this.dataArr) 1267 .each((ri: RepeatItem<RepeatData>) => { 1268 ListItem() { 1269 Text(ri.item.msg).fontSize(30) 1270 } 1271 }) 1272 .key((item: RepeatData, index: number) => item.msg) // 第一种方式:将.key()函数返回值设为与节点数据的改变保持一致的值,如msg属性值 1273 // 第二种方式:删除.key()函数 1274 .virtualScroll() 1275 } 1276 .cachedCount(2) 1277 .width('100%') 1278 .height('40%') 1279 .border({ width: 1 }) 1280 .backgroundColor(0xFAEEE0) 1281 1282 Button('click').onClick(() => { 1283 this.dataArr.splice(0, 1, new RepeatData('key0', 'new msg')); // 改变列表第一个节点数据的msg属性值 1284 }) 1285 } 1286 } 1287} 1288``` 1289 1290运行效果如下,点击按钮后数据发生改变。 1291 1292 1293 1294第二种预期场景:当列表节点数据的属性值发生改变但是key不变时,不会立即触发页面刷新,从而控制节点的刷新频率,提高页面整体的渲染性能。 1295 1296实现方式:定义`.key()`函数,返回值为节点数据对象的`id`属性。点击按钮后,保持`id`属性值不变(即key不变),修改`msg`属性值,页面不会刷新。代码示例如下: 1297 1298需要注意的是,直接修改`msg`属性(即`this.dataArr[0].msg = 'new msg'`),页面仍然会触发刷新。原因是`msg`属性值被`@Trace`装饰,如果被直接修改,会触发状态变量变化逻辑,页面立即刷新。 1299 1300```ts 1301@ObservedV2 1302class RepeatData { 1303 @Trace id: string; 1304 @Trace msg: string; 1305 1306 constructor(id: string, msg: string) { 1307 this.id = id; 1308 this.msg = msg; 1309 } 1310} 1311 1312@Entry 1313@ComponentV2 1314struct RepeatRerender { 1315 @Local dataArr: Array<RepeatData> = []; 1316 1317 aboutToAppear(): void { 1318 for (let i = 0; i < 10; i++) { 1319 this.dataArr.push(new RepeatData(`key${i}`, `data${i}`)); 1320 } 1321 } 1322 1323 build() { 1324 Column({ space: 20 }) { 1325 List() { 1326 Repeat<RepeatData>(this.dataArr) 1327 .each((ri: RepeatItem<RepeatData>) => { 1328 ListItem() { 1329 Text(ri.item.msg).fontSize(30) 1330 } 1331 }) 1332 .key((item: RepeatData, index: number) => item.id) // 将.key()函数返回值设为不受子节点数据改变影响的值,如id属性值 1333 .virtualScroll() 1334 } 1335 .cachedCount(2) 1336 .width('100%') 1337 .height('40%') 1338 .border({ width: 1 }) 1339 .backgroundColor(0xFAEEE0) 1340 1341 Button('click').onClick(() => { 1342 this.dataArr.splice(0, 1, new RepeatData('key0', 'new msg')); // 改变列表第一个节点数据的msg属性值,保持id属性值不变 1343 }) 1344 } 1345 } 1346} 1347``` 1348 1349运行效果如下,点击按钮后数据不发生改变。 1350 1351 1352 1353## 常见问题 1354 1355### 屏幕外的列表数据发生变化时,保证滚动条位置不变 1356 1357以下示例中,屏幕外的数据源变化将影响屏幕中List列表Scroller停留的位置: 1358在List组件中声明Repeat组件,实现key值生成逻辑和each逻辑(如下示例代码),点击按钮“insert”,在屏幕显示的第一个元素前面插入一个元素,屏幕出现向下滚动。 1359 1360```ts 1361// 定义一个类,标记为可观察的 1362// 类中自定义一个数组,标记为可追踪的 1363@ObservedV2 1364class ArrayHolder { 1365 @Trace arr: Array<number> = []; 1366 1367 // constructor,用于初始化数组个数 1368 constructor(count: number) { 1369 for (let i = 0; i < count; i++) { 1370 this.arr.push(i); 1371 } 1372 } 1373} 1374 1375@Entry 1376@ComponentV2 1377struct RepeatTemplateSingle { 1378 @Local arrayHolder: ArrayHolder = new ArrayHolder(100); 1379 @Local totalCount: number = this.arrayHolder.arr.length; 1380 scroller: Scroller = new Scroller(); 1381 1382 build() { 1383 Column({ space: 5 }) { 1384 List({ space: 20, initialIndex: 19, scroller: this.scroller }) { 1385 Repeat(this.arrayHolder.arr) 1386 .virtualScroll({ totalCount: this.totalCount }) 1387 .templateId((item, index) => { 1388 return 'number'; 1389 }) 1390 .template('number', (r) => { 1391 ListItem() { 1392 Text(r.index! + ":" + r.item + "Reuse"); 1393 } 1394 }) 1395 .each((r) => { 1396 ListItem() { 1397 Text(r.index! + ":" + r.item + "eachMessage"); 1398 } 1399 }) 1400 } 1401 .height('30%') 1402 1403 Button(`insert totalCount ${this.totalCount}`) 1404 .height(60) 1405 .onClick(() => { 1406 // 插入元素,元素位置为屏幕显示的前一个元素 1407 this.arrayHolder.arr.splice(18, 0, this.totalCount); 1408 this.totalCount = this.arrayHolder.arr.length; 1409 }) 1410 } 1411 .width('100%') 1412 .margin({ top: 5 }) 1413 } 1414} 1415``` 1416 1417运行效果: 1418 1419 1420 1421以下为修正后的示例: 1422在一些场景中,我们不希望屏幕外的数据源变化影响屏幕中List列表Scroller停留的位置,可以通过List组件的[onScrollIndex](../arkts-layout-development-create-list.md#响应滚动位置)事件对列表滚动动作进行监听,当列表发生滚动时,获取列表滚动位置。使用Scroller组件的[scrollToIndex](../../reference/apis-arkui/arkui-ts/ts-container-scroll.md#scrolltoindex)特性,滑动到指定index位置,实现屏幕外的数据源增加/删除数据时,Scroller停留的位置不变的效果。 1423 1424示例代码仅对增加数据的情况进行展示。 1425 1426```ts 1427// ...ArrayHolder的定义和上述demo代码一致 1428 1429@Entry 1430@ComponentV2 1431struct RepeatTemplateSingle { 1432 @Local arrayHolder: ArrayHolder = new ArrayHolder(100); 1433 @Local totalCount: number = this.arrayHolder.arr.length; 1434 scroller: Scroller = new Scroller(); 1435 1436 private start: number = 1; 1437 private end: number = 1; 1438 1439 build() { 1440 Column({ space: 5 }) { 1441 List({ space: 20, initialIndex: 19, scroller: this.scroller }) { 1442 Repeat(this.arrayHolder.arr) 1443 .virtualScroll({ totalCount: this.totalCount }) 1444 .templateId((item, index) => { 1445 return 'number'; 1446 }) 1447 .template('number', (r) => { 1448 ListItem() { 1449 Text(r.index! + ":" + r.item + "Reuse") 1450 } 1451 }) 1452 .each((r) => { 1453 ListItem() { 1454 Text(r.index! + ":" + r.item + "eachMessage") 1455 } 1456 }) 1457 } 1458 .onScrollIndex((start, end) => { 1459 this.start = start; 1460 this.end = end; 1461 }) 1462 .height('30%') 1463 1464 Button(`insert totalCount ${this.totalCount}`) 1465 .height(60) 1466 .onClick(() => { 1467 // 插入元素,元素位置为屏幕显示的前一个元素 1468 this.arrayHolder.arr.splice(18, 0, this.totalCount); 1469 let rect = this.scroller.getItemRect(this.start); // 获取子组件的大小位置 1470 this.scroller.scrollToIndex(this.start + 1); // 滑动到指定index 1471 this.scroller.scrollBy(0, -rect.y); // 滑动指定距离 1472 this.totalCount = this.arrayHolder.arr.length; 1473 }) 1474 } 1475 .width('100%') 1476 .margin({ top: 5 }) 1477 } 1478} 1479``` 1480 1481运行效果: 1482 1483 1484 1485### totalCount值大于数据源长度 1486 1487当数据源总长度很大时,会使用懒加载的方式先加载一部分数据,为了使Repeat显示正确的滚动条样式,需要将数据总长度赋值给totalCount,即数据源全部加载完成前,totalCount大于array.length。 1488 1489totalCount > array.length时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,开发者需要对数据请求的错误场景(如网络延迟)进行保护操作,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。 1490 1491上述规范可以通过实现父组件List/Grid的[onScrollIndex](../arkts-layout-development-create-list.md#响应滚动位置)属性的回调函数完成。示例代码如下: 1492 1493```ts 1494@ObservedV2 1495class VehicleData { 1496 @Trace name: string; 1497 @Trace price: number; 1498 1499 constructor(name: string, price: number) { 1500 this.name = name; 1501 this.price = price; 1502 } 1503} 1504 1505@ObservedV2 1506class VehicleDB { 1507 public vehicleItems: VehicleData[] = []; 1508 1509 constructor() { 1510 // 数组初始化大小 20 1511 for (let i = 1; i <= 20; i++) { 1512 this.vehicleItems.push(new VehicleData(`Vehicle${i}`, i)); 1513 } 1514 } 1515} 1516 1517@Entry 1518@ComponentV2 1519struct entryCompSucc { 1520 @Local vehicleItems: VehicleData[] = new VehicleDB().vehicleItems; 1521 @Local listChildrenSize: ChildrenMainSize = new ChildrenMainSize(60); 1522 @Local totalCount: number = this.vehicleItems.length; 1523 scroller: Scroller = new Scroller(); 1524 1525 build() { 1526 Column({ space: 3 }) { 1527 List({ scroller: this.scroller }) { 1528 Repeat(this.vehicleItems) 1529 .virtualScroll({ totalCount: 50 }) // 数组预期长度 50 1530 .templateId(() => 'default') 1531 .template('default', (ri) => { 1532 ListItem() { 1533 Column() { 1534 Text(`${ri.item.name} + ${ri.index}`) 1535 .width('90%') 1536 .height(this.listChildrenSize.childDefaultSize) 1537 .backgroundColor(0xFFA07A) 1538 .textAlign(TextAlign.Center) 1539 .fontSize(20) 1540 .fontWeight(FontWeight.Bold) 1541 } 1542 }.border({ width: 1 }) 1543 }, { cachedCount: 5 }) 1544 .each((ri) => { 1545 ListItem() { 1546 Text("Wrong: " + `${ri.item.name} + ${ri.index}`) 1547 .width('90%') 1548 .height(this.listChildrenSize.childDefaultSize) 1549 .backgroundColor(0xFFA07A) 1550 .textAlign(TextAlign.Center) 1551 .fontSize(20) 1552 .fontWeight(FontWeight.Bold) 1553 }.border({ width: 1 }) 1554 }) 1555 .key((item, index) => `${index}:${item}`) 1556 } 1557 .height('50%') 1558 .margin({ top: 20 }) 1559 .childrenMainSize(this.listChildrenSize) 1560 .alignListItem(ListItemAlign.Center) 1561 .onScrollIndex((start, end) => { 1562 console.log('onScrollIndex', start, end); 1563 // 数据懒加载 1564 if (this.vehicleItems.length < 50) { 1565 for (let i = 0; i < 10; i++) { 1566 if (this.vehicleItems.length < 50) { 1567 this.vehicleItems.push(new VehicleData("Vehicle_loaded", i)); 1568 } 1569 } 1570 } 1571 }) 1572 } 1573 } 1574} 1575``` 1576 1577示例代码运行效果: 1578 1579 1580 1581### Repeat与@Builder混用的限制 1582 1583当Repeat与@Builder混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递`RepeatItem.item`或`RepeatItem.index`,将会出现UI渲染异常。 1584 1585示例代码如下: 1586 1587```ts 1588@Entry 1589@ComponentV2 1590struct RepeatBuilderPage { 1591 @Local simpleList1: Array<number> = []; 1592 @Local simpleList2: Array<number> = []; 1593 1594 aboutToAppear(): void { 1595 for (let i = 0; i < 100; i++) { 1596 this.simpleList1.push(i); 1597 this.simpleList2.push(i); 1598 } 1599 } 1600 1601 build() { 1602 Column({ space: 20 }) { 1603 Text('Repeat与@Builder混用,左边是异常场景,右边是正常场景,向下滑动一段距离可以看出差别') 1604 .fontSize(15) 1605 .fontColor(Color.Gray) 1606 1607 Row({ space: 20 }) { 1608 List({ initialIndex: 5, space: 20 }) { 1609 Repeat<number>(this.simpleList1) 1610 .each((ri) => {}) 1611 .virtualScroll({ totalCount: this.simpleList1.length }) 1612 .templateId((item: number, index: number) => "default") 1613 .template('default', (ri) => { 1614 ListItem() { 1615 Column() { 1616 Text('Text id = ' + ri.item) 1617 .fontSize(20) 1618 this.buildItem1(ri.item) // 错误示例,为避免渲染异常,应修改为:this.buildItem1(ri) 1619 } 1620 } 1621 .border({ width: 1 }) 1622 }, { cachedCount: 3 }) 1623 } 1624 .cachedCount(1) 1625 .border({ width: 1 }) 1626 .width('45%') 1627 .height('60%') 1628 1629 List({ initialIndex: 5, space: 20 }) { 1630 Repeat<number>(this.simpleList2) 1631 .each((ri) => {}) 1632 .virtualScroll({ totalCount: this.simpleList2.length }) 1633 .templateId((item: number, index: number) => "default") 1634 .template('default', (ri) => { 1635 ListItem() { 1636 Column() { 1637 Text('Text id = ' + ri.item) 1638 .fontSize(20) 1639 this.buildItem2(ri) // 正确示例,渲染正常 1640 } 1641 } 1642 .border({ width: 1 }) 1643 }, { cachedCount: 3 }) 1644 } 1645 .cachedCount(1) 1646 .border({ width: 1 }) 1647 .width('45%') 1648 .height('60%') 1649 } 1650 } 1651 .height('100%') 1652 .justifyContent(FlexAlign.Center) 1653 } 1654 1655 @Builder 1656 // @Builder参数必须传RepeatItem类型才能正常渲染 1657 buildItem1(item: number) { 1658 Text('Builder1 id = ' + item) 1659 .fontSize(20) 1660 .fontColor(Color.Red) 1661 .margin({ top: 2 }) 1662 } 1663 1664 @Builder 1665 buildItem2(ri: RepeatItem<number>) { 1666 Text('Builder2 id = ' + ri.item) 1667 .fontSize(20) 1668 .fontColor(Color.Red) 1669 .margin({ top: 2 }) 1670 } 1671} 1672``` 1673 1674界面展示如下图,进入页面后向下滑动一段距离可以看出差别,左边是错误用法,右边是正确用法(Text组件为黑色,Builder组件为红色)。上述代码展示了开发过程中易出错的场景,即在@Builder构造函数中传参方式为值传递。 1675 1676