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 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 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 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 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 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  747 748 749 750 751 752