【鸿蒙原生开发会议随记 Pro】拒绝面条代码 基于 MVVM 的代码架构与状态管理选型
我们将引入 MVVM(Model-View-ViewModel) 架构模式,并结合 HarmonyOS NEXT 特有的状态管理机制,为我们的应用打造一套坚固的代码脚手架。我们要明确一个核心原则:让 UI 只负责渲染,让逻辑只负责思考。
文章目录
在前两篇文章中,我们像产品经理一样规划了“会议随记 Pro”的商业蓝图,又像架构师一样搭建了分层的工程目录。现在,我们要正式进入代码的世界。但在我们写下第一个 UI 组件之前,我必须和你聊聊一个可能会决定项目生死的关键话题:代码架构。
你一定有过这样的经历,接手一个老项目,打开一个名为 MeetingDetail.ets 的文件,发现里面洋洋洒洒写了两千行代码。UI 布局、网络请求、数据库操作、甚至还有复杂的业务算法,全部纠缠在一起。你想修改一个按钮的颜色,结果不小心删掉了一行逻辑代码,导致整个页面无法加载数据。
这种代码,我们形象地称之为面条代码。它们纠缠不清,不仅难以维护,更是 Bug 的温床。
今天这篇文章,我们要彻底解决这个问题。我们将引入 MVVM(Model-View-ViewModel) 架构模式,并结合 HarmonyOS NEXT 特有的状态管理机制,为我们的应用打造一套坚固的代码脚手架。我们要明确一个核心原则:让 UI 只负责渲染,让逻辑只负责思考。

