(四)鸿蒙HarmonyOS主力开发语言ArkTS-渲染控制

320 阅读7分钟

系列文章目录

(一)鸿蒙HarmonyOS开发基础 (二)鸿蒙HarmonyOS主力开发语言ArkTS-基本语法 (三)鸿蒙HarmonyOS主力开发语言ArkTS-状态管理


@TOC


一、渲染控制概述

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

二、if/else:条件渲染

在这里插入图片描述 ArkTS提供了条件渲染能力,根据应用状态使用if、else和else if渲染对应UI内容。从API version 9开始,该接口支持在ArkTS卡片中使用。条件渲染语句在构建组件时遵循一定规则,并在状态变量变化时更新UI。

使用规则

  • 支持if、else和else if语句,条件语句可使用状态变量。
  • 允许在容器组件内使用,构建不同子组件。
  • 条件渲染语句在组件父子关系中“透明”,需遵守父组件规则。
  • 每个分支构建函数需遵循规则,创建组件。空函数产生语法错误。
  • 某些容器组件限制子组件类型或数量,条件渲染内创建的组件需遵守这些限制。

更新机制

当条件中的状态变量变化时,条件渲染语句更新:

  • 评估条件,如果分支无变化,则不执行后续步骤。
  • 删除此前构建的所有子组件。
  • 执行新分支的构造函数,将新组件添加到父容器中。

使用场景

以下是一个使用if进行条件渲染的示例:

@Entry
@Component
struct ViewA {
  @State count: number = 0;

  build() {
    Column() {
      Text(`count=${this.count}`)

      if (this.count > 0) {
        Text(`count is positive`)
          .fontColor(Color.Green)
      }

      Button('increase count')
        .onClick(() => {
          this.count++;
        })

      Button('decrease count')
        .onClick(() => {
          this.count--;
        })
    }
  }
}

在这个示例中,根据count的值,条件渲染会显示或隐藏不同的文本。当count变化时,UI会相应地更新。

if ... else ...语句和子组件状态

示例代码

CounterView 组件

@Component
struct CounterView {
  @State counter: number = 0;
  label: string = 'unknown';

  build() {
    Row() {
      Text(`${this.label}`)
      Button(`counter ${this.counter} +1`)
        .onClick(() => {
          this.counter += 1;
        })
    }
  }
}

MainView 组件

@Entry
@Component
struct MainView {
  @State toggle: boolean = true;

  build() {
    Column() {
      if (this.toggle) {
        CounterView({ label: 'CounterView #positive' })
      } else {
        CounterView({ label: 'CounterView #negative' })
      }
      Button(`toggle ${this.toggle}`)
        .onClick(() => {
          this.toggle = !this.toggle;
        })
    }
  }
}

说明

  • CounterView 组件在初次渲染时创建,并携带名为 counter 的状态变量。当修改 CounterView.counter 时,组件重新渲染并保留状态变量值。
  • MainView.toggle 值更改为 false 时,MainView 内的 if 语句将更新,删除旧的 CounterView 实例并创建新的实例。新实例的 counter 状态变量重置为初始值 0。
  • CounterView 的两个不同实例(标签为 '#positive' 和 '#negative')是同一组件的不同实例。if 分支的更改不会更新现有子组件或保留其状态。

保留状态的方法

修改后的 CounterView 组件

@Component
struct CounterView {
  @Link counter: number;
  label: string = 'unknown';

  build() {
    Row() {
      Text(`${this.label}`)
      Button(`counter ${this.counter} +1`)
        .onClick(() => {
          this.counter += 1;
        })
    }
  }
}

修改后的 MainView 组件

@Entry
@Component
struct MainView {
  @State toggle: boolean = true;
  @State counter: number = 0;

  build() {
    Column() {
      if (this.toggle) {
        CounterView({ counter: $counter, label: 'CounterView #positive' })
      } else {
        CounterView({ counter: $counter, label: 'CounterView #negative' })
      }
      Button(`toggle ${this.toggle}`)
        .onClick(() => {
          this.toggle = !this.toggle;
        })
    }
  }
}
  • 在修改后的示例中,counter 状态变量归父组件 MainView 所有。当 CounterView 实例被删除时,该变量不会被销毁。
  • CounterView 通过 @Link 装饰器引用状态,以避免在条件内容或重复内容被销毁时丢失状态。

嵌套 if 语句

  • 条件语句的嵌套对父组件的相关规则没有影响。

示例代码:CompA 组件

@Entry
@Component
struct CompA {
  @State toggle: boolean = false;
  @State toggleColor: boolean = false;

