鸿蒙开发-UI语法

不能在自定义组件的build()或@Builder方法里直接改变状态变量,这可能会造成循环渲染的风险。
Text('${this.count++}')在全量更新或最小化更新会产生不同的影响
全量更新(API8及以前版本): ArkUI可能会陷入一个无限的重渲染的循环里,因为Text组件的每一次渲染都会改变应用的状态,就会再引起下一轮渲染的开启。 当 this.columnColor 更改时,都会执行整个build构建函数,因此,Text(${this.count++})绑定的文本也会更改,每次重新渲染Text(${this.count++}),又会使this.count状态变量更新,导致新一轮的build执行,从而陷入无限循环。
最小化更新(API9-至今版本): 当 this.columnColor 更改时,只有Column组件会更新,Text组件不会更改。 只当 this.textColor 更改时,会去更新整个Text组件,其所有属性函数都会执行,所以会看到Text(${this.count++})自增。因为目前UI以组件为单位进行更新,如果组件上某一个属性发生改变,会更新整体的组件。所以整体的更新链路是:this.textColor = Color.Pink -> Text组件整体更新->this.count++ ->Text组件整体更新。值得注意的是,这种写法在初次渲染时会导致Text组件渲染两次,从而对性能产生影响。
build函数中更改应用状态的行为可能会比上面的示例更加隐蔽,比如:

在@Builder,@Extend或@Styles方法内改变状态变量 。

在计算参数时调用函数中改变应用状态变量,例如 Text('${this.calcLabel()}')。

对当前数组做出修改,sort()改变了数组this.arr,随后的filter方法会返回一个新的数组。
不建议在onDidBuild函数中更改状态变量、使用animateTo等功能,这可能会导致不稳定的UI表现。
不允许在aboutToDisappear函数中改变状态变量,特别是@Link变量的修改可能会导致应用程序行为不稳定。
不建议在生命周期aboutToDisappear内使用async await,如果在生命周期的aboutToDisappear使用异步操作(Promise或者回调方法),自定义组件将被保留在Promise的闭包中,直到回调方法被执行完,这个行为阻止了自定义组件的垃圾回收。

if else

动效场景下if分支切换保护失效
在动画当中改变IfElse分支,而这个IfElse是用来做数据保护的,继续使用该分支会导致访问数据异常,然后造成crash。
不能在动画中处理if else 的数据处理

解法
方式1:给数据继续加判空的保护,即在使用data时再加一层判空,即"Text(this.data1?.str)"。
方式2:给IfElse下直接要被删除的组件显示的添加transition(TransitionEffect.IDENTITY)属性,避免系统添加默认转场。

反例:

class MyData {
  str: string;
  constructor(str: string) {
    this.str = str;
  }
}
@Entry
@Component
struct Index {
  @State data1: MyData|undefined = new MyData("branch 0");
  @State data2: MyData|undefined = new MyData("branch 1");


  build() {
    Column() {
      if (this.data1) {
        // 如果在动画中增加/删除,会给Text增加默认转场
        // 对于删除时,增加默认透明度转场后,会延长组件的生命周期,Text组件没有真正删除,而是等转场动画做完后才删除
        Text(this.data1.str)
          .id("1")
      } else if (this.data2) {
        // 如果在动画中增加/删除,会给Text增加默认转场
        Text(this.data2.str)
          .id("2")
      }


      Button("play with animation")
        .onClick(() => {
          animateTo({}, ()=>{
            // 在animateTo中修改if条件,在动画当中,会给if下的第一层组件默认转场
            if (this.data1) {
              this.data1 = undefined;
              this.data2 = new MyData("branch 1");
            } else {
              this.data1 = new MyData("branch 0");
              this.data2 = undefined;
            }
          })
        })


      Button("play directlp")
        .onClick(() => {
          // 直接改if条件,不在动画当中,可以正常切换,也不会加默认转场
          if (this.data1) {
            this.data1 = undefined;
            this.data2 = new MyData("branch 1");
          } else {
            this.data1 = new MyData("branch 0");
            this.data2 = undefined;
          }
        })
    }.width("100%")
    .padding(10)
  }
}

