一篇文章快速搞懂 ArkTS 的状态管理与 MVVM TodoList 实现

489 阅读8分钟

什么是状态管理

状态管理指的是我们通过去修改数据,驱动 UI 变化。在前端层面,大多数 UI (React、Vue、...)框架都有自己的状态管理模块

image.png

ArkTS 状态管理概述

在 ArkTS 中也有类似的框架去实现数据驱动 UI 变化:

  • @State:@State装饰的变量拥有其所属组件的状态,可以作为其子组件单向和双向同步的数据源。当其数值改变时,会引起相关组件的渲染刷新。
  • @Prop:@Prop装饰的变量可以和父组件建立单向同步关系,@Prop装饰的变量是可变的,但修改不会同步回父组件。
  • @Link:@Link装饰的变量可以和父组件建立双向同步关系,子组件中@Link装饰变量的修改会同步给父组件中建立双向数据绑定的数据源,父组件的更新也会同步给@Link装饰的变量。
  • @Provide/@Consume:@Provide/@Consume装饰的变量用于跨组件层级(多层组件)同步状态变量,可以不需要通过参数命名机制传递,通过alias(别名)或者属性名绑定。
  • @Observed:@Observed装饰class,需要观察多层嵌套场景的class需要被@Observed装饰。单独使用@Observed没有任何作用,需要和@ObjectLink、@Prop联用。
  • @ObjectLink:@ObjectLink装饰的变量接收@Observed装饰的class的实例,应用于观察多层嵌套场景,和父组件的数据源构建双向同步。

本文希望通过一些简单的实例向大家快速掌握 ArkTS 的状态管理(更多细节任何可以参考官方文档)

@State、@Prop 和 @Link

@State 和 @Prop 类似与 React 的 state 和 props 概念;

  • @State 用来标记某个变量为组件的内部状态;
  • @Prop 可以理解为某个自定义组件的参数;
  • @Link 这个概念 React 没有,他可以满足父组件将 state 传入子组件之后,子组件用 @Link 来接受的话,子组件的修改可以同步回父组件

具体实例:

image.png

import NavBack from '../components/NavBack';
import Demo from '../components/Demo';

@Entry
@Component
struct StateAndProp {
  scroller: Scroller = new Scroller()
  @State count: number = 0;
  @State title: Model = new Model('Hello', new ClassA('World'));
  @State list: Item[] = [new Item(1), new Item(2)];

  @Builder
  normalBuilder() {
    Column() {
      Row() {
        Text(this.count.toString())
        Button('点击').onClick(() => this.count++)
      }
      Sub0({ count: this.count })
      Sub1({ count: this.count })
    }
  }

  @Builder
  nestBuilder() {
    Column() {
      Text(this.title.value)
      Text(this.title.name.value)
      Button('点击').onClick(() => {
        this.title.value = '你好'
      })
      Button('点击').onClick(() => {
        this.title.name.value = '世界'
      })
    }
  }

  @Builder
  arrayBuilder() {

    Column() {
      List({ space: 12 }) {
        ForEach(this.list, (item: Item) => {
          ListItem() {
            Text(item.value.toString())
              .width('100%')
              .onClick(() => {

              })
          }
        }, (item: Item) => item.value.toString())
      }

    }

    Button('操作').onClick(() => {
      this.list = [new Item(22)];
      this.list[0] = new Item(22);
      this.list.pop();
      this.list.push(new Item(22));
      this.list[0].value = 22; // 无法触发 UI 变化;ObservedAndObjectLink demo 中解决
    })
  }

  build() {
    Flex({
      direction: FlexDirection.Column,
      justifyContent: FlexAlign.Start
    }) {
      NavBack()

      Scroll(this.scroller) {
        Column() {
          Demo({
            title: '非嵌套类型',
            slotBuilderParam: (): void => {
              this.normalBuilder()
            }
          })

          Demo({
            title: '嵌套类型',
            slotBuilderParam: (): void => {
              this.nestBuilder()
            }
          })

          Demo({
            title: 'Array 类型',
            slotBuilderParam: (): void => {
              this.arrayBuilder()
            }
          })
        }
      }
    }
  }
}


class ClassA {
  public value: string;

  constructor(value: string) {
    this.value = value;
  }
}

class Model {
  public value: string;
  public name: ClassA;

  constructor(value: string, a: ClassA) {
    this.value = value;
    this.name = a;
  }
}

class Item {
  public value: number;

  constructor(value: number) {
    this.value = value;
  }
}

@Component
struct Sub0 {
  @Prop count: number = -1;

  build() {
    Row() {
      Text(this.count.toString())
      Button('点击').onClick(() => this.count++)

    }

  }
}

