HarmonyOS应用开发04-组件状态管理

系列文章目录

HarmonyOS应用开发01-ArkTS基础知识

HarmonyOS应用开发02-程序框架UIAbility、启动模式与路由跳转

HarmonyOS应用开发03-基础组件-让我们来码出复杂酷炫的UI

HarmonyOS应用开发04-组件状态管理


上海今天好冷啊(多想有人可以拥抱在一起啊,哈哈哈)

前言

ArkUI开发框架提供了多维度的状态管理机制,和UI相关联的数据,不仅可以在组件内使用,还可以在不同组件层级间传递,比如父子组件之间,爷孙组件之间等,也可以是全局范围内的传递,还可以是跨设备传递。另外,从数据的传递形式来看,可以分为只读的单向传递和可变更的双向传递。


先上图:

一、组件的状态管理

ArkUI作为一种声明式UI,具有状态驱动UI更新的特点。当用户进行界面交互或有外部事件引起状态改变时,状态的变化会触发组件自动更新。所以在ArkUI中,我们只需要通过一个变量来记录状态。当改变状态的时候,ArkUI就会自动更新界面中受影响的部分。

1、ArkUI框架提供了多种管理状态的装饰器来修饰变量,使用这些装饰器修饰的变量即称为状态变量。

2、在组件范围传递的状态管理常见的场景如下:

场景 装饰器
组件内的状态管理 @State
与父组件双向同步状态 @Link
跨组件层级双向同步状态 @Provide@Consume

(1)、在组件内使用@State装饰器来修饰变量,可以使组件根据不同的状态来呈现不同的效果。

(2)、若当前组件的状态需要通过其父组件传递而来,此时需要使用@Prop装饰器;

(3)、若是父子组件状态需要相互绑定进行双向同步,则需要使用@Link装饰器。

(4)、使用@Provide@Consume装饰器可以实现跨组件层级双向同步状态。

(5)、在实际应用开发中,应用会根据需要封装数据模型。如果需要观察嵌套类对象属性变化,需要使用@Observed@ObjectLink装饰器,因为上述表格中的装饰器只能观察到对象的第一层属性变化。

(6)、当状态改变,需要对状态变化进行监听做一些相应的操作时,可以使用@Watch装饰器来修饰状态。

二、应用实例

1、组件内的状态管理:@State

实际开发中由于交互,组件的内容呈现可能产生变化。当需要在组件内使用状态来控制UI的不同呈现方式时,可以使用@State装饰器。

@State状态数据的特征:
  • 支持多种数据类型:允许 classnumberbooleanstring 强类型的按值和按引用类型。允许这些强类型构成的数组,即Array<class>Array<string>Array<boolean>Array<number>。不允许 objectany
  • 内部私有:标记为@State的属性是私有变量,只能在组件内访问
  • 支持多个实例:组件不同实例的内部状态数据独立。
  • 需要本地初始化:必须为所有@State变量分配初始值,将变量保持未初始化可能导致框架行为未定义,初始值需要是有意义的值,比如设置 class 类型的值为 null 就是无意义的,会导致编译报错。
  • 创建自定义组件时支持通过状态变量名设置初始值:在创建组件实例时,可以通过变量名显式指定@State状态属性的初始值。

使用@State装饰符改变组件状态,声明式UI的特点就是UI是随数据更改而自动刷新的,我们这里定义了一个类型为 boolean 的变量 isChecked,其被 @State 装饰后,框架内建立了数据和视图之间的绑定,其值的改变影响UI的显示。

typescript 复制代码
@State isChecked: boolean = false;

核心代码:

typescript 复制代码
@Component
export default struct StudentListItem {
  @State isChecked: boolean = false;
  private name?: string;

  @Builder checkIcon(icon: Resource) {
    Image(icon)
      .objectFit(ImageFit.Contain)
      .width($r('app.float.checkbox_width'))
      .height($r('app.float.checkbox_height'))
      .margin($r('app.float.checkbox_margin'))
  }