方式1:给数据继续加判空的保护,即在使用data时再加一层判空,即"Text(this.data1?.str)"。

class MyData {
  str: string;
  constructor(str: string) {
    this.str = str;
  }
}
@Entry
@Component
struct Index {
  @State data1: MyData|undefined = new MyData("branch 0");
  @State data2: MyData|undefined = new MyData("branch 1");


  build() {
    Column() {
      if (this.data1) {
        // 如果在动画中增加/删除,会给Text增加默认转场
        // 对于删除时,增加默认透明度转场后,会延长组件的生命周期,Text组件没有真正删除,而是等转场动画做完后才删除
        // 在使用数据时再加一层判空保护,如果data1存在才去使用data1当中的str
        Text(this.data1?.str)
          .id("1")
      } else if (this.data2) {
        // 如果在动画中增加/删除,会给Text增加默认转场
        // 在使用数据时再加一层判空保护
        Text(this.data2?.str)
          .id("2")
      }


      Button("play with animation")
        .onClick(() => {
          animateTo({}, ()=>{
            // 在animateTo中修改if条件,在动画当中,会给if下的第一层组件默认转场
            if (this.data1) {
              this.data1 = undefined;
              this.data2 = new MyData("branch 1");
            } else {
              this.data1 = new MyData("branch 0");
              this.data2 = undefined;
            }
          })
        })
    }.width("100%")
    .padding(10)
  }
}

方式2:给IfElse下直接要被删除的组件显示的添加transition(TransitionEffect.IDENTITY)属性,避免系统添加默认转场。

class MyData {
  str: string;
  constructor(str: string) {
    this.str = str;
  }
}
@Entry
@Component
struct Index {
  @State data1: MyData|undefined = new MyData("branch 0");
  @State data2: MyData|undefined = new MyData("branch 1");


  build() {
    Column() {
      if (this.data1) {
        // 在IfElse的根组件显示指定空的转场效果,避免默认转场动画
        Text(this.data1.str)
          .transition(TransitionEffect.IDENTITY)
          .id("1")
      } else if (this.data2) {
        // 在IfElse的根组件显示指定空的转场效果,避免默认转场动画
        Text(this.data2.str)
          .transition(TransitionEffect.IDENTITY)
          .id("2")
      }


      Button("play with animation")
        .onClick(() => {
          animateTo({}, ()=>{
            // 在animateTo中修改if条件,在动画当中,会给if下的第一层组件默认转场
            // 但由于已经显示指定转场了就不会再添加默认转场
            if (this.data1) {
              this.data1 = undefined;
              this.data2 = new MyData("branch 1");
            } else {
              this.data1 = new MyData("branch 0");
              this.data2 = undefined;
            }
          })
        })
    }.width("100%")
    .padding(10)
  }
}

循环渲染

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

非首次渲染
从本例可以看出@State 能够监听到简单数据类型数组数据源 simpleList 数组项的变化。
当 simpleList 数组项发生变化时,会触发 ForEach 进行重新渲染。
ForEach 遍历新的数据源 ['one', 'two', 'new three'],并生成对应的键值one、two和new three。
其中,键值one和two在上次渲染中已经存在,所以 ForEach 复用了对应的组件并进行了渲染。对于第三个数组项 "new three",由于其通过键值生成规则 item 生成的键值new three在上次渲染中不存在,因此 ForEach 为该数组项创建了一个新的组件。

数据源数组项发生变化。上拉加载更多

class Article {
  id: string;
  title: string;
  brief: string;


  constructor(id: string, title: string, brief: string) {
    this.id = id;
    this.title = title;
    this.brief = brief;
  }
}


