MVVM架构与ArkUI状态管理

MVVM 架构与 HarmonyOS ArkUI 状态管理

前言

初学 HarmonyOS 开发时,我一直在纠结一个问题:@State@Prop@Link 这些装饰器到底在干什么?为什么要这么写?直到我理解了 MVVM 架构,一切才豁然开朗------原来这些装饰器就是 MVVM 在 ArkUI 中的具体实现。

本文从 MVVM 的原理出发,结合 ArkUI 状态管理 V1 和 V2,帮你建立"架构思想 → 代码实践"的完整认知链路。


一、为什么需要架构模式?

先看一段"没有架构"的代码:

复制代码
按钮点击 → 直接调 API → 拿到数据 → 手动更新 UI 上的文字、颜色、图标

看起来能跑,但问题很快就来了:

  • 数据和 UI 混在一起,改 UI 可能破坏数据逻辑
  • 页面复杂后,代码成一团乱麻
  • 无法复用------换个页面要把逻辑重写一遍

架构模式的核心目的:把代码分层,各司其职。


二、架构模式演进:MVC → MVP → MVVM

2.1 MVC(Model-View-Controller)

最古老的架构,1970 年代就有了:

复制代码
用户操作 → Controller → 更新 Model → Model 通知 View 刷新

问题:View 经常直接读 Model,Controller 越写越胖,最后变成了 "Massive View Controller"(巨型控制器)。

2.2 MVP(Model-View-Presenter)

把 Controller 改成 Presenter,切断 View 和 Model 的直接联系:

复制代码
用户操作 → View 通知 Presenter → Presenter 调 Model → Presenter 手动调 View 的方法更新

问题 :Presenter 要手动调 View 的每个更新方法,累且容易遗漏。比如 View 上有 10 个数据要更新,Presenter 就得写 10 行 view.setTemp(35)view.setHumidity(72)......

2.3 MVVM(Model-View-ViewModel)

核心突破:数据绑定------ViewModel 改了数据,View 自动刷新,不用手动调。

复制代码
用户操作 → View 通知 ViewModel → ViewModel 调 Model → Model 返回数据
                                              ↓
                        ViewModel 更新自身状态 ⇄ View 自动刷新(数据绑定)

注意这里的双向箭头 ⇄:View 可以直读 ViewModel 的状态,ViewModel 状态变了 View 自动刷新;但 View 不能直接写 Model,必须通过 ViewModel。这是 MVVM 的关键约束------数据绑定是观察式的,不是命令式的。

这就是 MVVM 的精髓:你只管改数据,UI 自己更新。


三、MVVM 各层职责详解

Model 层

职责:数据定义 + 业务逻辑 + 数据获取

  • 定义数据类型(class / interface)
  • 封装网络请求、数据库操作
  • 不关心 UI 怎么展示

在我的天气 App 中,Model 层包括:

  • WeatherModel.ets:定义 CityInfoWeatherNow 等数据类型
  • AmapService.ets:封装高德逆地理编码 API
  • QWeatherService.ets:封装和风天气实况 API
  • LocationService.ets:封装 GPS 定位

View 层

职责:UI 渲染 + 用户交互

  • 只负责"怎么展示"
  • 不包含业务逻辑
  • 用户操作通知 ViewModel,不直接调 Model

在 ArkUI 中,View 层就是 build() 里的 UI 声明代码:

typescript 复制代码
build() {
  Column() {
    Text(this.currentCity.name)        // 展示城市名
    Text(this.currentWeather.temp + '°') // 展示温度
    Button('刷新').onClick(() => {       // 用户操作
      this.loadWeather()                // 通知 ViewModel
    })
  }
}

ViewModel 层

职责:连接 Model 和 View 的桥梁

  • 持有 UI 需要的状态数据
  • 调用 Model 获取数据
  • 把 Model 返回的数据转成 View 能直接用的格式
  • 通过数据绑定让 View 自动刷新
ArkUI 的两种 ViewModel 实现方式

方式一:合在组件 struct 里(常见做法)

ArkUI 官方示例大多把 ViewModel 合在 @Component struct 中:

typescript 复制代码
@Entry
@Component
struct Index {
  // ViewModel 的状态
  @State currentCity: CityInfo = new CityInfo()
  @State currentWeather: WeatherNow = new WeatherNow()
  @State loading: boolean = false
  @State errorMsg: string = ''