  build() {
    Row() {
      if (this.isChecked) {
        this.checkIcon($r('app.media.ic_checked'))
      } else {
        this.checkIcon($r('app.media.ic_unchecked'))
      }

      Text(this.name)
        .fontColor(this.isChecked ? Color.Red : Color.Black)
        .fontSize(this.isChecked ? $r('app.float.item_checked_font_size') : $r('app.float.item_font_size'))
        .fontWeight(500)
        .opacity(this.isChecked ? 0.5 : 1.0)
        .decoration({ type: this.isChecked ? TextDecorationType.LineThrough : TextDecorationType.None })
    }
    .borderRadius(22)
    .backgroundColor($r('app.color.start_window_background'))
    .width('100%')
    .height($r('app.float.list_item_height'))
    .onClick(() => {
      this.isChecked = !this.isChecked;
    })
  }
}

2、从父组件单向同步状态:@Prop

当子组件中的状态依赖从父组件传递而来时,需要使用@Prop装饰器,@Prop修饰的变量可以和其父组件中的状态建立单向同步关系。当父组件中状态变化时,该状态值也会更新至@Prop修饰的变量;对@Prop修饰的变量的修改不会影响其父组件中的状态。

@Prop@State有相同的语义,但初始化方式不同,@Prop装饰的变量可以和父组件的@State变量建立单向的数据绑定。即@Prop修饰的变量必须使用其父组件提供的@State变量进行初始化,允许组件内部修改@Prop变量值但更改不会通知给父组件。

@Prop状态数据具有以下特征:
  • 支持简单数据类型:仅支持 numberstringboolean简单类型;
  • 内部私有:标记为 @Prop 的属性是私有变量,只能在组件内访问。
  • 支持多个实例:组件不同实例的内部状态数据独立。
  • 不支持内部初始化:在创建组件的新实例时,必须将值传递给 @Prop 修饰的变量进行初始化,不支持在组件内部进行初始化。

在示例应用中,当用户点击切换子学生列表的 Toggle({ type: ToggleType.Switch, isOn: true }) 按钮状态为false时,列表进入Grid网格模式,点击切换为true状态时,列表切换为List线性列表模式。整个列表是自定义组件StudentListPage。中间区域则是用来显示每个目标项,目标项是自定义组件StudentListItem。从图中可以看出,StudentListItemStudentListPage 的子组件。StudentListPageStudentListItem 的父组件。

  • 对于父组件StudentListPage,其列表显示模式会随Switch模式的变化而变化,因此父组件拥有编辑模式状态。

  • 对于子组件StudentListItem,其高度和宽度也会随编辑模式变化,因此子组件也拥有编辑模式状态。

  • 但是是否进入编辑模式,其触发点是在用户点击切换列表的 Toggle({ type: ToggleType.Switch, isOn: true }) ,状态变化的源头仅在于父组件 StudentListPage 。当父组件 StudentListPage 中的编辑模式变化时,子组件 StudentListItem 的编辑模式状态需要随之变化。

实现思路:

1、在父组件StudentListPage中可以定义一个是否切换显示模式的状态,@State isListModel: boolean = true; 即用@State修饰isListModel。@State修饰的变量不仅是组件内部的状态,也可以作为子组件单向或双向同步的数据源。

2、ArkUI提供了@Prop装饰器,@Prop修饰的变量可以和其父组件中的状态建立单向同步关系,所以用@Prop修饰子组件 StudentListItem 中的isListModel变量 @Prop isListModel: boolean;

3、在父组件StudentListPage中,用@State修饰isListModel,定义列表显示模式状态。然后利用条件渲染实现根据是否切换显示模式,显示不同的列表显示模式。同时,在父组件中需要在用户点击时切换改变状态,触发界面更新。

代码如下:

typescript 复制代码
import router from '@ohos.router';
import CommonConstants from '../common/constants/CommonConstants';
import { DataItemBean } from '../viewmodel/DataItemBean';
import prompt from '@system.prompt';
// import DataItemBean from '../viewmodel/DataItemBean';

@Component
export default struct StudentListItem {

