鸿蒙Next —— 状态管理实践

一、MVVM模式

MVVM模式是一种软件架构模式,由三个部分组成:Model(数据模型层),View(视图层),ViewModel(视图模型层)。核心是分离应用程序的视图和业务逻辑,通过数据绑定实现视图和业务逻辑的解耦。

以下是ArkUI MVVM模式示意图:

  • Model层:存储数据和相关逻辑的模型。它表示组件或其他相关业务逻辑之间传输的数据。Model是对原始数据的进一步处理。
  • View层:在ArkUI中通常是@Component装饰组件渲染的UI。
  • ViewModel层:在ArkUI中,ViewModel是存储在自定义组件的状态变量(如@State @Prop等装饰器修饰的变量)、LocalStorage和AppStorage中的数据。
    • 自定义组件通过执行其build()方法或者@Builder装饰的方法来渲染UI,即ViewModel可以渲染View。
    • View可以通过相应event handler来改变ViewModel,即事件驱动ViewModel的改变,另外ViewModel提供了@Watch回调方法用于监听状态数据的改变。
    • 在ViewModel被改变时,需要同步回Model层,这样才能保证ViewModel和Model的一致性,即应用自身数据的一致性。
    • ViewModel结构设计应始终为了适配自定义组件的构建和更新,这也是将Model和ViewModel分开的原因。

二、组件内状态管理(V1)

V1状态管理中装饰器有:@State, @Prop, @Link, @Observerd, @ObjectLink, @Provide, @Consume, @LocalStorageLink, @LocalStorageProp, @StorageLink, @StorageProp, @Watch,我们下面一一分析这些装饰器的作用。

@State 组件内状态

@State修饰的变量称之为状态变量,它是组件内状态装饰器,当状态变量改变时对应的UI也会发生相应的变化,大部分时候和其他装饰器联用时@State修饰的变量往往是作为数据源。

@State支持修饰基础类型、class、数组、map、set等类型,现在先来感受下组件内状态管理的基本用法:

less 复制代码
@Entry
@Component
struct EntryIndex {
  @State count: number = 0
  build() {
    Scroll() {
      Column() {
        Button(`${this.count}`, {
          type: ButtonType.Capsule
        }).onClick(() => {
            this.count++
          })
      }
    }
  }
}

以上示例中点击按钮后count计数会+1,这个时候因为组件Button和count变量有数据绑定的关系,所以Button组件会重新渲染内容改变。这就是@State最基础的用法,实际项目中有很多情况是复杂组件之间的变量传递,下面这张图体现了 哪些装饰器修饰的变量可以传递给@State 以及 @State装饰的变量可以传递给哪些装饰器修饰的变量

@State状态变量是不会根据父组件或子组件传递变量的改变而改变,所以他是组件内状态,接下来看下他是如何与其他装饰器联动的。

@Prop 父子单向同步

@Prop 装饰的变量可以和父组件建立单向同步的关系,@Prop状态变量是可变的,但是它的变化是不会同步给父组件,父组件变量变化会同步给该@Prop状态变量。

@Prop支持装饰基础类型、class、数组、map、set等类型,大多数情况下父组件传递变量是@State修饰的。下图体现了 @Prop状态变量可以接收哪些父组件装饰器的状态变量 以及 @Prop状态变量可以传递哪些装饰器修饰的子组件状态变量

看起来基本和@State支持情况一致。ps: @Prop是深度拷贝变量,所以会有一定的性能损耗,大对象建议用Link代替。

@Link 装饰的变量可以和父组件建立单向同步的关系,@Link状态变量是可变的,但是它的变化会同步给父组件,父组件变量变化会同步给该@Link状态变量。

@Link支持装饰基础类型、class、数组、map、set等类型。其实总结就一句话,@Link除了能父子双向同步之外和@Prop基本一致。

我们直接看下实际代码应用中@Link和@Prop的差异:

less 复制代码
@Entry
@Component
struct EntryIndex {
  @State count: number = 0
  build() {
    Scroll() {
      Column() {
        Button(`关联子组件的计数器: ${this.count}`, {
          type: ButtonType.Capsule
        }).onClick(() => {
            this.count++
          })
        ChildComponent1({
          count: this.count,
        })
      }
    }
  }
}

@Component
struct ChildComponent1 {
  @Prop count: number
  build() {
    Column() {
      Button(`子组件1的计数器(Prop): ${this.count}`, {
        type: ButtonType.Capsule
      }).margin(12)
        .onClick(() => {
          this.count++
        })
    }
  }
}

@Component
struct ChildComponent2 {
  @Link count: number
  build() {
    Column() {
      Button(`子组件2的计数器(Link): ${this.count}`, {
        type: ButtonType.Capsule
      }).margin(12)
        .onClick(() => {
          this.count++
        })
    }
  }
}

父组件有一个@State装饰的count状态变量,分别传递给两个子组件,子组件1的count是用@Prop装饰,子组件2的count是用@Link装饰。当代码运行时:

  • 点击父组件的Button,无论是父组件自身的Button内容会+1,还是其他两个子组件的Button都会+1。
  • 点击子组件1的Button,仅仅只有子组件1的Button内容会+1,父组件和子组件2的Button内容不变。
  • 点击子组件2的Button,父组件和子组件2Button内容会+1,子组件1的Button内容变成和父组件和子组件2一致。

以上几种装饰器已经能实现大部分组件状态管理的需求,但实际项目中还是有很多更复杂的场景,比如跨代组件状态同步。

@Provide和@Consume:后代组件双向同步

@Provider和@Consume两者配合应用于跨代组件双向同步(不仅仅是爷孙组件),所以他们不需要传递变量,而是采用别名绑定的方式。

@Provider和@Consume支持装饰基础类型、class、数组、map、set、Date等类型。

scss 复制代码
@Entry
@Component
struct EntryIndex {
  @Provide('desCount') desCount: number = 0
  build() {
    Scroll() {
      Column() {
        Button(`关联后代组件的计数器: ${this.desCount}`, {
          type: ButtonType.Capsule
        }).onClick(() => {
            this.desCount++
          })
        // 伪代码表示组件层级嵌套
        Child(){
          DeDescendComponent1()
        }
      }
    }
  }
}

@Component
struct DescendComponent1 {
  @Consume('desCount') desCount: number
  build() {
    Column() {
      Button(`后代组件1的计数器: ${this.desCount}`, {
        type: ButtonType.Capsule
      }).onClick(() => {
          this.desCount++
        })
    }
  }
}

以上用@Provider和@Consume装饰的且别名为"desCount"的变量数据会双向同步。

@Provider装饰的变量当然也可以传递,只是这个变量不会同步数据,它只有和@Consume配合才能双向同步。

@Consume会特殊一些,它是禁止父组件传递变量给@Consume装饰的变量,因此它只有传递给子组件的权力。

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

在实际应用开发中,对象传递可能比基础类型传递要更加常见,以上已经提及的装饰器只能观察到第一层,也就是说如果对象内部属性的变化是无法被观察到的。举个例子:

less 复制代码
// 定义一个类
class CountObj {
  name: string = ''
  count: number = 0
}

@Entry
@Component
struct EntryIndex {
  @State countObj: CountObj = new CountObj()
  build() {
    Scroll() {
      Column() {
        Button(`${this.countObj.count}`, {
          type: ButtonType.Capsule
        }).onClick(() => {
            this.countObj.count++
          })
      }
    }
  }
}

在这个示例里,this.countObj.count++是不会让Button重新渲染的,因为@State只能监听 countObj 本身,而不能监听到内部属性,所以我们要做的第一步是用@Observed装饰这个类,使这个类具备了内部属性被观察的能力,但这也仅仅是第一层属性,如果内部属性是对象,我们还是要给这个对象类用@Observed装饰。