  // ViewModel 的命令
  async loadWeather() {
    try {
      this.loading = true
      this.errorMsg = ''
      let location = await LocationService.getCurrentLocation()
      let city = await AmapService.reverseGeocode(location.latitude, location.longitude)
      let weather = await QWeatherService.fetchWeatherNow(city.adcode)
      this.currentCity = city
      this.currentWeather = weather
    } catch (err) {
      this.errorMsg = '获取天气失败'
    } finally {
      this.loading = false
    }
  }

  // View
  build() {
    Column() {
      if (this.loading) {
        Text('正在定位...')
      } else if (this.errorMsg) {
        Text(this.errorMsg).fontColor(Color.Red)
      } else {
        Text(this.currentCity.name)
        Text(this.currentWeather.temp + '°')
      }
    }
  }
}

优点:简单直观,适合中小项目。

缺点:View 和 ViewModel 耦合在一起,项目复杂后 struct 会膨胀成"Massive ViewModel"------和 MVC 的"Massive View Controller"是同样的问题。

方式二:抽离 ViewModel 为独立类(推荐)

更接近"真·MVVM"的做法是把业务逻辑抽离到单独的 ViewModel 类中:

typescript 复制代码
// WeatherViewModel.ets --- 独立的 ViewModel
@ObservedV2
class WeatherViewModel {
  @Trace currentCity: CityInfo = new CityInfo()
  @Trace currentWeather: WeatherNow = new WeatherNow()
  @Trace loading: boolean = false
  @Trace errorMsg: string = ''

  async loadWeather() {
    try {
      this.loading = true
      this.errorMsg = ''
      let location = await LocationService.getCurrentLocation()
      let city = await AmapService.reverseGeocode(location.latitude, location.longitude)
      let weather = await QWeatherService.fetchWeatherNow(city.adcode)
      this.currentCity = city
      this.currentWeather = weather
    } catch (err) {
      this.errorMsg = '获取天气失败'
    } finally {
      this.loading = false
    }
  }
}

// Index.ets --- 纯 View
@Entry
@ComponentV2
struct Index {
  @Local viewModel: WeatherViewModel = new WeatherViewModel()

  aboutToAppear() {
    this.viewModel.loadWeather()
  }

  build() {
    Column() {
      if (this.viewModel.loading) {
        LoadingProgress()
      } else if (this.viewModel.errorMsg) {
        Text(this.viewModel.errorMsg).fontColor(Color.Red)
      } else {
        Text(this.viewModel.currentCity.name)
        Text(this.viewModel.currentWeather.temp + '°')
      }
    }
  }
}

优点

  • ViewModel 可独立测试(不依赖 UI 框架)
  • View 层极简,只负责渲染
  • ViewModel 可以被多个页面复用

我的建议:小项目用方式一快速上手,项目变大时迁移到方式二。


四、数据绑定:MVVM 的灵魂

4.1 什么是数据绑定?

传统做法(MVP):手动更新 UI

复制代码
this.label_temp.setText(weather.temp)     // 手动设文字
this.label_temp.setColor(Color.Red)       // 手动设颜色
this.progressBar.setVisibility(View.GONE) // 手动控制显隐

数据绑定(MVVM):改数据,UI 自己更新

复制代码
this.temp = '35'   // 就这一行,UI 上的温度文字自动变成 35°

4.2 ArkUI 的数据绑定机制

ArkUI 通过装饰器实现数据绑定,不同装饰器 = 不同范围和方式的绑定:

装饰器 绑定范围 类比
@State 组件内部 自己房间里的灯,自己开关
@Prop 父→子,单向 老师发讲义,学生看但不改
@Link 父↔子,双向 共享文档,双方都能编辑
@Provide/@Consume 跨层,双向 公司公告板,谁都能看能改

五、ArkUI 状态管理 V1 详解

5.1 @State --- 组件自身状态

最基本的响应式数据,改了就刷新 UI:

typescript 复制代码
@State temp: string = ''

浅监听陷阱@State 对数组和对象是浅监听,修改内部属性不触发刷新:

typescript 复制代码
@State users: User[] = []
this.users[0].name = '新名字'   // ❌ UI 不刷新
this.users = [...this.users]    // ✅ 重新赋值整个数组才刷新

@State user: UserInfo = new UserInfo()
this.user.name = '新名字'       // ❌ UI 不刷新
this.user = new UserInfo()      // ✅ 重新赋值整个对象才刷新