@Component
struct Sub1 {
  @Link count: number;

  build() {
    Row() {
      Text(this.count.toString())
      Button('点击').onClick(() => this.count++)

    }
  }
}

运行代码以后我们会发现;当变量类型是为基础类型的时候,修改变量数据可以正确触发 UI 更新,但是当变量是 嵌套对象 或者是 对象数组的时候,UI 无法触发更新,需要需要使用 @Observed装饰器和@ObjectLink装饰器来支持嵌套类对象属性变化

内心 OS:习惯了前端 UI 框架状态管理以后,ArkTS 这样的限制还是有点麻烦的

@Provide 和 @Consume

在 React 中,我们使用 Context 来实现跨组件层级的状态管理,在 Context 也有 provider和 consumer 的概念。 通用的在 ArkTS 中 我们使用 @Provide和@Consume,应用于与后代组件的双向数据同步,应用于状态数据在多个层级之间传递的场景。不同于上文提到的父子组件之间通过命名参数机制传递,@Provide和@Consume摆脱参数传递机制的束缚,实现跨层级传递。

具体实例:

image.png

import NavBack from '../components/NavBack';
import Demo from '../components/Demo';

@Entry
@Component
struct ProvideAndConsume {
  @Provide count: number = 0;

  build() {
    Column() {
      NavBack()

      Demo({
        title: '跨组件同步'
      })

      Column() {
        Text(this.count.toString()).fontColor('#fff')
        Button('+1')
          .margin(10)
          .onClick(() => {
            this.count++;
          })
        A()

      }.padding(20).backgroundColor('#00f')
    }.alignItems(HorizontalAlign.Start)
  }
}

@Component
struct A {
  build() {
    Column() {
      Text('A')
      B()
    }.padding(20).backgroundColor('#f00')
  }
}

@Component
struct B {
  @Consume count: number;

  build() {
    Column() {
      Text('B' + this.count).padding(20).backgroundColor('#0f0')
    }
  }
}

运行代码以后我们会发现:B 是 父组件的孙子组件,他们虽然没有通过 @Prop 或者 @Link 层层往下传递,但是通过 @Provide和@Consume 实现了孙子组件与父组件的数据同步。

@Observed 和 @ObjectLink

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

具体实例:

image.png

import Demo from '../components/Demo';
import NavBack from '../components/NavBack';

@Component
struct TodoItemView {
  @ObjectLink todoItem: TodoItem;

  build() {
    Row() {
      Text(this.todoItem.id.toString() + '. ')

      Checkbox()
        .select(this.todoItem.complete)
        .shape(CheckBoxShape.CIRCLE)
        .onClick(() => {
          this.todoItem.toggle();
        })
      Text(this.todoItem.name)
    }.width('100%')
  }
}

@Component
struct ObjectLinkText {
  @ObjectLink a: ClassA;

  build() {
    Text(this.a.value)

  }
}

@Entry
@Component
struct ObservedAndObjectLink {
  @State title: Model = new Model('Hello', new ClassA('World'));
  @State todoList: TodoItem[] = [new TodoItem('eat'), new TodoItem('run')];

  @Builder
  objectBuilder() {
    Column() {
      Text(this.title.value)
      Text(this.title.name.value)
      ObjectLinkText({ a: this.title.name })

      Button('class类型赋值').onClick(() => {
        this.title = new Model('Hi', new ClassA('ArkUI'));
      })
      Button('class属性的赋值').onClick(() => {
        this.title.value = 'Hi';
      })
      Button('嵌套的属性赋值观察不到?').onClick(() => {
        this.title.name.value = 'ArkUI';
      })
    }
  }

  @LocalBuilder
  arrayBuilder() {
    Column() {
      ForEach(this.todoList,
        (item: TodoItem) => {
          TodoItemView({
            todoItem: item
          })

        },
        (item: TodoItem): string => item.id.toString()
      )
    }
  }

  build() {
    Column() {
      NavBack()
      Demo({
        title: '嵌套对象',
        slotBuilderParam: () => {
          this.objectBuilder()
        }
      })

      Demo({
        title: '对象数组',
        slotBuilderParam: this.arrayBuilder
      })


    }.alignItems(HorizontalAlign.Start)
    .height('100%')
    .width('100%')
  }
}

let NextID: number = 1;

@Observed
class TodoItem {
  public id: number;
  public name: string;
  complete: boolean;

  constructor(name: string) {
    this.id = NextID++;
    this.name = name;
    this.complete = false;
  }

  toggle() {
    this.complete = !this.complete;
  }
}

@Observed
class ClassA {
  public value: string;

