HarmonyOS开发5.0【长列表LazyForEach与RecyclerView的使用】

声明式UI && 命令式UI

传统的命令式UI编程范式中,开发者需要明确地指示系统如何一步一步地构建和更新UI,手动处理每一个UI更新和状态变化,随着应用复杂度增加,管理UI和状态同步变得更加困难。所以声明式UI应运而生,它的出现就是为了简化UI开发,减少手动管理状态和UI更新的复杂性。现代前端框架(Jetpack Compose、SwiftUI)都采用了声明式UI的编程范式。

在声明式UI编程范式中,开发者不再手动构建、更新UI,而是「描述界面应该是什么样子的」:开发者定义界面状态,然后框架会根据状态自动更新UI。

相对于命令式UI,声明式UI更加简洁和易于维护,但缺乏了灵活性——开发者无法完全控制UI更新的粒度。所以声明式UI的性能是一大挑战,尤其是复杂长列表场景下的性能问题。

为了解决长列表的渲染问题,Jetpack Compose 提供了LazyColumnLazyRow等组件,SwiftUI也有ListLazyVStack等组件。作为鸿蒙系统的UI体系ArkUI自然也有用于长列表的组件LazyForEach

LazyForEach从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。当在滚动容器中使用了LazyForEach,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会进行组件销毁回收以降低内存占用。

LazyForEach用法

本文就针对ArkUI中的LazyForEach来探究一二。

LazyForEach 的渲染依赖IDataSourceDataChangeListener,我们一个一个来看:

IDataSource

LazyForEach 的数据获取、更新都是通过IDataSource来完成的:

  • totalCount(): number 获得数据总数
  • getData(index: number): Object获取索引值index对应的数据
  • registerDataChangeListener(listener: DataChangeListener)注册数据改变的监听器
  • unregisterDataChangeListener(listener: DataChangeListener)注销数据改变的监听器

DataChangeListener

DataChangeListener,官方定义其为数据变化监听器,用于通知LazyForEach组件数据更新。除掉已废弃的方法外,共有以下几个方法:

  • onDataReloaded()通知组件重新加载所有数据。键值没有变化的数据项会使用原先的子组件,键值发生变化的会重建子组件。重新加载数据完成后调用。
  • onDataAdd(index: number)通知组件index的位置有数据添加。添加数据完成后调用
  • onDataMove(from: number, to: number)通知组件数据有移动。将from和to位置的数据进行交换。数据移动起始位置与数据移动目标位置交换完成后调用。
  • onDataDelete(index: number)通知组件删除index位置的数据并刷新LazyForEach的展示内容。删除数据完成后调用。
  • onDataChange(index: number)通知组件index的位置有数据有变化。改变数据完成后调用。
  • onDatasetChange(dataOperations: DataOperation[])进行批量的数据处理,该接口不可与上述接口混用。批量数据处理后调用。

披着马甲的RecyclerView?

这…这不对吧?你给我干哪儿来了?这还是国内么?


相信大部分Android开发者看到LazyForEach的API都是这样两眼一黑:这…这确定不是RecyclerView?连API都能一一对应上:

  • DataChangeListener.onDataReloaded() -> RecyclerView.Adapter.notifyDataSetChanged()
  • DataChangeListener.onDataAdd() -> RecyclerView.Adapter.notifyItemInserted()
  • DataChangeListener.onDataDelete() -> RecyclerView.Adapter.notifyItemRangeRemoved()
  • DataChangeListener.onDataChange() -> RecyclerView.Adapter.notifyItemChanged()

一个简单的demo

我们写一个简单的长列表来体验下鸿蒙的LazyForEach用法:页面顶部3个按钮对应列表的增、删、改功能,列表的item显示当前item的index,数据源部分代码如下:

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

  public totalCount(): number {
    return 0;
  }

  public getData(index: number): string {
    return this.originDataArray[index];
  }

  // 该方法为框架侧调用,为LazyForEach组件向其数据源处添加listener监听
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      console.info('add listener');
      this.listeners.push(listener);
    }
  }

  // 该方法为框架侧调用,为对应的LazyForEach组件在数据源处去除listener监听
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      console.info('remove listener');
      this.listeners.splice(pos, 1);
    }
  }

  // 通知LazyForEach组件需要重载所有子组件
  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    })
  }

  // 通知LazyForEach组件需要在index对应索引处添加子组件
  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index);
    })
  }

  // 通知LazyForEach组件在index对应索引处数据有变化,需要重建该子组件
  notifyDataChange(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataChange(index);
    })
  }

  // 通知LazyForEach组件需要在index对应索引处删除该子组件
  notifyDataDelete(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataDelete(index);
    })
  }

  // 通知LazyForEach组件将from索引和to索引处的子组件进行交换
  notifyDataMove(from: number, to: number): void {
    this.listeners.forEach(listener => {
      listener.onDataMove(from, to);
    })
  }
}


