跳至主要內容

状态管理

大约 9 分钟

在声明式UI编程框架中,UI是程序状态的运行结果,用户构建了一个UI模型,其中应用的运行时的状态是参数。当参数改变时,UI作为返回结果,也将进行对应的改变。这些运行时的状态变化所带来的UI的重新渲染,在ArkUI中统称为状态管理机制。

自定义组件拥有变量,变量必须被装饰器装饰才可以成为状态变量,状态变量的改变会引起UI的渲染刷新。如果不使用状态变量,UI只能在初始化时渲染,后续将不会再刷新。 下图展示了State和View(UI)之间的关系。

https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V2/arkts-state-management-overview-0000001524537145-V2

  • View(UI):UI渲染,指将build方法内的UI描述和@Builder装饰的方法内的UI描述映射到界面。

  • State:状态,指驱动UI更新的数据。用户通过触发组件的事件方法,改变状态数据。状态数据的改变,引起UI的重新渲染。

基本概念

  • 状态变量:被状态装饰器装饰的变量,状态变量值的改变会引起UI的渲染更新。示例:@State num: number = 1,其中,@State是状态装饰器,num是状态变量。
  • 常规变量:没有被状态装饰器装饰的变量,通常应用于辅助计算。它的改变永远不会引起UI的刷新。以下示例中increaseBy变量为常规变量。
  • 数据源/同步源:状态变量的原始来源,可以同步给不同的状态数据。通常意义为父组件传给子组件的数据。以下示例中数据源为count: 1。
  • 命名参数机制:父组件通过指定参数传递给子组件的状态变量,为父子传递同步参数的主要手段。示例:CompA: ({ aProp: this.aProp })。
  • 从父组件初始化:父组件使用命名参数机制,将指定参数传递给子组件。子组件初始化的默认值在有父组件传值的情况下,会被覆盖。示例:
@Component
struct MyComponent {
  @State count: number = 0;
  private increaseBy: number = 1;

  build() {
  }
}

@Component
struct Parent {
  build() {
    Column() {
      // 从父组件初始化,覆盖本地定义的默认值
      MyComponent({ count: 1, increaseBy: 2 })
    }
  }
}
  • 初始化子节点:父组件中状态变量可以传递给子组件,初始化子组件对应的状态变量。示例同上。

  • 本地初始化:在变量声明的时候赋值,作为变量的默认值。示例:@State count: number = 0。

管理组件拥有的状态

@State装饰器:组件内状态

@state 装饰器标记的变量必须初始化, 不能为空值

@state 支持Object、class、string、number、boolean、enum类型,以及这些类型的数组。不支持any,不支持简单类型和复杂类型的联合类型,不允许使用undefined和null。

嵌套类型以及数组中的对象属性无法触发视图更新

class Person {
  name: string
  age: number
  gf: Person


  constructor(name: string, age: number, gf?: Person) {
    this.age = age
    this.name = name
    this.gf = gf
  }
}

@Entry
@Component
struct Second {
  @State p: Person = new Person('jack', 21, new Person('aaa', 11))

  build() {
    Column() {
      Text(`${this.p.name}: ${this.p.age}`)
        .fontSize(50)
        .onClick(() => {
          this.p.age ++
        })

      Text(`${this.p.gf.name}: ${this.p.gf.age}`)
        .fontSize(50)
        .onClick(() => {
          console.log(`${this.p.gf.name}: ${this.p.gf.age}`)
          this.p.gf.age ++
        })
    }
    .width("100%")
  }
}

当点击下面时,不会触发视图更新, 只有点击上面非嵌套属性时才会触发视图整体更新

@State装饰的变量,或称为状态变量,一旦变量拥有了状态属性,就和自定义组件的渲染绑定起来。当状态改变时,UI会发生对应的渲染改变。

在状态变量相关装饰器中,@State是最基础的,使变量拥有状态属性的装饰器,它也是大部分状态变量的数据源。

@State装饰的变量,与声明式范式中的其他被装饰变量一样,是私有的,只能从组件内部访问,在声明时必须指定其类型和本地初始化。初始化也可选择使用命名参数机制从父组件完成初始化。

