声明式UI && 命令式UI
传统的命令式UI
编程范式中,开发者需要明确地指示系统如何一步一步地构建和更新UI,手动处理每一个UI更新和状态变化,随着应用复杂度增加,管理UI和状态同步变得更加困难。所以声明式UI
应运而生,它的出现就是为了简化UI开发,减少手动管理状态和UI更新的复杂性。现代前端框架(Jetpack Compose、SwiftUI)都采用了声明式UI的编程范式。
在声明式UI编程范式中,开发者不再手动构建、更新UI,而是「描述界面应该是什么样子的」:开发者定义界面状态,然后框架会根据状态自动更新UI。
相对于命令式UI,声明式UI更加简洁和易于维护,但缺乏了灵活性——开发者无法完全控制UI更新的粒度。所以声明式UI的性能是一大挑战,尤其是复杂长列表场景下的性能问题。
为了解决长列表的渲染问题,Jetpack Compose 提供了LazyColumn
和LazyRow
等组件,SwiftUI
也有List
和LazyVStack
等组件。作为鸿蒙系统的UI体系ArkUI
自然也有用于长列表的组件LazyForEach
:
LazyForEach从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。当在滚动容器中使用了LazyForEach,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会进行组件销毁回收以降低内存占用。
LazyForEach用法
本文就针对ArkUI中的LazyForEach
来探究一二。
LazyForEach 的渲染依赖IDataSource
和DataChangeListener
,我们一个一个来看下:
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
那如果是复杂的数据更新操作呢?
比如列表原来的数据为 ['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时需要注意:
- onDatasetChange与其它操作数据的接口不能混用。
- 传入onDatasetChange的operations,其中每一项operation的index均从修改前的原数组内寻找。因此,opeartions中的index跟操作Datasource中的index不是一一对应的。
- 调用一次onDatasetChange,一个index对应的数据只能被操作一次,若被操作多次,LazyForEach仅使第一个操作生效。
- 部分操作可以由开发者传入键值,LazyForEach不会再去重复调用keygenerator获取键值,需要开发者保证传入的键值的正确性。
- 若本次操作集合中有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同样也有:
关于ScrollView嵌套RecyclerView使用上的问题,可以移步:
不同的是,早期的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)
}
}
}
}