一、 为什么要折腾 MVVM?从面条代码的痛点说起
在传统的开发模式或者早期的 FA 模型中,我们往往习惯于在 UI 组件的生命周期里直接写业务逻辑。比如在 aboutToAppear 方法里,你可能会直接调用 http.createHttp().request(...),然后在回调里拿到数据,直接赋值给 @State 变量。这看起来很直观,写起来也快。
但请 思考一下我们的“会议随记 Pro”的录音页面。这个页面非常复杂,它不仅要显示波形图(UI),还要实时计算时长(逻辑),要监听麦克风的状态(逻辑),要计算当前的会议成本(逻辑),还得在录音结束时把数据写入数据库(数据层)。
如果你把这一切都写在 RecordPage.ets 这个 UI 文件里,这个文件很快就会膨胀到不可阅读的程度。
当我们采用 MVVM 模式时,情况就完全不同了。我们将代码拆分为三个部分。Model(模型层) 是我们在上一篇中定义好的 Meeting、Project 等数据结构,它们代表了数据的真实形态。View(视图层) 是我们用 ArkUI 写的界面,它只负责长得好看,比如按钮是圆的还是方的,文字是红的还是黑的。而连接这两者的桥梁,就是 ViewModel(视图模型层)。
ViewModel 是 UI 的管家。它持有 UI 所需的数据状态,并暴露操作这些数据的方法。比如,View 层有一个“开始录音”的按钮,View 不会自己去调用麦克风,它只会告诉 ViewModel:“嘿,用户想录音了”。ViewModel 收到指令,去调用底层的录音服务,更新“正在录音”的状态。View 监听到状态变化,自动把按钮图标从“播放”变成“暂停”。
这种单向数据流和状态驱动的开发模式,是 HarmonyOS 开发的精髓。不仅让代码逻辑清晰,更重要的是方便测试。你甚至可以在不启动 UI 的情况下,单独对 ViewModel 进行单元测试,这在商业级项目的开发中是至关重要的。
二、 鸿蒙状态管理的三剑客 State、Prop 与 Link
在构建 MVVM 之前,我们必须先精通手中的武器。HarmonyOS NEXT 提供了一套强大的状态管理机制,其中最常用的就是 @State、@Prop 和 @Link。很多开发者只是机械地使用它们,却不理解它们在架构中的真正含义。
让我们结合“会议随记 Pro”的场景来深度剖析一下。
@State,是组件内部的灵魂。当你把一个变量标记为 @State,你就告诉了编译器:这个变量是我的私有财产,但我允许 UI 监听它的变化。一旦它变了,用到它的 UI 必须自动刷新。在本应用中,BaseViewModel 里定义的 isLoading 状态,或者录音页面里当前的 duration(时长),都属于这种类型。它们不需要外界干涉,完全由组件或 ViewModel 内部控制。
@Prop,是一种单向的父子传递。想象一下,我们在首页有一个“会议列表”,列表里有一个个“会议卡片”组件。父组件(列表)把会议标题传给子组件(卡片)。这时候,子组件里的标题变量就应该用 @Prop 修饰。这意味着:父组件改了标题,子组件会跟着变;但子组件如果自己改了标题,是不会影响父组件的,而且下一次父组件更新时,子组件的修改会被覆盖。这就像是上级给下级下达命令,下级只能执行,不能反向修改命令本身。在我们的项目中,通用的展示型组件,比如“项目颜色标签”,就大量使用 @Prop。
@Link,是双向同步的强力胶水。比如我们在“设置页”里封装了一个“时薪设置组件”。父页面持有“当前时薪”这个数据,子组件(设置组件)不仅要显示它,还要允许用户修改它。当用户在子组件里输入了新的金额,父页面里的数据必须同步更新,以便进行存储。这时候,我们就必须使用 @Link。它让父子组件共享了对同一份数据的读写权限。但请注意,@Link 不能滥用,因为它破坏了单向数据流的封闭性,如果传递层级过深,会让数据流向变得难以追踪。我们只在那些确实需要双向控制的场景下使用它。
除了这三剑客,还有一个针对复杂对象的 @Observed 和 @ObjectLink。我们的 Meeting 对象是一个嵌套了多层属性的类。如果我们在数组里修改了某个 Meeting 的 title 属性,普通的 @State 数组可能监听不到这个深层的变化。这时候,我们需要把 Meeting 类标记为 @Observed,并在子组件中用 @ObjectLink 接收它。这是很多新手容易踩坑的地方,也是导致“明明改了数据,界面却不刷新”的罪魁祸首。
三、 封装 BaseViewModel
理解了理论,我们开始动手。在写具体的业务 ViewModel 之前,我们需要一个基类来处理通用的逻辑。
在一个商业 App 中,几乎每个页面都面临三个共性问题:加载中(Loading)的状态管理、错误信息(Error)的提示、以及异步任务(Async Task)的异常捕获。如果我们把 isLoading = true 和 try-catch 复制粘贴到每一个方法里,那简直是重复劳动的地狱。
我们需要封装一个 BaseViewModel。
请在 entry/src/main/ets/commons/base 目录下新建 BaseViewModel.ts。我们不需要它继承任何系统类,它就是一个纯粹的 TypeScript 类,但我们要利用好泛型和异步封装。
你看,我们这样做。首先定义两个基础状态:loading 和 error。
// entry/src/main/ets/commons/base/BaseViewModel.ts
/**
* 所有 ViewModel 的基类
* 负责统一处理 Loading 状态、错误捕获和资源释放
*/
export class BaseViewModel {
/**
* 页面是否正在加载数据
* UI 层可以监听这个变量来显示/隐藏 Loading 组件
*/
isLoading: boolean = false;
/**
* 错误信息提示
* 当发生异常时,这里会被赋值,UI 层可以监听并弹出 Toast
*/
errorMessage: string = '';
/**
* 统一的异步任务执行器
* 自动管理 isLoading 的状态切换,并自动捕获异常
* * @param task 需要执行的异步函数
* @param onError 可选的错误回调,如果需要特殊处理错误可传入
*/
async launch<T>(task: () => Promise<T>, onError?: (e: Error) => void): Promise<T | null> {
try {
// 任务开始前,自动显示 Loading
this.isLoading = true;
this.errorMessage = '';
// 执行真正的业务逻辑
const result = await task();
return result;
} catch (error) {
// 统一的错误日志打印
console.error(`[ViewModel] Task Failed: ${JSON.stringify(error)}`);
// 更新错误状态
this.errorMessage = error.message || '未知错误,请重试';
// 如果调用者提供了自定义错误处理,则执行
if (onError) {
onError(error as Error);
}
return null;
} finally {
// 无论成功还是失败,任务结束时自动关闭 Loading
this.isLoading = false;
}
}
}
但是,如果没有这个封装,我们在“获取会议列表”时,代码可能是这样的:
// 不好的写法
async getMeetings() {
this.isLoading = true;
try {
const list = await db.query();
this.list = list;
} catch (e) {
this.errorMessage = e.message;
} finally {
this.isLoading = false;
}
}
有了 BaseViewModel,我们的代码就变成了这样:
// 优雅的写法
async getMeetings() {
await this.launch(async () => {
this.list = await db.query();
});
}
代码量减少了一半,而且逻辑更加聚焦于业务本身,而不是状态的维护。这就是架构设计的魅力。
四、 构建录音页面的 MVVM 脚手架
有了基类,我们现在来实战构建 录音页面 RecordPage 的 MVVM 结构。这将是我们 App 中最复杂的页面之一,用它来练手最合适不过。
我们需要创建两个文件:RecordViewModel.ts 和 RecordPage.ets。
首先是 RecordViewModel。它继承自 BaseViewModel。它需要持有录音时长、当前分贝值(用于波形图)、以及录音状态。注意,这里我们不使用 @State 装饰器,因为 ViewModel 本身是一个普通的类。我们将依靠 View 层来实例化它,并利用 View 层的状态机制来驱动更新。为了保持兼容性和易理解性,我们采用 View 持有 State,ViewModel 操作数据 的模式。
// entry/src/main/ets/features/record/viewmodel/RecordViewModel.ts
import { BaseViewModel } from '../../../commons/base/BaseViewModel';
export class RecordViewModel extends BaseViewModel {
// 录音时长(秒)
duration: number = 0;
// 当前录音状态:idle, recording, paused
recordStatus: 'idle' | 'recording' | 'paused' = 'idle';
// 模拟一个定时器引用
private timer: number = -1;
/**
* 开始录音
* 这里的逻辑纯粹是状态的流转和底层服务的调用
*/
async startRecording() {
await this.launch(async () => {
// 1. 调用底层 AudioService 开始录音 (后续章节实现)
// await AudioService.start();
// 2. 更新状态
this.recordStatus = 'recording';
// 3. 开启计时器
this.startTimer();
});
}
/**
* 停止录音
*/
async stopRecording() {
await this.launch(async () => {
// await AudioService.stop();
this.recordStatus = 'idle';
this.stopTimer();
// 这里未来会加入写入数据库的逻辑
});
}
private startTimer() {
this.stopTimer();
this.timer = setInterval(() => {
this.duration++;
// 在这里,我们其实修改了类内部的属性
// UI 层需要一种机制来感知这个 duration 的变化
}, 1000);
}
private stopTimer() {
if (this.timer !== -1) {
clearInterval(this.timer);
this.timer = -1;
}
}
}
RecordPage,是 View 层。这里的关键在于如何让 UI 感知到 ViewModel 中 duration 的变化。
在 HarmonyOS 开发中,如果不使用 V2 版本的 @Observable,普通的类属性变化是不会自动触发 UI 刷新的。所以,我们通常会在 View 层定义 @State 变量,并通过 ViewModel 的方法来更新这些 @State 变量,或者让 ViewModel 返回一个可观察的对象。
但在更现代的开发实践中,为了让代码更整洁,我们推荐一种状态代理的模式。即 ViewModel 负责计算,View 负责存储状态。或者,我们让 ViewModel 本身成为一个被 @State 管理的对象(但这需要 ViewModel 是 Struct 或者是被深度监听的 Class)。
为了降低理解门槛,我们这里采用最稳妥的ViewModel 驱动 View 状态的方式。但为了让大家体验更高级的写法,我这里演示一种基于Callback或者UIState对象的模式。
不过,最直白的方式其实是在 View 中把 ViewModel 标记为 @State。这要求 ViewModel 必须是一个可以被观察的对象。在 ArkTS 中,我们可以简单地把 ViewModel 的属性通过 View 的方法暴露出来,或者在 View 中定义对应的 @State 变量,在 ViewModel 的回调中更新它们。
让我们看一个结合了实际可行性的代码架构。我们在 View 中定义状态,ViewModel 只是纯逻辑类。
// entry/src/main/ets/features/record/pages/RecordPage.ets
import { RecordViewModel } from '../viewmodel/RecordViewModel';
@Entry
@Component
struct RecordPage {
// 1. 实例化 ViewModel
// 注意:这里没有用 @State 修饰 vm,因为 vm 本身的方法不直接触发 UI 刷新
// 我们用单独的 @State 变量来驱动 UI
private vm: RecordViewModel = new RecordViewModel();
// 2. 定义 UI 状态
@State duration: number = 0;
@State status: string = 'idle';
@State isLoading: boolean = false;
aboutToAppear() {
// 可以在这里做一些初始化
}
build() {
Column() {
// 顶部导航栏
Text('正在录音')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.margin({ top: 20, bottom: 20 })
// 核心内容区:显示时长
// 这里使用了 formatTime 工具函数(假设在 commons 里)
Text(this.formatDuration(this.duration))
.fontSize(60)
.fontColor($r('app.color.brand_color'))
.fontWeight(FontWeight.Bold)
// 状态显示
Text(`当前状态: ${this.status}`)
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 10 })
// 底部操作区
Row() {
if (this.status === 'recording') {
Button('停止')
.onClick(() => {
this.handleStop();
})
.backgroundColor(Color.Red)
} else {
Button('开始')
.onClick(() => {
this.handleStart();
})
.backgroundColor($r('app.color.brand_color'))
}
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 50 })
// Loading 遮罩
if (this.isLoading) {
LoadingProgress()
.width(50)
.height(50)
}
}
.height('100%')
.width('100%')
.backgroundColor($r('app.color.bg_page'))
}
// --- 胶水代码区:连接 View 和 ViewModel ---
async handleStart() {
// 更新 Loading 状态
this.isLoading = true;
// 调用 VM 的逻辑
await this.vm.launch(async () => {
// 模拟异步启动录音
await new Promise<void>(resolve => setTimeout(resolve, 500));
this.status = 'recording';
// 启动一个界面定时器来同步显示
// 注意:这里为了 UI 刷新,我们在 View 层维护了一个定时器来读取 VM 的数据
// 或者直接在 View 层计时。为了演示 MVVM,我们假设 VM 内部有复杂逻辑
// 真实场景中,VM 可以通过 callback 通知 View 更新
this.startUITimer();
});
this.isLoading = false;
}
async handleStop() {
this.isLoading = true;
await this.vm.stopRecording();
this.status = 'idle';
this.stopUITimer();
this.isLoading = false;
}
// UI 层的定时器,只负责从 VM 同步数据或者自我更新
private uiTimer: number = -1;
startUITimer() {
this.uiTimer = setInterval(() => {
// 假设 VM 中有真实的音频时长(可能来自底层 SDK 回调)
// 这里我们简单自增
this.duration++;
}, 1000);
}
stopUITimer() {
clearInterval(this.uiTimer);
}
formatDuration(seconds: number): string {
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
}
在这个架构中,View 层非常傻。它不知道录音文件存在哪里,也不知道数据库怎么写。它只知道:用户点了按钮 -> 我叫 ViewModel 去干活 -> ViewModel 干完了 -> 我更新 status 和 duration -> 界面重绘。
当然,随着鸿蒙生态的发展,V2 版本的状态管理(@ObservedV2 和 @Trace) 正在变得越来越流行,它允许 ViewModel 中的属性变化直接驱动 UI 更新,不再需要像上面代码那样在 View 层写胶水代码和手动同步状态。
但在现阶段,为了保证你的代码能稳定运行在绝大多数设备上,掌握这种基础的逻辑与视图分离的写法,是理解更高级框架的必经之路。
五、 总结
今天我们没有写太多花哨的界面,但我们完成了一次代码灵魂的洗礼。
我们拒绝了将所有逻辑堆砌在 UI 组件里的面条代码,而是坚定地选择了 MVVM 架构。我们深度辨析了 @State、@Prop 和 @Link 的使用场景,确保数据流向清晰可控。
更重要的是,我们手写了一个功能强大的 BaseViewModel,它将成为我们未来几十个页面开发的基石,帮我们统一处理 Loading 和 Error,让业务代码变得清爽无比。
现在,你的工程里已经有了合理的目录结构,有了通用的 ViewModel 封装,有了清晰的数据模型。你已经做好了迎接复杂业务挑战的准备。
更多推荐



所有评论(0)