  // 从父组件单向同步状态:@Prop
  @Prop isListModel: boolean;


  build() {
    if (this.isListModel) {
      Column() {
        Row() {
          Row() {
            if (this.isChecked) {
              this.checkIcon($r('app.media.ic_checked'))
            } else {
              this.checkIcon($r('app.media.ic_unchecked'))
            }
          }
          .margin(10)

          Text(this.studentData.title)
            .fontColor(this.isChecked ? Color.Red : Color.Black)
            .fontSize(this.isChecked ? $r('app.float.item_checked_font_size') : $r('app.float.item_font_size'))
            .fontWeight(this.isChecked ? FontWeight.Bolder : FontWeight.Bold)
            .opacity(this.isChecked ? 0.5 : 1.0)
            .decoration({ type: this.isChecked ? TextDecorationType.Underline : TextDecorationType.None })

          Blank()


          Image($r('app.media.ic_arrow_next'))
            .width('30vp')
            .height('30vp')
            .margin({ right: '10vp' })
            .onClick(() => {
              // console.log('Next Click' + this.name);
              console.log('Next Click' + this.studentData.title);
              console.log('Next Click' + this.studentData.image);

              router.pushUrl({
                // url: 'pages/StudentDetailPage',
                url: CommonConstants.STUDENT_DETAIL_URL,
                params: {
                  // name: this.name,
                  studentData: this.studentData
                }
              }).catch((error) => {
                console.log('Next Click', 'IndexPage push error' + JSON.stringify(error));
              })
            })
        }
        .width('100%')
        .height($r('app.float.list_item_height'))
      }
      .borderRadius(22)
      .backgroundColor($r('app.color.start_window_background'))
      .width('100%')
      .height(this.isChecked ? $r('app.float.list_item_expand_height') : $r('app.float.list_item_height'))
      .onClick(() => {
          this.isChecked = !this.isChecked;
      })
    } else {
      Column() {
        Row() {
          Blank()

          Image($r('app.media.ic_arrow_next'))
            .width('15vp')
            .height('15vp')
            .margin(10)
            .onClick(() => {
              // console.log('Next Click' + this.name);
              console.log('Next Click' + this.studentData.title);
              console.log('Next Click' + this.studentData.image);

              router.pushUrl({
                // url: 'pages/StudentDetailPage',
                url: CommonConstants.STUDENT_DETAIL_URL,
                params: {
                  // name: this.name,
                  studentData: this.studentData
                }
              }).catch((error) => {
                console.log('Next Click', 'IndexPage push error' + JSON.stringify(error));
              })
            })
        }
        .width('100%')

        Blank()

        Row() {
          Row() {
            if (this.isChecked) {
              this.checkIcon($r('app.media.ic_checked'), '16vp', '16vp')
            } else {
              this.checkIcon($r('app.media.ic_unchecked'), '16vp', '16vp')
            }
          }
          .onClick(() => {
            ...
          })
          .margin(5)

          Blank()

          Row() {
            Text(this.studentData.title)
              .fontColor(this.isChecked ? Color.Yellow : Color.White)
              .fontSize('12vp')
              .fontWeight(500)
              .opacity(this.isChecked ? 0.5 : 1.0)
              .decoration({ type: this.isChecked ? TextDecorationType.LineThrough : TextDecorationType.None })
          }
          .backgroundColor($r('app.color.transparent_backgroundColor'))
          .borderRadius({ topLeft: 8, bottomRight: 10 })
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        }
        .alignItems(VerticalAlign.Bottom)
        .width('100%')
      }
      .borderRadius(10)
      .width('100%')
      .height($r('app.float.grid_item_height'))
      .backgroundImage(this.studentData.image)
      .backgroundImageSize(ImageSize.Cover)
      .onClick(...)
    }
  }
}

若是父子组件状态需要相互绑定进行双向同步时,可以使用@Link装饰器。父组件中用于初始化子组件@Link变量的必须是在父组件中定义的状态变量。

