前言

在探索HarmonyOS的过程中,大家作为开发,脑子里不免会有疑问:“为什么我改个状态变量,UI就自动更新了,这到底是怎么监听的呢?”其实这种因状态变化引发 UI 重新渲染的机制,我们统称为状态管理机制,ArkUI 作为鸿蒙主推的声明式UI开发框架,其状态管理机制就是构建响应式应用的核心。
目前官网上主推的是V2,不过大家也都是从V1摸爬滚打过来的,V1改踩的坑想必也都踩了一遍,官方开发文档在状态管理概述中已经讲清楚了ArkUI状态管理V1和V2的区别对比了,但是我今天想从软件架构和Clean Code的角度来讲一下V1的问题和V2的设计为什么更优雅


一、回顾下状态管理V1设计中的问题

先看下状态管理有哪几种装饰器及它们适用的场景和可能的问题:

1、@State

  • 父组件同步失效
    @State 组件内状态,本来设计为组件内部私有变量,却允许父组件传入来初始化,那这样在组件初始化时,子组件的@State变量仅在初始化时接受父组件传值,后续父组件值的修改并不会影响子组件。

@Entry
@Component
struct Parent {
  @State counter: number = 16;
  build() {
    Column() {
      // 父组件意外覆盖子组件的内部状态
      Child({ internalCounter: this.counter }) // 🚨 问题:外部可修改@State变量
      Button(String(this.counter))
        .onClick(()=>{
          this.counter = 100
        })
    }
  }
}

// 子组件 ChildComponent.ets
@Component
struct Child {
  @State internalCounter: number = 0 // 本应是内部状态

  build() {
    Column() {
      Text(`子组件: ${this.internalCounter}`)
    }
  }
}
  • 嵌套对象监听能力弱
    只能监听对象第一层属性的变化(如 obj.key = value)
    深层嵌套属性(如 obj.address.city = ‘北京’)的修改无法触发UI刷新

  • 数组更新局限
    数组整体赋值、增删项可被监听
    数组内部元素属性修改(如 arr[0].prop = value)无法触发UI刷新

2、@Prop

  • 初始化行为
    @Prop - 持续接收父级的状态变化,而且是深拷贝;组件内部可修改,并不同步回父组件,父组件变了又能同步回来,对一个变量子组件和父组件都能修改,存在责任唯一的问题;相比起@State重点就是父组件修改后子组件会同步修改

  • 套娃
    如果子组件使用@Prop、那你的孙子组件也要用@Prop

  • 嵌套对象监听能力弱
    和@State一样,也无法监听嵌套对象属性值的变化,因为父组件传入时是用@State传入的
    解决办法:加个@Observed 装饰嵌套类就好了,但是在嵌套场景下,每一层都要用@Observed装饰且每一层都要被@Prop接收,这样多层级的传递很繁琐,增加了维护成本。

3、@Link

  • 初始化行为
    @Link装饰变量与其父组件的数据源共享相同的值,相比Prop的深拷贝可以提高性能,然而开放了双向,那么对数据修改的责任唯一以及异步竞争的问题就出来了。
  • 套娃
    同@Prop一样,子组件使用@Link、那你的孙子组件也要用@Link,既然这样,那么为什么不使用@Provide、@Consume呢

4、@Watch

  • 死循环风险
    @Watch回调中修改被监听的状态变量会触发无限循环更新
  • 无初始化回调
    首次初始化状态时不会触发@Watch回调

5、其他装饰器

@StorageLink, @StorageProp, @LocalStorageLink,@LocalStorageProp与AppStorage和LocalStorage同步变量,造成了变量范围的扩大,冲突域的增加,双向写入更造成了数据的owner的模糊;特别是很多同学为简单使用魔法字符串作为key,存在随意定义,难以维护的问题。

为了克服 V1 的局限,ArkUI 推出了状态管理 V2,比较好的避免了上面所说的一些问题,从设计上也更优雅,我个人总结一下主要有如下几个方面的提升。

二、状态管理 V2:数据驱动的 “深度进化”

1、更清晰简单的变量Scope

  • @Local—对于组件内部的变量装饰器,由在状态管理V1中的@State改为@Local,变量只能从组件内部初始化;从而也表达了组件内部状态不能被外部修改的语义,同时能与UI进行同步;
  • @Param—对于组件外部需要传递的参数使用@Param装饰,支持本地初始化但是不允许组件内部直接修改;还可以配合使用@Once来界定一次传入还是后续变化都要监听。这个定义相对于原来@Prop的父子变量同步更加清晰。

2、数据单向流动

  • 在状态管理V1中,允许使用@Link对父组件中@State装饰的变量建立双向绑定关系,这也是我对V1版状态管理中觉得最不优雅的地方。
  • 这个设计,就相当于在函数中调用传入指针,允许在函数内修改调用方传入的变量,V2
    统一通过**@Param**接受外部传入,而无需开发者去理解@State,@Prop,@Link,@ObjectLink这么多的复杂性。

3、抛弃共享变量,改用事件通知机制

  • 干掉了通过@Link的共享变量方式,使用**@Event**机制把变量的变化传递到父组件的回调函数,由回调函数来接收事件执行具体的行为,这样负责更新UI的责任主体回到父组件中,而不是直接去控制父组件中的某个变量。这个才是我们所熟悉的更优雅的方式,数据始终是单向的,从父组件到子组件的变量变化和传入,到子组件把事件通知到父组件的回调函数,再通过回调函数修改父组件的变量进而传递到子组件中。
  • 同时还提供了一个全新的操作符!!,以方便开发者实现双向的变量绑定。注意,这里也仅仅是个语法糖,不用开发者显式的从父组件传入@Event装饰的回调实现而已,并不改变整体的设计原则,从易用性角度考虑非常周到。
  • 同时也解决了另外一个问题,兄弟组件之间的状态同步,无需使用LocalStorage来传递,通过父组件的事件回调方法解决,而无需搞一个页面级的更大Scope的共享变量来实现。

