HarmonyOS之状态管理

  • 概述

    在像Android一样的系统里,我们可以自然地用UI事件来驱动数据变更,比如按钮点击后改变一个数据,但是如果想要反过来让数据改变时自动地驱动UI变化则需要手动设置observe回调监听,虽然可以实现但是需要维护很多同步的代码。

    在现在的Android SDK版本中其实也提供了双向绑定的能力,就是在gradle中开启databind,然后在xml中完成数据绑定工作,绑定的数据一旦发生变化,UI中的组件就会自动响应成最新值。在Jetpack中,你还可以通过LiveData和Flow来实现代码上的监听处理。

    在混合开发潮流中的Flutter中,也使用了这种响应式的UI架构,可驱动UI变化的数据叫做State。

    以上的双向绑定模式其实都是MVVM的实现,原理就是把数据改变的监听回调封装到了SDK层,使得开发者不需要去额外维护这部分逻辑。

    鸿蒙系统采用的也是这样的模式,是一样的原理,只是使用方式上不太一样,它使用注解的方式来声明这种数据,注解在鸿蒙中被称为装饰器。关于这些可驱动UI变化的数据的管理就叫做状态管理。

  • 组件内状态

    组件内的状态又会根据使用场景分成几个不同的类型,使用不同的装饰器来表示。

    • @State

      @State是最基础、也是最简单的状态管理装饰器,被它修饰的变量一旦被UI组件使用,则它的变化就会自动引起对应UI组件的更新。

      @Entry
      @Component
      struct Index {
        @State message: string = 'Hello World';
      
        //@Entry的build方法中的根结点必须是容器类组件(Text组件可以包含Span)
        build() {
          Text(this.message)
        }
      }
      
    • @Prop

      @Prop只能在子组件中使用,因此不能用于@Entry组件,是用于子组件响应父组件中的状态的,也就是说当父组件中的绑定状态发生变化时,子组件中的状态也会随之发生变化,倘若子组件中的UI组件使用了该变量则会自动更新。不过这里需要注意的是,首先绑定是单向的,即子组件中的@Prop修饰的变量发生变化后是不会引起父组件中的变量变化的;其次,@Prop修饰的变量可以有初始值,即父组件可以不用传递初始值,这种情况下其实就是不存在绑定关系,父组件传递的初始值其实就是要绑定的状态变量而已,之所以存在可以不用传递绑定变量这种情况是因为允许子组件有更多的复用可能性,也就是说,这种情况下,完全把决定权交给了使用该子组件的地方,传递了就是使用绑定能力,不传就是不使用该能力。

    • @Link

      @Prop一样,@Link也是用于子组件,不过它还是双向绑定,即子组件的@Link变量的变化同样也能引起父组件的状态变量的响应,而且,@Link变量不能指定初始值,必须由父组件传递,也就是说必须使用绑定关系。

      @Component
      struct Parent {
        @State comm: string = 'Parent'
        @State freeLink: string = 'Parent Link'
      
        build() {
          Row() {
            Column() {
              ChildItem({ comm: this.comm, free: this.freeLink })
            }
            .width('100%')
          }
          .height('100%')
        }
      }
      
      @Component
      struct ChildItem {
        //子组件内有初始值时,父组件可以不用传递初始值,此时也不会建立绑定关系,这样子组件就有了可绑可不绑两种使用方式
        @Prop comm: string = 'Yes'
        //双向绑定不允许子组件初始化,这就意味着父组件必须传递初始值,即必须建立绑定关系
        @Link free: string
      
        build() {
          Column() {
            Text(`孩子${this.comm}`)
            Text(`孩子${this.free}`)
              .onClick(() => {
                this.free = 'Change From Child'
              })
          }
        }
      }
      
    • @Provide和@Consume

      上面讲的是直系父子组件的状态联动,那么如果需要实现跨多层级的父子组件状态传递的话该怎么办呢,如果采用上面的命名参数的方式逐级传递的话想想都会觉得恐怖,多余的代码、重复的逻辑、混乱的可读性...因此,产生了跨级传递装饰器@Provide@Consume,它们之间是双向的绑定。

      其中@Provide装饰的变量是在祖先组件中,可以理解为被“提供”给后代的状态变量。@Consume装饰的变量是在后代组件中,去“消费(绑定)”祖先组件提供的变量。

      // 通过相同的变量名绑定
      @Provide a: number = 0;
      @Consume a: number;
      
      // 通过相同的变量别名绑定
      @Provide('a') b: number = 0;
      @Consume('a') c: number;
      

      @Provide和@Consume通过相同的变量名或者相同的变量别名绑定时,@Provide修饰的变量和@Consume修饰的变量是一对多的关系。不允许在同一个自定义组件内,包括其子组件中声明多个同名或者同别名的@Provide装饰的变量,@Provide的属性名或别名需要唯一且确定,如果声明多个同名或者同别名的@Provide装饰的变量,会发生运行时报错。

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

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

      @Prop title: Model;
      // 可以观察到第一层的变化
      this.title.value = 'Hi'
      // 观察不到第二层的变化
      this.title.a.value = 'ArkUi' 
      

      这就引出了@Observed/@ObjectLink装饰器。

      @Observed作用于某个Class上,@ObjectLink作用于需要被观察的属性上,比如:

      @Observed
      class ClassA {
        public c: number;
      
        constructor(c: number) {
          this.c = c;
        }
      }
      
      //第一层可以直接观察到属性变化,因此这里的@Observed也可以不加
      @Observed
      class ClassB {
        public a: ClassA;
        public b: number;
      
        constructor(a: ClassA, b: number) {
          this.a = a;
          this.b = b;
        }
      }
      
      @Component
      struct Parent {
          @State b: B = new B(0, new A(1))
        
        build() {
          Row() {
            ChildItem({ comm: this.comm, free: this.freeLink, objectLink: this.b.aa })
          }
        }
      
      @Component
      struct ChildItem {
        //子组件内有初始值时,父组件可以不用传递初始值,此时也不会建立绑定关系,这样子组件就有了可绑可不绑两种使用方式
        @Prop comm: string = 'Yes'
        //双向绑定不允许子组件初始化,这就意味着父组件必须传递初始值,即必须建立绑定关系
        @Link free: string
        // @Prop objectLink: A
        @ObjectLink objectLink: A
      
        build() {
          Column() {
            Text(`孩子${this.free}`)
              .onClick(() => {
                this.free = 'Change From Child'
                // this.objectLink = new A(22)  //@ObjectLink不被允许重新赋值
                this.objectLink.nn = 22
              })
            Text(`${this.objectLink.nn}`)
              .fontSize(15)
          }
        }
      }
      

      @Observed属性要放在无法自动观察到的层级属性类上,单用@Observed属性是没用的,必须搭配@ObjectLink或@Prop,@ObjectLink修饰的变量无法被重新赋值,只可以修改其属性的值,@Prop修饰的是深拷贝,也就是说不同子组件的@Prop变量的修改不会引起其他组件的变化,因为它是单向绑定;而@ObjectLink则是双向绑定,不同子组件修改的@ObjectLink变量均指向同一个父组件的变量。

      还有,假如上面的Class A里还有一个嵌套类C,则C的属性变化也不会被监听到,如果需要再多层级监听,则需要@ObjectLink直接修饰C类型变量,这里就不能用@Prop替换了,因为@Prop是深拷贝,因此多个组件之间互不影响,其他组件并不能监听到。

      这里注意思考@Prop的深拷贝原理,就可以理解这些规则了。

  • 管理应用状态

    • LocalStorage

      LocalStorage用于在一个UIAbility下的视图共享或者同一个UI页面下的组件共享。

      前者用法如下:

      // EntryAbility.ts
      import UIAbility from '@ohos.app.ability.UIAbility';
      import window from '@ohos.window';
      let para:Record<string,number> = { 'PropA': 47 };
      let localStorage: LocalStorage = new LocalStorage(para);
      export default class EntryAbility extends UIAbility {
          storage: LocalStorage = localStorage
      
          onWindowStageCreate(windowStage: window.WindowStage) {
              windowStage.loadContent('pages/Index', this.storage);
          }
      }
      
      //对应UI页面下使用:
      // 通过getShared接口获取stage共享的LocalStorage实例
      let storage = LocalStorage.getShared()
      
      //传入
      @Entry(storage)
      @Component
      struct CompA {
        // can access LocalStorage instance using 
        // @LocalStorageLink/Prop decorated variables
        @LocalStorageLink('PropA') varA: number = 1;
      
        build() {
          Column() {
            Text(`${this.varA}`).fontSize(50)
          }
        }
      }
      

      对于单个页面内组件传递:

      // 创建新实例并使用给定对象初始化
      let para:Record<string,number> = { 'PropA': 47 };
      let storage: LocalStorage = new LocalStorage(para);
      
      @Component
      struct Child {
        // @LocalStorageLink变量装饰器与LocalStorage中的'PropA'属性建立双向绑定
        @LocalStorageLink('PropA') storLink2: number = 1;
      
        build() {
          Button(`Child from LocalStorage ${this.storLink2}`)
            // 更改将同步至LocalStorage中的'PropA'以及Parent.storLink1
            .onClick(() => this.storLink2 += 1)
        }
      }
      // 使LocalStorage可从@Component组件访问
      @Entry(storage)
      @Component
      struct CompA {
        // @LocalStorageLink变量装饰器与LocalStorage中的'PropA'属性建立双向绑定
        @LocalStorageLink('PropA') storLink1: number = 1;
      
        build() {
          Column({ space: 15 }) {
            Button(`Parent from LocalStorage ${this.storLink1}`) // initial value from LocalStorage will be 47, because 'PropA' initialized already
              .onClick(() => this.storLink1 += 1)
            // @Component子组件自动获得对CompA LocalStorage实例的访问权限。
            Child()
          }
        }
      }
      

      获取属性装饰器有两种:LocalStorageLink和LocalStorageProp,同样代表双向和单向绑定,不再赘述。

    • AppStorage

      AppStorage是应用全局的UI状态存储,是和应用的进程绑定的,由UI框架在应用程序启动时创建,为应用程序UI状态属性提供中央存储。还相当于整个应用的“中枢”,持久化数据PersistentStorage环境变量Environment都是通过AppStorage中转,才可以和UI交互。

      AppStorage.setOrCreate('PropA', 47);
      let storage = new LocalStorage();
      storage.setOrCreate('PropA',48) ;
      
      @Entry(storage)
      @Component
      struct CompA {
        @StorageLink('PropA') storLink: number = 1;
        @LocalStorageLink('PropA') localStorLink: number = 1;
      
        build() {
          Column({ space: 20 }) {
            Text(`From AppStorage ${this.storLink}`)
              .onClick(() => this.storLink += 1)
      
            Text(`From LocalStorage ${this.localStorLink}`)
              .onClick(() => this.localStorLink += 1)
          }
        }
      }
      

      和LocalStorange的使用类似,需要注意的是,因为AppStorage是应用级的数据,因此不要使用它来实现事件通知,因为他可能关联着很多UI组件,这会引起组件的更新。如果只是用于消息传递,推荐使用 emitter方式。

    • PersistentStorage

      LocalStorage和AppStorage都是运行时的内存,但是在应用退出再次启动后,依然能保存选定的结果,是应用开发中十分常见的现象,这就需要用到PersistentStorage。

      PersistentStorage是应用程序中的可选单例对象。此对象的作用是持久化存储选定的AppStorage属性,以确保这些属性在应用程序重新启动时的值与应用程序关闭时的值相同。

      UI和业务逻辑不直接访问PersistentStorage中的属性,所有属性访问都是对AppStorage的访问,AppStorage中的更改会自动同步到PersistentStorage。

      PersistentStorage和AppStorage中的属性建立双向同步。应用开发通常通过AppStorage访问PersistentStorage,另外还有一些接口可以用于管理持久化属性,但是业务逻辑始终是通过AppStorage获取和设置属性的。

      PersistentStorage的持久化变量最好是小于2kb的数据,不要大量的数据持久化,因为PersistentStorage写入磁盘的操作是同步的,大量的数据本地化读写会同步在UI线程中执行,影响UI渲染性能。如果开发者需要存储大量的数据,建议使用数据库api。

      PersistentStorage和UIContext相关联,需要在UIContext明确的时候才可以调用,可以通过在runScopedTask里明确上下文。如果没有在UIContext明确的地方调用,将导致无法持久化数据。

      //注意这个是将持久化数据读取到AppStorage中,47是不存在时的默认值
      PersistentStorage.persistProp('aProp', 47);
      
      @Entry
      @Component
      struct Index {
        @State message: string = 'Hello World'
        @StorageLink('aProp') aProp: number = 48
      
        build() {
          Row() {
            Column() {
              Text(this.message)
              // 应用退出时会保存当前结果。重新启动后,会显示上一次的保存结果
              Text(`${this.aProp}`)
                .onClick(() => {
                  this.aProp += 1;
                })
            }
          }
        }
      }
      

      在AppStorage中创建属性后,调用PersistentStorage.persistProp()接口时,会使用在AppStorage中已经存在的值,并覆盖PersistentStorage中的同名属性,所以建议要使用相反的调用顺序:

      let aProp = AppStorage.setOrCreate('aProp', 47);
      PersistentStorage.persistProp('aProp', 48);
      

      上例中,应用在非首次运行时,先执行AppStorage.setOrCreate('aProp', 47):属性“aProp”在AppStorage中创建,其类型为number,其值设置为指定的默认值47。'aProp'是持久化的属性,所以会被写回PersistentStorage磁盘中,PersistentStorage存储的上次退出应用的值丢失。

    • Environment

      开发者如果需要应用程序运行的设备的环境参数,以此来作出不同的场景判断,比如多语言,暗黑模式等,需要用到Environment设备环境查询。Environment是ArkUI框架在应用程序启动时创建的单例对象。它为AppStorage提供了一系列描述应用程序运行状态的属性。Environment的所有属性都是不可变的(即应用不可写入),所有的属性都是简单类型。

      // 将设备的语言code存入AppStorage,默认值为en
      Environment.envProp('languageCode', 'en');
      
      @Entry
      @Component
      struct Index {
        //环境属性不允许被写回
        @StorageProp('languageCode') languageCode: string = 'en';
      
        build() {
          Row() {
            Column() {
              // 输出当前设备的languageCode
              Text(this.languageCode)
            }
          }
        }
      }
      

      同样,Environment和UIContext相关联,需要在UIContext明确的时候才可以调用。可以通过在runScopedTask里明确上下文。如果没有在UIContext明确的地方调用,将导致无法查询到设备环境数据。

      如果在AppStorage中已经创建属性后,再调用Environment.envProp()创建同名的属性,会调用失败。因为AppStorage已经有同名属性,Environment环境变量不会再写入AppStorage中,所以建议AppStorage中属性不要使用Environment预置环境变量名。

  • 其他状态管理

    • @Watch

      @Watch用于监听状态变量的变化,当状态变量变化时,@Watch的回调方法将被调用。@Watch在ArkUI框架内部判断数值有无更新使用的是严格相等(===),遵循严格相等规范。当在严格相等为false的情况下,就会触发@Watch的回调。

      @Component
      struct TotalView {
        @Prop @Watch('onCountUpdated') count: number = 0;
        @State total: number = 0;
        // @Watch 回调
        onCountUpdated(propName: string): void {
          this.total += this.count;
        }
      
        build() {
          Text(`Total: ${this.total}`)
        }
      }
      
      @Entry
      @Component
      struct CountModifier {
        @State count: number = 0;
      
        build() {
          Column() {
            Button('add to basket')
              .onClick(() => {
                this.count++
              })
            TotalView({ count: this.count })
          }
        }
      }
      
    • $$语法:内置组件双向同步

      当前$$支持基础类型变量,以及@State、@Link和@Prop装饰的变量。它有什么用呢?

      比如我们在监听TextInput的输入变化时修改数据,同时其他组件又引用了这个数据的话,我们需要手动处理这些逻辑,但是如果使用$$语法的话,我们完全不用去考虑手动实现:

      // xxx.ets
      @Entry
      @Component
      struct TextInputExample {
        @State text: string = ''
        controller: TextInputController = new TextInputController()
      
        build() {
          Column({ space: 20 }) {
            Text(this.text)
            //TextInput输入文本变化后,上面的Text组件也会跟着变化
            TextInput({ text: $$this.text, placeholder: 'input your word...', controller: this.controller })
              .placeholderColor(Color.Grey)
              .placeholderFont({ size: 14, weight: 400 })
              .caretColor(Color.Blue)
              .width(300)
          }.width('100%').height('100%').justifyContent(FlexAlign.Center)
        }
      }
      

      注意这个语法要在编译器版本至少4.0.3.700的IDE上才不会报错。

      注意它和Link双向同步是不一样的,Link是父子组件之间的同步,这个是单个组件内的子组件之间的同步。

      当前$$支持的组件在 $$语法

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容

  • HarmonyOS开发学习笔记 基本步骤 开发环境搭建 按照官网步骤 语言选择 官方建议选择arkTs(TypeS...
    小仙女喂得猪呀阅读 407评论 0 3
  • 前言 为了金三银四的跳槽季做准备,并且我是vue技术栈的,所以整理了若干个vue的面试题。 每次看别人的博客,都会...
    93ac81ebff1e阅读 220评论 0 1
  • 用TCP协议发送时,由于TCP是数据流协议,因此不存在包大小的限制(暂不考虑缓冲区的大小) HTTP/2 头压缩算...
    JLong阅读 479评论 0 0
  • 2018web前端最新面试题总结 一、Html/Css基础模块 基础部分 什么是HTML?答:​ HTML并不是...
    duans_阅读 4,665评论 3 27
  • 前端开发面试题 面试题目: 根据你的等级和职位的变化,入门级到专家级,广度和深度都会有所增加。 题目类型: 理论知...
    怡宝丶阅读 2,570评论 0 7