  build() {
    Column() {
      Text('Before')
        .fontSize(15)
      if (this.toggle) {
        Text('Top True, positive 1 top')
          .backgroundColor('#aaffaa').fontSize(20)
        // 内部if语句
        if (this.toggleColor) {
          Text('Top True, Nested True, positive COLOR  Nested ')
            .backgroundColor('#00aaaa').fontSize(15)
        } else {
          Text('Top True, Nested False, Negative COLOR  Nested ')
            .backgroundColor('#aaaaff').fontSize(15)
        }
      } else {
        Text('Top false, negative top level').fontSize(20)
          .backgroundColor('#ffaaaa')
        if (this.toggleColor) {
          Text('positive COLOR  Nested ')
            .backgroundColor('#00aaaa').fontSize(15)
        } else {
          Text('Negative COLOR  Nested ')
            .backgroundColor('#aaaaff').fontSize(15)
        }
      }
      Text('After')
        .fontSize(15)
      Button('Toggle Outer')
        .onClick(() => {
          this.toggle = !this.toggle;
        })
      Button('Toggle Inner')
        .onClick(() => {
          this.toggleColor = !this.toggleColor;
        })
    }
  }
}

三、ForEach循环渲染

在这里插入图片描述

ForEach接口

使用场景

  • 基于数组类型数据的循环渲染
  • 需要与容器组件配合使用
  • 返回的组件应能包含在父容器组件中

支持版本

  • API version 9及之后

接口描述

ForEach(
  arr: Array,
  itemGenerator: (item: any, index: number) => void,
  keyGenerator?: (item: any, index: number) => string
)

参数

参数名参数类型必填参数描述
arrArray数据源,为Array类型的数组。说明:- 可以设置为空数组,此时不会创建子组件。- 可以设置返回值为数组类型的函数,例如arr.slice(1, 3),但设置的函数不应改变包括数组本身在内的任何状态变量,例如不应使用Array.splice(),Array.sort()或Array.reverse()这些会改变原数组的函数。
itemGenerator(item: any, index: number) => void组件生成函数。- 为数组中的每个元素创建对应的组件。- item参数:arr数组中的数据项。- index参数(可选):arr数组中的数据项索引。说明:- 组件的类型必须是ForEach的父容器所允许的。例如,ListItem组件要求ForEach的父容器组件必须为List组件。
keyGenerator(item: any, index: number) => string键值生成函数。- 为数据源arr的每个数组项生成唯一且持久的键值。函数返回值为开发者自定义的键值生成规则。- item参数:arr数组中的数据项。- index参数(可选):arr数组中的数据项索引。说明:- 如果函数缺省,框架默认的键值生成函数为(item: T, index: number) => { return index + '__' + JSON.stringify(item); }- 键值生成函数不应改变任何组件状态。

组件创建规则

ForEach的itemGenerator函数根据键值生成规则为数据源数组项创建组件,包括首次渲染和非首次渲染。

首次渲染时,根据键值生成规则为数组项生成唯一键值,并创建相应组件。

代码示例:

@Entry
@Component
struct Parent {
  @State simpleList: Array<string> = ['one', 'two', 'three'];