@State装饰的变量拥有以下特点:

  • @State装饰的变量与子组件中的@Prop装饰变量之间建立单向数据同步,与@Link、@ObjectLink装饰变量之间建立双向数据同步。
  • @State装饰的变量生命周期与其所属自定义组件的生命周期相同。

案例

class Task {
  static id: number = 1
  name: string = `Task${Task.id++}`
  finished: boolean = false
}

@Styles function card() {
  .width("95%")
  .padding(20)
  .backgroundColor(Color.White)
  .borderRadius(15)
  .shadow(
    {
      radius: 6,
      color: "#1F000000",
      offsetX: 2,
      offsetY: 4
    }
  )
}

@Extend(Text) function finishedTask() {
  .decoration({ type: TextDecorationType.LineThrough })
  .fontSize("##B1B2B1")
}

@Entry
@Component
struct Second {
  @State totalTask: number = 0
  @State finishTask: number = 0
  @State task: Task[] = []

  handTaskChange() {
    this.totalTask = this.task.length
    this.finishTask = this.task.filter(item => item.finished).length
  }

  build() {
    Column({ space: 10 }) {
      // 任务进度卡片
      Row() {
        Text("任务进度: ")
          .fontSize(30)
          .fontWeight(FontWeight.Bold)

        Stack() {
          Progress({
            value: this.finishTask,
            total: this.totalTask,
            type: ProgressType.Ring
          })
            .width(100)

          Row() {
            Text(this.finishTask.toString())
              .fontSize(24)
              .fontColor("#36D")
            Text("/" + this.totalTask.toString())
              .fontSize(24)
          }
        }
      }
      .card()
      .margin({ top: 20, bottom: 10 })
      .justifyContent(FlexAlign.SpaceEvenly)

      // 新增任务按钮
      Button("添加任务")
        .width(200)
        .onClick(() => {
          this.task.push(new Task())
          this.handTaskChange()
        })

      // 渲染任务列表
      List({ space: 10 }) {
        ForEach(
          this.task,
          (item: Task, index) => {
            ListItem() {
              Row() {
                Text(item.name)
                  .fontSize(24)

                Checkbox()
                  .select(item.finished)
                  .onChange(value => {
                    item.finished = value
                    this.handTaskChange()
                  })
              }
              .card()
              .justifyContent(FlexAlign.SpaceBetween)
            }
            .swipeAction({
              end: this.DeleteButton(index)
            })
          }
        )
      }
      .width("100%")
      .alignListItem(ListItemAlign.Center)
      .layoutWeight(1)
    }
    .width("100%")
    .height("100%")
    .backgroundColor("#F1F2F3")
  }

  // 构建函数
  @Builder DeleteButton(index: number) {
    Text("Del")
      .fontColor(Color.White)
      .padding(20)
      .backgroundColor(Color.Red)
      .borderRadius("50%")
      .margin(5)
      .onClick(() => {
        this.task.splice(index, 1)
        this.handTaskChange()
      })
  }
}

@Prop 父子组件单向同步: @Prop装饰的变量可以和父组件建立单向的同步关系。@Prop装饰的变量是可变的,但是变化不会同步回其父组件。 @Link 父子组件双向同步: 子组件中被@Link装饰的变量与其父组件中对应的数据源建立双向数据绑定。@Link装饰的变量与其父组件中的数据源共享相同的值。

在最新版本中, @Prop 可以在子组件初始化, 但是编辑器 eslint 会报错

@Component
struct Child {
  @Prop value: number;

  build() {
    Text(`${this.value}`)
      .fontSize(50)
      .onClick(() => {
        console.log(`${this.value}`)
        this.value++
      })
  }
}

@Entry
@Component
struct Index {
  @State arr: number[] = [1, 2, 3];

  build() {
    Row() {
      Column() {
        Child({ value: this.arr[0] })
        Child({ value: this.arr[1] })
        Child({ value: this.arr[2] })

        Divider().height(5)

        ForEach(this.arr,
          item => {
            Child({ 'value': item } as Record<string, number>)
          },
          item => item.toString()
        )
        Text('replace entire arr')
          .fontSize(50)
          .onClick(() => {
            // 两个数组都包含项“3”。
            this.arr = this.arr[0] == 1 ? [3, 4, 5] : [1, 2, 3];
          })
      }
    }
  }
}

