鸿蒙 MVVM 实战:从 Demo 到工程化,聊聊登录、状态管理与埋点系统设计
本文基于 HarmonyOS Next(ArkTS / API 12+)实际开发经验总结,代码示例来自个人练习 Demo,架构思路来自真实项目沉淀。
前言
入职鸿蒙开发组已经一周多了,每天在看公司项目代码的同时,我会同步自己写一个 Demo 来验证理解。今天把这段时间的几个核心技术点整理成文章,主要涵盖三块:
- MVVM 分层架构:如何在鸿蒙里做好 Component / ViewModel / Controller / Biz / Imp 的职责划分
- 状态管理 :
AppStorageV2和PersistenceV2的区别及正确使用姿势 - 埋点系统设计 :如何封装一个可维护的
TrackingHelper,处理好生命周期和幂等上报
一、MVVM 分层架构实践
1.1 为什么需要分层?
鸿蒙里很容易把所有逻辑塞进 @ComponentV2 里,但这样做的问题是:组件变得又重又难测试,改一个业务逻辑要翻遍整个 UI 文件。
我的 Demo 里采用了和公司项目相似的五层结构:
markdown
Component(UI 展示 + 用户交互)
↓ 调用
Controller(流程编排:参数校验、状态流转、异常处理)
↓ 调用
Biz(业务逻辑:响应码判断、数据聚合)
↓ 调用
Imp(接口实现:网络请求、本地存储)
↓
外部系统(网络、数据库)
每一层只向下依赖,UI 不直接调 Biz,Controller 不直接写 UI。
1.2 登录流程示例
以登录为例,看看每一层在做什么:
LoginPage.ets(Component 层)
typescript
@HMRouter({ pageUrl: 'pages/Login' })
@ComponentV2
export struct Login {
@Local vm: AuthViewModel = new AuthViewModel()
@Local controller: AuthController = new AuthController(this.vm)
aboutToAppear(): void {
// 路由守卫:已登录直接跳首页
if (hasToken()) {
HMUtil.replace({ navigationId: 'MainNavigation', pageUrl: 'pages/Home' })
}
}
build() {
Column({ space: 20 }) {
TextInput({ placeholder: '请输入账户', text: this.vm.userName })
.onChange((val) => { this.vm.userName = val })
Button('登录').onClick(() => { this.controller.login() })
}
}
}
组件只做两件事:绑定状态、抛出事件 。vm.userName 的变化自动触发 UI 刷新,点击登录只是调 Controller 方法,不关心具体逻辑。
AuthController.ets(Controller 层)
typescript
export class AuthController {
private biz: AuthBiz = new AuthBiz()
private vm: AuthViewModel
constructor(vm: AuthViewModel) {
this.vm = vm
}
login(): void {
if (!this.vm.userName || !this.vm.password) return
this.vm.isLoading = true
this.biz.login({ userName: this.vm.userName, password: this.vm.password })
.then((result) => {
if (!result) return
saveAuth(result.token, result.userName, result.userId)
HMUtil.replace({ navigationId: 'MainNavigation', pageUrl: 'pages/Home' })
})
.finally(() => { this.vm.isLoading = false })
}
}
Controller 负责:参数校验 → 调 Biz → 处理结果 → 更新 ViewModel → 路由跳转。这些逻辑放在 Controller 里,UI 和 Biz 都不需要感知。
1.3 聊天发送流程
聊天消息发送的流程稍复杂,涉及 loading 气泡和打字机效果:
typescript
sendMessage(): void {
const content = this.vm.inputContent.trim()
if (!content) return
// 1. 用户消息立即入队展示
const userMsg = new ChatMessage()
userMsg.role = 'user'
userMsg.content = content
this.vm.historyMessage.push(userMsg)
this.vm.inputContent = ''
// 2. 显示 AI loading 气泡
this.vm.isLoading = true
// 3. 调 Biz 获取响应
this.biz.sendMessage(content, this.vm.sessionId)
.then((result) => {
if (!result) return
this.vm.sessionId = result.sessionId
this.vm.pendingResponse = result.content // 组件消费后自动清空
})
.finally(() => { this.vm.isLoading = false })
}
pendingResponse 是一个"信箱"字段:Controller 写入完整回复文本,ChatTabComp 消费后启动打字机动画,消费完毕清空,防止重复播放。
二、状态管理:AppStorageV2 vs PersistenceV2
这两个 API 很像,但使用场景完全不同,我一开始也分不清楚。
2.1 AppStorageV2 ------ 运行时全局共享
typescript
@ObservedV2
export class AppTabState {
@Trace currentIndex: number = 0
}
// 全局单例:任何页面 connect 同一个 key,拿到的是同一个对象
export const tabState: AppTabState =
AppStorageV2.connect(AppTabState, 'AppTabState', () => new AppTabState())!
特点:
- 生命周期随应用进程,App 重启后重置为初始值
- 适合:Tab 选中态、当前用户信息(已在内存中)、全局 UI 状态
- 任何组件
AppStorageV2.connect同一个 key,拿到的是同一个实例,修改会自动同步到所有订阅方
在 HomePage 里这样用:
typescript
@Local tabState: AppTabState =
AppStorageV2.connect(AppTabState, 'AppTabState', () => new AppTabState())!
// Tabs onChange 时修改 @Trace 属性,所有订阅方自动刷新
.onChange((index) => { this.tabState.currentIndex = index })
2.2 PersistenceV2 ------ 持久化存储
typescript
@ObservedV2
export class AuthPersist {
@Trace token: string = ''
@Trace userName: string = ''
@Trace userId: string = ''
}
export function saveAuth(token: string, userName: string, userId: string): void {
const persist = PersistenceV2.connect(AuthPersist, 'auth_persist', () => new AuthPersist())!
persist.token = token
persist.userName = userName
persist.userId = userId
}
export function hasToken(): boolean {
return PersistenceV2.connect(AuthPersist, 'auth_persist', () => new AuthPersist())!.token.length > 0
}
特点:
- App 重启后数据依然存在,底层写入本地存储
- 适合:登录 Token、用户偏好设置、离线缓存
- 首次 connect 时使用工厂函数创建并持久化,后续直接读磁盘快照
选择原则 :进程结束后还需要的数据用 PersistenceV2,仅运行时共享的数据用 AppStorageV2。
三、埋点系统设计:封装 TrackingHelper
埋点是业务开发里经常被忽视但又很重要的一块。本节基于通用架构思路,分享如何设计一个可维护的埋点帮助类。
3.1 核心设计目标
- 语义化接口 :调用方只调
onSend()、onReplyComplete()这样的场景方法,不感知字段细节 - 自包含状态 :所有埋点状态(计数、计时、标志位)封装在
TrackingHelper内部 - 幂等上报:防止前后台切换、组件销毁、stop 按钮三条路径对同一次 Reply 重复上报
- 可替换 sink :通过构造时传入的
sink回调桥接到具体上报 SDK,解耦上报逻辑
3.2 结构概览
typescript
export type TrackSink = (event: TrackEvent) => void
export class TrackingHelper {
// 计数(窗口内累计,前后台切换时重置)
private sendCount: number = 0
private replyCount: number = 0
private normalSendCount: number = 0
private normalReplyCount: number = 0
// 会话计时
private enterTime: number = 0
private lastSendTime: number = 0
// 幂等标志
private replyReported: boolean = false
private replyStarted: boolean = false // 是否收到首字 delta
private isBackground: boolean = false
private sink: TrackSink
constructor(sink: TrackSink) {
this.sink = sink
}
// 场景方法:页面打开成功
onOpenSuccess(pageSource: string): void { ... }
// 场景方法:用户发送消息
onSend(msg: string, isPreset: boolean, isVoice: boolean): void { ... }
// 场景方法:收到首字 delta(标记 bot 已开始回答)
onReplyStart(): void { this.replyStarted = true }
// 场景方法:正常回复完成
onReplyComplete(msg: string, tabName: string, firstDeltaTime: number): boolean { ... }
// 场景方法:前台→后台
onBackground(loading: boolean): void { ... }
// 场景方法:组件销毁
onDispose(loading: boolean): void { ... }
}
3.3 核心难点:pending reply 补报
最棘手的是这个场景:用户发送消息后(onSend 已上报),在 AI 回复过程中切后台或退出页面。此时 AiAgentReplyMsg 还没上报,如果直接上报 Close 就会有"发送有记录,回复没记录"的数据断层。
处理方式:
typescript
onBackground(loading: boolean): void {
if (this.isBackground) return
this.isBackground = true
// 补报:loading 中 + 已收到首字 + 未上报 Reply → 补一条 EXCEPTION 类型
this.flushPendingReplyIfNeeded(loading)
this.reportClose()
this.resetCounters()
// 防止 in-flight 请求返回后再次计数
this.replyReported = true
}
private flushPendingReplyIfNeeded(loading: boolean): void {
if (loading && !this.replyReported && this.lastSendTime > 0 && this.replyStarted) {
this.replyCount++
this.normalSendCount++
this.replyReported = true
// 上报 EXCEPTION 类型的 AiAgentReplyMsg
this.emit(this.buildReplyEvent(this.currentMessage, AgentReplyType.EXCEPTION))
}
}
replyStarted 的作用是区分两种 loading 状态:
loading = true且replyStarted = false:请求已发出但 AI 还没开始回答 → 不补报(相当于发送失败,或 AI 尚未响应)loading = true且replyStarted = true:AI 已经开始打字,回答被中断 → 补报 EXCEPTION
这样数据口径更准确:normalReplyCount 只统计完整正常完成的回答,中断的回答只计入 replyCount。
3.4 发送类型区分
发送消息有三种入口,tabType 字段区分:
typescript
onSend(msg: string, isPreset: boolean, isVoice: boolean): void {
this.sendCount++
this.lastSendTime = Date.now()
this.replyReported = false
this.replyStarted = false
const event = new TrackEvent('AiAgentSendMsg')
if (isVoice) {
event.tabType = 'VOICE'
} else {
event.tabType = isPreset ? 'CLICK_BUBBLE' : 'MANUAL'
}
event.searchKey = msg
this.emit(event)
}
3.5 Close 事件携带统计数据
页面关闭时上报一次会话级统计:
typescript
private reportClose(): void {
const event = new TrackEvent('AiAgentClose')
event.duration = Date.now() - this.enterTime // 页面停留时长(ms)
event.sendCount = this.sendCount // 本次窗口总发送次数
event.replyCount = this.replyCount // 总回复次数(含异常)
event.normalSendCount = this.normalSendCount // 有正常或中断回复的发送次数
event.normalReplyCount = this.normalReplyCount // 完整正常回复次数
this.emit(event)
}
四个计数字段的统计口径:
| 字段 | 含义 |
|---|---|
sendCount |
用户发送的总次数 |
replyCount |
AI 有任何回复(含异常)的次数 |
normalSendCount |
AI 至少开始回答的发送次数 |
normalReplyCount |
AI 完整正常完成回答的次数 |
四、路由守卫:用 aboutToAppear 实现登录拦截
鸿蒙 HMRouter 没有全局路由守卫,但可以在 aboutToAppear 里做页面级拦截:
typescript
@HMRouter({ pageUrl: 'pages/Login' })
@ComponentV2
export struct Login {
aboutToAppear(): void {
if (hasToken()) {
HMUtil.replace({
navigationId: 'MainNavigation',
pageUrl: 'pages/Home'
})
}
}
}
用 replace 而不是 push,这样首页不会出现在路由栈里,用户无法从首页"返回"到登录页。
类似地,已登录用户直接访问登录页也会被自动跳转走。这是最简单的路由守卫实现,适合 Demo 级别的项目。生产项目通常会在路由拦截器层统一处理。
五、总结
今天练习 Demo 和完成埋点需求让我对几个知识点的理解更扎实了:
-
MVVM 分层的价值不在于规范,而在于每层职责清晰后,改一处不会动到其他层,排查问题也只需要在对应层找。
-
AppStorageV2 vs PersistenceV2 的选择标准很简单:App 重启后还需要的用 Persistence,不需要的用 AppStorage。
-
埋点系统 容易被当成"随手上报"来处理,但一旦涉及生命周期(前后台切换、组件销毁)和幂等性,就需要专门设计。
TrackingHelper这种封装方式很值得借鉴:调用方只关心语义,所有状态管理在内部消化。 -
路由守卫 在鸿蒙里依靠
aboutToAppear+replace实现页面级拦截,简单有效。
如果你也在学鸿蒙开发,欢迎交流,一起填坑 🛠️
代码仓库:个人练习 Demo(MyApplication),基于 HarmonyOS Next API 12,DevEco Studio 5.x