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