@Entry
@Component
struct ArticleListView {
  @State isListReachEnd: boolean = false;
  @State articleList: Array<Article> = [
    new Article('001', '第1篇文章', '文章简介内容'),
    new Article('002', '第2篇文章', '文章简介内容'),
    new Article('003', '第3篇文章', '文章简介内容'),
    new Article('004', '第4篇文章', '文章简介内容'),
    new Article('005', '第5篇文章', '文章简介内容'),
    new Article('006', '第6篇文章', '文章简介内容')
  ]


  loadMoreArticles() {
    this.articleList.push(new Article('007', '加载的新文章', '文章简介内容'));
  }


  build() {
    Column({ space: 5 }) {
      List() {
        ForEach(this.articleList, (item: Article) => {
          ListItem() {
            ArticleCard({ article: item })
              .margin({ top: 20 })
          }
        }, (item: Article) => item.id)
      }
      .onReachEnd(() => {
        this.isListReachEnd = true;
      })
      .parallelGesture(
        PanGesture({ direction: PanDirection.Up, distance: 80 })
          .onActionStart(() => {
            if (this.isListReachEnd) {
              this.loadMoreArticles();
              this.isListReachEnd = false;
            }
          })
      )
      .padding(20)
      .scrollBar(BarState.Off)
    }
    .width('100%')
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
}


@Component
struct ArticleCard {
  @Prop article: Article;


  build() {
    Row() {
      // 此处'app.media.icon'仅作示例,请开发者自行替换,否则imageSource创建失败会导致后续无法正常执行。
      Image($r('app.media.icon'))
        .width(80)
        .height(80)
        .margin({ right: 20 })


      Column() {
        Text(this.article.title)
          .fontSize(20)
          .margin({ bottom: 8 })
        Text(this.article.brief)
          .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)
  }
}

数据源数组项子属性变化
当数据源的数组项为对象数据类型,并且只修改某个数组项的属性值时,由于数据源为复杂数据类型,ArkUI框架无法监听到@State装饰器修饰的数据源数组项的属性变化,从而无法触发ForEach的重新渲染。为实现ForEach重新渲染,需要结合@Observed和@ObjectLink装饰器使用。例如,在文章列表卡片上点击“点赞”按钮,从而修改文章的点赞数量。

@Observed
class Article {
  id: string;
  title: string;
  brief: string;
  isLiked: boolean;
  likesCount: number;


  constructor(id: string, title: string, brief: string, isLiked: boolean, likesCount: number) {
    this.id = id;
    this.title = title;
    this.brief = brief;
    this.isLiked = isLiked;
    this.likesCount = likesCount;
  }
}


@Entry
@Component
struct ArticleListView {
  @State articleList: Array<Article> = [
    new Article('001', '第0篇文章', '文章简介内容', false, 100),
    new Article('002', '第1篇文章', '文章简介内容', false, 100),
    new Article('003', '第2篇文章', '文章简介内容', false, 100),
    new Article('004', '第4篇文章', '文章简介内容', false, 100),
    new Article('005', '第5篇文章', '文章简介内容', false, 100),
    new Article('006', '第6篇文章', '文章简介内容', false, 100),
  ];


  build() {
    List() {
      ForEach(this.articleList, (item: Article) => {
        ListItem() {
          ArticleCard({
            article: item
          })
            .margin({ top: 20 })
        }
      }, (item: Article) => item.id)
    }
    .padding(20)
    .scrollBar(BarState.Off)
    .backgroundColor(0xF1F3F5)
  }
}


@Component
struct ArticleCard {
  @ObjectLink article: Article;


  handleLiked() {
    this.article.isLiked = !this.article.isLiked;
    this.article.likesCount = this.article.isLiked ? this.article.likesCount + 1 : this.article.likesCount - 1;
  }


