鸿蒙 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

相关推荐
IT_陈寒1 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端
kyriewen1 小时前
AI生成代码快如闪电,但我修了三个小时——它到底帮了谁?
前端·javascript·ai编程
ayqy贾杰2 小时前
基层管理的三板斧,在AI时代行不通了
前端·后端·团队管理
Apifox2 小时前
Apifox 5 月更新|Postman 导入优化、Runner 支持非 root 运行、请求代码自动带鉴权
前端·后端·安全
miaowmiaow2 小时前
PSD2Code 近期更新与深度解析:从设计稿到生产级代码的完整技术栈
前端·人工智能·ai编程
Hilaku3 小时前
多标签页并发请求导致 Token 刷新失败?只有 15行代码就能解决 !
前端·javascript·程序员
Nile3 小时前
解密Palantir系列一:4. Ontology 不是哲学
开发语言·前端·javascript
因_崔斯汀3 小时前
ECharts 区域地图可视化实战:以山东地图为例
前端
Bacon3 小时前
手摸手带你搞清楚 AI Agent 的六大核心概念
前端·人工智能