@ObjectLink装饰的对象实例必须是已经用@Observed装饰的类,@ObjectLink仅支持装饰 class 对象类型。

下面是一个使用示例,涉及到父组件 -> 子组件 -> 孙子组件的状态管理:

scss 复制代码
@Entry
@Component
struct EntryIndex {
  @State countArr: CountObj[] = []
  build() {
    Scroll() {
      Column() {
        Button(`增加对象计数器`, {
          type: ButtonType.Capsule
        }).onClick(() => {
            let countObj = new CountObj()
            countObj.name = '测试'
            countObj.count = 1
            this.countArr.push(countObj)
          })
        ChildComponent2({
          countArr: this.countArr
        })
      }
    }
  }
}

@Component
struct ChildComponent2 {
  @Link countArr: CountObj[]
  build() {
    Column() {
      List(){
        ForEach(this.countArr, (item: CountObj) => {
          ListItem(){
            DescendComponentList2({
              desCountObj: item
            })
          }
        })
      }
    }
  }
}

@Component
struct DescendComponentList2 {
  @ObjectLink desCountObj: CountObj
  build() {
    Text(`对象计数器(ObjectLink)${this.desCountObj.name}:${this.desCountObj.count}`)
      .onClick(() => {
        this.desCountObj.count++
      })
  }
}
  1. 父组件里@State装饰countArr数组对象,把countArr变量传递给了子组件,子组件用@Link装饰变量接收,这样保证父子是双向同步的,父组件和子组件都能观察到内数组的增删改查。
  2. 点击Button给countArr增加一个countObj对象,子组件观察到数组增加了,于是列表项增加了一个孙子组件。
  3. 孙子组件用@ObjectLink装饰了desCountObj变量,同时CountObj已经被@Observed装饰了,这个时候孙子组件内对desCountObj对象内属性的操作都会同步给子组件和父组件。

通过这样的方式,未来增加其他兄弟组件都能观察到属性的变化,从而实现各组件UI联动。

三、管理应用内状态

在第二章已经介绍了组件树上共享状态变量,从而实现UI和数据的绑定和联动。那么实际项目遇到的情况可能更加复杂,不同页面之间甚至是不同UIAbility之间都存在数据共享的情况。

页面间状态存储

LocalStorage是ArkTS提供的页面级别的状态存储,可以从应用逻辑里使用LocalStorage,也可以从UI内部使用LocalStorage。

应用逻辑中使用简单看下示例代码:

typescript 复制代码
let para: Record<string,number> = { 'PropA': 47 };
let storage: LocalStorage = new LocalStorage(para); // 创建新实例并使用给定对象初始化
let propA: number | undefined = storage.get('PropA') // propA == 47
let link1: SubscribedAbstractProperty<number> = storage.link('PropA'); // link1.get() == 47
let link2: SubscribedAbstractProperty<number> = storage.link('PropA'); // link2.get() == 47
let prop: SubscribedAbstractProperty<number> = storage.prop('PropA'); // prop.get() == 47
link1.set(48); // two-way sync: link1.get() == link2.get() == prop.get() == 48
prop.set(1); // one-way sync: prop.get() == 1; but link1.get() == link2.get() == 48
link1.set(49); // two-way sync: link1.get() == link2.get() == prop.get() == 49

这样在LocalStorage对象内保存了 'PropA' 属性,由link方法获取的变量支持双向同步,prop方法获取的变量支持单向同步。我们重点看下UI内部的使用方式。

首先有两个装饰器@LocalStorageLink和@LocalStorageProp,一看名字我们就大致明白了这两个装饰器的作用,@LocalStorageLink支持双向同步,@LocalStorageProp支持单向同步,他们都是用别名的方式来绑定关系,接下来看一个示例来理解下:

typescript 复制代码
let localStorage = LocalStorage.getShared()
localStorage.setOrCreate("count", 0)