export class MyDataSource extends BasicDataSource {
  private dataArray: string[] = [];

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

  public getData(index: number): string {
    return this.dataArray[index];
  }

  public addData(index: number, data: string): void {
    this.dataArray.splice(index, 0, data);
    this.notifyDataAdd(index);
  }

  public pushData(data: string): void {
    this.dataArray.push(data);
    this.notifyDataAdd(this.dataArray.length - 1);
  }

  public deleteData(index: number): void {
    this.dataArray.splice(index, 1);
    this.notifyDataDelete(index);
  }

  public changeData(index: number, data: string): void {
    this.dataArray.splice(index, 1, data);
    this.notifyDataChange(index);
  }
}

UI部分正常使用LazyForEach展示数据即可:

@Entry
@Component
struct Index {

  private data: MyDataSource = new MyDataSource();

  aboutToAppear(): void {
    for (let i = 0; i <= 4; i++) {
      this.data.pushData(`index ${i}`)
    }
  }

  build() {

    Column() {

      Button('add')
        .borderRadius(8)
        .backgroundColor(0x317aff)
        .margin({top: 12, left: 20, right: 20})
        .width(360)
        .height(40)
        .onClick(() => {
          const lastIndex = this.data.totalCount()
          this.data.addData(lastIndex, `index ${lastIndex}`)
        })

      Button('remove')
        .borderRadius(8)
        .backgroundColor(0xF55A42)
        .margin({top: 12, left: 20, right: 20})
        .width(360)
        .height(40)
        .onClick(() => {
          const lastIndex = this.data.totalCount()
          this.data.notifyDataMove(lastIndex - 1, lastIndex - 1)
        })

      List({ space: 3 }) {
        LazyForEach(this.data, (item: string) => {
          ListItem() {
            Row() {
              Text(item)
                .fontSize(40)
                .textAlign(TextAlign.Center)
                .width('100%')
                .height(55)
                .borderRadius(8)
                .backgroundColor(0xF5F5F5)
                .onAppear(() => {
                  console.info("appear:" + item)
                })
            }.margin({ left: 10, right: 10 , top: 10 })
          }
        }, (item: string) => item)
      }.cachedCount(5)
      .width('100%')
      .height('auto')
      .layoutWeight(1)
    }.width('100%')
    .height('100%')
  }
}

demo功能也很简单:

  • 点击add按钮在列表底部添加新元素
  • 点击remove按钮删除列表底部最后一个元素
  • 点击update按钮在将第一个元素文案更新为index new 0

1

那如果是复杂的数据更新操作呢?

比如列表原来的数据为 ['Hello a', 'Hello b', 'Hello c', 'Hello d', 'Hello e'],经过一系列变化后需要调整成['Hello x', 'Hello 1', 'Hello 2', 'Hello b', 'Hello c', 'Hello e', 'Hello d'],这时候如何更新UI展示?

此时就需要用到onDatasetChange(dataOperations: DataOperation[])API了:

#BasicDataSource
class BasicDataSource implements IDataSource {
  private listeners: DataChangeListener[] = [];
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      console.info('add listener');
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      console.info('remove listener');
      this.listeners.splice(pos, 1);
    }
  }
  
  notifyDatasetChange(operations: DataOperation[]): void {
    this.listeners.forEach(listener => {
      listener.onDatasetChange(operations);
    })
  }
}

#MyDataSource
class MyDataSource extends BasicDataSource {
  
  private dataArray: string[] = ['Hello a', 'Hello b', 'Hello c', 'Hello d', 'Hello e'];

  public operateData(): void {
    this.dataArray =
      ['Hello x', 'Hello 1', 'Hello 2', 'Hello b', 'Hello c', 'Hello e', 'Hello d']
    this.notifyDatasetChange([
      { type: DataOperationType.CHANGE, index: 0 },
      { type: DataOperationType.ADD, index: 1, count: 2 },
      { type: DataOperationType.EXCHANGE, index: { start: 3, end: 4 } },
    ]);
  }
}

复杂的数据操作需要我们告诉组件如何变化,以上述的例子为例:

// 修改之前的数组
['Hello a', 'Hello b', 'Hello c', 'Hello d', 'Hello e']
// 修改之后的数组
['Hello x', 'Hello 1', 'Hello 2', 'Hello b', 'Hello c', 'Hello e', 'Hello d']
  • 第一个元素从’Hello a’变为’Hello x’,因此第一个operation为{ type: DataOperationType.CHANGE, index: 0 }
  • 新增了元素’Hello 1’和’Hello 2’,下标为1和2,所以第二个operation为{ type: DataOperationType.ADD, index: 1, count: 2 }
  • 元素’Hello d’和’Hello e’交换了位置,所以第三个operation为{ type: DataOperationType.EXCHANGE, index: { start: 3, end: 4 } }