  constructor(value: string) {
    this.value = value;
  }
}

class Model {
  public value: string;
  public name: ClassA;

  constructor(value: string, a: ClassA) {
    this.value = value;
    this.name = a;
  }
}

运行代码我们发现,通过使用 @Observed 和 @ObjectLink 以后,这样的嵌套属性修改也可以触发 UI 更新。

this.title.name.value = 'ArkUI';

MVVM 实践

什么是 MVVM?

image.png 应用通过状态去渲染更新UI是程序设计中相对复杂,但又十分重要的,往往决定了应用程序的性能。程序的状态数据通常包含了数组、对象,或者是嵌套对象组合而成。在这些情况下,ArkUI采取MVVM = Model + View + ViewModel模式,其中状态管理模块起到的就是ViewModel的作用,将数据与视图绑定在一起,更新数据的时候直接更新视图。

  • Model层:存储数据和相关逻辑的模型。它表示组件或其他相关业务逻辑之间传输的数据。Model是对原始数据的进一步处理。
  • View层:在ArkUI中通常是@Component装饰组件渲染的UI。
  • ViewModel层:在ArkUI中,ViewModel是存储在自定义组件的状态变量。

具体实践 ToDoList

image.png

image.png

项目目录

--pages
  -- TodoMVVM // 页面入口
  -- Todo
    -- model // 我们暂时不实现model里的逻辑,这个通常是本地数据库操作 或者远程的服务端接口调用
    -- view // UI组件
      -- DialogView // 新增 TODO 的弹窗
      -- TodoItemView // 具体的 TODO 项的展示,UI 支持完成 TODO 和删除 TODO
      -- TodoListView // 使用 ForEach,显示一组 TodoItemView
    -- viewModel // TodoList 的状态管理实现
      -- ToDoItem // 描述一个 Todo 的属性, 
      -- ToDoList // 描述一组 Todo,主要是一些 CRUD 的方法

状态管理跟踪图

image.png

具体实现

// TodoItem.ets 
let NextID: number = 1;

@Observed
export default class TodoItem {
  public id: number;
  public name: string;
  isComplete: boolean;

  constructor(name: string) {
    this.id = NextID++;
    this.name = name;
    this.isComplete = false;
  }

  toggle() {
    this.isComplete = !this.isComplete;
  }
}
// TodoList.ets
import TodoItem from './TodoItem';

@Observed
export class ObservedArray<T> extends Array<T> {
  constructor(args: T[]) {
    if (args instanceof Array) {
      super(...args);
    } else {
      super(args)
    }
  }
}


class TodoList {
  filter: 'all' | 'done' = 'all';
  todoListData: ObservedArray<TodoItem>;
  doneCount: number = 0;

  constructor(todoListData: Array<TodoItem>) {
    this.filter = 'all';
    this.todoListData = new ObservedArray<TodoItem>(todoListData);
  }

  getData(): Array<TodoItem> {
    return this.todoListData;
  }

  deleteTodo(todo: TodoItem) {
    const index = this.todoListData.findIndex(item => item.id === todo.id);
    this.todoListData.splice(index, 1);
  }

  addTodo(todo: TodoItem) {
    this.todoListData.push(todo);
  }

  toggleTodo(todo: TodoItem) {
    const index = this.todoListData.findIndex(item => item.id === todo.id);
    this.todoListData[index].toggle();
    this.doneCount = this.todoListData.filter(i => i.isComplete).length;
  }
}


export default TodoList;
// TodoMVVM.ets
import NavBack from '../components/NavBack';
import TodoListView from './Todo/view/TodoListView';
import TodoItem from './Todo/viewModel/TodoItem';
import TodoList from './Todo/viewModel/TodoList';

@Entry
@Component
struct TodoMVVM {
  @Provide todoList: TodoList = new TodoList([
    new TodoItem('eat'),
    new TodoItem('run'),
    new TodoItem('sleep'),
  ]);

  build() {
    Column() {
      NavBack()
      TodoListView({
        todoListData: this.todoList.todoListData,
        doneCount: this.todoList.doneCount
      })

    }.alignItems(HorizontalAlign.Start)
    .height('100%')
    .width('100%')
  }
}
// TodoItemView.ets
import TodoItem from '../viewModel/TodoItem';
import TodoList from '../viewModel/TodoList';

@Component
export default struct TodoItemView {
  @ObjectLink todoItem: TodoItem;
  @Consume todoList: TodoList

