• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# MVVM模式
2
3当开发者了解了状态管理的概念之后,跃跃欲试去开发一款自己的应用,倘若开发者在应用开发时不注意设计自己的项目结构,随着项目越来越庞大,状态变量设计的越来越多,组件与组件之间的关系变得越来越模糊,当开发一个新需求时,牵一发而动全身,需求开发和维护成本也会成倍增加,为此,本文旨在介绍MVVM模式以及ArkUI的UI开发模式与MVVM的关系,指引开发者如何去设计自己的项目结构,从而在产品迭代和升级时,能更容易的去开发和维护。
4
5## MVVM模式介绍
6
7### 概念
8
9在应用开发中,UI的更新需要随着数据状态的变化进行实时同步,而这种同步往往决定了应用程序的性能和用户体验。为了解决数据与UI同步的复杂性,ArkUI采用了 Model-View-ViewModel(MVVM)架构模式。MVVM 将应用分为Model、View和ViewModel三个核心部分,实现数据、视图与逻辑的分离。通过这种模式,UI可以随着状态的变化自动更新,无需手动处理,从而更加高效地管理数据和视图的绑定与更新。
10
11- Model:负责存储和管理应用的数据以及业务逻辑,不直接与用户界面交互。通常从后端接口获取数据,是应用程序的数据基础,确保数据的一致性和完整性。
12- View:负责用户界面展示数据并与用户交互,不包含任何业务逻辑。它通过绑定ViewModel层提供的数据来动态更新UI。
13- ViewModel:负责管理UI状态和交互逻辑。作为连接Model和View的桥梁,ViewModel监控Model数据的变化,通知View更新UI,同时处理用户交互事件并转换为数据操作。
14
15ArkUI的UI开发模式就属于MVVM模式,通过对MVVM概念的基本介绍,开发者大致能猜到状态管理能在MVVM中起什么样的作用,状态管理旨在数据驱动更新,让开发者只用关注页面设计,而不去关注整个UI的刷新逻辑,数据的维护也无需开发者进行感知,由状态变量自动更新完成,而这就是属于ViewModel层所需要支持的内容,因此开发者使用MVVM模式开发自己的应用是最省心省力的。
16
17### ArkUI开发模式图
18
19ArkUI的UI开发开发模式即是MVVM模式,而状态管理在MVVM模式中扮演者ViewModel的角色,向上刷新UI,向下更新数据,整体框架如下图:
20
21![MVVM图](./figures/MVVM_架构.png)
22
23### 分层说明
24
25**View层**
26
27* 页面组件:所有应用基本都是按照页面进行分类的,比如登录页,列表页,编辑页,帮助页,版权页等。每个页对应需要的数据可能是完全不一样的,也可能多个页面需要的数据是同一套。
28* 业务组件:本身具备本APP部分业务能力的功能组件,典型的就是这个业务组件可能关联了本项目的ViewModel中的数据,不可以被共享给其他项目使用。
29* 通用组件:像内置组件一样,这类组件不会关联本APP中ViewModel的数据,这些组件可实现跨越多个项目进行共享,来完成比较通用的功能。
30
31**ViewModel层**
32
33* 页面数据:按照页面组织的数据,用户打开页面时,可能某些页面并不会切换到,因此,这个页面数据最好设计成懒加载的模式。
34
35> ViewModel层数据和Model层数据的区别:
36>
37> Model层数据是按照整个工程,项目来组织数据,是一套完成本APP的业务数据。
38>
39> ViewModel层数据,是提供某个页面上使用的数据,它可能是整个APP的业务数据的一部分。另外ViewModel层还可以附加对应Page的辅助页面显示数据,这部分数据可能与本APP的业务完全无关,仅仅是为页面展示提供便利的辅助数据。
40
41**Model层**
42
43Model层是应用的原始数据提供者,这一层在UI来看,有两种模式
44
45* 本地实现:通过纯NativeC++实现
46
47* 远端实现:通过IO端口(RestFul)实现
48
49> 注意:
50>
51> 采用本地实现时,系统的对数据加工和处理,基本上一定会存在非UI线程模型,这个时候,被加工的数据变更可能需要即时通知ViewModel层,来引起数据的变化,从而引起UI的相应更新。这个时候,自动线程转换就会变得非常重要。常规下,ViewModel层,View层,都只能在UI线程下执行,才能正常工作。因此需要一种机制,当需要通知UI更新时,需要自动完成线程切换。
52
53### 架构核心原则
54
55**不可跨层访问**
56
57* View层不可以直接调用Model层的数据,只能通过ViewModel提供的方法进行调用。
58* Model层数据,不可以直接操作UI,Model层只能通知ViewModel层数据有更新,由ViewModel层更新对应的数据。
59
60**下层不可访问上层数据**
61
62下层的数据通过通知模式更新上层数据。在业务逻辑中,下层不可直接写代码去获取上层数据。如ViewModel层的逻辑处理,不能去依赖View层界面上的某个值。
63
64**非父子组件间不可直接访问**
65
66这是针对View层设计的核心原则,一个组件应该具备这样的逻辑:
67
68* 禁止直接访问父组件(使用事件或是订阅能力)
69* 禁止直接访问兄弟组件能力。这是因为组件应该仅能访问自己看的见的子节点(通过传参)和父节点(通过事件或通知),以此完成组件之间的解耦。
70
71对于一个组件,这样设计的原因是:
72
73* 组件自己使用了哪些子组件是明确的,因此可以访问。
74* 组件被放置于哪个父节点下是未知的,因此组件想访问父节点,就只能通过通知或者事件能力完成。
75* 组件不可能知道自己的兄弟节点是谁,因此组件不可以操纵兄弟节点。
76
77## 备忘录开发实战
78
79本节通过备忘录应用的开发,让开发者了解如何通过ArkUI框架设计自己的应用,本节未设计代码架构直接进行功能开发,即根据需求做即时开发,不考虑后续维护,同时向开发者介绍功能开发所需的装饰器。
80
81### @State状态变量
82
83* @State装饰器作为最常用的装饰器,用来定义状态变量,一般作为父组件的数据源,当开发者点击时,通过触发状态变量的更新从而刷新UI,去掉@State则不再支持刷新UI。
84
85```typescript
86@Entry
87@Component
88struct Index {
89  @State isFinished: boolean = false;
90
91  build() {
92    Column() {
93      Row() {
94        Text('全部待办')
95          .fontSize(30)
96          .fontWeight(FontWeight.Bold)
97      }
98      .width('100%')
99      .margin({top: 10, bottom: 10})
100
101      // 待办事项
102      Row({space: 15}) {
103        if (this.isFinished) {
104          Image($r('app.media.finished'))
105            .width(28)
106            .height(28)
107        }
108        else {
109          Image($r('app.media.unfinished'))
110            .width(28)
111            .height(28)
112        }
113        Text('学习高数')
114          .fontSize(24)
115          .fontWeight(450)
116          .decoration({type: this.isFinished ? TextDecorationType.LineThrough : TextDecorationType.None})
117      }
118      .height('40%')
119      .width('100%')
120      .border({width: 5})
121      .padding({left: 15})
122      .onClick(() => {
123        this.isFinished = !this.isFinished;
124      })
125    }
126    .height('100%')
127    .width('100%')
128    .margin({top: 5, bottom: 5})
129    .backgroundColor('#90f1f3f5')
130  }
131}
132```
133
134效果图:
135
136![state](./figures/MVVM_state.gif)
137
138### @Prop、@Link的作用
139
140上述示例中,所有的代码都写在了@Entry组件中,随着需要渲染的组件越来越多,@Entry组件必然需要进行拆分,为此拆分出的子组件就需要使用@Prop和@Link装饰器:
141
142* @Prop是父子间单向传递,子组件会深拷贝父组件数据,可从父组件更新,也可自己更新数据,但不会同步父组件数据。
143* @Link是父子间双向传递,父组件改变,会通知所有的@Link,同时@Link的更新也会通知父组件对应变量进行刷新。
144
145```typescript
146@Component
147struct TodoComponent {
148  build() {
149    Row() {
150      Text('全部待办')
151        .fontSize(30)
152        .fontWeight(FontWeight.Bold)
153    }
154    .width('100%')
155    .margin({top: 10, bottom: 10})
156  }
157}
158
159@Component
160struct AllChooseComponent {
161  @Link isFinished: boolean;
162
163  build() {
164    Row() {
165      Button('全选', {type: ButtonType.Normal})
166        .onClick(() => {
167          this.isFinished = !this.isFinished;
168        })
169        .fontSize(30)
170        .fontWeight(FontWeight.Bold)
171        .backgroundColor('#f7f6cc74')
172    }
173    .padding({left: 15})
174    .width('100%')
175    .margin({top: 10, bottom: 10})
176  }
177}
178
179@Component
180struct ThingsComponent1 {
181  @Prop isFinished: boolean;
182
183  build() {
184    // 待办事项1
185    Row({space: 15}) {
186      if (this.isFinished) {
187        Image($r('app.media.finished'))
188          .width(28)
189          .height(28)
190      }
191      else {
192        Image($r('app.media.unfinished'))
193          .width(28)
194          .height(28)
195      }
196      Text('学习语文')
197        .fontSize(24)
198        .fontWeight(450)
199        .decoration({type: this.isFinished ? TextDecorationType.LineThrough : TextDecorationType.None})
200    }
201    .height('40%')
202    .width('100%')
203    .border({width: 5})
204    .padding({left: 15})
205    .onClick(() => {
206      this.isFinished = !this.isFinished;
207    })
208  }
209}
210
211@Component
212struct ThingsComponent2 {
213  @Prop isFinished: boolean;
214
215  build() {
216    // 待办事项1
217    Row({space: 15}) {
218      if (this.isFinished) {
219        Image($r('app.media.finished'))
220          .width(28)
221          .height(28)
222      }
223      else {
224        Image($r('app.media.unfinished'))
225          .width(28)
226          .height(28)
227      }
228      Text('学习高数')
229        .fontSize(24)
230        .fontWeight(450)
231        .decoration({type: this.isFinished ? TextDecorationType.LineThrough : TextDecorationType.None})
232    }
233    .height('40%')
234    .width('100%')
235    .border({width: 5})
236    .padding({left: 15})
237    .onClick(() => {
238      this.isFinished = !this.isFinished;
239    })
240  }
241}
242
243@Entry
244@Component
245struct Index {
246  @State isFinished: boolean = false;
247
248  build() {
249    Column() {
250      // 全部待办
251      TodoComponent()
252
253      // 全选
254      AllChooseComponent({isFinished: this.isFinished})
255
256      // 待办事项1
257      ThingsComponent1({isFinished: this.isFinished})
258
259      // 待办事项2
260      ThingsComponent2({isFinished: this.isFinished})
261    }
262    .height('100%')
263    .width('100%')
264    .margin({top: 5, bottom: 5})
265    .backgroundColor('#90f1f3f5')
266  }
267}
268```
269
270效果图如下:
271
272![Prop&Link](./figures/MVVM_Prop&Link.gif)
273
274### 循环渲染组件
275
276* 上个示例虽然拆分出了子组件,但是发现组件1和组件2的代码十分类似,当渲染的组件除了数据外其他设置都相同时,此时就需要使用到ForEach循环渲染。
277* ForEach使用之后,冗余代码变得更少,并且代码结构更加清晰。
278
279```typescript
280@Component
281struct TodoComponent {
282  build() {
283    Row() {
284      Text('全部待办')
285        .fontSize(30)
286        .fontWeight(FontWeight.Bold)
287    }
288    .width('100%')
289    .margin({top: 10, bottom: 10})
290  }
291}
292
293@Component
294struct AllChooseComponent {
295  @Link isFinished: boolean;
296
297  build() {
298    Row() {
299      Button('全选', {type: ButtonType.Normal})
300        .onClick(() => {
301          this.isFinished = !this.isFinished;
302        })
303        .fontSize(30)
304        .fontWeight(FontWeight.Bold)
305        .backgroundColor('#f7f6cc74')
306    }
307    .padding({left: 15})
308    .width('100%')
309    .margin({top: 10, bottom: 10})
310  }
311}
312
313@Component
314struct ThingsComponent {
315  @Prop isFinished: boolean;
316  @Prop things: string;
317  build() {
318    // 待办事项1
319    Row({space: 15}) {
320      if (this.isFinished) {
321        Image($r('app.media.finished'))
322          .width(28)
323          .height(28)
324      }
325      else {
326        Image($r('app.media.unfinished'))
327          .width(28)
328          .height(28)
329      }
330      Text(`${this.things}`)
331        .fontSize(24)
332        .fontWeight(450)
333        .decoration({type: this.isFinished ? TextDecorationType.LineThrough : TextDecorationType.None})
334    }
335    .height('8%')
336    .width('90%')
337    .padding({left: 15})
338    .opacity(this.isFinished ? 0.3: 1)
339    .border({width:1})
340    .borderColor(Color.White)
341    .borderRadius(25)
342    .backgroundColor(Color.White)
343    .onClick(() => {
344      this.isFinished = !this.isFinished;
345    })
346  }
347}
348
349@Entry
350@Component
351struct Index {
352  @State isFinished: boolean = false;
353  @State planList: string[] = [
354    '7.30 起床',
355    '8.30 早餐',
356    '11.30 中餐',
357    '17.30 晚餐',
358    '21.30 夜宵',
359    '22.30 洗澡',
360    '1.30 起床'
361  ];
362
363  build() {
364    Column() {
365      // 全部待办
366      TodoComponent()
367
368      // 全选
369      AllChooseComponent({isFinished: this.isFinished})
370
371      List() {
372        ForEach(this.planList, (item: string) => {
373          // 待办事项1
374          ThingsComponent({isFinished: this.isFinished, things: item})
375            .margin(5)
376        })
377      }
378
379    }
380    .height('100%')
381    .width('100%')
382    .margin({top: 5, bottom: 5})
383    .backgroundColor('#90f1f3f5')
384  }
385}
386```
387
388效果图如下:
389
390![ForEach](./figures/MVVM_ForEach.gif)
391
392### @Builder方法
393
394* Builder方法用于组件内定义方法,可以使得相同代码可以在组件内进行复用。
395* 本示例不仅使用了@Builder方法进行去重,同时对数据进行了移出,可以看到此时代码更加清晰易读,相对于最开始的代码,@Entry组件基本只用于处理页面构建逻辑,而不处理大量与页面设计无关的内容。
396
397```typescript
398@Observed
399class TodoListData {
400  planList: string[] = [
401    '7.30 起床',
402    '8.30 早餐',
403    '11.30 中餐',
404    '17.30 晚餐',
405    '21.30 夜宵',
406    '22.30 洗澡',
407    '1.30 起床'
408  ];
409}
410
411@Component
412struct TodoComponent {
413  build() {
414    Row() {
415      Text('全部待办')
416        .fontSize(30)
417        .fontWeight(FontWeight.Bold)
418    }
419    .width('100%')
420    .margin({top: 10, bottom: 10})
421  }
422}
423
424@Component
425struct AllChooseComponent {
426  @Link isFinished: boolean;
427
428  build() {
429    Row() {
430      Button('全选', {type: ButtonType.Capsule})
431        .onClick(() => {
432          this.isFinished = !this.isFinished;
433        })
434        .fontSize(30)
435        .fontWeight(FontWeight.Bold)
436        .backgroundColor('#f7f6cc74')
437    }
438    .padding({left: 15})
439    .width('100%')
440    .margin({top: 10, bottom: 10})
441  }
442}
443
444@Component
445struct ThingsComponent {
446  @Prop isFinished: boolean;
447  @Prop things: string;
448
449  @Builder displayIcon(icon: Resource) {
450    Image(icon)
451      .width(28)
452      .height(28)
453      .onClick(() => {
454        this.isFinished = !this.isFinished;
455      })
456  }
457
458  build() {
459    // 待办事项1
460    Row({space: 15}) {
461      if (this.isFinished) {
462        this.displayIcon($r('app.media.finished'));
463      }
464      else {
465        this.displayIcon($r('app.media.unfinished'));
466      }
467      Text(`${this.things}`)
468        .fontSize(24)
469        .fontWeight(450)
470        .decoration({type: this.isFinished ? TextDecorationType.LineThrough : TextDecorationType.None})
471        .onClick(() => {
472          this.things += '啦';
473        })
474    }
475    .height('8%')
476    .width('90%')
477    .padding({left: 15})
478    .opacity(this.isFinished ? 0.3: 1)
479    .border({width:1})
480    .borderColor(Color.White)
481    .borderRadius(25)
482    .backgroundColor(Color.White)
483  }
484}
485
486@Entry
487@Component
488struct Index {
489  @State isFinished: boolean = false;
490  @State data: TodoListData = new TodoListData();
491
492  build() {
493    Column() {
494      // 全部待办
495      TodoComponent()
496
497      // 全选
498      AllChooseComponent({isFinished: this.isFinished})
499
500      List() {
501        ForEach(this.data.planList, (item: string) => {
502          // 待办事项1
503          ThingsComponent({isFinished: this.isFinished, things: item})
504            .margin(5)
505        })
506      }
507
508    }
509    .height('100%')
510    .width('100%')
511    .margin({top: 5, bottom: 5})
512    .backgroundColor('#90f1f3f5')
513  }
514}
515```
516
517 效果图如下:
518
519![builder](./figures/MVVM_builder.gif)
520
521### 总结
522
523* 通过对代码结构的一步步优化,可以看到@Enrty组件作为页面的入口,其build函数应该只需要考虑将需要的组件进行组合,类似于搭积木,将需要的组件搭起来。被page调用的子组件则类似积木,等着被需要的page进行调用。状态变量类似于粘合剂,当触发UI刷新事件时,状态变量能自动完成对应绑定的组件的刷新,从而实现page的按需刷新。
524* 虽然现有的架构并未使用到MVVM的设计理念,但是MVVM的核心理念已经呼之欲出,这也是为什么说ArkUI的UI开发天生属于MVVM模式,page和组件就是View层,page负责搭积木,组件就是积木被page组织;组件需要刷新,通过状态变量驱动组件刷新从而更新page;ViewModel的数据需要有来源,这就是Model层来源。
525* 示例中的代码功能还是比较简单的,但是已经感觉到功能越来越多的情况下,主page的代码越来越多,当备忘录需要添加的功能越来越多时,其他的page也需要使用到主page的组件时,应该如何去组织项目结构呢,MVVM模式是组织的首选。
526
527## 通过MVVM开发备忘录实战
528
529上一章节中,展示了非MVVM模式如何组织代码,能感觉到随着主page的代码越来越庞大,应该采取合理的方式进行分层,使得项目结构清晰,组件之间不去互相引用,导致后期维护时牵一发而动全身,加大后期功能更新的困难,为此本章通过对MVVM的核心文件组织模式介绍入手,向开发者展示如何使用MVVM来组织上一章节的代码。
530
531### MVVM文件结构说明
532
533* src
534  * ets
535    * pages ------ 存放页面组件
536    * views ------ 存放业务组件
537    * shares ------ 存放通用组件
538    * service ------ 数据服务
539      * app.ts ------ 服务入口
540      * LoginViewMode ----- 登录页ViewModel
541      * xxxModel ------ 其他页ViewModel
542
543### 分层设计技巧
544
545**Model层**
546
547* model层存放本应用核心数据结构,这层本身和UI开发关系不大,让用户按照自己的业务逻辑进行封装。
548
549**ViewModel层**
550
551> 注意:
552>
553> ViewModel层不只是存放数据,他同时需要提供数据的服务及处理,因此很多框架会以“service”来进行表达此层。
554
555* ViewModel层是为视图服务的数据层。它的设计一般来说,有两个特点:
556  1、按照页面组织数据。
557  2、每个页面数据进行懒加载。
558
559**View层**
560
561View层根据需要来组织,但View层需要区分一下三种组件:
562
563* 页面组件:提供整体页面布局,实现多页面之间的跳转,前后台事件处理等页面内容。
564* 业务组件:被页面引用,构建出页面。
565* 共享组件:与项目无关的多项目共享组件。
566
567> 共享组件和业务组件的区别:
568>
569> 业务组件包含了ViewModel层数据,没有ViewModel,这个组件不能运行。
570>
571> 共享组件:不包含任务ViewModel层的数据,他需要的数据需要从外部传入。共享组件包含一个自包含组件,只要外部参数(无业务参数)满足,就可以工作。
572
573### 代码示例
574
575现在按照MVVM模式组织结构,重构如下:
576
577* src
578  * ets
579    * pages
580      * index
581    * View
582      * TodoComponent
583      * AllchooseComponent
584      * ThingsComponent
585    * ViewModel
586      * ThingsViewModel
587
588文件代码如下:
589
590* Index.ets
591
592  ```typescript
593  // import view
594  import { TodoComponent } from './../View/TodoComponent'
595  import { MultiChooseComponent } from './../View/AllchooseComponent'
596  import { ThingsComponent } from './../View/ThingsComponent'
597
598  // import viewModel
599  import { TodoListData } from '../ViewModel/ThingsViewModel'
600
601  @Entry
602  @Component
603  struct Index {
604    @State isFinished: boolean = false;
605    @State data: TodoListData = new TodoListData();
606
607    build() {
608      Column() {
609        Row({space: 40}) {
610          // 全部待办
611          TodoComponent()
612
613          // 全选
614          MultiChooseComponent({isFinished: this.isFinished})
615        }
616
617        List() {
618          ForEach(this.data.planList, (item: string) => {
619            // 待办事项1
620            ThingsComponent({isFinished: this.isFinished, things: item})
621              .margin(5)
622          })
623        }
624
625      }
626      .height('100%')
627      .width('100%')
628      .margin({top: 5, bottom: 5})
629      .backgroundColor('#90f1f3f5')
630    }
631  }
632  ```
633
634  * TodoComponent
635
636  ```typescript
637  @Component
638  export struct TodoComponent {
639    build() {
640      Row() {
641        Text('全部待办')
642          .fontSize(30)
643          .fontWeight(FontWeight.Bold)
644      }
645      .padding({left: 15})
646      .width('50%')
647      .margin({top: 10, bottom: 10})
648    }
649  }
650  ```
651
652
653
654  * AllchooseComponent.ets
655
656  ```typescript
657  @Component
658  export struct MultiChooseComponent {
659    @Link isFinished: boolean;
660
661    build() {
662      Row() {
663        Button('多选', {type: ButtonType.Capsule})
664          .onClick(() => {
665            this.isFinished = !this.isFinished;
666          })
667          .fontSize(30)
668          .fontWeight(FontWeight.Bold)
669          .backgroundColor('#f7f6cc74')
670      }
671      .padding({left: 15})
672      .width('100%')
673      .margin({top: 10, bottom: 10})
674    }
675  }
676  ```
677
678  * ThingsComponent
679
680  ```typescript
681  @Component
682  export struct ThingsComponent {
683    @Prop isFinished: boolean;
684    @Prop things: string;
685
686    @Builder displayIcon(icon: Resource) {
687      Image(icon)
688        .width(28)
689        .height(28)
690        .onClick(() => {
691          this.isFinished = !this.isFinished;
692        })
693    }
694
695    build() {
696      // 待办事项1
697      Row({space: 15}) {
698        if (this.isFinished) {
699          this.displayIcon($r('app.media.finished'));
700        }
701        else {
702          this.displayIcon($r('app.media.unfinished'));
703        }
704        Text(`${this.things}`)
705          .fontSize(24)
706          .fontWeight(450)
707          .decoration({type: this.isFinished ? TextDecorationType.LineThrough : TextDecorationType.None})
708          .onClick(() => {
709            this.things += '啦';
710          })
711      }
712      .height('8%')
713      .width('90%')
714      .padding({left: 15})
715      .opacity(this.isFinished ? 0.3: 1)
716      .border({width:1})
717      .borderColor(Color.White)
718      .borderRadius(25)
719      .backgroundColor(Color.White)
720    }
721  }
722
723  ```
724
725  ThingsViewModel.ets
726
727  ```typescript
728  @Observed
729  export class TodoListData {
730    planList: string[] = [
731      '7.30 起床',
732      '8.30 早餐',
733      '11.30 中餐',
734      '17.30 晚餐',
735      '21.30 夜宵',
736      '22.30 洗澡',
737      '1.30 起床'
738    ];
739  }
740  ```
741
742  经过MVVM模式拆分之后的代码,项目结构更加清晰,各个模块的职责更加清晰,假如有新的page需要用到事件这个组件,只需要import对应的组件即可,因为是固定的本地数据,没有去写Model层的逻辑,后续开发者也可以照着示例去重构自己的项目结构。
743
744  效果图如下:
745
746  ![MVVM_index.gif](./figures/MVVM_index.gif)
747
748
749
750
751
752