@Link@State有相同的语义,但初始化方式不同,@Link装饰的变量可以和父组件的 @State 变量建立双向的数据绑定。即@Link修饰的变量必须使用其父组件提供的@State变量进行初始化,允许组件内部修改@Link变量值且更改会通知给父组件。

  • 支持多种数据类型: @Link 变量的值与 @State 变量的类型相同,即 classnumberstringboolean 或这些类型的数组。
  • 内部私有:标记为 @Link 的属性是私有变量,只能在组件内访问。
  • 支持多个实例:组件不同实例的内部状态数据独立。
  • 不支持内部初始化:在创建组件的新实例时,必须将值传递给 @Link 修饰的变量进行初始化,不支持在组件内部进行初始化。初始化使用 $ 符号。

如上图所示,在示例应用中,当用户点击同一个目标,目标项会展开或者收起。当用户点击不同的目标项时,除了被点击的目标项展开,同时前一次被点击的目标项会收起。

  • 在子目标列表中,每个列表项都有其位置索引值index属性,表示目标项在列表中的位置。index从0开始,即第一个目标项的索引值为0,第二个目标项的索引值为1,以此类推。此外,clickIndex用来记录被点击的目标项索引。当点击目标一时,clickIndex为0,点击目标三时,clickIndex为2。

  • 在父组件子目标列表和每个子组件目标项中都拥有clickIndex状态。当目标一展开时,clickIndex为0。此时点击目标三,目标三的clickIndex变为2,只要其父组件子目标列表感知到clickIndex状态变化,同时将此变化传递给目标一。目标一的clickIndex即可同步改变为2,即目标一感知到此时点击了目标三。

实现思路:

1、将列表和目标项对应到列表组件StudentListPage和列表项StudentListItem 。首先,需要在父组件StudentListPage中定义clickIndex状态。

2、若此时子组件中的clickIndex用@Prop装饰器修饰,当子组件中clickIndex变化时,父组件无法感知,因为@Prop装饰器建立的是从父组件到子组件的单向同步关系。

ArkUI提供了@Link装饰器,用于与父组件双向同步状态。当子组件 StudentListItem 中的clickIndex用@Link修饰,可与父组件 StudentListPage 中的clickIndex建立双向同步关系。

在父组件中使用子组件时,将父组件的clickIndex传递给子组件的clickIndex。其中父组件的clickIndex加上 $ 表示传递的是引用。

代码示例:

(1)、StudentListPage
typescript 复制代码
import DataModel from '../viewmodel/DataModel';
import StudentListItem from '../view/StudentListItem';
import router from '@ohos.router';
import prompt from '@system.prompt';
import { DataItemBean } from '../viewmodel/DataItemBean';
import CommonConstants from '../common/constants/CommonConstants';
import { StudentDetailDialog } from '../view/StudentDetailDialog';

const TAG = '[StudentListPage]';

@Entry
@Component
export struct StudentListPage {
  
  ...

  // 是否是List组件模式
  // 组件内的状态管理:@State
  @State isListModel: boolean = true;

  // 是否点击当前Item
  @State clickIndex: number = 0;

  ...