这是我做聊天室时踩过的大坑------修改会话列表中某条会话的 lastMsg,UI 不更新,必须重新赋值整个数组。V1 的推荐解法 就是用展开运算符重新赋值;V2 的根治方案 见第六章 @Trace

5.2 @Prop --- 父传子,单向

typescript 复制代码
// 父组件
Child({ title: this.cityName })

// 子组件
@Prop title: string = ''   // 父改了会同步,子改了不会回传
typescript 复制代码
// 父组件
Child({ currentTab: $currentTab })   // 注意 $ 符号

// 子组件
@Link currentTab: number   // 子改了,父也会同步

隐患:双向绑定很方便,但调试时很难追踪"谁改了这个值"。

5.4 @Provide / @Consume --- 跨层共享

typescript 复制代码
// 祖先组件
@Provide('pageStack') pageStack: NavPathStack = new NavPathStack()

// 任意后代组件
@Consume('pageStack') pageStack: NavPathStack   // 直接拿到

我在聊天室里就是这样让所有页面共享 NavPathStack 的,不用一层层传参。

@Observed 标记一个 class,让它的属性变化能被框架感知。配合 @ObjectLink 在子组件中使用:

typescript 复制代码
@Observed
class UserInfo {
  name: string = ''
  age: number = 0
}

// 子组件
@ObjectLink user: UserInfo   // 能监听到 user.name 变化

局限@Observed 只能标记整个类,不能指定哪些属性要监听。而且 @ObjectLink 只能在子组件用,父组件里改属性还是不刷新。

5.6 @Watch --- 变化回调

typescript 复制代码
@State @Watch('onTabChange') currentTab: number = 0

onTabChange() {
  console.log('Tab 变了')
}

简单但不够强大------拿不到变化前的值,也不能监听深层属性。


六、ArkUI 状态管理 V2 详解

V2 的核心理念:显式优于隐式,精准优于全量。

6.1 @ComponentV2 + @Local

typescript 复制代码
@ComponentV2
struct MyPage {
  @Local loading: boolean = false   // 替代 @State,但更严格
}

@Local 不允许从外部初始化,数据归属更明确。

这是 V2 最重要的变化。 V1 的 @Link 双向绑定被拆成两步:

typescript 复制代码
// V1:隐式双向
@Link currentTab: number
this.currentTab = 1   // 子组件直接改,父组件自动同步

// V2:显式单向数据流
@Param currentTab: number = 0                    // 读:父传子
@Event onTabChange: (tab: number) => void         // 写:通知父

// 子组件想改值:
this.onTabChange(1)   // 不直接改,通知父组件改

// 父组件:
Child({
  currentTab: this.currentTab,
  onTabChange: (tab: number) => { this.currentTab = tab }
})

数据流变成完全单向 :父→子传数据,子→父发事件。就像 React 的理念------单向数据流让状态变化可追踪

注意@Param 必须写默认值。如果父组件没传值,子组件就用默认值。可以用 @Require 强制要求父组件必须传入:

typescript 复制代码
@Require @Param title: string = ''   // 不传编译报错

6.3 @ObservedV2 + @Trace --- 精准监听

V1 的痛点:@Observed 不能指定监听哪个属性,而且深层属性变化检测不到。

V2 的解法:

typescript 复制代码
@ObservedV2
class User {
  @Trace name: string = ''    // 标记要监听的属性
  @Trace age: number = 0      // 标记要监听的属性
  phone: string = ''          // 没标记,改了不触发刷新
}

关键前提@Trace 标记的是"这个属性变化时要通知框架",但要让 UI 真正刷新,这个类的实例必须被 @Local@Param 等状态装饰器包装 。如果只是普通变量,即使属性加了 @Trace 也不会触发 UI 刷新:

typescript 复制代码
@ComponentV2
struct MyPage {
  @Local user: User = new User()   // ✅ @Local 包装,@Trace 生效
  plainUser: User = new User()      // ❌ 普通变量,@Trace 不生效

  changeName() {
    this.user.name = '新名字'       // ✅ UI 刷新
    this.plainUser.name = '新名字'  // ❌ UI 不刷新
  }
}

另外,@ObservedV2 的类实例必须用 new 创建 才具备观察能力,不能用字面量 {} 创建。且 @ObservedV2 的实例不支持 JSON.stringify 序列化 ,这在使用 JSON.parse 反序列化时要注意------反序列化后会失去观察能力。

