HarmonyOS ArkTS MVVM架构最佳实践:状态管理、分层设计与工程落地
我第一次把MVVM搬到HarmonyOS ArkTS项目上,其实是带着一肚子不情愿的。当时项目已经从 Demo 阶段一路狂奔到了上线前,页面里的 @State 变量越来越多,业务逻辑散落在 build() 里和各个自定义事件回调中,谁也不敢轻易动别人写过的代码。每次在 DevEco Studio 里全局搜索一个方法,都能引出五六处修改点,这种滋味相信不少做 HarmonyOS 开发的同学都体会过。
后来我下定决心,基于 ArkTS 重新梳理了一套 MVVM 架构,并且用在新业务模块上。当时没有多少现成参考,很多方案是边踩坑边总结出来的。到今天这套架构已经支撑了三四个版本迭代,团队里新同学看代码也能很快定位状态、动作和数据层的位置,新增需求基本不用改页面,才算真正体会到 MVVM 的价值。这篇文章就把我总结出的“基于 HarmonyOS ArkTS 的 MVVM 架构最佳实践”完整写出来,从状态管理机制、工程分层、基类设计、实战案例到问题排查,一次讲透。
这里没有那种“从入门到放弃”的空洞概念,全部是能直接抄走、能落地的代码和步骤。适合对 ArkTS 语法有一定了解、正在为项目架构发愁、或者准备把老代码重构到 MVVM 模型的开发者。如果你刚接触 HarmonyOS 开发,也能通过文章理解 MVVM 在状态驱动 UI 下的核心价值。
1. 为什么在 HarmonyOS 项目中必须谈 MVVM 架构
1.1 ArkTS 页面开发中“状态蔓延”的真实痛点
ArkTS 本质上是 TypeScript 的超集,但它驱动 UI 更新的方式,和 Web 端那套“手动操作 DOM”或者“响应式库自己维护依赖”都不太一样。在 HarmonyOS 里,页面渲染由状态驱动:你声明 @State 变量,修改这个变量,对应组件自动刷新。听上去很简单,但业务一复杂,问题立刻浮现。
举一个很常见的例子:一个详情页需要同时展示用户信息、商品列表、优惠券弹窗状态、加载动画控制、错误提示内容。如果全部用 @State 平铺在 Page 里,那这个 Page 的成员变量大概会膨胀到十几个甚至二十几个。然后页面的每一个按钮事件里,都在直接 set 这些变量。等业务逻辑再多一点,你会发现所谓的“页面”已经变成了一个巨大的状态桶和业务处理中心,既没法复用,也没法单测,出问题只能靠人肉断点定位。
这就是我说的“状态蔓延”。而 MVVM 架构在这里能发挥的价值,就是强制把“数据长什么样”和“用户操作之后数据怎么变化”从视图里抽离出去。View 层只做一件事:把 ViewModel 的状态渲染出来,把用户的动作转交给 ViewModel。至于数据从哪来、校验规则是什么、失败之后怎么处理,都可以在 ViewModel 和 Model 层独立完成。
1.2 MVC、MVP 和 MVVM 在 ArkTS 下的取舍
传统 Android 或 iOS 开发里,MVC、MVP、MVVM 都是老生常谈。在 HarmonyOS ArkTS 场景下,我的结论非常明确: MVVM 是最贴合状态驱动 UI 机制的选择。
MVC 的问题在于 Controller 和 View 往往还藕断丝连,在 ArkTS 里 Controller 很容易变成“Page 的又一个 @State 变量集合”,最终页面还是没瘦身。MVP 通过 Presenter 接口反向操作 View,在 HarmonyOS 里需要额外维护一套 IView 接口,在页面深度低、业务不复杂时完全是负担,反而拉长链路。
MVVM 则不同。ArkTS 的 UI 层天然支持可观察对象到组件属性的绑定,ViewModel 不需要持有 View 的引用,数据变了 UI 自己会变。这正好就是 MVVM 中 ViewModel 与 View 解耦的核心诉求。所以与其说是我们“选择”了 MVVM,不如说是 ArkTS 的状态驱动模型在呼唤 MVVM。
1.3 “伪 MVVM” 与真正的 MVVM 之间的差距
我第一次做重构时也走过弯路。那时候我建立了一个叫 LoginViewModel 的类,把登录页所有逻辑都塞进去,然后在 Page 里 new 出来,表面上是 MVVM,实际做的事情等同于“把 Page 的代码复制到另一个类”。页面确实瘦了一点,但 ViewModel 里充满了对 UI 控件的直接调用,比如“this.passwordInput.focus()”“this.showToast('失败')”,这和当初的 Controller 没有任何本质区别。
真正落地 MVVM 之后我才明白,ViewModel 不应该知道 View 的存在,更不应该知道页面上有哪个输入框。它的职责只有两个:维护一组可观察的状态、暴露一组业务动作。UI 怎么根据状态展示,是组件层自己决定的事;业务动作触发后状态怎么变,才轮到 ViewModel 管。文章后面我会基于这个“状态 + 动作”模型给你完整代码,照着写能少走很多弯路。
2. ArkTS 里 MVVM 的状态管理基石:V1 与 V2
2.1 传统 V1 装饰器的适用边界
HarmonyOS 的 ArkUI 框架其实提供了“两代”状态管理能力。第一代也就是 V1,以 @State、@Prop、@Link、@Provide、@Consume、@Observed、@ObjectLink 这一套为代表。这套能力 Vue 开发者很眼熟,但它有几个需要特别小心的坑。
@State 是页面内状态的基础,修改后触发页面局部刷新,没问题。但如果你把一个对象赋值给 @State,对象内部的嵌套属性被改动,UI 不会感知。这时候需要配合 @Observed 修饰类,并用 @ObjectLink 承接子组件里的引用。@Prop 和 @Link 则是父子组件传值的关键:@Prop 是单向同步,适合子组件只展示;@Link 是双向同步,适合子组件需要改父组件数据的场景。但 @Link 用多了会带来数据流不清晰的问题,我后面会专门讲。
以下是我整理的 V1 常用装饰器对比:
| 装饰器 | 同步方向 | 典型使用场景 | 注意点 |
|---|---|---|---|
| @State | 组件内部自更新 | 页面内简单状态 | 嵌套对象属性变化不感知 |
| @Prop | 父到子单向 | 子组件只读展示 | 修改不会同步回父组件 |
| @Link | 父子双向 | 子组件需要修改父状态 | 数据流不直观,避免滥用 |
| @Provide/@Consume | 跨层级单向/双向 | 祖先提供后代消费 | 依赖隐式,定位麻烦 |
| @Observed/@ObjectLink | 深度观察对象 | 嵌套数据对象刷新 | 需要类级修饰,容易漏 |
这些装饰器完全能用,但它的观察粒度是“对象引用 + 属性赋值”,深层数据路径变化时,经常出现“数据明明变了,UI 不刷新”的问题。早期我做购物车模块,直接修改 @Observed 类里一个数组某一项的某个字段,界面毫无反应,排查半天才意识到观察机制的限制。所以 V1 适合状态结构简单、层级不深的项目;一旦业务状态复杂到一定程度,必须升级到 V2。
2.2 V2 状态管理:@ObservedV2 与 @Trace 的正确打开方式
从 API 12 开始,ArkUI 引入了新一代表态管理 V2,核心是 @ObservedV2 修饰类、@Trace 修饰属性、@ComponentV2 修饰自定义组件、@Local 修饰组件内状态。这套模型最大的变化是: 观察能力被精确到属性级别 ,并且依赖收集发生在编译期,性能上比 V1 的“对象整体比较+通知刷新”更好。
在实际 MVVM 架构里,我会把 ViewModel 里的状态类全部用 @ObservedV2 + @Trace 来定义,因为 ViewModel 里大部分状态都是深层嵌套的,比如用户对象包含昵称、头像、等级、会员到期时间等等。如果只依赖 V1,修改 userInfo.nickName 这种路径随时可能翻车;而在 @ObservedV2 + @Trace 下,精确到属性,动态态刷新非常稳。
下面是一个典型的状态类写法:
@ObservedV2
export class UserInfoModel {
@Trace nickName: string = '';
@Trace avatar: string = '';
@Trace level: number = 0;
@Trace vipExpireTime: number = 0;
}
这里要提醒一句: V1 和 V2 不要混用在同一个类上 。一个类一旦被 @ObservedV2 标记,它的属性统一走 @Trace 观察;如果同一个对象的某个属性又指望用 @Observed 那套去监听,行为会非常不可预期。我见过团队里有人为了图省事在一个类里既用 @ObjectLink 又用 @Trace,结果界面时而刷新时而不刷新,这类问题特别难查,不如从一开始就统一规范。
2.3 为什么 MVVM 的 ViewModel 必须自带“可观察状态”
MVVM 和 MVC 最大的区别,就是 ViewModel 主动把状态暴露出去,等待 View 来订阅,而不是 View 去“拉数据”。所以在 ArkTS 里,ViewModel 里的状态必须是“可观察的”。你只有用 @ObservedV2 + @Trace 或者 @State 包装这些状态,View 才能建立绑定。
我习惯用一句话来解释给组里的新人:**View 是显示器,ViewModel 是遥控器,状态数据是电视台的节目。显示器永远不会自己知道节目内容,它只负责把遥控器选择的频道播出来。**你按遥控器,频道换了,显示器自动更新。这里“按遥控器”就是 ViewModel 暴露的动作,“频道信号”就是可观察状态,“显示器刷新”就是组件渲染。
如果没有“可观察”这个能力,ViewModel 即使改了数据,View 也不知道,还得靠人工通知,那就退化成 MVP 了,完全失去 MVVM 的意义。所以在 ArkTS 里评估一个 MVVM 架构是否合格,第一个检查点就是:ViewModel 中的所有状态字段,是不是都声明成了可观察状态。
3. 从零搭建一个可落地的 MVVM 项目骨架
3.1 工程模块划分:不把鸡蛋放在同一个 entry 里
很多 HarmonyOS 项目一开始就是单 entry 模块,所有代码堆在一个包下。短期没问题,但业务复杂后编译时间会越来越久,模块之间耦合也越来越难拆。我在做了 MVVM 架构重构的同时,也顺手把工程结构做成了标准的多模块分层,和 MVVM 的模型层、仓库层能一一对应。
建议结构如下:
App/
├── entry/ # 应用入口,只做组装和路由
├── feature_login/ # 登录模块(HAR)
├── feature_home/ # 首页模块(HAR)
├── feature_detail/ # 详情模块(HAR)
└── core/
├── core_common/ # 基类、工具、通用组件
├── core_network/ # 网络层封装
├── core_model/ # 通用领域模型
└── core_repository/ # 数据仓库基类与实现
每个 feature 模块内部继续按 MVVM 分包:
pages
放页面组件,
viewmodel
放 ViewModel,
model
放模块内部数据模型和请求参数,
repo
放这个模块特有的仓库实现。这样每个模块都自给自足,对外只暴露有限的页面入口和路由信息,其余细节全部私有,谁也不会无意中改坏别人的代码。
3.2 BaseViewModel 基类:生命周期与异步任务的统一收口
在没有基类约束的情况下,不同人写的 ViewModel 风格会差异很大:有的人在登录页面退出时不取消网络请求,有的人忘了清理定时器,最终导致隐式内存泄漏。所以我写了一个轻量级的 BaseViewModel,把和生命周期相关的收尾工作统一做掉。
核心设计如下:
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export abstract class BaseViewModel {
protected pageContext?: common.UIAbilityContext;
private cancelled: boolean = false;
onInit(context: common.UIAbilityContext) {
this.pageContext = context;
this.cancelled = false;
}
isActive(): boolean {
return !this.cancelled;
}
onDestroy() {
this.cancelled = true;
this.pageContext = undefined;
}
protected async runTask<T>(task: Promise<T>): Promise<T | undefined> {
try {
return await task;
} catch (e) {
if (this.cancelled) {
// 页面已销毁,静默吞掉异常,避免无意义回调
return undefined;
}
throw e;
}
}
protected getErrorMessage(e: BusinessError): string {
return e?.message ?? '网络异常,请稍后重试';
}
}
页面在
onPageShow
或
aboutToAppear
时调用
onInit
注入上下文,在
onPageHide
或
aboutToDisappear
时调用
onDestroy
。
runTask
方法保证了异步请求返回时如果页面已经销毁,不会继续触发后续逻辑。这不复杂,但能挡住一大批线上偶发问题。
3.3 ViewModel 的生命周期与获取方式
在 HarmonyOS 中,没有像 Android 那样内置的 ViewModelStore 和 ViewModelProvider。所以我采用了一个非常轻量的方案: 每个页面持有一个 ViewModel 实例,页面销毁时释放。 为了保证状态在页面旋转或窗口大小变化时不丢失,我建议把 ViewModel 持有在 Application 级别或者通过路由参数传递,但绝大多数页面不需要这种强恢复能力,直接在页面里 new 就足够了。
为了让代码更清晰,我封装了一个简单的
ViewModelProvider
,本质是个极简工厂:
export class ViewModelProvider {
private static instanceMap: Map<string, BaseViewModel> = new Map();
static obtain<T extends BaseViewModel>(key: string, factory: () => T): T {
let vm = this.instanceMap.get(key) as T;
if (!vm) {
vm = factory();
this.instanceMap.set(key, vm);
}
return vm;
}
static clear(key: string) {
const vm = this.instanceMap.get(key);
vm?.onDestroy();
this.instanceMap.delete(key);
}
}
这套方案本质是“按 key 缓存单例”,适合那些跨页面共享的 ViewModel,比如用户会话。如果只是页面局部状态,直接用
private vm: LoginViewModel = new LoginViewModel()
就行,不必走容器。容器用多了反而让销毁时机变得模糊,这违背了 MVVM 让状态流清晰的本意。
3.4 Repository + 网络层:让数据来源对 ViewModel 透明
MVVM 中的 Model 层不是简单的 JSON 结构体,我更推荐在 Model 层内再拆出一个 Repository 子层。Repository 对 ViewModel 屏蔽数据来源:你今天用远端接口,明天改成先从本地缓存读、再打接口刷新,ViewModel 完全不用动。
网络层封装我比较推荐基于
@ohos.net.http
做统一拦截,或者直接引入 Axios 适配鸿蒙的版本。但不管用哪个,核心原则都是:
ViewModel 里永远不要出现 URL、请求头、错误码解析这类细节。
一个标准的后端接口模型大致长这样:
export class ApiResult<T> {
code: number = 0;
message: string = '';
data: T | null = null;
isSuccess(): boolean {
return this.code === 0;
}
}
Repository 层把所有后端接口封装成领域方法:
export class UserRepository {
async login(username: string, password: string): Promise<ApiResult<LoginResponse>> {
const resp = await httpRequest<LoginResponse>({
url: '/api/user/login',
method: 'POST',
data: { username, password }
});
return resp;
}
}
这样 ViewModel 调用 Repository 时,只面对语义化方法名和业务模型,和后端接口定义完全解耦。以后再遇到后端接口路径调整、字段重构,只需要改 Repository 层,架构整体依然稳定。
4. 实战:登录 + 列表页面的完整 MVVM 链路
4.1 定义业务 Model 与 Repository
理论讲再多,不如直接写一个完整链路。这里我拿最典型的“登录后拉取文章列表”场景来演示,虽然业务不大,但能覆盖状态管理、异步任务、表单校验、列表分页、错误处理这些核心节点。
先定义数据模型。登录接口返回用户信息和 token,列表接口返回文章数组。注意这里所有模型全部用普通类定义,如果这些模型要被 View 观察,再在 ViewModel 层把它包一层可观察状态,不直接污染模型。
export class LoginRequest {
username: string = '';
password: string = '';
constructor(username: string, password: string) {
this.username = username;
this.password = password;
}
}
export class LoginResponse {
token: string = '';
userInfo: UserInfoRecord = new UserInfoRecord();
}
export class UserInfoRecord {
uid: string = '';
nickName: string = '';
avatar: string = '';
}
export class ArticleItem {
id: string = '';
title: string = '';
summary: string = '';
publishTime: number = 0;
}
export class ArticlePageResult {
list: ArticleItem[] = [];
hasMore: boolean = false;
}
Repository 层提供两个方法,分别对应登录和文章分页:
export class UserRepository {
async login(request: LoginRequest): Promise<ApiResult<LoginResponse>> {
return httpRequest<LoginResponse>({
url: '/api/user/login',
method: 'POST',
data: JSON.stringify(request)
});
}
async fetchArticleList(page: number, pageSize: number): Promise<ApiResult<ArticlePageResult>> {
return httpRequest<ArticlePageResult>({
url: '/api/article/list',
method: 'GET',
params: { page, pageSize }
});
}
}
4.2 用 @ObservedV2 定义 LoginViewModel 与 ArticleListViewModel
现在是最核心的 ViewModel 设计。以登录为例,它的状态至少包括:
-
userInfo:登录成功后要展示的用户信息 -
isLoading:登录中的 loading 状态 -
errorMessage:登录失败的错误提示 -
isLoggedIn:是否已登录的状态标记
对应动作是
login(username, password)
。表单校验这种纯函数逻辑我放在单独的工具里,不进 ViewModel,因为 ViewModel 已经承担了状态维护,不需要再承担 UI 字符串规则解析。
import { BaseViewModel } from '@core_common';
@ObservedV2
export class LoginViewModel extends BaseViewModel {
@Trace userInfo: UserInfoRecord | null = null;
@Trace isLoading: boolean = false;
@Trace errorMessage: string = '';
@Trace isLoggedIn: boolean = false;
private userRepo: UserRepository = new UserRepository();
async login(username: string, password: string): Promise<boolean> {
if (!this.validate(username, password)) {
this.errorMessage = '请输入正确的用户名和密码';
return false;
}
this.isLoading = true;
this.errorMessage = '';
const result = await this.runTask(this.userRepo.login(new LoginRequest(username, password)));
if (!result) {
// 页面已销毁或请求失败,不做额外状态变更
this.isLoading = false;
return false;
}
this.isLoading = false;
if (!result.isSuccess()) {
this.errorMessage = result.message;
return false;
}
this.userInfo = result.data?.userInfo ?? null;
this.isLoggedIn = true;
return true;
}
private validate(username: string, password: string): boolean {
return username.trim().length >= 4 && password.length >= 6;
}
}
ArticleListViewModel 则要面对分页加载、下拉刷新、上拉加载更多这老三样。我会把“页码”和“是否还有更多”放在内部管理,对外只暴露列表和加载状态,UI 层不需要关心当前到底在第几页。
@ObservedV2
export class ArticleListViewModel extends BaseViewModel {
@Trace articleList: ArticleItem[] = [];
@Trace listLoading: boolean = false;
@Trace listError: string = '';
private page: number = 1;
private pageSize: number = 10;
private hasMore: boolean = true;
private repo: UserRepository = new UserRepository();
async loadFirstPage(): Promise<void> {
this.page = 1;
this.hasMore = true;
await this.loadPage();
}
async loadNextPage(): Promise<void> {
if (!this.hasMore || this.listLoading) {
return;
}
this.page += 1;
await this.loadPage();
}
private async loadPage(): Promise<void> {
this.listLoading = true;
this.listError = '';
const result = await this.runTask(this.repo.fetchArticleList(this.page, this.pageSize));
if (!result) {
this.listLoading = false;
return;
}
this.listLoading = false;
if (!result.isSuccess()) {
this.listError = result.message;
return;
}
const pageData = result.data;
if (!pageData) {
return;
}
if (this.page === 1) {
this.articleList = pageData.list;
} else {
this.articleList = this.articleList.concat(pageData.list);
}
this.hasMore = pageData.hasMore;
}
}
4.3 在页面中组装 View 与 ViewModel
接下来是 View 层。登录页拆成两个组件:
LoginPage
是页面入口,持有
LoginViewModel
;
LoginForm
是纯展示和事件上报子组件,不从外部直接操作状态。
子组件里用 @Local 管理输入框本地值,点击登录时把值抛给父页面的 ViewModel:
@ComponentV2
export struct LoginForm {
@Event onLogin: (username: string, password: string) => void = () => {};
@Local username: string = '';
@Local password: string = '';
build() {
Column({ space: 16 }) {
TextInput({ placeholder: '用户名', text: this.username })
.onChange((value: string) => {
this.username = value;
})
TextInput({ placeholder: '密码', text: this.password })
.type(InputType.Password)
.onChange((value: string) => {
this.password = value;
})
Button('登录')
.onClick(() => {
this.onLogin(this.username, this.password);
})
}
.padding(24)
}
}
页面入口负责把 ViewModel 的状态绑定到 View,并把事件传给子组件。这里要注意一个细节:不要直接把
vm
整个对象传给子组件,那样会让子组件对 ViewModel 内部状态产生耦合。最好的做法是
子组件只收具体属性和事件回调
,父子边界非常清晰。
@Entry
@ComponentV2
struct LoginPage {
private vm: LoginViewModel = new LoginViewModel();
build() {
Column({ space: 16 }) {
if (this.vm.userInfo) {
Text(`欢迎,${this.vm.userInfo.nickName}`)
} else {
LoginForm({
onLogin: (username: string, password: string) => {
this.vm.login(username, password);
}
})
}
if (this.vm.isLoading) {
LoadingProgress()
}
if (this.vm.errorMessage) {
Text(this.vm.errorMessage).fontColor('#ff0000')
}
}
.width('100%')
.padding({ top: 100 })
}
aboutToDisappear(): void {
this.vm.onDestroy();
}
}
列表页的组装类似,区别在于用
List
+
LazyForEach
做长列表。LazyForEach 的 keyGenerator 必须提供稳定且唯一的 key,这是列表更新性能的关键。
@Entry
@ComponentV2
struct ArticleListPage {
private vm: ArticleListViewModel = new ArticleListViewModel();
aboutToAppear(): void {
this.vm.loadFirstPage();
}
build() {
List({ space: 8 }) {
LazyForEach(this.vm.articleList, (item: ArticleItem) => {
ListItem() {
ArticleCard({ title: item.title, summary: item.summary })
}
}, (item: ArticleItem) => item.id)
}
.onReachEnd(() => {
this.vm.loadNextPage();
})
.refresh {
Refresh() {
// 这里做下拉刷新,核心是请求第一页
Text('下拉刷新')
}
.onRefresh(() => {
this.vm.loadFirstPage();
})
}
}
}
特别说明一下
LazyForEach
的 keyGenerator。如果你用数组 index 作为 key,一旦列表头被删除或插入,整列节点的 key 全乱,复用机制会严重错乱,可能出现内容串行、滚动跳动等诡异问题。
一定要用业务 id 作为 key
,这是长列表渲染稳定性的第一原则。
4.4 分页加载、下拉刷新、错误重试的状态机设计
关于页面“加载中、成功、失败、加载更多”这几种状态,我建议用一个轻量状态机来管理,而不是散落多个 boolean。虽然上面示例里用
listLoading
和
listError
分开处理也能跑通,但业务再复杂一点,会出现“既在 loading 又在显示 error”这种互相矛盾的状态。
我比较推荐的做法是定义枚举:
export enum PageStatus {
Initial,
Loading,
Success,
Error,
LoadingMore,
NoMore
}
然后在 ViewModel 里维护一个
@Trace status: PageStatus = PageStatus.Initial
。UI 根据 status 渲染“骨架屏、内容区、错误重试页、底部加载更多”等。新增一种业务状态时,不需要在页面里加新的 boolean 变量,只需要扩展枚举和对应渲染分支,维护成本低很多。
我在这块踩过比较大的一次坑是“加载更多请求失败时,用户一直点击,结果触发了五六个并发请求,然后返回后数据顺序混乱”。后来我在
loadNextPage
里加了
if (this.hasMore || this.listLoading) return
这个护栏,才把问题挡住。页面上所有“触发新请求”的入口,都必须检查当前是否已经在请求中,这条规则要写进团队的代码评审清单。
5. 常见问题与排查技巧实录
5.1 数据改了但 UI 不刷新:状态管理版本不一致
这类问题在 ArkTS MVVM 开发中真的是高频中的高频。典型场景是:ViewModel 里的状态类用了 @ObservedV2 + @Trace,但列表的某个字段却被普通 JSON 对象替换,比如
this.articleList = resp.data.list
,而
resp.data.list
里的对象不是 @ObservedV2 类的实例。这时列表长度变化能触发刷新,但单个对象的字段变化就死活不刷新。
排查思路很简单:在
build()
里临时加一个 Text 组件,绑定这个字段,看它是否刷新。如果 Text 不刷新,基本可以定位是对象的“可观察性”没建立起来。
规范做法有两个:一是所有进入 ViewModel 状态的数据,必须是经过模型类 new 出来的实例,不能是裸 JSON;二是尽量不直接修改深层对象属性,而是整体替换对象引用。整体替换的收益是永远能触发刷新,不用猜框架的深层观察行为。
5.2 页面退出后异步回调还在执行
这个问题的典型场景:用户点击登录,马上退出了登录页,网络请求回来之后回调里还在 set
isLoading
、
errorMessage
,虽然页面已经销毁,但状态对象还活在闭包里,严重时会有内存泄漏和崩溃。
我在
BaseViewModel
里用
runTask
和
isActive()
做了统一拦截。核心逻辑是:请求 await 返回后,先检查
this.cancelled
,如果页面已经销毁,直接返回,不再执行任何后续代码路径。这套方案要求 ViewModel 的销毁时机被严格绑定到页面生命周期。页面在
aboutToDisappear
里调用
vm.onDestroy()
,这一行不能省。
5.3 列表更新剧烈抖动:key 生成器不稳定
有时候列表明明正确加载了,但滑动时卡片会跳动、闪烁,甚至出现重复内容。多半是 LazyForEach 的 keyGenerator 不稳定,比如用了 index 或时间戳。我用一个简单的表把这个排查点固定下来:
| 现象 | 原因 | 快速验证方法 | 正确做法 |
|---|---|---|---|
| 滚动后内容串位 | key 不唯一 | 删除列表第一条,观察数据 | 用业务 id 生成 key |
| 数据更新后闪烁 | key 每次都变 | 检查 keyGenerator 是否掺入时间戳 | key 生成要保持稳定 |
| 加载更多后重复 | 前后两次数据 key 冲突 | 打印列表 key 值 | 后端保证 id 全局唯一 |
| 列表整体卡顿 | 页面级状态过大 | 用 @Local 收窄子组件状态 | 拆分小组件,减少刷新范围 |
LazyForEach 的 keyGenerator,一句话总结就是“稳定、唯一、不随内容变化而变化”。最好直接使用后端下发的 id,不要自己做拼接,除非你有非常充分的理由。
5.4 跨页面状态共享:AppStorage 还是 ViewModel 容器
MVVM 架构下,页面之间的状态共享是个绕不开的命题。比如登录成功后,用户信息要在首页、我的页面、个人详情页等多处展示。这时候如果每个页面各自请求一次接口,非常浪费;如果每个页面各自持有用户信息的 ViewModel 副本,又会出现“一处修改、其他页面不同步”的问题。
我的方案是 用 ViewModelProvider 按 key 持有全局共享的 UserViewModel ,页面登录成功时只在容器中更新一次,其他页面通过同一个 key 获取同一个实例。AppStorage 或 PersistentStorage 更适合存持久化标记,比如“是否已登录”和“用户uid”,但复杂业务状态最好还是放在 ViewModel 里,因为状态会伴随业务逻辑,而不是裸数据。
当然,全局共享的 ViewModel 要非常克制,不能什么状态都往里面塞。一个标准是: 超过两个页面需要同时感知变化的数据才放进共享 ViewModel,否则就放在页面自己的 ViewModel 里。 这样可以防止全局状态爆炸。
5.5 模块之间的 ViewModel 协作问题
多模块化之后,feature_login 里的 UserSessionViewModel 如果要被 feature_home 使用,直接 import feature_login 是不对的,会造成模块反向依赖。我的做法是在 core_common 里定义抽象接口:
export interface UserSessionProvider {
getToken(): string;
getUserInfo(): UserInfoRecord | null;
isLoggedIn(): boolean;
}
然后在 feature_login 里实现这个接口,通过模块入口初始化时注册到 core_common 的容器里。feature_home 只依赖 core_common 的接口,具体实现是谁、怎么实现,一概不关心。这样模块边界保持干净,MVVM 的状态管理能够跨模块但又不失解耦。
6. 性能优化与架构演进中的几点体会
6.1 控制观察粒度:不是所有数据都适合 @Trace
@Trace 虽然很方便,但也不是万能药。如果在一个状态类里给几十个属性全部打上 @Trace,那么其中任何一个属性变化,都会导致绑定该状态对象的组件做 diff 更新。对于非常庞大的对象,比如一份含几百个字段的商品详情,全量观察反而带来额外开销。
我的经验是:能被拆成子状态的,就拆成子状态;子组件用 @Local 管局部 UI 状态,用 @Param 接收核心展示数据。ViewModel 里只对影响页面整体结构的状态字段加 @Trace,类似“loading、status、userInfo、列表数组”这种;那些只影响一个小图标、一行文案的临时状态,留在组件本地就好。
同时,给状态类做“写保护”:对外暴露的是只读 getter,修改统一走 ViewModel 的动作方法。这样状态流转的路径单一,排查问题只需要看动作方法,而不是满页面搜赋值点。
6.2 优先使用整体赋值代替深层修改
这是一个很小的习惯,但能让很多诡异 bug 消失。比如要改一个用户昵称,与其去修改
userInfo.nickName
,不如整体替换:
const newUserInfo = new UserInfoRecord();
newUserInfo.uid = this.userInfo.uid;
newUserInfo.nickName = '新昵称';
newUserInfo.avatar = this.userInfo.avatar;
this.userInfo = newUserInfo;
虽然代码多三行,但 UI 刷新行为变得绝对确定。在 MVVM 架构初期,稳定比省事重要得多。等团队对状态管理机制非常熟了,再逐步开放深层修改。
6.3 MVVM 不是银弹:什么时候不要硬套 MVVM
最后说句掏心窝的话。MVVM 适合业务逻辑复杂、状态多、多人协作的中大型模块,但一个只展示静态内容、没有任何交互的“关于页”或者“协议页”,硬套 MVVM 只会增加文件数量和维护成本。我自己在项目里的判断标准是: 模块里的页面超过 2 个、或者页面内交互状态超过 5 个、或者这个页面要被搜索和复用,才优先考虑 MVVM。 反过来,简单页面直接用 Page 内状态就行。
架构的价值不是让你写出一套看起来很厉害的设计,而是让变化更可控、让协作更顺畅。如果你的项目里增删一个需求,已经要动到三个以上文件,且每次改动都心惊胆战,那才是认真考虑 MVVM 重构的合适时机。如果你的页面本来就很简单,强行套上多层抽象,反而是在给维护添堵。
在这套架构稳定跑过几个版本之后,我越来越确信一件事:HarmonyOS 的 ArkTS 状态驱动机制,配合 MVVM 的分层理念,是当前阶段开发中大型应用最省心的组合。你可以先从新手 Demo 开始,把 V1、V2 状态管理试着玩明白,再拿一个真实业务模块做重构,对照我上面这些代码和坑,大概率能少走一半弯路。
更多推荐
所有评论(0)