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:定义CityInfo、WeatherNow等数据类型AmapService.ets:封装高德逆地理编码 APIQWeatherService.ets:封装和风天气实况 APILocationService.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 = '' // 父改了会同步,子改了不会回传
5.3 @Link --- 父子双向绑定
typescript
// 父组件
Child({ currentTab: $currentTab }) // 注意 $ 符号
// 子组件
@Link currentTab: number // 子改了,父也会同步
隐患:双向绑定很方便,但调试时很难追踪"谁改了这个值"。
5.4 @Provide / @Consume --- 跨层共享
typescript
// 祖先组件
@Provide('pageStack') pageStack: NavPathStack = new NavPathStack()
// 任意后代组件
@Consume('pageStack') pageStack: NavPathStack // 直接拿到
我在聊天室里就是这样让所有页面共享 NavPathStack 的,不用一层层传参。
5.5 @Observed / @ObjectLink --- 嵌套对象的深层监听
@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 不允许从外部初始化,数据归属更明确。
6.2 @Param + @Event --- 替代 @Link
这是 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
}
}
total 或 completedCount 变了才重算,否则用缓存。性能优化利器。
注意 :@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 中的必然选择。