  build() {
    Navigation() {
      Row() {
        if (this.isListModel) {
          Scroll() {
            Column() {
              // Swiper组件
              this.SwiperBuilder(this.studentList2)
      
              List({ space: 16 }) {
                ForEach(this.studentList2, (item: DataItemBean, index: number) => {
                  ListItem() {
                    // StudentListItem({ studentData: item, isListModel: true })

                    // 将父组件的列表显示模式状态this.isListModel传递给子组件的编辑模式状态isListModel
                    // 此处指定的参数都将在初始渲染时覆盖本地定义的默认值,并不是所有的参数都需要从父组件初始化
                    StudentListItem({
                      studentData: item,
                      index: index,
                      isListModel: this.isListModel,
                      clickIndex: $clickIndex, // 带有"@Link"装饰的属性必须初始化为"$"
                    })
                  }
                }, (item, index) => JSON.stringify(item) + index)
              }
              // .width('90%')
              .divider({ strokeWidth: 1, color: Color.Gray, startMargin: 30, endMargin: 0 })
              // .listDirection(Axis.Horizontal)

              Text('---没有更多了---').fontSize('22vp').margin('30vp')
            }
          }
          .scrollBar(BarState.Off)
          .edgeEffect(EdgeEffect.Spring)
        } else {
          Scroll() {
            Column() {
              // Swiper组件
              this.SwiperBuilder(this.studentList2)

              Grid() {
                ForEach(this.studentList2, (item: DataItemBean) => {
                  GridItem() {
                    StudentListItem({
                      studentData: item,
                      isListModel: this.isListModel,
                      clickIndex: $clickIndex, // 带有"@Link"装饰的属性必须初始化为"$"
                    })
                  }
                }, (item: string) => JSON.stringify(item))
              }
              .columnsTemplate('1fr 1fr 1fr')
              .rowsTemplate('1fr 1fr 1fr 1fr 1fr')
              .columnsGap('10vp')
              .rowsGap('10vp')
              .height('640vp')
              // .layoutDirection(GridDirection.Row)

              Text('---没有更多了---').fontSize('22vp').margin('30vp')
            }
          }
          .scrollBar(BarState.Off)
          .edgeEffect(EdgeEffect.Spring)
        }
      }
      .width('90%')
      // .margin({ left: 10, right: 10 })
    }
    .title('学生名单')
    .size({ width: '100%', height: '100%' })
    .titleMode(NavigationTitleMode.Mini)
    .hideBackButton(true)
    .menus(this.NavigationMenus())
    .backgroundColor($r('app.color.page_background'))
  }
}

4、@Watch

@Watch 用来监听状态变量的变化,当它修饰的状态变量发生变更时,回调相应的方式

📢: 注意: @Watch装饰器只能监听@State@Prop@Link@ObjectLink@Provide@Consume@StorageProp以及@StorageLink装饰的变量。

(2)、StudentListItem

ArkUI中监听状态变化@Watch的能力。用@Watch修饰的状态,当状态发生变化时,会触发声明时定义的回调。给StudentListItem的中的clickIndex状态加上@Watch("onClickIndexChanged")。这表示需要监听clickIndex状态的变化。当clickIndex状态变化时,将触发onClickIndexChanged回调:如果点击的列表项索引不等于当前列表项索引,则将isExpanded状态置为false,从而收起该目标项。

typescript 复制代码
import router from '@ohos.router';
import CommonConstants from '../common/constants/CommonConstants';
import { DataItemBean } from '../viewmodel/DataItemBean';
import prompt from '@system.prompt';
// import DataItemBean from '../viewmodel/DataItemBean';

@Component
export default struct StudentListItem {
  
  ...

  private index: number;

  // @State isListModel: boolean = true;

  // 从父组件单向同步状态:@Prop
  @Prop isListModel: boolean;

  // 是否选中当前Item
  @State isChecked: boolean = false;

  // 1、ArkUI提供了@Link装饰器,用于与父组件双向同步状态。当子组件StudentListItem中的clickIndex用@Link修饰,可与父组件StudentListPage中的clickIndex建立双向同步关系。
  // 2、ArkUI中监听状态变化@Watch的能力。用@Watch修饰的状态,当状态发生变化时,会触发声明时定义的回调。
  // 给StudentListItem的中的clickIndex状态加上@Watch("onClickIndexChanged")。这表示需要监听clickIndex状态的变化。

  @Link @Watch('onClickIndexChanged') clickIndex: number;

  // 当clickIndex状态变化时,将触发onClickIndexChanged回调:如果点击的列表项索引不等于当前列表项索引,则将isExpanded状态置为false,从而收起该目标项。
  onClickIndexChanged() {
    if (this.clickIndex != this.index) {
      this.isChecked = false;
    } else {
      this.isChecked = true;
    }
  }

  ...


