从零开始的鸿蒙开发之旅(四)——状态管理进阶

470 阅读7分钟

前言

好久没更新鸿蒙系列了,因为开发过程中踩的坑实在太多了

  • “我这个组件怎么没更新?”
  • “我要怎么把这个值传过去?”
  • “哎呀,怎么点了没反应?”
  • “天呐,怎么闪退了!”

作为鸿蒙开发新上手的同学,经常会在开发过程中遇到组件间状态管理的各种问题,这些问题无非就是数据没传递 、 UI没刷新 ,看似简单的两个问题,如果对鸿蒙复杂又简陋的状态管理不够了解的话是不好解决的。

本文基于鸿蒙V1稳定版状态管理

状态管理简述

由于很多同学可能刚接触声明式UI,我还是简述一下状态管理是啥。

组件状态

组件状态其实很好理解,就是我组件里有一个变量,这个变量的变更会影响到组件的渲染,,那么这个组件就是有状态的,一个永远不会变的组件是没有状态的。

  • 一个固定text的TextView是无状态的
  • 一个text会变更的TextView是有状态的

在鸿蒙里对应组件状态的是一个装饰器@State,用@State装饰的变量的变化会导致组件响应变化,它私有,它必须显式赋值,它可以响应对象自身的赋值变化。

状态管理

有了组件状态,就一定需要管理。管理其实无非以下几个方面:修改  、存储 、读取 。但是!如果涉及到主体和客体,这个问题可能就有些棘手了,谁来修改谁的状态,谁来读取谁的状态,又要把状态存储到哪里?这三个问题将很大程度上影响状态管理的逻辑。尤其是根据编程的范围最小原则,总不能所有东西都是全局的。这里就不卖关子了,大家看一下华为的文档也都明白。我直接梳理了下面这张图。

比起其他声明式UI的状态上提以及副作用控制,鸿蒙反而使用五花八门的装饰器来做状态管理。

所以当我们考虑到状态管理的时候,要考虑这几个方面

  • 状态更新是否嵌套
  • 状态管理的范围多大
  • 状态管理是双向还是单向

很简单的三个问题,但嵌套是一个大坑,只能用ObjectLink 咯,但是很遗憾,ObjectLink是必须父子传递的,

我就是要在当前组件嵌套观察状态!那么我就陷入大坑咯。

案例分析 

需求介绍

现在我们需要实现这样一个页面。

  • View A组件有id:number, isSelected:boolean 两个属性,其中自身的click方法可以让isSelected取反
  • View B是View A的父组件,使用List和ForEach来展示ViewAList,ViewB具备全选按钮,需要让ViewAList所有的isSelected变true
  • View C在View B的底部,用于展示Select的数量

这其实是一个很常见的集合布局的最简单抽象,适用于购物、CMS等多种业务场景。

代码分析

现在我们分析一下每一个View的代码结构

class ClassA {
  public id: number;
  isSelected: boolean = false;

  constructor() {
    this.id = NextID++;
  }
}

@Component
struct ViewA {
   a: ClassA;

  build() {
    Row() {
      Button(`ViewA [${this.a.id}] isSelect = ${this.a.isSelected}`)
        .width(320)
        .margin(10)
        .onClick(() => {
          this.a.isSelected = !this.a.isSelected
        })
        .backgroundColor(Color.Green)
    }
  }
}
@Entry
@Component
struct ViewB {
  arrA: ClassA[] = [new ClassA(), new ClassA()];
  build() {
    Column() {
      List({ space: 0, initialIndex: 0 }) {
        ForEach(this.arrA,
          (item: ClassA) => {
            ListItem() {
              ViewA({ a: item })
            }
          },
          (item: ClassA): string => item.id.toString()
        )
      }

      Button(`View B 全选`)
        .width(320)
        .margin(10)
        .onClick(() => {
          this.arrA.forEach(item => {
            item.isSelected = true
          })
        }).backgroundColor(Color.Pink)
      ViewC({ count: this.arrA.filter(item => item.isSelected === true).length })
    }
  }
}
@Component
struct ViewC {
  count: number = -1;
  label: string = 'ViewA1';

  build() {
    Row() {
      Text(`ViewC isSelected count = ${this.count}`)
        .width(320)
        .margin(10)
    }
  }
}

我现在把三块代码都粘出来了,现在这三块代码没有用上状态的装饰器。我们要做的就是把装饰器装上,可以自己先按照自己的理解装上看看能否满足功能。

装饰器选择和代码改造

首先,我们需要自底向上分析。

View A中的属性a是一定需要加上状态a的,因为其中的isSelectA是变化的,那么首先想到@State

@State a: ClassA = new ClassA() 

现在可以发现,全选和单选功能都OK了,但是View C的count怎么没变呢?