  build() {
    Flex({ alignItems: ItemAlign.Center }) {
      Text(this.todoItem.id.toString() + '. ')

      Checkbox()
        .select(this.todoItem.isComplete)
        .shape(CheckBoxShape.CIRCLE)
        .onClick(() => {
          this.todoList.toggleTodo(this.todoItem)
        })

      Text(this.todoItem.name).flexGrow(1).fontSize(20).fontWeight(1000)

      Button('删除')
        .onClick(() => {
          this.todoList.deleteTodo(this.todoItem);
        })
    }.width('100%').margin(5)
  }
}
// TodoListView.ets
import TodoItem from '../viewModel/TodoItem';
import TodoList, { ObservedArray } from '../viewModel/TodoList';
import TodoItemView from '../view/TodoItemView';
import DialogView from './DialogView';

@Component
struct TodoListView {
  @ObjectLink todoListData: ObservedArray<TodoItem>
  @Prop doneCount: number;
  @Consume todoList: TodoList
  @State textValue: string = ''
  @State inputValue: string = ''
  dialogController: CustomDialogController | null = new CustomDialogController({
    builder: DialogView({
      confirm: () => {
        this.todoList.addTodo(new TodoItem(this.textValue))
        this.textValue = '';
      },
      textValue: $textValue,
      inputValue: $inputValue
    }),
    autoCancel: true,

  })

  aboutToDisappear() {
    this.dialogController = null // 将dialogController置空
  }

  build() {
    Column() {
      Row() {
        Button('新增').onClick(() => {
          if (this.dialogController != null) {
            this.dialogController.open()
          }
        })
        Text(`${this.todoList.doneCount}/${this.todoListData.length}`).margin(({ left: 10 }))
      }.width('100%')

      ForEach(this.todoListData,
        (item: TodoItem) => {
          TodoItemView({ todoItem: item })
        },
        (item: TodoItem): string => item.id.toString()
      )
    }.margin(20)
  }
}

export default TodoListView
// DialogView.ets 添加 todo 弹窗
@CustomDialog
@Component
struct CustomDialogExample {
  @Link textValue: string
  @Link inputValue: string
  controller?: CustomDialogController
  cancel: () => void = () => {
  }
  confirm: () => void = () => {
  }

  build() {
    Column() {
      Text('新增 TODO').margin({ bottom: 10 })
      TextInput({ placeholder: '', text: this.textValue }).height(60).width('90%')
        .onChange((value: string) => {
          this.textValue = value
        })
      Flex({ justifyContent: FlexAlign.SpaceAround }) {
        Button('cancel')
          .onClick(() => {
            if (this.controller != undefined) {
              this.controller.close()
              this.cancel()
            }
          }).backgroundColor(0xffffff).fontColor(Color.Black)
        Button('confirm')
          .onClick(() => {
            if (this.controller != undefined) {
              this.inputValue = this.textValue
              this.controller.close()
              this.confirm()
            }
          }).backgroundColor(0xffffff).fontColor(Color.Red)
      }
    }.borderRadius(10).padding(20)
  }
}


export default CustomDialogExample

子组件如何对父组件暴露方法

在 React 中,我们除了使用 props 和 state 以外,有的时候作为一个复杂的子组件,我们会提供一个方法提供给父组件,具体举例为,如果子组件是一个封装的 Video,视频的播放状态可以通过变量去同步,也可以通过 子组件提供 .play 或者 .pause,那么在 ArkTS 中如何实现呢?

@Component
struct Child {
  @State private text: string = 'pause'
  private controller: ChildController = new ChildController();

  aboutToAppear() {
    if (this.controller) {
      this.controller.play = this.play
      this.controller.pause = this.pause
    }
  }

  play = () => {
    this.text = 'play';

  }
  pause = () => {
    this.text = 'pause';

  }

  build() {
    Column() {
      Text(this.text)
    }
  }
}

class ChildController {
  play = () => {
  }
  pause = () => {

  }
}


@Entry
@Component
struct Parent {
  controller = new ChildController()

  build() {
    Column() {
      Child({ controller: this.controller })

      Button('播放').onClick(() => {
        this.controller.play();
      })
      Button('暂停').onClick(() => {
        this.controller.pause();

      })
    }
  }
}

关键实现:通过父组件向子组件传入 controller,子组件在 aboutToAppear 生命周期上完成必要方法的挂着,那么父组件就可以使用子组件暴露的方法了

其实我们在 ArkUI 中用的 ArkWeb,Scroller 等类似组件的时候,也都是这样处理的

最后

鸿蒙的开发文档由于本身细节很多,文本主要想通过一些具体的实例快速让大家了解到 ArkTS 的状态管理,当大家伴随着项目复杂度的提升,一些 FAQ,或者更加细节的问题可以继续通过 developer.huawei.com/consumer/cn… 深入理解