什么是状态管理
状态管理指的是我们通过去修改数据,驱动 UI 变化。在前端层面,大多数 UI (React、Vue、...)框架都有自己的状态管理模块
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 来接受的话,子组件的修改可以同步回父组件
具体实例:
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摆脱参数传递机制的束缚,实现跨层级传递。
具体实例:
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装饰器。
具体实例:
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?
应用通过状态去渲染更新UI是程序设计中相对复杂,但又十分重要的,往往决定了应用程序的性能。程序的状态数据通常包含了数组、对象,或者是嵌套对象组合而成。在这些情况下,ArkUI采取MVVM = Model + View + ViewModel模式,其中状态管理模块起到的就是ViewModel的作用,将数据与视图绑定在一起,更新数据的时候直接更新视图。
- Model层:存储数据和相关逻辑的模型。它表示组件或其他相关业务逻辑之间传输的数据。Model是对原始数据的进一步处理。
- View层:在ArkUI中通常是@Component装饰组件渲染的UI。
- ViewModel层:在ArkUI中,ViewModel是存储在自定义组件的状态变量。
具体实践 ToDoList
项目目录
--pages
-- TodoMVVM // 页面入口
-- Todo
-- model // 我们暂时不实现model里的逻辑,这个通常是本地数据库操作 或者远程的服务端接口调用
-- view // UI组件
-- DialogView // 新增 TODO 的弹窗
-- TodoItemView // 具体的 TODO 项的展示,UI 支持完成 TODO 和删除 TODO
-- TodoListView // 使用 ForEach,显示一组 TodoItemView
-- viewModel // TodoList 的状态管理实现
-- ToDoItem // 描述一个 Todo 的属性,
-- ToDoList // 描述一组 Todo,主要是一些 CRUD 的方法
状态管理跟踪图
具体实现
// 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… 深入理解