使用onDatasetChange(dataOperations: DataOperation[])API时需要注意:

  1. onDatasetChange与其它操作数据的接口不能混用。
  2. 传入onDatasetChange的operations,其中每一项operation的index均从修改前的原数组内寻找。因此,opeartions中的index跟操作Datasource中的index不是一一对应的。
  3. 调用一次onDatasetChange,一个index对应的数据只能被操作一次,若被操作多次,LazyForEach仅使第一个操作生效。
  4. 部分操作可以由开发者传入键值,LazyForEach不会再去重复调用keygenerator获取键值,需要开发者保证传入的键值的正确性。
  5. 若本次操作集合中有RELOAD操作,则其余操作全不生效。

通过@Observed 更新子组件

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

LazyForEach提供了一个名为keyGenerator的参数,这是一个函数,开发者可以通过它自定义键值的生成规则。如果开发者没有定义keyGenerator函数,则ArkUI框架会使用默认的键值生成函数,即(item: Object, index: number) => { return viewId + ‘-’ + index.toString(); }, viewId在编译器转换过程中生成,同一个LazyForEach组件内其viewId是一致的。

上述的列表更新都是依靠LazyForEach的刷新机制:当item变化时,通过将将原来的子组件全部销毁再重新构建的方式来更新子组件。这种通过改变键值去刷新的方式渲染性能较低。因此鸿蒙系统也提供了@Observed机制进行深度观测,可以做到仅刷新使用了该属性的组件,提高渲染性能。还是上面的例子,这次我们将数据源换成被@Observed修饰的类:

@Observed
class StringData {
  message: string;
  constructor(message: string) {
    this.message = message;
  }  
}

@Entry
@Component
struct MyComponent {
  private moved: number[] = [];
  @State data: MyDataSource = new MyDataSource();

  aboutToAppear() {
    for (let i = 0; i <= 20; i++) {
      this.data.pushData(new StringData(`Hello ${i}`));
    }
  }

  build() {
    List({ space: 3 }) {
      LazyForEach(this.data, (item: StringData, index: number) => {
        ListItem() {
          ChildComponent({data: item})
        }
        .onClick(() => {
          item.message += '0';
        })
      }, (item: StringData, index: number) => index.toString())
    }.cachedCount(5)
  }
}

@Component
struct ChildComponent {
  @Prop data: StringData
  build() {
    Row() {
      Text(this.data.message).fontSize(50)
        .onAppear(() => {
          console.info("appear:" + this.data.message)
        })
    }.margin({ left: 10, right: 10 })
  }
}

此时点击LazyForEach子组件改变item.message时,重渲染依赖的是ChildComponent@Prop成员变量对其子属性的监听,此时框架只会刷新Text(this.data.message),不会去重建整个ListItem子组件。

实际开发时,开发者需要根据其自身业务特点选择使用哪种刷新方式:改变键值 or 通过@Observed

吐槽

作为一名Android开发者,使用LazyForEach后,彷佛看到了故人之姿。用法和API设计都和RecyclerView太像了,甚至RecyclerView需要注意的用法上的问题,LazyForEach同样也有:

2

不同的是,早期的RecyclerView出来让人惊艳:相比于它的前辈 ListView,同时通过Adapter将数据和UI隔离,设计非常灵活,可拓展性非常强。

然而使用LazyForEach时我却总有些恍惚:不是声明式UI么?不是应该描述、定义列表界面状态,然后ArkUI框架根据列表状态自动完成UI的更新么?为什么还会有DataChangeListener这种东西存在?

官方文档里也明确表示了LazyForEach不支持状态变量:


LazyForEach必须使用DataChangeListener对象进行更新,对第一个参数dataSource重新赋值会异常;dataSource使用状态变量时,状态变量改变不会触发LazyForEach的UI刷新。

猜测还是和性能有关系,所以官方也没将LazyForEach归类为容器组件而是把它划到了渲染控制模块里。不过个人觉得这种违背声明式UI的初衷,将逻辑抛给开发者的方式并不可取。

对比之下,同样是声明式UI的Compose在长列表的处理就显得优雅了许多:

var items by remember { mutableStateOf(listOf("Item 0", "Item 1", "Item 2")) }

@Composable
fun LazyColumnDemo() {
    var items by remember { mutableStateOf(listOf("Item 0", "Item 1", "Item 2")) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            Button(onClick = {
                items = items + "Item ${items.size}"
            }) {
                Text("Add Item")
            }

            Button(onClick = {
                if (items.isNotEmpty()) {
                    items = items.dropLast(1)
                }
            }) {
                Text("Remove Item")
            }

            Button(onClick = {
                if (items.isNotEmpty()) {
                    items = items.toMutableList().apply {
                        this[0] = "new"
                    }
                }
            }) {
                Text("Update First")
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        LazyColumn(
            modifier = Modifier.fillMaxSize()
        ) {
            itemsIndexed(items) { index, item ->
                ListItem(index = index, text = item)
            }
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值