聊天室里"修改 @State 数组内对象属性不刷新"的坑,用 V2 的 @Trace 就完美解决了------不用 hack 整个数组重新赋值。

6.4 @Monitor --- 增强版 @Watch

typescript 复制代码
@Local currentTab: number = 0

@Monitor('currentTab')
onTabChange(monitor: IMonitor) {
  let before = monitor.value()?.before   // 变之前的值
  let after = monitor.value()?.now       // 变之后的值
  console.log(`Tab 从 ${before} 变成 ${after}`)
}

还能监听深层属性:@Monitor('user.name')

6.5 @Computed --- 派生计算属性

V2 新增,V1 没有。类似 Vue 的 computed:

typescript 复制代码
@ObservedV2
class TaskStatsModel {
  @Trace total: number = 0
  @Trace completedCount: number = 0

  @Computed
  get pendingCount(): number {
    return this.total - this.completedCount
  }
}

totalcompletedCount 变了才重算,否则用缓存。性能优化利器。

注意@Computed 只能装饰 getter,不能写 setter。getter 内必须引用 @Trace 属性才能建立依赖关系,否则永远不会重新计算。

6.6 @Once --- 一次性同步

typescript 复制代码
@Once @Param priority: number = 0   // 只在创建时同步一次

适合"初始化后不再变"的数据,避免不必要的刷新。@Once 必须搭配 @Param 使用 ,单独使用不合法。加了 @Once 后,@Param 变量可以在组件内部修改(因为后续父组件不再同步了)。

6.7 @Provider / @Consumer

和 V1 的 @Provide/@Consume 用法类似,但默认用变量名当 key(API 12+),不用手写字符串:

typescript 复制代码
@Provider() pageStack: NavPathStack = new NavPathStack()
@Consumer() pageStack: NavPathStack = new NavPathStack()

注意 :API 12 之前的版本仍需手动指定字符串 key,如 @Provider('pageStack')


七、V1 vs V2 全景对比

用途 V1 V2 核心改进
组件声明 @Component @ComponentV2 两套不能混用
自身状态 @State @Local 更严格,不允许外部初始化
父传子 @Prop @Param 支持 @Require 强制必传
双向绑定 @Link @Param + @Event 隐式双向 → 显式单向数据流
跨层共享 @Provide/@Consume @Provider/@Consumer key 默认用变量名(API 12+)
类监听 @Observed @ObservedV2 + @Trace 全量监听 → 精准到属性
子组件对象 @ObjectLink @Param 统一了,不再单独一个装饰器
变化回调 @Watch @Monitor 支持前后值、深层路径
计算属性 @Computed 缓存计算,性能优化
一次性同步 @Once 避免多余刷新
必传校验 @Require 编译期校验

八、实战:天气 App 中的 MVVM 映射

我正在开发的天气 App 就是典型的 MVVM 架构:

复制代码
┌──────────── Model 层 ────────────┐
│ WeatherModel.ets   (数据类型)     │
│ LocationService.ets (GPS 定位)    │
│ AmapService.ets     (逆地理编码)  │
│ QWeatherService.ets (天气查询)    │
└──────────────┬───────────────────┘
               │ 返回数据
               ▼
┌──────────── ViewModel 层 ────────┐
│ Index.ets 中的:                   │
│   @State currentCity             │  ⇄ 数据绑定到 View
│   @State currentWeather          │  ⇄ 数据绑定到 View
│   @State loading                 │  ⇄ 数据绑定到 View
│   @State errorMsg                │  ⇄ 数据绑定到 View
│   loadWeather() 方法             │  ← 调 Model、改 @State
└──────────────┬───────────────────┘
               ⇄ 数据绑定(@State 变化 → View 自动刷新)
               ▼
┌──────────── View 层 ─────────────┐
│ Index.ets build() 中的:          │
│   Text(城市名)                   │
│   Text(温度 + °)                 │
│   详细信息网格                    │
│   加载中/错误提示                  │
└──────────────────────────────────┘

数据流:

复制代码
用户打开 App
  → aboutToAppear()(ViewModel 命令)
  → LocationService.getCurrentLocation()(调 Model)
  → AmapService.reverseGeocode()(调 Model)
  → QWeatherService.fetchWeatherNow()(调 Model)
  → this.currentCity = city(更新 ViewModel 状态)
  → this.currentWeather = weather(更新 ViewModel 状态)
  → UI 自动刷新(数据绑定)✅