如果点击界面上的“1”六次、“2”五次、“3”四次,将所有变量的本地取值都变为“7”。

7
7
7
----
7
7
7

单击replace entire arr后,屏幕将显示以下信息,为什么?

3
4
5
----
7
4
5
  • 在子组件Child中做的所有的修改都不会同步回父组件Index组件,所以即使6个组件显示都为7,但在父组件Index中,this.arr保存的值依旧是[1,2,3]。
  • 点击replace entire arr,this.arr[0] == 1成立,将this.arr赋值为[3, 4, 5];
  • 因为this.arr[0]已更改,Child({value: this.arr[0]})组件将this.arr[0]更新同步到实例@Prop装饰的变量。Child({value: this.arr[1]})和Child({value: this.arr[2]})的情况也类似。
  • this.arr的更改触发ForEach更新,this.arr更新的前后都有数值为3的数组项:[3, 4, 5] 和[1, 2, 3]。根据diff算法,数组项“3”将被保留,删除“1”和“2”的数组项,添加为“4”和“5”的数组项。这就意味着,数组项“3”的组件不会重新生成,而是将其移动到第一位。所以“3”对应的组件不会更新,此时“3”对应的组件数值为“7”,ForEach最终的渲染结果是“7”,“4”,“5”。

diff算法是一种用于比较两个数据结构(比如两个数组或两个树)之间差异的算法。在前端开发中,diff算法通常用于虚拟DOM的更新和渲染优化。

在React等前端框架中,当数据发生变化时,diff算法可以帮助确定哪些DOM节点需要被更新,哪些需要被添加或删除,以及哪些可以被保留而不进行重新渲染。这可以提高性能并减少不必要的DOM操作。

diff算法通常包括以下步骤:

  • 比较两个数据结构的差异,找出新增、删除和更新的部分。
  • 标记需要进行更新的部分,并记录其变化。
  • 应用这些变化,更新DOM或其他视图。
@Styles function btn() {
  .margin(12)
  .width(312)
  .height(40)
}


@Component
struct Child {
  @Link items: number[];

  build() {
    Column() {
      Button(`Button1: push`)
        .btn()
        .onClick(() => {
          this.items.push(this.items.length + 1);
        })
      Button(`Button2: replace whole item`)
        .btn()
        .onClick(() => {
          this.items = [100, 200, 300];
        })
    }
  }
}

@Entry
@Component
struct Parent {
  @State arr: number[] = [1, 2, 3];

  build() {
    Column() {
      Button('Button Parent push')
        .btn()
        .onClick(() => {
          this.arr.push(this.arr.length + 1);
        })
      Child({ items: $arr })
        .margin(12)
      ForEach(this.arr,
        (item: number) => {
          Button(`${item}`)
            .margin(12)
            .width(312)
            .height(40)
            .backgroundColor('#11a2a2a2')
            .fontColor('#e6000000')
        },
        (item: ForEachInterface) => item.toString()
      )
    }
  }
}

@Observed装饰器和@ObjectLink装饰器:嵌套类对象属性变化

上文所述的装饰器仅能观察到第一层的变化,但是在实际应用开发中,应用会根据开发需要,封装自己的数据模型。对于多层嵌套的情况,比如二维数组,或者数组项class,或者class的属性是class,他们的第二层的属性变化是无法观察到的。这就引出了@Observed/@ObjectLink装饰器。

以下是引入@Observed/@ObjectLink装饰器的示例:

@Observed
class Person {
  name: string
  age: number
  gf: Person

  constructor(name: string, age: number, gf?: Person) {
    this.name = name
    this.age = age
    this.gf = gf
  }
}

@Entry
@Component
struct Child {
  @State p: Person = new Person("Waite", 18, new Person("www", 11))
  @State gfs: Person[] = [
    new Person("Jack", 18),
    new Person("XiaoMing", 20)
  ]