  build() {
    Row() {
      // 此处'app.media.icon'仅作示例,请开发者自行替换,否则imageSource创建失败会导致后续无法正常执行。
      Image($r('app.media.icon'))
        .width(80)
        .height(80)
        .margin({ right: 20 })


      Column() {
        Text(this.article.title)
          .fontSize(20)
          .margin({ bottom: 8 })
        Text(this.article.brief)
          .fontSize(16)
          .fontColor(Color.Gray)
          .margin({ bottom: 8 })


        Row() {
          // 此处app.media.iconLiked','app.media.iconUnLiked'仅作示例,请开发者自行替换,否则imageSource创建失败会导致后续无法正常执行。
          Image(this.article.isLiked ? $r('app.media.iconLiked') : $r('app.media.iconUnLiked'))
            .width(24)
            .height(24)
            .margin({ right: 8 })
          Text(this.article.likesCount.toString())
            .fontSize(16)
        }
        .onClick(() => this.handleLiked())
        .justifyContent(FlexAlign.Center)
      }
      .alignItems(HorizontalAlign.Start)
      .width('80%')
      .height('100%')
    }
    .padding(20)
    .borderRadius(12)
    .backgroundColor('#FFECECEC')
    .height(120)
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
  }
}
Article类被@Observed装饰器修饰。父组件ArticleListView传入Article对象实例给子组件ArticleCard,子组件使用@ObjectLink装饰器接收该实例。

当点击第1个文章卡片上的点赞图标时,会触发ArticleCard组件的handleLiked函数。该函数修改第1个卡片对应组件里article实例的isLiked和likesCount属性值。
article实例是@ObjectLink装饰的状态变量,它的属性值变化,触发对应的ArticleCard组件渲染,读取到的isLiked和likesCount为修改后的新值。

拖拽排序
当ForEach在List组件下使用,并且设置了onMove事件,ForEach每次迭代都生成一个ListItem时,可以使能拖拽排序。拖拽排序离手后,如果数据位置发生变化,则会触发onMove事件,上报数据移动原始索引号和目标索引号。在onMove事件中,需要根据上报的起始索引号和目标索引号修改数据源。数据源修改前后,要保持每个数据的键值不变,只是顺序发生变化,才能保证落位动画正常执行。

使用建议

  • 为满足键值的唯一性,对于对象数据类型,建议使用对象数据中的唯一id作为键值。
  • 尽量避免在最终的键值生成规则中包含数据项索引index,以防止出现渲染结果非预期渲染性能降低。如果业务确实需要使用index,例如列表需要通过index进行条件渲染,开发者需要接受ForEach在改变数据源后重新创建组件所带来的性能损耗。
  • 基本数据类型的数据项没有唯一ID属性。如果使用基本数据类型本身作为键值,必须确保数组项无重复。因此,对于数据源会发生变化的场景,建议将基本数据类型数组转化为具备唯一ID属性的对象数据类型数组,再使用唯一ID属性作为键值。
  • 对于以上限制规则,index参数存在的意义为:index是开发者保证键值唯一性的最终手段;对数据项进行修改时,由于itemGenerator中的item参数是不可修改的,所以须用index索引值对数据源进行修改,进而触发UI重新渲染。
  • ForEach在下列容器组件 ListGridSwiper以及WaterFlow 内使用的时候,不要与LazyForEach 混用。 以List为例,同时包含ForEach、LazyForEach的情形是不推荐的。
  • 数组项是对象数据类型的情况下,不建议用内容相同的数组项替换旧的数组项。如果数组项变更,但是变更前后的键值不变,会出现数据变化不渲染

不推荐案例

开发者在使用ForEach的过程中,若对于键值生成规则的理解不够充分,可能会出现错误的使用方式。错误使用一方面会导致功能层面问题,例如渲染结果非预期,另一方面会导致性能层面问题,例如渲染性能降低

渲染结果非预期

在本示例中,通过设置ForEach的第三个参数KeyGenerator函数,自定义键值生成规则为数据源的索引index的字符串类型值。当点击父组件Parent中“在第1项后插入新项”文本组件后,界面会出现非预期的结果。

ForEach(this.simpleList, (item: string) => {
ChildItem({ item: item })
}, (item: string) => item) // 需要保证key唯一

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容