然后仔细看一眼传递count的代码,用了arrA,是不是没给arrA加上状态呢?那就加个@State吧,然后就会发现还是不行。

因为@State无法观察嵌套和数组内变化arrA中item的isSelected的变化不会通知arrA变化! 

为了佐证上面的论点,可以给arrA增加个Watch方法。同时再增加一个按钮

@State @Watch('onChange')arrA: ClassA[] = [new ClassA(), new ClassA()];

onChange() {
  console.log('viewB', 'arr change');
}
...
      Button(`View B Test`)
        .width(320)
        .margin(10)
        .onClick(() => {
          this.arrA.length = 1
          }).backgroundColor(Color.Pink)

再运行以后测试就会发现全选按钮是不会触发onChange,而Test按钮却可以,因为Test是直接操作了数组的变化,这是可以观察到的。所以再次证明了arrA中item的isSelected的变化不会通知arrA变化! 

那么问题来了,咋办呢,看一下上面的表格,嵌套那用ObjectLink吧,不好意思,ObjectLink只能用于父子传递,这里作为父组件,用不了ObjectLink。那能不能像flutter一样,强刷一下setState或者Android自定义View的invalidate ,不好意思,也没有这样的方法。

穷途末路之下,我的选择是自己抽出来需要观察的对象。在这里就是@State selectedCount: number = 0 ,然后还需要给View A加上@Link count: number 并在View B里传递过去,再给click方法都作出改变,以及View C增加@Prop装饰器

ViewA({ a: item, count: this.selectedCount })

this.count = this.count + (this.a.isSelected ? 1 : -1)

this.arrA.forEach(item => {
            item.isSelected = true
            this.selectedCount = this.arrA.length
})
ViewC({ count: this.selectedCount})

@Prop count: number

上面是改动的代码,为了增加一个count,就加了这么多代码,还增加了变量,真是难受啊,如果同学们有更好的方法可以指导一下,目前我实践下来这可能是问题最少的方案。

问题延伸

上面的场景只是复杂业务场景的最小程度的抽象。实际上还可能会有更多问题,例如

  • arrA: ClassA[] 可能存在刷新、增删等操作,需要更改Foreach的token才能响应变化,可以考虑给item增加时间戳
  • arrA在复杂业务中可能存在更多层级包裹,必须手写adapt方法尽可能抽到只剩一层
  • forEach里也许是更复杂的component,再加上如果需要使用tab的预加载,那么必须将contentList内置到组件,而外组件和其他组件也需要用到这个contentList的话,状态问题就会变得非常复杂

其他框架实现嵌套

以我较为熟悉的Compose和Flutter举例,

class ClassAListNotifier with ChangeNotifier {
  List<ClassA> _arrA = [
    ClassA(id: 1),
    ClassA(id: 2),
    // ...更多初始数据
  ];

  List<ClassA> get arrA => _arrA;

  void toggleSelection(int id) {
    final item = _arrA.firstWhere((element) => element.id == id);
    if (item != null) {
      item.isSelect = !item.isSelect;
      notifyListeners();
    }
  }
}
    var listState by remember { mutableStateOf(arrA) } // 将列表转换为可观察状态

    // 更新单个元素的isSelect,并触发UI刷新
    fun updateSelection(id: Int, isSelected: Boolean) {
        val index = listState.indexOfFirst { it.id == id }
        if (index != -1) {
            val updatedItem = listState[index].copy(isSelect = isSelected)
            listState = listState.toMutableList().apply { this[index] = updatedItem }
        }
    }

Flutter使用Provider库,继承ChangeNotifier后notifyListeners可以很好的手动提示更新

Compose的话,本身可以使用mutableList,而mutableState是可以嵌套包裹的,再加上强大的flow,compose开发状态管理还是轻松的。

这两者虽然也都有自己的问题,但可以看出来设计者明确的状态管理意图以及操作的简化,以前我总觉得Flutter的状态管理不是很好用,现在看来是我以前声音太大了。

总结

在鸿蒙上想用好状态管理由以下三个方面

  • 数据结构能剥多开就剥多开,没有嵌套变化的情况下问题都好解决,官方说法叫精细化拆分复杂状态 
  • 想清楚变化源的流向,选择合适的装饰器
  • 集中化状态修改逻辑 将逻辑尽可能放在父组件完成,比如子组件的修改方法可以直接由函数参数从父组件传递

对应存在的问题

  • 复杂的数据结构的拆分再加上本身棘手的各种NPE和undefined,导致数据结构处理异常复杂
  • 复杂的装饰器选择
  • 代码逻辑复杂且松散,再怎么集中化也还是改变不了

目前鸿蒙的状态管理还在不断升级,但是V2版本仍然在对装饰器推陈出新,更容易到处修改逻辑以及增加代码的复杂度。