  build() {
    Row() {
      Column() {
        ForEach(this.simpleList, (item: string) => {
          ChildItem({ 'item': item } as Record<string, string>)
        }, (item: string) => item)
      }
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
}

@Component
struct ChildItem {
  @Prop item: string;

  build() {
    Text(this.item)
      .fontSize(50)
  }
}

在代码中,键值生成规则是item。ForEach渲染时,为数组项生成键值one、two和three,并创建对应的ChildItem组件。

当数组项生成的键值相同时,框架行为未定义。例如,渲染相同数据项two时,只创建一个ChildItem组件。下面的数组最后页面效果和上图一样。

@State simpleList: Array<string> = ['one', 'two', 'two', 'three'];

循环渲染过程

系统行为

  • 为每个数组元素生成唯一且持久的键值
  • 当键值变化时,视为数组元素已被替换或修改,并基于新的键值创建新的组件

非首次渲染 在ForEach组件进行非首次渲染时,它会检查新生成的键值是否在上次渲染中已经存在。如果键值不存在,则会创建一个新的组件;如果键值存在,则不会创建新的组件,而是直接渲染该键值所对应的组件。例如,在以下的代码示例中,通过点击事件修改了数组的第三项值为"new three",这将触发ForEach组件进行非首次渲染。

主要应用场景

  • 数据源不变

  • 数据源数组项发生变化,如插入、删除操作

  • 数据源数组项子属性变化

数据源不变

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


1.  @Entry
2.  @Component
3.  struct ArticleList {
4.    @State simpleList: Array<number> = [1, 2, 3, 4, 5];

6.    build() {
7.      Column() {
8.        ForEach(this.simpleList, (item: string) => {
9.          ArticleSkeletonView()
10.            .margin({ top: 20 })
11.        }, (item: string) => item)
12.      }
13.      .padding(20)
14.      .width('100%')
15.      .height('100%')
16.    }
17.  }

19.  @Builder
20.  function textArea(width: number | Resource | string = '100%', height: number | Resource | string = '100%') {
21.    Row()
22.      .width(width)
23.      .height(height)
24.      .backgroundColor('#FFF2F3F4')
25.  }

27.  @Component
28.  struct ArticleSkeletonView {
29.    build() {
30.      Row() {
31.        Column() {
32.          textArea(80, 80)
33.        }
34.        .margin({ right: 20 })

36.        Column() {
37.          textArea('60%', 20)
38.          textArea('50%', 20)
39.        }
40.        .alignItems(HorizontalAlign.Start)
41.        .justifyContent(FlexAlign.SpaceAround)
42.        .height('100%')
43.      }
44.      .padding(20)
45.      .borderRadius(12)
46.      .backgroundColor('#FFECECEC')
47.      .height(120)
48.      .width('100%')
49.      .justifyContent(FlexAlign.SpaceBetween)
50.    }
51.  }


运行效果如下图所示。

图5 骨架屏运行效果图

数据源数组项发生变化

在数据源数组项发生变化的场景下,例如进行数组插入、删除操作或者数组项索引位置发生交换时,数据源应为对象数组类型,并使用对象的唯一ID作为最终键值。例如,当在页面上通过手势上滑加载下一页数据时,会在数据源数组尾部新增新获取的数据项,从而使得数据源数组长度增大。


1.  class Article {
2.    id: string;
3.    title: string;
4.    brief: string;

6.    constructor(id: string, title: string, brief: string) {
7.      this.id = id;
8.      this.title = title;
9.      this.brief = brief;
10.    }
11.  }

13.  @Entry
14.  @Component
15.  struct ArticleListView {
16.    @State isListReachEnd: boolean = false;
17.    @State articleList: Array<Article> = [
18.      new Article('001', '第1篇文章', '文章简介内容'),
19.      new Article('002', '第2篇文章', '文章简介内容'),
20.      new Article('003', '第3篇文章', '文章简介内容'),
21.      new Article('004', '第4篇文章', '文章简介内容'),
22.      new Article('005', '第5篇文章', '文章简介内容'),
23.      new Article('006', '第6篇文章', '文章简介内容')
24.    ]

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

30.    build() {
31.      Column({ space: 5 }) {
32.        List() {
33.          ForEach(this.articleList, (item: Article) => {
34.            ListItem() {
35.              ArticleCard({ article: item })
36.                .margin({ top: 20 })
37.            }
38.          }, (item: Article) => item.id)
39.        }
40.        .onReachEnd(() => {
41.          this.isListReachEnd = true;
42.        })
43.        .parallelGesture(
44.          PanGesture({ direction: PanDirection.Up, distance: 80 })
45.            .onActionStart(() => {
46.              if (this.isListReachEnd) {
47.                this.loadMoreArticles();
48.                this.isListReachEnd = false;
49.              }
50.            })
51.        )
52.        .padding(20)
53.        .scrollBar(BarState.Off)
54.      }
55.      .width('100%')
56.      .height('100%')
57.      .backgroundColor(0xF1F3F5)
58.    }
59.  }

61.  @Component
62.  struct ArticleCard {
63.    @Prop article: Article;

65.    build() {
66.      Row() {
67.        Image($r('app.media.icon'))
68.          .width(80)
69.          .height(80)
70.          .margin({ right: 20 })

72.        Column() {
73.          Text(this.article.title)
74.            .fontSize(20)
75.            .margin({ bottom: 8 })
76.          Text(this.article.brief)
77.            .fontSize(16)
78.            .fontColor(Color.Gray)
79.            .margin({ bottom: 8 })
80.        }
81.        .alignItems(HorizontalAlign.Start)
82.        .width('80%')
83.        .height('100%')
84.      }
85.      .padding(20)
86.      .borderRadius(12)
87.      .backgroundColor('#FFECECEC')
88.      .height(120)
89.      .width('100%')
90.      .justifyContent(FlexAlign.SpaceBetween)
91.    }
92.  }


初始运行效果(左图)和手势上滑加载后效果(右图)如下图所示。

图6 数据源数组项变化案例运行效果图
函数。此函数会在articleList数据源的尾部添加一个新的数据项,从而增加数据源的长度。

  • 数据源被@State装饰器修饰,ArkUI框架能够感知到数据源长度的变化,并触发ForEach进行重新渲染。

    数据源数组项子属性变化

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

    
    1.  @Observed
    2.  class Article {
    3.    id: string;
    4.    title: string;
    5.    brief: string;
    6.    isLiked: boolean;
    7.    likesCount: number;
    
    9.    constructor(id: string, title: string, brief: string, isLiked: boolean, likesCount: number) {
    10.      this.id = id;
    11.      this.title = title;
    12.      this.brief = brief;
    13.      this.isLiked = isLiked;
    14.      this.likesCount = likesCount;
    15.    }
    16.  }
    
    18.  @Entry
    19.  @Component
    20.  struct ArticleListView {
    21.    @State articleList: Array<Article> = [
    22.      new Article('001', '第0篇文章', '文章简介内容', false, 100),
    23.      new Article('002', '第1篇文章', '文章简介内容', false, 100),
    24.      new Article('003', '第2篇文章', '文章简介内容', false, 100),
    25.      new Article('004', '第4篇文章', '文章简介内容', false, 100),
    26.      new Article('005', '第5篇文章', '文章简介内容', false, 100),
    27.      new Article('006', '第6篇文章', '文章简介内容', false, 100),
    28.    ];
    
    30.    build() {
    31.      List() {
    32.        ForEach(this.articleList, (item: Article) => {
    33.          ListItem() {
    34.            ArticleCard({
    35.              article: item
    36.            })
    37.              .margin({ top: 20 })
    38.          }
    39.        }, (item: Article) => item.id)
    40.      }
    41.      .padding(20)
    42.      .scrollBar(BarState.Off)
    43.      .backgroundColor(0xF1F3F5)
    44.    }
    45.  }
    
    47.  @Component
    48.  struct ArticleCard {
    49.    @ObjectLink article: Article;
    
    51.    handleLiked() {
    52.      this.article.isLiked = !this.article.isLiked;
    53.      this.article.likesCount = this.article.isLiked ? this.article.likesCount + 1 : this.article.likesCount - 1;
    54.    }
    
    56.    build() {
    57.      Row() {
    58.        Image($r('app.media.icon'))
    59.          .width(80)
    60.          .height(80)
    61.          .margin({ right: 20 })
    
    63.        Column() {
    64.          Text(this.article.title)
    65.            .fontSize(20)
    66.            .margin({ bottom: 8 })
    67.          Text(this.article.brief)
    68.            .fontSize(16)
    69.            .fontColor(Color.Gray)
    70.            .margin({ bottom: 8 })
    
    72.          Row() {
    73.            Image(this.article.isLiked ? $r('app.media.iconLiked') : $r('app.media.iconUnLiked'))
    74.              .width(24)
    75.              .height(24)
    76.              .margin({ right: 8 })
    77.            Text(this.article.likesCount.toString())
    78.              .fontSize(16)
    79.          }
    80.          .onClick(() => this.handleLiked())
    81.          .justifyContent(FlexAlign.Center)
    82.        }
    83.        .alignItems(HorizontalAlign.Start)
    84.        .width('80%')
    85.        .height('100%')
    86.      }
    87.      .padding(20)
    88.      .borderRadius(12)
    89.      .backgroundColor('#FFECECEC')
    90.      .height(120)
    91.      .width('100%')
    92.      .justifyContent(FlexAlign.SpaceBetween)
    93.    }
    94.  }
    
    
    

    上述代码的初始运行效果(左图)和点击第1个文章卡片上的点赞图标后的运行效果(右图)如下图所示。

    图7 数据源数组项子属性变化案例运行效果图

    不推荐案例

    在这里插入图片描述

    错误使用示例

    1. 当最终键值生成规则包含index时,期望的界面渲染结果为['one', 'new item', 'two', 'three'],而实际的渲染结果为['one', 'two', 'three', 'three'],渲染结果不符合开发者预期。因此,开发者在使用ForEach时应尽量避免最终键值生成规则中包含index。
    2. 尽管此示例中界面渲染的结果符合预期,但每次插入一条新数组项时,ForEach都会为从该数组项起后面的所有数组项全部重新创建组件。当数据源数据量较大或组件结构复杂时,由于组件无法得到复用,将导致性能体验不佳。

    使用建议

    • 尽量避免在最终的键值生成规则中包含数据项索引index,以防止出现渲染结果非预期渲染性能降低。如果业务确实需要使用index,例如列表需要通过index进行条件渲染,开发者需要接受ForEach在改变数据源后重新创建组件所带来的性能损耗。
    • 为满足键值的唯一性,对于对象数据类型,建议使用对象数据中的唯一id作为键值。
    • 基本数据类型的数据项没有唯一ID属性。如果使用基本数据类型本身作为键值,必须确保数组项无重复。因此,对于数据源会发生变化的场景,建议将基本数据类型数组转化为具备唯一ID属性的对象数据类型数组,再使用ID属性作为键值生成规则。