鸿蒙 MVVM 实战:从 Demo 到工程化,聊聊登录、状态管理与埋点系统设计

鸿蒙 MVVM 实战:从 Demo 到工程化,聊聊登录、状态管理与埋点系统设计

本文基于 HarmonyOS Next(ArkTS / API 12+)实际开发经验总结,代码示例来自个人练习 Demo,架构思路来自真实项目沉淀。


前言

入职鸿蒙开发组已经一周多了,每天在看公司项目代码的同时,我会同步自己写一个 Demo 来验证理解。今天把这段时间的几个核心技术点整理成文章,主要涵盖三块:

  1. MVVM 分层架构:如何在鸿蒙里做好 Component / ViewModel / Controller / Biz / Imp 的职责划分
  2. 状态管理AppStorageV2PersistenceV2 的区别及正确使用姿势
  3. 埋点系统设计 :如何封装一个可维护的 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 = truereplyStarted = false:请求已发出但 AI 还没开始回答 → 不补报(相当于发送失败,或 AI 尚未响应)
  • loading = truereplyStarted = 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 和完成埋点需求让我对几个知识点的理解更扎实了:

  1. MVVM 分层的价值不在于规范,而在于每层职责清晰后,改一处不会动到其他层,排查问题也只需要在对应层找。

  2. AppStorageV2 vs PersistenceV2 的选择标准很简单:App 重启后还需要的用 Persistence,不需要的用 AppStorage。

  3. 埋点系统 容易被当成"随手上报"来处理,但一旦涉及生命周期(前后台切换、组件销毁)和幂等性,就需要专门设计。TrackingHelper 这种封装方式很值得借鉴:调用方只关心语义,所有状态管理在内部消化。

  4. 路由守卫 在鸿蒙里依靠 aboutToAppear + replace 实现页面级拦截,简单有效。


如果你也在学鸿蒙开发,欢迎交流,一起填坑 🛠️

代码仓库:个人练习 Demo(MyApplication),基于 HarmonyOS Next API 12,DevEco Studio 5.x

相关推荐
Momo__23 分钟前
VueUse createReusableTemplate —— 单文件组件内的模板复用神器
前端·vue.js
程序员小富29 分钟前
我开源了一个开发者专属的智能 JSON 工具,得到了媳妇高度认可
前端·vue.js·后端
小小小小宇29 分钟前
程序员如何给 LLM 装工具以及看懂推理过程
前端
写代码的皮筏艇29 分钟前
React中的forwardRef
前端·react.js·面试
槑有老呆38 分钟前
花三个月工资请了个 AI 程序员,结果它连青岛啤酒股价都查不了
前端
风骏时光牛马40 分钟前
Verilog开发常见问题汇总解析
前端
子兮曰42 分钟前
AI Coding Method Map:一张图看懂 AI 编程的完整链路
前端·人工智能·后端
weedsfly1 小时前
语法糖褪去之后——Babel 转译产物中的 JavaScript 本貌
前端·javascript
JustHappy1 小时前
「软件设计思想杂谈🤔」“切图仔”也能懂编译原理?框架源码也许没那么难。聊聊 Vue 的编译(上)
前端·javascript·vue.js