4、监听器的装饰方式从变量改到方法上

  • 原来在状态管理V1中,看到@Watch(‘callback’),总要按住ctrl点击定位到函数实现上去,因为这里装饰的是变量定义,而程序员真正关注的逻辑其实应该在回调函数上。
  • 在状态管理V2中,使用**@Monitor**(‘someVar’),这样就改到了更容易理解的逻辑,即在处理变量变化的监听函数中注解一个需要监听的变量,主要的关注点放在了回调方法上。
  • 这里也提一个更进一步的优化想法:如果从编译器上改进提供一个语法糖,对监听的变量指定可以不用字符串,而是用对变量的引用也许书写起来会更好,比如写成这样@Monitor(this.someVar)更符合直觉和逻辑,并且不用magic string更有利于IDE做重构和错误检查。

5、跨组件层级的@Consumer可用本地默认值

  • 在状态管理V1原来的跨多层级组件变量传递使用@Provide和@Consume机制在大型团队组件化开发时有一个巨大的问题,就是如果组件的实现用了@Consume,如果有些值即便不是必须的,也会传染给上层的组件,如果上层组件没有把需要的变量通过@Provide提供出来,就会抛出异常。这相当于组件绑架了使用方并且过多的暴露了自己的内部。
  • 在状态管理V2中解决了这个问题,@Consumer不再强依赖上层组件的@Provider的变量,可以本地初始化,并且找不到时使用本地默认值。而且在开发文档中,明确提出了:从组件独立角度,要减少使用@Provider和@Consumer。这体现了在设计之初就考虑组件的独立性问题;当然,这里支持了跨层级组件的双向同步了,似乎打破了前面所说的数据单向流动的设计原则,然而我认为这是一个易用性和独立性的权衡的结果。
  • 当前的考虑与设计已经是限制了场景在需要紧密粘合的跨层级组件之间了,也就是使用Consumer的组件从设计上应该不考虑被独立开放使用,换句话说,如果你的组件实现是开放给外部使用,根本就不该有变量被@Consumer装饰,这应该成为独立组件设计的一条检查规则;另外,通过可以使用本地变量默认值避免强迫父组件的所需provide的变量声明,一定程度也提升了便利性。

6、抛弃掉@(Local)StorageLink, @(Local)StorageProp

为啥对这个东西带来的不优雅深恶痛绝?

  • 容易被滥用,本来组件你的状态就要隔离,而很多人在实际开发中为了方便父子、兄弟、跨多层级的双向变量同步,倾向于用LocalStorage和AppStorage来解决,应用或者页面全局一个大Map,用起来多方便,然而对于代码维护和组件间的隔离是噩梦。
  • 存入的key使用string类型来索引,这会导致magic string满天飞,如果开发人员定义不规范,那么多个组件之间变量名冲突都可能发生,这样就不是组件之间的独立性问题了,对使用组件的app都有影响。
    那么在状态管理V2中怎么用LocalStorage和AppStorage? 很简单,只有调用API一条路径。变量传递和事件响应都归一到前面讲的数据单向流动和事件通知机制了。同时,没有了装饰器来访问其中的值,对于上面所说的magic string漫天飞的问题也解决了。

总结

1、ArkUI 状态管理从 V1 到 V2 的演进,是技术不断优化和完善的过程。
2、我认为ArkUI状态管理V1开放了太多的灵活性,作为框架而言,在“框”这个维度是做得不够的,从而让开发人员可以按照方便但是非预期的方式去使用,从而造成代码可维护性和可读性的问题;
3、我们在设计一个框架,相比于灵活性,可能需要从软件架构设计的层面考虑大型软件并行开发所需的组件独立和松耦合方面考虑更多;状态管理V2可以看得出非常清晰的设计原则,并且从易用性角度做了针对性的设计,作为一个应用框架,它从 “框” 和 “架” 上面都考虑得比较优雅了;如果可能,也许状态管理V1根本就不应该存在,这样就不会有@ComponentV2这样让我这种有点强迫症的人看着别扭的装饰器存在了。从机制上已经非常清晰,希望未来尽快把状态管理V1日落掉,比如通过工程构建参数的方式,把@Component所装饰的默认就是状态管理V2的设计。

自由讨论:

当前状态管理的策略是允许V1和V2在工程中混用,对于不熟悉的人容易造成一些问题,这些在官方文档的自定义组件混用场景指导中也说得很清楚,这是考虑了V1的开发者基础,一次迁移不现实所以允许混用。
而我个人的看法,这可能这不是一个最佳的策略,从代码和维护性和对于开发者简单的角度,至少应该在module的级别进行完全隔离,即一个模块中要么只能用V1,要么只能用V2,通过编译参数来区分支持V1还是V2;因为V1已经开发这么多应用了,应该并非有什么是V1真的解决不了的,V2做得更好不带着历史负担可以演进的更快更好,如果未来的演进目标是日落V1,对V1不再演进也能让开发者尽快迁移到V2了,那么混用的结果可能是拉长V1的日落周期。那么大家的看法呢?

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