完整的 loadWeather 方法包含异常处理和加载状态:

typescript 复制代码
async loadWeather() {
  try {
    this.loading = true
    this.errorMsg = ''
    let location = await LocationService.getCurrentLocation()
    let city = await AmapService.reverseGeocode(location.latitude, location.longitude)
    let weather = await QWeatherService.fetchWeatherNow(city.adcode)
    this.currentCity = city
    this.currentWeather = weather
  } catch (err) {
    this.errorMsg = '获取天气失败,请检查网络和定位权限'
  } finally {
    this.loading = false
  }
}

注意 try-catch-finally:任何一步失败都不会导致 loading 卡在 true,errorMsg 也会驱动错误 UI 显示。整个 View 层没有写任何"手动更新 UI"的代码------这就是 MVVM 的威力。


九、性能注意事项

9.1 @State 粒度与刷新范围

@State 变量变化时,ArkUI 会重新执行整个 build() 方法。如果一个 struct 里有 10 个 @State 变量,任何一个变了都会重跑整个 build。

优化建议:把频繁变化的状态拆到独立子组件中,避免"牵一发动全身"。

9.2 ForEach 与列表渲染

ForEach 的第三个参数是 key 生成器,必须正确返回唯一标识。如果 key 不变,组件会复用而不重建;key 变了,才会销毁旧组件创建新的。

聊天室里我们用 version 字段做 key,修改后递增 version 强制重建------这虽然管用但代价是整个 ListItem 重建。V2 的 @Trace 可以做到只刷新变化的部分。

9.3 @Computed 的缓存代价

@Computed 有缓存但不是免费的------它需要维护依赖关系图。适合"计算开销大但依赖变化少"的场景。如果计算本身很简单(如 a + b),直接写 getter 更划算。

9.4 @Trace 不要滥用

每个 @Trace 属性都会增加框架的监听开销。只给 UI 真正需要响应的属性加 @Trace,内部计算用的临时字段不需要。

9.5 嵌套层级不宜过深

官方建议 @ObservedV2 的嵌套不超过三层。嵌套过深会增加观测链路的复杂度,影响性能。


十、选 V1 还是 V2?

初学者:先用 V1,V1 更简单,社区资料更多。

何时切换 V2

  • 频繁遇到"@State 数组内对象属性改了不刷新"的坑
  • 项目复杂到 @Link 双向绑定难以追踪
  • 需要精确控制哪些属性触发刷新(性能优化)

我的建议:用 V1 做完第一个项目,理解 MVVM 思想后,第二个项目直接上 V2。


十一、总结

概念 一句话
MVVM 你只管改数据,UI 自己更新
Model 数据 + 业务逻辑
View UI 渲染
ViewModel 桥梁,调 Model 改状态,状态变了 View 自动刷新
数据绑定 MVVM 的灵魂,ArkUI 用装饰器实现
V1 简单够用,有浅监听坑
V2 显式精准,解决 V1 痛点

理解了 MVVM,再看 @State@Param@Event 这些装饰器,你就知道它们不是"规定要这么写",而是 MVVM 架构在 ArkUI 中的必然选择。

相关推荐
程序员黑豆3 小时前
鸿蒙应用开发:一次开发多端适配与自适应布局实战
前端·harmonyos
歪歪歪比巴卜3 小时前
服务商批量接手客户跨平台社媒账号的技术落地:体检体系与重启架构
矩阵·架构·aigc
BerryS3N3 小时前
2026年AI前沿技术全景深度解析:大模型推理范式变革、Agentic AI工程架构与底层基础设施演进
人工智能·架构
神王宝宝 王者小学3 小时前
面向领域驱动架构的查询实现方式
前端·python·架构
三翼鸟数字化技术团队4 小时前
GN (generate ninja) 学习手册
harmonyos
计算机魔术师4 小时前
传统基础架构 - 微服务
大数据·后端·微服务·架构·开发工具·编程语言
程序员黑豆5 小时前
鸿蒙应用开发:网络请求三种方式详解(http / rcp / axios)
前端·harmonyos
小雨青年5 小时前
【HarmonyOS 7 沉浸光感深度实战】 02 全局开关、MaterialState 与最小 Demo
华为·harmonyos
晨米酱5 小时前
Matt Pocock Skills v1.2:控制从主流程深入到每一步
面试·架构·agent