  build() {
    Column({ space: 10 }) {
      PersonChild({ p: this.p.gf })
        .onClick(() => {
          console.log(`${this.p.gf.age}`)
          this.p.gf.age++
        })

      Text("Girl Friends List")

      ForEach(
        this.gfs,
        item => {
          PersonChild({ p: item })
            .onClick(() => item.age++)
        }
      )
    }
  }
}

@Component
struct PersonChild {
  @ObjectLink p: Person

  build() {
    Column() {
      Text(`${this.p.name}: ${this.p.age}`)
    }
  }
}

为了给变量加 @ObjectLink 装饰器, 写成组件 -> 因为装饰器没法装饰一个参数

@Observed class 不管嵌套几个类型, 凡是嵌套的, 都要加上 @Observed 装饰器

完善案例, 当 Checkbox 选中时, 会触发对应的任务完成状态, 任务完成状态改变时, 会触发任务进度的更新

@Observed
class Task {
  static id: number = 1
  name: string = `Task${Task.id++}`
  finished: boolean = false
}

@Styles function card() {
  .width("95%")
  .padding(20)
  .backgroundColor(Color.White)
  .borderRadius(15)
  .shadow(
    {
      radius: 6,
      color: "#1F000000",
      offsetX: 2,
      offsetY: 4
    }
  )
}

@Extend(Text) function finishedTask() {
  .fontColor("#B1B2B1")
}

@Entry
@Component
struct Second {
  @State totalTask: number = 0
  @State finishTask: number = 0
  @State task: Task[] = []

  handTaskChange() {
    this.totalTask = this.task.length
    this.finishTask = this.task.filter(item => item.finished).length
  }

  build() {
    Column({ space: 10 }) {
      // 任务进度卡片
      TaskStatistics({ finishTask: this.finishTask, totalTask: this.totalTask })

      // 新增任务按钮
      Button("添加任务")
        .width(200)
        .onClick(() => {
          this.task.push(new Task())
          this.handTaskChange()
        })

      // 渲染任务列表
      List({ space: 10 }) {
        ForEach(
          this.task,
          (item: Task, index) => {
            ListItem() {
              itemChild({item: item, onTaskChanged: () => this.handTaskChange()})
            }
            .swipeAction({
              end: this.DeleteButton(index)
            })
          }
        )
      }
      .width("100%")
      .height(0)
      .alignListItem(ListItemAlign.Center)
      .layoutWeight(1)
    }
    .width("100%")
    .height("100%")
    .backgroundColor("#F1F2F3")
  }

  // 构建函数
  @Builder DeleteButton(index: number) {
    Text("Del")
      .fontColor(Color.White)
      .padding(20)
      .backgroundColor(Color.Red)
      .borderRadius("50%")
      .margin(5)
      .onClick(() => {
        this.task.splice(index, 1)
        this.handTaskChange()
      })
  }
}

@Component
struct TaskStatistics {
  @Prop finishTask: number
  @Prop totalTask: number

  build() {
    Row() {
      Text("任务进度: ")
        .fontSize(30)
        .fontWeight(FontWeight.Bold)

      Stack() {
        Progress({
          value: this.finishTask,
          total: this.totalTask,
          type: ProgressType.Ring
        })
          .width(100)

        Row() {
          Text(this.finishTask.toString())
            .fontSize(24)
            .fontColor("#36D")
          Text("/" + this.totalTask.toString())
            .fontSize(24)
        }
      }
    }
    .card()
    .margin({ top: 20, bottom: 10 })
    .justifyContent(FlexAlign.SpaceEvenly)
  }
}

@Component
struct itemChild {
  @ObjectLink item: Task
  onTaskChanged: () => void

  build(){
    Row() {
      if (this.item.finished) {
        Text(this.item.name)
          .finishedTask()
      }
      else {
        Text(this.item.name)
      }


      Checkbox()
        .select(this.item.finished)
        .onChange(value => {
          this.item.finished = value
          this.onTaskChanged()
        })
    }
    .card()
    .justifyContent(FlexAlign.SpaceBetween)
  }
}