@Entry(LocalStorage.getShared())
@Component
struct EntryIndex {
  @LocalStorageLink('count') count: number = 0
  build() {
    Scroll() {
      Column() {
        Button(`关联其他页面的计数器: ${this.count}`, {
          type: ButtonType.Capsule
        }).onClick(() => {
            this.count++
          })
      }
    }
  }
}

@Entry(LocalStorage.getShared())
@Component
export struct EntryNextPage {
  @LocalStorageProp('count') count: number = 0
  build() {
    Column(){
      Button(`单向关联上个页面的计数器: ${this.count}`, {
        type: ButtonType.Capsule
      }).onClick(() => {
          this.count++
        })
    }
  }
}
  • 首先第一步要绑定LocalStorage和页面,两个页面的@Entry都绑定LocalStorage.getShared(),这代表他们共用一个LocalStorage对象,然后创建一个key为count的共享属性。
  • 接着我们在组件内就可以通过定义相同的别名'count'来观察到属性变化,在第一个页面用的是@LocalStorageLink双向同步,所以当点击按钮后count++,其他所有被@LocalStorageLink和@LocalStorageProp装饰的变量都会观察到+1,从而重新渲染组件内容。
  • 在第二个页面采用@LocalStorageProp观察count,所以点击Button使count++,这只能让自身页面的Button内容改变,而不能让其他页面同步变化。

应用全局状态存储

AppStorage是应用全局的UI状态存储,是和应用的进程绑定的。其实它的用法和LocalStorage基本上一模一样,装饰器@StorageLink双向同步和@StorageProp单向同步。使用示例:

typescript 复制代码
AppStorage.setOrCreate("count", 0)
// EntryIndex是EntryAbility的页面
@Entry
@Component
struct EntryIndex {
  @StorageLink('count') count: number = 0
  build() {
    Scroll() {
      Column() {
        Button(`关联全局的计数器: ${this.count}`, {
          type: ButtonType.Capsule
        }).onClick(() => {
            this.count++
          })
      }
    }
  }
}

// SecondIndex是SecondAbility的页面
@Entry
@Component
export struct SecondIndex {
  @StorageLink('count') count: number = 0
  build() {
    Column(){

      Button(`关联全局的计数器: ${this.count}`, {
        type: ButtonType.Capsule
      }).onClick(() => {
          this.count++
        })
    }
  }
}

AppStorage.setOrCreate("count", 0)创建了一个共享属性,其他页面通过@StorageLink和@StorageProp就能联动了,

以上存储属于运行时内存存储,如果想要持久化存储,可以使用PersistentStorage,而且ArkTS已经把PersistentStorage和AppStorage关联起来可以共享同一属性,我们在UI内部使用时依然只需要关心@StorageLink和@StorageProp装饰器,在某处创建好需要持久化的属性即可。

参考资料

状态管理概述

相关推荐
御承扬4 小时前
鸿蒙原生系列之动画效果(转场动画)
华为·harmonyos·转场动画
子榆.4 小时前
Flutter 与开源鸿蒙(OpenHarmony)深度集成实战:从零构建跨平台应用
flutter·开源·harmonyos
luxy20044 小时前
HarmonyOS 5.0 WiFi连接调试工具
华为·harmonyos
夏小鱼的blog4 小时前
【HarmonyOS应用开发入门】 第二期:Stage模型与应用架构解析
harmonyos·开源鸿蒙
养猪喝咖啡5 小时前
ArkTS 文本输入组件(TextInput)详解
harmonyos
养猪喝咖啡6 小时前
HarmonyOS ArkTS 页面导航(Navigation)全面介绍
harmonyos
养猪喝咖啡6 小时前
HarmonyOS ArkTS 从 Router 到 Navigation 的迁移指南
harmonyos
养猪喝咖啡6 小时前
HarmonyOS ArkTS Stack 实战:做一个“悬浮按钮 + 遮罩弹层 + 底部菜单”的完整小项目
harmonyos
Archilect6 小时前
从几何到路径:ArkUI 下的双层容器、缩放偏移与抛掷曲线设计
harmonyos