  build() {
    if (this.isListModel) {
      Column() {
        Row() {
          
          ...

          Blank()

          if (this.isChecked) {
            Text("爆")
              .margin({ right: 10 })
              .textAlign(TextAlign.Center)
              .fontWeight(600)
              .fontSize('14fp')
              .backgroundColor(Color.Red)
              .fontColor(Color.White)
              .borderRadius(5)
              .width(22)
              .height(22)
          }

          ...

        }
        .width('100%')
        .height($r('app.float.list_item_height'))

        if (this.isChecked) {
          Row() {
            
            ...

            Image(this.studentData.image)
              .width('50vp')
              .height('50vp')
              .border({ width: 3 })
              .borderColor(Color.Yellow)
              .borderRadius(100)
              .borderStyle(BorderStyle.Dotted)
              .onClick(this.onItemChildImageClick)

          }
          .width('100%')
          .padding({ left: 10, right: 10 })
          .justifyContent(FlexAlign.SpaceAround)
        }
      }
      .borderRadius(22)
      .backgroundColor($r('app.color.start_window_background'))
      .width('100%')
      .height(this.isChecked ? $r('app.float.list_item_expand_height') : $r('app.float.list_item_height'))
      .onClick(() => {
        this.isChecked = !this.isChecked;

        this.clickIndex = this.index;
      })
    } else {
      Column() {
        Row() {
          Blank()

          ...
          
        }
        .width('100%')

        Blank()

        Row() {
          Row() {
            if (this.isChecked) {
              this.checkIcon($r('app.media.ic_checked'), '16vp', '16vp')
            } else {
              this.checkIcon($r('app.media.ic_unchecked'), '16vp', '16vp')
            }
          }
          .onClick(() => {
            this.isChecked = !this.isChecked;
          })
          .margin(5)

          Blank()

          Row() {
            Text(this.studentData.title)
              .fontColor(this.isChecked ? Color.Yellow : Color.White)
              .fontSize('12vp')
              .fontWeight(500)
              .opacity(this.isChecked ? 0.5 : 1.0)
              .decoration({ type: this.isChecked ? TextDecorationType.LineThrough : TextDecorationType.None })
          }
          .backgroundColor($r('app.color.transparent_backgroundColor'))
          .borderRadius({ topLeft: 8, bottomRight: 10 })
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        }
        .alignItems(VerticalAlign.Bottom)
        .width('100%')
      }
      .borderRadius(10)
      .width('100%')
      .height($r('app.float.grid_item_height'))
      .backgroundImage(this.studentData.image)
      .backgroundImageSize(ImageSize.Cover)
      .onClick(this.onItemChildImageClick)
    }
  }
}

总结

在实际开发过程中,我们可以根据具体的业务场景选择不同的状态管理模式进行开发。选择合适的状态管理模式对应相应的业务场景,这一点才是相对最重要的。

相关推荐
煸橙干儿~~2 分钟前
应用性能优化实践(三)减少丢帧卡顿
华为·harmonyos
Mercury Random26 分钟前
Qwen 个人笔记
android·笔记
苏苏码不动了33 分钟前
Android 如何使用jdk命令给应用/APK重新签名。
android
aqi001 小时前
FFmpeg开发笔记(五十三)移动端的国产直播录制工具EasyPusher
android·ffmpeg·音视频·直播·流媒体
xiaoduyyy2 小时前
【Android】ToolBar,滑动菜单,悬浮按钮和可交互提示等的使用方法
android
liyy6142 小时前
Android架构组件:MVVM模式的实战应用与数据绑定技巧
android
OH五星上将3 小时前
OpenHarmony(鸿蒙南向开发)——小型系统内核(LiteOS-A)【扩展组件】上
linux·嵌入式硬件·harmonyos·openharmony·鸿蒙开发·liteos-a·鸿蒙内核
K1t04 小时前
Android-UI设计
android·ui
吃汉堡吃到饱6 小时前
【Android】浅析MVC与MVP
android·mvc
OH五星上将7 小时前
OpenHarmony(鸿蒙南向开发)——小型系统内核(LiteOS-A)【内核通信机制】下
harmonyos·openharmony·鸿蒙开发·liteos-a·鸿蒙内核·子系统·内核通信