阅读视图

发现新文章,点击刷新页面。

鸿蒙聊天 Demo 练习 04:聊天历史本地缓存,实现消息记录持久化

鸿蒙聊天 Demo 练习 04:聊天历史本地缓存,实现消息记录持久化

一、本次分支

feature/chat-local-storage

二、本次目标

本次在原有聊天 Demo 的基础上,给聊天页面新增本地历史记录缓存能力。

之前聊天消息只保存在页面状态 chatList 里,只要退出页面、刷新页面或者重启应用,聊天记录就会丢失。

本次要把聊天记录保存到鸿蒙本地 Preferences 中,让聊天记录可以持久化保存。

本次完成的核心流程:

  1. 新增 ChatStorage.ets,专门封装聊天记录本地缓存。
  2. 使用 Preferences 保存 conversationIdchatList
  3. 页面进入时读取本地缓存并恢复聊天记录。
  4. 用户发送消息后,立即保存用户消息。
  5. 后端返回 assistant 回复后,再次保存完整聊天记录。
  6. 请求失败时,也把错误提示消息保存下来。
  7. Header 区域新增“清空”按钮。
  8. 点击清空后,同时清空页面状态和本地缓存。

最终效果:

用户发送消息
↓
页面展示用户消息
↓
保存到本地缓存
↓
调用后端接口
↓
页面展示 assistant 回复
↓
再次保存到本地缓存
↓
退出页面 / 重启应用
↓
再次进入聊天页
↓
自动恢复历史聊天记录

本次还没有做多会话列表,也没有接入数据库,只是先完成单个会话的本地持久化,为后续登录、token 保存、会话列表和数据库历史消息打基础。

三、涉及文件

entry/src/main/ets/models/ChatModel.ets
entry/src/main/ets/utils/ChatStorage.ets
entry/src/main/ets/pages/Setting.ets
docs/04-chat-local-storage.md

四、为什么要做聊天历史缓存

之前聊天 Demo 的数据流是:

用户输入
↓
创建用户消息
↓
追加到 chatList
↓
请求后端
↓
创建 assistant 消息
↓
追加到 chatList

这个流程可以完成聊天展示,但是有一个明显问题:

chatList 只是页面内存状态,不是持久化数据。

也就是说:

页面还在,消息就在
页面销毁,消息就没了
应用重启,消息也没了

真实项目中,聊天记录、用户信息、token、草稿、设置项等数据,很多都需要本地保存。

所以本次把聊天流程改造成:

鸿蒙页面
↓
chatList 状态更新
↓
ChatStorage 保存到 Preferences
↓
页面重新进入时读取 Preferences
↓
恢复聊天记录

这样 Demo 就从“临时页面状态”升级成了“有本地持久化能力”的应用。

五、项目结构变化

本次主要新增了一个 utils 工具目录,用来放本地缓存逻辑。

entry/src/main/ets
├── api
│   └── ChatApi.ets
│
├── constants
│   ├── ApiConstants.ets
│   └── RouteConstants.ets
│
├── models
│   └── ChatModel.ets
│
├── pages
│   └── Setting.ets
│
├── stores
│   └── TabState.ets
│
└── utils
    └── ChatStorage.ets

现在聊天相关代码大概可以分成三层:

pages/Setting.ets
  页面层,负责 UI 展示、输入、点击、调用方法

api/ChatApi.ets
  接口层,负责请求 Next.js 后端

utils/ChatStorage.ets
  本地存储层,负责保存和读取聊天历史

models/ChatModel.ets
  类型层,负责统一消息结构

这次的重点不是单纯会用 Preferences,而是把缓存逻辑从页面里拆出来,让页面不要越来越臃肿。

六、聊天消息模型

文件:

entry/src/main/ets/models/ChatModel.ets

代码:

export interface ChatMessage {
  id: number
  role: 'user' | 'assistant'
  content: string
  createTime: number
}

这个类型表示页面里真正要展示的一条聊天消息。

字段说明:

id:消息唯一标识
role:消息角色,用户消息是 user,AI 回复是 assistant
content:消息内容
createTime:消息创建时间

这里统一使用:

role: 'user' | 'assistant'

而不是:

type: 'user' | 'ai'

原因是 role 更接近真实聊天接口设计,后续接入真实 AI 接口、数据库消息表、OpenAI 风格接口时更容易对齐。

七、新增 ChatStorage 本地缓存工具

文件:

entry/src/main/ets/utils/ChatStorage.ets

完整代码:

import { preferences } from '@kit.ArkData'
import { common } from '@kit.AbilityKit'
import { ChatMessage } from '../models/ChatModel'

interface ChatCacheData {
  conversationId: number
  chatList: ChatMessage[]
}

export class ChatStorage {
  private static readonly STORE_NAME: string = 'chat_storage'
  private static readonly CHAT_CACHE_KEY: string = 'chat_cache'

  static async saveChatCache(
    context: common.UIAbilityContext,
    conversationId: number,
    chatList: ChatMessage[]
  ): Promise<void> {
    const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)

    const cacheData: ChatCacheData = {
      conversationId,
      chatList
    }

    await pref.put(ChatStorage.CHAT_CACHE_KEY, JSON.stringify(cacheData))
    await pref.flush()
  }

  static async getChatCache(context: common.UIAbilityContext): Promise<ChatCacheData> {
    const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)
    const cacheValue = await pref.get(ChatStorage.CHAT_CACHE_KEY, '')

    if (typeof cacheValue !== 'string' || cacheValue.length === 0) {
      return {
        conversationId: 0,
        chatList: []
      }
    }

    try {
      const cacheData = JSON.parse(cacheValue) as ChatCacheData

      return {
        conversationId: cacheData.conversationId || 0,
        chatList: cacheData.chatList || []
      }
    } catch (error) {
      console.error(`parse chat cache error: ${JSON.stringify(error)}`)

      return {
        conversationId: 0,
        chatList: []
      }
    }
  }

  static async clearChatCache(context: common.UIAbilityContext): Promise<void> {
    const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)
    await pref.delete(ChatStorage.CHAT_CACHE_KEY)
    await pref.flush()
  }
}

八、ChatStorage 的职责

ChatStorage 只做三件事:

saveChatCache:保存聊天缓存
getChatCache:读取聊天缓存
clearChatCache:清空聊天缓存

页面不需要关心:

Preferences 怎么创建
缓存 key 是什么
数据怎么 JSON.stringify
数据怎么 JSON.parse
异常时怎么兜底

页面只需要调用:

await ChatStorage.saveChatCache(context, this.conversationId, this.chatList)

这样就完成了页面层和存储层的解耦。

九、为什么要同时保存 conversationId 和 chatList

这次不是只保存消息列表,而是保存了:

conversationId
chatList

原因是当前聊天接口已经支持 conversationId

如果只保存 chatList,不保存 conversationId,就会出现一个问题:

页面看起来恢复了旧聊天记录
但是下一次发消息时,后端不知道属于哪个会话

所以本地缓存结构设计成:

interface ChatCacheData {
  conversationId: number
  chatList: ChatMessage[]
}

这样页面恢复时可以同时恢复:

当前会话 ID
当前会话消息列表

十、Preferences 的基本使用流程

本次使用的是鸿蒙的 Preferences

核心流程是:

获取 Preferences 实例
↓
put 写入数据
↓
flush 持久化

保存缓存:

const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)

await pref.put(ChatStorage.CHAT_CACHE_KEY, JSON.stringify(cacheData))
await pref.flush()

读取缓存:

const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)
const cacheValue = await pref.get(ChatStorage.CHAT_CACHE_KEY, '')

删除缓存:

const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)
await pref.delete(ChatStorage.CHAT_CACHE_KEY)
await pref.flush()

这里要注意:

put 之后要 flush
delete 之后也要 flush

否则数据可能只是更新到了内存里,没有真正持久化到本地。

十一、为什么要 JSON.stringify

Preferences 适合保存简单数据。

但是这次要保存的是一个对象:

{
  conversationId: 123,
  chatList: []
}

所以需要先转成字符串:

JSON.stringify(cacheData)

读取出来之后再转回对象:

JSON.parse(cacheValue)

整体流程:

对象
↓
JSON.stringify
↓
字符串
↓
Preferences
↓
字符串
↓
JSON.parse
↓
对象

十二、修改 Setting 页面

文件:

entry/src/main/ets/pages/Setting.ets

本次在 Setting.ets 中主要做了这些改动:

  1. 引入 common
  2. 引入 ChatMessage
  3. 引入 ChatStorage
  4. 页面进入时读取缓存。
  5. 发送消息后保存缓存。
  6. assistant 回复后保存缓存。
  7. 请求失败时保存错误提示。
  8. 新增清空聊天历史方法。
  9. Header 增加“清空”按钮。

十三、Setting.ets 新增引用

import { common } from '@kit.AbilityKit'

import { ChatMessage } from '../models/ChatModel'
import { ChatStorage } from '../utils/ChatStorage'

common.UIAbilityContext 用来给 Preferences 提供上下文。

ChatMessage 用来统一聊天消息类型。

ChatStorage 用来读写本地聊天缓存。

十四、chatList 类型调整

原来如果页面里自己定义了 ChatItem

interface ChatItem {
  id: number
  role: 'user' | 'assistant'
  content: string
  createTime: number
}

现在可以删掉,统一使用模型文件里的 ChatMessage

@Local chatList: ChatMessage[] = []

这样做的好处是:

页面展示使用 ChatMessage
本地缓存使用 ChatMessage
后续数据库消息也可以参考 ChatMessage

类型统一之后,后面维护会更简单。

十五、页面进入时读取本地缓存

aboutToAppear 中调用:

aboutToAppear(): void {
  globalTabState.setCurrentTab(RouteConstants.SETTING)

  this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)

  this.loadChatCache()
}

新增读取方法:

async loadChatCache(): Promise<void> {
  const context = getContext(this) as common.UIAbilityContext
  const cacheData = await ChatStorage.getChatCache(context)

  this.conversationId = cacheData.conversationId
  this.chatList = cacheData.chatList

  this.scrollToBottom()
}

这里没有把 aboutToAppear 直接写成 async,而是单独封装了 loadChatCache

这样写更清晰:

aboutToAppear:负责生命周期入口
loadChatCache:负责异步读取缓存

十六、保存聊天缓存方法

新增方法:

async saveChatCache(): Promise<void> {
  const context = getContext(this) as common.UIAbilityContext
  await ChatStorage.saveChatCache(context, this.conversationId, this.chatList)
}

这样页面里每次需要保存时,只需要写:

await this.saveChatCache()

不用每次都重复写:

getContext
ChatStorage.saveChatCache
conversationId
chatList

十七、发送用户消息后保存

原来发送消息时,只是把用户消息追加到 chatList

this.chatList = this.chatList.concat([tempUserMessage])
this.scrollToBottom()

现在改成:

this.chatList = this.chatList.concat([tempUserMessage])
await this.saveChatCache()
this.scrollToBottom()

这样做的好处是:

用户消息先展示
用户消息立即保存
即使后端请求失败,用户刚才发的内容也不会丢

十八、assistant 回复后保存

拿到后端返回的 assistant 消息后:

this.chatList = this.chatList.concat(assistantMessages)
await this.saveChatCache()
this.scrollToBottom()

这一步保存的是完整聊天记录:

用户消息
assistant 回复
conversationId

这样下次进入页面时,聊天上下文可以完整恢复。

十九、请求失败时也保存错误消息

如果后端返回异常:

const failMessage: ChatMessage = {
  id: Date.now(),
  role: 'assistant',
  content: res.message || '后端返回异常,请稍后重试。',
  createTime: Date.now()
}

this.chatList = this.chatList.concat([failMessage])
await this.saveChatCache()
this.scrollToBottom()

如果请求直接失败:

const errorMessage: ChatMessage = {
  id: Date.now(),
  role: 'assistant',
  content: '请求后端失败,请检查 Next.js 服务是否启动,以及接口地址是否正确。',
  createTime: Date.now()
}

this.chatList = this.chatList.concat([errorMessage])
await this.saveChatCache()
this.scrollToBottom()

这样做的原因是:

错误提示也是聊天页面的一部分
用户下次进入页面时,能看到上次失败的上下文
方便排查问题

二十、新增清空聊天历史功能

新增方法:

async clearChatHistory(): Promise<void> {
  const context = getContext(this) as common.UIAbilityContext

  this.chatList = []
  this.conversationId = 0

  await ChatStorage.clearChatCache(context)
}

这里需要同时清空三个东西:

chatList:页面上的消息
conversationId:当前会话 ID
Preferences:本地缓存

不能只清空 chatList

如果只清空页面消息,不清空 conversationId,下一次发送消息时还可能继续沿用旧会话 ID。

二十一、Header 新增清空按钮

原来的 Header 只有标题。

本次改成:

@Builder
Header() {
  Row() {
    Text('聊天 Demo')
      .fontSize(22)
      .fontWeight(FontWeight.Bold)
      .fontColor('#222222')

    Blank()

    Button('清空')
      .height(32)
      .fontSize(14)
      .enabled(this.chatList.length > 0 && !this.isSending)
      .onClick(() => {
        this.clearChatHistory()
      })
  }
  .width('100%')
  .height(56)
  .padding({ left: 16, right: 16 })
  .backgroundColor(Color.White)
  .alignItems(VerticalAlign.Center)
}

这里用了:

Blank()

让标题靠左,按钮靠右。

按钮禁用条件是:

.enabled(this.chatList.length > 0 && !this.isSending)

意思是:

没有聊天记录时不能点
正在发送消息时不能点

这样可以避免一些异常操作。

二十二、完整聊天流程

1. 页面初始化流程

进入 Setting 页面
↓
aboutToAppear 执行
↓
调用 loadChatCache
↓
读取 Preferences
↓
恢复 conversationId
↓
恢复 chatList
↓
滚动到底部

2. 发送消息流程

用户输入内容
↓
点击发送
↓
校验内容是否为空
↓
设置 isSending = true
↓
清空输入框
↓
创建用户消息
↓
追加到 chatList
↓
保存本地缓存
↓
调用 sendChatMessage
↓
拿到后端返回
↓
更新 conversationId
↓
过滤 assistant 消息
↓
追加到 chatList
↓
再次保存本地缓存
↓
滚动到底部
↓
设置 isSending = false

3. 清空历史流程

点击清空按钮
↓
chatList = []
↓
conversationId = 0
↓
删除 Preferences 缓存

二十三、为什么还是用 concat

这次继续使用:

this.chatList = this.chatList.concat([newMessage])

而不是:

this.chatList.push(newMessage)

原因是:

concat 会返回一个新数组
push 是在原数组上修改

在 ArkUI 状态更新里,使用新数组赋值更容易触发 UI 刷新。

也就是说:

this.chatList = this.chatList.concat([tempUserMessage])

这行代码的意思是:

基于旧数组生成一个新数组
再把新数组重新赋值给 chatList

这比直接 push 更适合响应式页面状态更新。

二十四、ArkTS 类型注意点

这次依然要注意 ArkTS 的类型严格性。

不建议直接写复杂匿名对象到函数参数里:

await ChatStorage.saveChatCache(context, this.conversationId, this.chatList)

这个没问题,因为参数类型明确。

但是如果是请求参数,最好不要写成:

const res = await sendChatMessage({
  conversationId: this.conversationId || undefined,
  content
})

更推荐:

const requestParams: ChatRequest = {
  content: content
}

if (this.conversationId > 0) {
  requestParams.conversationId = this.conversationId
}

const res = await sendChatMessage(requestParams)

这也是之前遇到 Object literal must correspond to some explicitly declared class or interface 后总结出来的经验。

二十五、可能遇到的问题

1. Cannot find module '../utils/ChatStorage'

原因:

ChatStorage.ets 文件没有创建
路径写错
utils 目录位置不对

检查文件是否在:

entry/src/main/ets/utils/ChatStorage.ets

引用路径应该是:

import { ChatStorage } from '../utils/ChatStorage'

2. Cannot find module '../models/ChatModel'

原因:

ChatModel.ets 文件不存在
或者里面没有导出 ChatMessage

确认文件内容:

export interface ChatMessage {
  id: number
  role: 'user' | 'assistant'
  content: string
  createTime: number
}

3. Preferences 读取后没有恢复消息

排查顺序:

1. 发送消息后是否调用了 saveChatCache
2. saveChatCache 里是否调用了 pref.flush()
3. getChatCache 是否正确读取 CHAT_CACHE_KEY
4. JSON.parse 是否报错
5. chatList 是否重新赋值

可以加日志:

console.info(`chat cache data: ${JSON.stringify(cacheData)}`)

4. 清空后重新进入页面又恢复旧数据

可能原因:

只清空了 chatList,没有删除 Preferences
delete 后没有 flush
清空的是错误的 key

确认清空方法里有:

await pref.delete(ChatStorage.CHAT_CACHE_KEY)
await pref.flush()

5. 点击清空后下一次聊天还沿用旧会话

原因:

清空时没有把 conversationId 重置为 0

正确做法:

this.chatList = []
this.conversationId = 0
await ChatStorage.clearChatCache(context)

二十六、测试步骤

1. 启动后端

如果当前聊天接口依赖 Next.js 后端,先启动后端:

cd server
npm run dev

如果第一次启动,需要先安装依赖:

cd server
npm install
npm run dev

2. 启动鸿蒙应用

用 DevEco Studio 运行到模拟器或真机。

3. 测试发送消息

输入:

你好

预期页面展示:

用户消息:你好
assistant 回复:这是 Next.js 后端返回的模拟回复:你好

4. 测试返回页面后恢复

操作:

切到其他页面
再回到聊天页面

预期:

刚才的聊天记录还在

5. 测试重启应用后恢复

操作:

关闭应用
重新打开应用
进入聊天页

预期:

历史聊天记录仍然存在

6. 测试清空聊天记录

点击右上角:

清空

预期:

页面消息清空
清空按钮禁用
重新进入页面后仍然为空

7. 测试清空后重新发送

再次输入:

重新开始

预期:

可以正常发送
conversationId 从新的会话开始
历史旧消息不会恢复

二十七、本次知识点总结

本次练习涉及以下知识点:

  1. 鸿蒙 Preferences 本地存储。
  2. preferences.getPreferences 获取本地存储实例。
  3. pref.put 写入缓存。
  4. pref.get 读取缓存。
  5. pref.delete 删除缓存。
  6. pref.flush 持久化缓存变更。
  7. 使用 JSON.stringify 保存复杂对象。
  8. 使用 JSON.parse 恢复复杂对象。
  9. 页面进入时通过 aboutToAppear 初始化数据。
  10. 异步生命周期逻辑可以拆成单独方法。
  11. chatList 使用 concat 触发 UI 更新。
  12. 页面层和存储层解耦。
  13. conversationId 和消息列表要一起保存。
  14. 清空历史时要同时清空页面状态和本地缓存。
  15. 为后续 token、本地用户信息、会话列表缓存打基础。

二十八、表达

这个功能可以这样说:

我在鸿蒙聊天 Demo 中新增了聊天历史本地持久化能力。之前聊天记录只保存在页面的 chatList 状态里,页面销毁或应用重启后数据就会丢失。为了解决这个问题,我新增了 ChatStorage.ets,使用鸿蒙 Preferences 保存 conversationIdchatList,并在页面 aboutToAppear 时读取缓存,恢复历史聊天记录。发送用户消息、收到 assistant 回复以及请求失败生成错误消息后,都会同步更新本地缓存。另外我还在 Header 中新增了清空按钮,点击后会同时清空页面消息、重置 conversationId,并删除本地缓存。这个功能让我练习了鸿蒙本地存储、页面生命周期、异步初始化、JSON 序列化和页面层与存储层的职责拆分。

二十九、本次提交命令

git add entry/src/main/ets/models/ChatModel.ets
git add entry/src/main/ets/utils/ChatStorage.ets
git add entry/src/main/ets/pages/Setting.ets
git add docs/04-chat-local-storage.md

git commit -m "feat: add chat local storage"
git push origin feature/chat-local-storage

如果合并到 main:

git checkout main
git pull
git merge feature/chat-local-storage
git push

删除本地分支:

git branch -d feature/chat-local-storage

删除远程分支:

git push origin --delete feature/chat-local-storage

三十、本次练习总结

这一节的重点不是做一个复杂的聊天系统,而是补齐聊天 Demo 中非常关键的一环:

页面状态
↓
本地缓存
↓
重新进入页面
↓
状态恢复

通过这次练习,我理解了几个关键点:

  1. @Local 状态只适合页面运行时展示,不适合长期保存。
  2. 需要持久化的数据应该放到本地存储或数据库中。
  3. Preferences 适合保存轻量级本地数据。
  4. 复杂对象要通过 JSON.stringify 转成字符串保存。
  5. 读取缓存时要做好空值和 JSON 解析异常兜底。
  6. 页面不要直接堆太多存储逻辑,应该抽成 ChatStorage
  7. 清空聊天记录时,不仅要清空页面,还要清空缓存和会话 ID。
  8. 本地缓存能力可以继续复用到登录 token、用户信息、主题设置等功能。

目前 Demo 已经具备了基础聊天、后端接口请求和本地历史缓存能力。

后续如果继续扩展,可以进入下一节:

请求封装升级:抽离通用 Request 工具

再往后就可以继续做:

登录页
登录状态保存
路由登录拦截
会话列表
后端数据库

这样整个 Demo 会越来越接近真实业务项目。

从 HarmonyOS AI 聊天模块理解工程化架构:MVVM、Controller、Provider、请求封装与 SSE

从 HarmonyOS AI 聊天模块理解工程化架构:MVVM、Controller、Provider、请求封装与 SSE

一、前言

这几天主要在看一个 HarmonyOS AI 聊天模块的源码。

一开始看这种项目,最大的感受就是文件很多、层级很多:页面、组件、ViewModel、Controller、Provider、HttpClient、Parser、Model 都有。直接从某个方法开始逐行看,很容易看着看着就迷路。

后来我发现,读这种业务模块不能一上来就陷进细节,而是要先把整体链路理清楚。

这个聊天模块表面上是一个 AI 对话页面,但实际包含了页面入口、聊天 UI、状态管理、业务流程控制、AI 平台适配、请求封装、SSE 流式响应、会话管理、业务卡片渲染等能力。

如果按架构分层来看,大致可以抽象成这样:

页面入口
  ↓
聊天组件
  ↓
状态中心
  ↓
业务流程控制器
  ↓
AI 平台适配层
  ↓
网络请求封装
  ↓
SSE 流式响应
  ↓
结果回写状态
  ↓
UI 自动刷新

换成代码里的概念,大概就是:

Page
  ↓
View
  ↓
ViewModel
  ↓
Controller
  ↓
Provider
  ↓
HttpClient

这篇文章主要记录我目前对这个 AI 聊天模块的理解,重点不是某个具体业务,而是总结其中体现出来的一些工程化思想:MVVM、组件化、解耦、Provider 适配器、请求统一封装、SSE 流式处理、状态驱动 UI。


二、整体架构概览

一个完整的 AI 聊天模块,通常不会只写在一个页面文件里,而是会拆成多个层次。

大概可以理解为:

pages/
  页面入口,负责路由注册和业务配置

view/
  UI 组件层,负责聊天页面展示

viewmodel/
  状态管理层,负责保存页面状态

controller/
  业务流程编排层,负责发送消息、会话切换、语音输入等流程

api/
  AI 平台适配层,负责对接不同 AI 平台

utils/
  工具封装层,负责请求、解析、转换等能力

model/
  数据模型层,负责定义消息、会话、卡片等结构

constant/
  常量和协议层,负责统一管理接口路径、事件类型、状态码等

可以用一句话概括:

View 负责展示,ViewModel 负责状态,Controller 负责流程,Provider 负责平台适配,HttpClient 负责请求。

这样拆分之后,每一层的职责会更清楚,不会把 UI、状态、请求、协议解析、错误处理全部堆在一个页面文件里。


三、页面入口层:负责装配,不负责核心逻辑

页面入口层通常负责几件事:

  1. 注册路由
  2. 创建 AI Provider
  3. 配置聊天组件参数
  4. 注入业务回调
  5. 渲染聊天组件

可以简单理解成,页面入口不是聊天逻辑的核心,它更像是一个“组装器”。

例如:

@ComponentV2
export struct ChatPage {
  @Local provider: AgentProvider | null = null

  aboutToAppear(): void {
    const config = new ProviderConfig()
    config.userId = 'current_user_id'
    this.provider = new SomeAIProvider(config)
  }

  build() {
    AgentChatComp({
      provider: this.provider,
      chatConfig: this.buildChatConfig(),
      cardsBuilder: this.cardsBuilder,
      loadingBuilder: this.loadingBuilder
    })
  }

  private buildChatConfig(): ChatConfig {
    const config = new ChatConfig()
    config.welcomeMessage = '你好,我是你的 AI 助手'
    config.quickPhrases = ['推荐问题 1', '推荐问题 2']
    return config
  }
}

这里比较重要的一点是:

页面入口只负责装配,不负责聊天核心逻辑。

它不应该直接处理发送 AI 请求、解析 SSE、维护消息数组、处理会话分页、解析卡片 JSON、上传附件等逻辑。

这些复杂逻辑应该交给后面的组件层、状态层、Controller 层和 Provider 层。

页面入口主要做的是:

创建 Provider
配置 ChatConfig
传入 Builder
挂载聊天组件

这样页面会比较轻,后期业务变化时也更好维护。


四、聊天组件层:UI 总容器

聊天组件层负责搭建聊天页面的整体 UI。

一个完整聊天页面通常包括:

  • 消息列表
  • 底部输入框
  • 推荐问题
  • 会话抽屉
  • 加载蒙层
  • 语音输入蒙层
  • 浮动按钮
  • 业务卡片

结构大概可以理解为:

Stack 根容器
├── 背景层
├── 主内容层
│   ├── MessageList
│   ├── QuickQuestionsCard
│   ├── FloatingButtons
│   ├── InputBar
│   ├── VoiceMaskOverlay
│   └── LoadingOverlay
└── ConversationDrawer

聊天组件一般会接收外部传入的配置:

@Param provider: AgentProvider | null = null
@Param chatConfig: ChatConfig = new ChatConfig()
@Param bgColor: ResourceColor = ''
@BuilderParam cardsBuilder: (cards: AgentCard[]) => void = emptyCardsBuilder
@BuilderParam loadingBuilder: () => void = defaultLoadingBuilder

同时,组件内部会创建一个 ViewModel:

@Local vm: ChatViewModel = new ChatViewModel()

然后把这个 vm 传给各个子组件:

MessageList({ vm: this.vm })
InputBar({ vm: this.vm })
ConversationDrawer({ vm: this.vm })
LoadingOverlay({ vm: this.vm })
VoiceMaskOverlay({ vm: this.vm })

这说明聊天组件本身的职责主要是:

接收外部能力
创建状态中心
初始化 Controller
组合聊天 UI
把 ViewModel 分发给子组件

它本身不直接发请求,也不直接解析 AI 协议。


五、MVVM:UI 和业务之间加一层状态中介

这个模块里最明显的架构思想就是 MVVM。

MVVM 可以拆成:

Model       数据模型
View        UI 展示
ViewModel   状态和交互中介

放到聊天模块里,可以对应成:

View:
  AgentChatComp
  MessageList
  InputBar
  BotBubble
  ConversationDrawer

ViewModel:
  ChatViewModel

Model:
  ChatItem
  ChatMessage
  AgentCard
  ConversationInfo
  ChatConfig

我目前对 MVVM 的理解是:

UI 不直接干业务,业务也不直接操作 UI,中间通过 ViewModel 传状态和方法。

比如用户点击发送按钮时,UI 组件不应该自己去拼请求、调接口、解析数据,而是调用 ViewModel 暴露的方法:

this.vm.sendMessage()

然后 ViewModel 再把操作交给 Controller:

async sendMessage(): Promise<void> {
  if (this.chatController !== null) {
    await this.chatController.sendMessage()
  }
}

业务处理完成后,Controller 再回写 ViewModel:

this.vm.chatHistory = nextMessages
this.vm.loading = false

ViewModel 状态变化后,UI 自动刷新。

所以 MVVM 的重点不是“多建一个类”,而是把 UI 和业务流程隔开。

可以记成一句话:

ViewModel 负责把用户操作转成业务动作,再把业务结果转成 UI 可以直接使用的状态。


六、ChatViewModel:聊天页面的状态中心

ViewModel 是整个聊天页面的状态中心。

它通常会保存这些状态:

  • 用户输入内容
  • 聊天消息列表
  • 当前会话 ID
  • 会话列表
  • loading 状态
  • 初始化状态
  • 错误状态
  • 推荐问题
  • 待发送附件
  • 语音面板状态
  • 滚动状态

例如:

@ObservedV2
export class ChatViewModel {
  @Trace userInput: string = ''
  @Trace chatHistory: ChatItem[] = []
  @Trace loading: boolean = false
  @Trace conversationId: string = ''
  @Trace conversations: ConversationInfo[] = []
  @Trace quickPhrases: string[] = []
  @Trace pendingAttachments: AttachmentInfo[] = []
  @Trace showDrawer: boolean = false
  @Trace initialLoaded: boolean = false
  @Trace loadFailed: boolean = false
}

这里有两个比较关键的点:

@ObservedV2 修饰类
@Trace 修饰需要被 UI 追踪的状态字段

也就是说:

ViewModel 里的状态变化
  ↓
依赖它的 UI 组件自动刷新

例如:

InputBar 修改 vm.userInput
MessageList 读取 vm.chatHistory
ConversationDrawer 读取 vm.conversations
LoadingOverlay 读取 vm.initialLoaded / vm.loadFailed
VoiceMaskOverlay 读取 vm.showVoicePanel

这就是状态驱动 UI。

可以记成一句话:

UI 看 vm,Controller 改 vm。


七、Controller 层:复杂业务流程不要塞进 ViewModel

在简单页面里,ViewModel 可能直接写一些业务逻辑。

但是在 AI 聊天这种模块里,如果所有逻辑都写进 ViewModel,很容易变成一个特别大的类。

因为一次发送消息可能涉及很多步骤:

校验输入
处理附件
清空输入框
追加用户消息
设置 loading
创建 AI 回复占位
调用 AI 接口
处理 SSE delta
处理完整消息
解析卡片
处理停止生成
处理失败重试
同步会话
收集埋点
恢复状态

如果这些都写在 ViewModel 里,ViewModel 后期会非常难维护。

所以这里单独拆出 Controller 层:

ChatController
  负责发送消息、停止生成、重试、流式回复

ConversationController
  负责会话列表、会话切换、删除会话、分页加载

VoiceInputController
  负责语音输入、录音状态、语音识别

ProgressiveRevealController
  负责卡片或内容的渐进展示

我对这个分层的理解是:

ViewModel 管状态,Controller 管流程。

发送消息的大致流程可以理解为:

InputBar 点击发送
  ↓
vm.sendMessage()
  ↓
ChatController.sendMessage()
  ↓
读取 vm.userInput
  ↓
创建用户消息
  ↓
写入 vm.chatHistory
  ↓
设置 vm.loading = true
  ↓
调用 provider.sendMessage()
  ↓
onDelta 更新 AI 气泡
  ↓
onMessage 处理完整消息和卡片
  ↓
onReplyComplete 收尾
  ↓
vm.loading = false

这样做的好处是:

  • UI 不关心请求细节
  • ViewModel 不承载复杂流程
  • Controller 专门负责把一次业务动作跑完整

八、Provider 层:平台适配与面向接口编程

AI 聊天模块可能接入不同平台,例如 Coze、Dify、OpenAI、MockProvider,或者公司内部 AI 服务。

如果 UI 组件直接依赖某个平台实现,就会产生强耦合。

例如下面这种写法就不太好:

const provider = new CozeProvider()
provider.sendMessage()

这样组件就和 Coze 绑死了。以后如果要换成 Dify,或者换成内部 AI 服务,就要改组件代码。

更好的方式是抽象一个统一接口或抽象类:

export abstract class AgentProvider {
  abstract getName(): string

  abstract sendMessage(
    message: string,
    conversationId: string,
    attachments: AgentAttachment[],
    onDelta: (delta: string, fullText: string) => void,
    onStatus?: (status: string) => void,
    onMessage?: (content: string, msgId: string) => void,
    onReplyComplete?: () => void
  ): Promise<AgentResult>
}

然后具体平台去继承它:

export class CozeProvider extends AgentProvider {
  getName(): string {
    return 'Coze'
  }

  async sendMessage(...): Promise<AgentResult> {
    // 这里写 Coze 平台的具体请求逻辑
  }
}

上层组件只依赖抽象:

@Param provider: AgentProvider | null = null

这样就变成:

传 CozeProvider,就接 Coze
传 DifyProvider,就接 Dify
传 MockProvider,就可以做测试

这就是典型的解耦。

可以总结为:

组件不关心具体实现类,只关心传入对象是否满足它依赖的接口或抽象类。


九、abstract 的意义:只定规则,不干具体活

在 Provider 抽象中,经常会看到 abstract 关键字。

例如:

abstract class AgentProvider {
  abstract getName(): string
  abstract sendMessage(...): Promise<AgentResult>
}

abstract 的意思是:

抽象的,只定义规范,不提供完整实现。

抽象类不能直接 new

const provider = new AgentProvider() // 不允许

它的作用是规定子类必须实现哪些方法。

比如:

任何 AI Provider 都必须有 getName()
任何 AI Provider 都必须有 sendMessage()

具体实现交给子类:

class CozeProvider extends AgentProvider {
  getName(): string {
    return 'Coze'
  }

  async sendMessage(...): Promise<AgentResult> {
    // 具体平台逻辑
  }
}

所以 abstract 在架构上的作用是:

定义统一规范
约束子类实现
让上层依赖抽象,而不是依赖具体类

一句话记忆:

abstract = 只定规则,不干具体活。


十、解耦:依赖抽象,而不是依赖具体实现

解耦不是简单地“多拆几个文件”。

真正的解耦,是降低模块之间的依赖关系。

一个比较好的分层应该是:

UI 层不关心请求细节
Controller 不关心底层 HTTP 实现
Provider 不关心 UI 怎么展示
HttpClient 不关心业务含义
Parser 不关心卡片怎么显示

例如:

ChatController:
我要发送消息,但我不关心你是 Coze 还是 OpenAI。

Provider:
我知道某个平台接口怎么调用,但我不关心 UI 怎么展示。

HttpClient:
我只负责发请求和解析基础流,不关心这是不是 AI 消息。

MessageList:
我只负责展示消息,不关心这条消息怎么从服务端来的。

如果不解耦,代码很容易变成:

一个页面里写 UI
一个页面里写状态
一个页面里写请求
一个页面里写 SSE
一个页面里写卡片解析
一个页面里写错误处理
一个页面里写埋点

短期可能能跑,但后期基本不好维护。

解耦后的结构是:

Page        入口和配置
View        展示
ViewModel   状态
Controller  流程
Provider    平台协议
HttpClient  请求
Parser      解析
Model       数据结构

我现在对解耦的理解是:

解耦就是让每一层只知道自己必须知道的东西。


十一、HAR 共享包:模块复用的工程结构

在 HarmonyOS 工程里,AI 聊天模块可以做成 HAR 共享包。

HAR 可以理解成:

HarmonyOS 里的共享代码包 / 组件库包 / 模块包。

它不是 exe,也不是可以双击运行的程序。

它更接近于:

前端里的 npm package
Android 里的 AAR
Java 里的 JAR

HAR 通常可以用来封装:

  • 公共组件
  • 工具方法
  • 业务模块
  • 页面能力
  • 网络请求封装
  • 数据模型
  • 资源文件

例如在模块入口统一导出能力:

export { AgentChatComp } from './view/AgentChatComp'
export { AgentProvider } from './api/AgentProvider'
export { CozeProvider } from './api/CozeProvider'
export { HttpClient } from './utils/HttpClient'
export { ChatViewModel } from './viewmodel/ChatViewModel'

主项目里只需要这样使用:

import { AgentChatComp, CozeProvider } from 'ai_chat_module'

HAR 的价值主要是:

复用
模块化
隔离业务
减少重复代码
方便维护

这里也可以区分一下 HAR 和解耦:

HAR 是工程结构上的模块拆分
解耦是代码设计上的职责拆分

也就是说:

HAR 让代码从工程上独立出来,解耦让代码从职责上独立出来。


十二、HttpClient:网络请求统一出口

在真实项目里,不应该到处直接写 HTTP 请求,而是应该有统一的请求封装层。

一个请求封装类通常会提供:

get()
post()
put()
upload()
stream()
abortStream()

它负责处理:

  • 普通请求
  • 文件上传
  • SSE 流式请求
  • 请求中断
  • 请求日志
  • 敏感信息脱敏
  • 超时处理
  • 错误处理

也就是说:

Provider 不直接碰底层 HTTP,Provider 只调用 HttpClient。

这样可以统一处理请求头、日志、错误、超时、中断等公共问题。

1. 普通请求

普通请求比较好理解:

传入 URL
传入 header
传入 body
发出请求
拿到响应
返回结果

2. 文件上传

文件上传通常会走 multipart/form-data

流程大概是:

读取本地文件
  ↓
构造 multipart/form-data
  ↓
上传到文件接口
  ↓
拿到 file_id
  ↓
聊天请求里传 file_id

也就是说,带附件聊天时,通常不是直接把本地路径传给 AI 接口,而是:

先上传文件,拿到 file_id,再把 file_id 放进聊天请求。

3. SSE 流式请求

AI 打字机效果一般不是前端自己用定时器假装输出,而是服务端通过 SSE 不断推送内容。

普通 HTTP 是:

请求一次
返回一次
结束

SSE 更像是:

请求一次
服务端不断返回 event
前端不断解析并更新 UI

十三、SSE 流式响应:AI 打字机效果的底层基础

SSE 全称是:

Server-Sent Events

常见格式大概是:

event: conversation.message.delta
data: {"content":"你好"}

event: conversation.message.delta
data: {"content":",我是 AI 助手"}

event: conversation.message.completed
data: {"finish_reason":"stop"}

AI 流式回复大概是:

服务端返回一小段
  ↓
前端解析一小段
  ↓
更新 AI 气泡
  ↓
再返回一小段
  ↓
再更新 AI 气泡

用户看到的效果就是“AI 正在打字”。

但是这里有一个细节比较重要:

网络流返回的数据块,不一定刚好是一个完整的 SSE 事件。

服务端可能发送的是一个完整事件:

event: xxx
data: {"content":"你好"}

但是客户端实际收到时,可能会被拆成几块:

第 1 块:event: x
第 2 块:xx\ndata: {"content"
第 3 块::"你好"}\n\n

所以前端不能拿到一块数据就直接解析,而是要做 buffer 拼包。

大概流程是:

1. 接收二进制数据块
2. 转成字符串
3. 放进 sseBuffer
4. 按空行 \n\n 拆完整事件
5. 解析 event 和 data
6. 回调给 Provider

这就是流式请求封装层的核心。

可以总结成一句话:

网络数据块不等于完整 SSE 事件,所以要先拼包再解析。


十四、停止生成:不是只改 UI 状态

AI 聊天里常见一个功能:停止生成。

一开始很容易以为停止生成只是:

loading = false

但实际上这不完整。

一个更完整的停止流程应该是:

用户点击停止生成
  ↓
Controller 调用 stopGenerate()
  ↓
Provider 调用 cancelChat()
  ↓
HttpClient.abortStream() 中断本地 SSE
  ↓
Provider 通知服务端取消当前生成
  ↓
保留已经生成的部分文本
  ↓
恢复 loading 状态

这里还要区分两种情况:

用户主动停止
网络异常中断

用户主动停止时,不应该提示“网络错误”。

网络异常中断时,才需要提示失败或者允许重试。

因此可以定义一个特殊错误类型:

StreamAbortedError

用来表示用户主动取消。


十五、请求日志与敏感信息脱敏

真实项目里,请求日志很重要。

但是日志不能随便打印敏感信息。

常见敏感字段有:

authorization
cookie
token
openId
x-api-key
set-cookie

请求封装层应该统一做脱敏处理。

例如日志中只显示:

authorization: *** (len=32)

而不是打印真实 token。

这里体现的是工程安全意识:

日志要能帮助排查问题,但不能泄露用户身份、token、cookie 等敏感信息。

这也是 Demo 代码和真实业务代码的一个明显区别。


十六、数据模型:让消息、会话、卡片结构清晰

AI 聊天模块里常见的数据模型包括:

ChatItem
  UI 上展示的一条消息

ChatMessage
  普通消息数据

AgentCard
  AI 返回的业务卡片

ConversationInfo
  会话信息

ChatConfig
  聊天组件配置

AgentResult
  AI 返回结果

AgentAttachment
  附件信息

这些模型的意义是:

不要让所有数据都用 any 或 JSON 字符串到处乱传,而是定义清楚每种数据长什么样。

例如:

export class ChatItem {
  role: string = ''
  content: string = ''
  time: string = ''
  cards: AgentCard[] = []
  attachments: AttachmentInfo[] = []
}

这样 UI 层、Controller 层、Provider 层之间传递数据时会更清楚。


十七、数组更新与响应式刷新

在 ArkUI 响应式场景中,数组更新要特别注意。

例如直接这样写:

this.chatHistory.push(newItem)

有时不如替换数组引用稳定。

更推荐:

this.chatHistory = this.chatHistory.concat(newItem)

或者:

this.chatHistory = this.chatHistory.map(item => {
  if (item.id === targetId) {
    return {
      ...item,
      content: newContent
    }
  }
  return item
})

核心思想是:

通过生成新数组,改变引用,触发 UI 更稳定刷新。

这和聊天打字机效果也有关系。

例如 AI 回复时,如果只是修改同一个对象里的 content,但列表 key 没变,有时 UI 不一定按预期刷新。

可以通过下面几种方式保证刷新稳定:

更新数组引用
更新对象引用
合理设置 ForEach key

十八、Builder 参数:让组件支持自定义 UI

聊天模块中经常需要支持业务卡片。

不同业务返回的卡片可能完全不同:

路线卡片
景点卡片
票务卡片
商品卡片
订单卡片
推荐问题卡片

如果聊天组件内部写死所有卡片 UI,组件就很难复用。

更好的方式是通过 Builder 参数传入:

@BuilderParam cardsBuilder: (cards: AgentCard[]) => void

外部页面自己决定这些卡片怎么画:

@Builder
cardsBuilder(cards: AgentCard[]) {
  RouteCardList({
    cards: cards.filter(item => item.cardType === 'route')
  })

  PoiCardList({
    cards: cards.filter(item => item.cardType === 'poi')
  })
}

这样聊天组件只负责:

我有一批 cards
我把它交给外部 cardsBuilder

它不关心具体业务卡片长什么样。

这也是一种解耦:

聊天组件负责通用聊天能力
业务页面负责具体业务卡片展示

十九、ChatConfig:把业务差异配置化

一个通用聊天组件不应该写死所有业务行为。

可以通过 ChatConfig 注入不同业务需要的配置:

  • 欢迎语
  • 推荐问题
  • 动态推荐问题加载
  • 抽屉配置
  • 链接点击回调
  • 埋点回调
  • 业务跳转回调
  • loading 样式
  • 错误样式

例如:

const config = new ChatConfig()
config.welcomeMessage = '你好,我是 AI 助手'
config.quickPhrases = ['问题 1', '问题 2']
config.onLinkClick = (url, text) => {
  // 业务页面决定怎么打开链接
}
config.onTrackEvent = (event) => {
  // 业务页面决定怎么上报埋点
}

这样组件内部不用关心具体业务平台怎么跳转、怎么埋点、怎么生成推荐问题。

可以总结为:

通用组件通过配置和回调接入业务能力。


二十、完整主链路

到这里,可以把整个 AI 聊天模块的主链路串起来:

用户输入问题
  ↓
InputBar 触发发送
  ↓
ChatViewModel 暴露 sendMessage 入口
  ↓
ChatController 编排发送流程
  ↓
创建用户消息,写入 chatHistory
  ↓
设置 loading 状态
  ↓
调用 AgentProvider.sendMessage
  ↓
具体 Provider 构造平台请求
  ↓
HttpClient.stream 发起 SSE 请求
  ↓
服务端不断返回 event/data
  ↓
HttpClient 解析出 eventType 和 eventData
  ↓
Provider 解析平台协议
  ↓
onDelta 回调给 ChatController
  ↓
ChatController 更新 AI 消息内容
  ↓
ChatViewModel 状态变化
  ↓
MessageList / BotBubble 自动刷新
  ↓
回复完成后收尾,恢复 loading

这条链路很重要。

只要能把这条链路讲清楚,大多数 AI 聊天项目的结构就不会看乱。


二十一、这套架构体现的核心思想

1. MVVM

View 负责展示
ViewModel 负责状态
Model 负责数据结构

2. Controller 编排

复杂业务流程不要全部塞进 ViewModel。

Controller 负责把一次完整业务流程串起来。

3. Provider 适配器

上层依赖统一接口。

不同 AI 平台各自实现自己的 Provider。

4. 请求统一封装

所有网络请求集中在 HttpClient。

统一处理:

日志
错误
上传
SSE
中断
超时
脱敏

5. 组件化

大页面拆成:

消息列表
输入栏
抽屉
气泡
卡片
蒙层

每个组件只负责自己的展示和交互。

6. 配置化

业务差异通过 ChatConfigBuilderParam 注入。

通用组件不写死业务逻辑。

7. 解耦

UI 不关心请求
请求不关心 UI
Provider 不关心展示
HttpClient 不关心业务语义

二十二、可以背下来的架构口诀

Page 负责入口
Comp 负责 UI
ViewModel 负责状态
Controller 负责编排
Provider 负责平台适配
HttpClient 负责请求
Parser 负责解析
Model 负责数据结构
Card 负责展示

再短一点:

UI 看 vm,Controller 改 vm,Provider 接平台,HttpClient 发请求。

再总结成一句:

把用户看到的 UI、页面状态、业务流程、平台协议和网络请求拆开,各自负责自己的事情。


二十三、实习阶段应该怎么读这种项目

第一次看公司项目,不要试图一次看懂所有文件。

我觉得可以按这个顺序来:

第一遍:看目录结构,知道每个目录大概干什么
第二遍:看页面入口,知道从哪里进来
第三遍:看核心组件,知道 UI 怎么组合
第四遍:看 ViewModel,知道状态有哪些
第五遍:看 Controller,知道一次业务流程怎么跑
第六遍:看 Provider,知道接口平台怎么适配
第七遍:看 HttpClient,知道请求和 SSE 怎么封装
第八遍:看 Parser 和 Card,知道数据怎么渲染成业务 UI

不要一开始就陷进某个很长的文件里。

应该先建立地图:

入口在哪?
状态在哪?
发送从哪开始?
请求在哪发?
结果回到哪里?
UI 怎么刷新?

有了主线之后,再逐个文件深入。


二十四、当前阶段学习总结

通过这一阶段的源码阅读,我对一个 AI 聊天模块的工程化设计有了更清楚的认识。

它并不是简单地在页面里写一个输入框和消息数组,而是通过多层架构把复杂能力拆开:

页面入口负责装配
聊天组件负责 UI 总装
ViewModel 负责状态管理
Controller 负责业务流程
Provider 负责 AI 平台适配
HttpClient 负责网络请求和 SSE
Model 负责数据结构
Builder 负责业务 UI 扩展

这套结构里体现了很多常见的工程化思想:

MVVM
解耦
面向接口编程
组件化
配置化
请求统一封装
状态驱动 UI

对于实习阶段来说,我觉得目前最重要的不是马上把每个方法都背下来,而是先理解:

为什么要这么分层?
每一层负责什么?
一次发送消息从 UI 到请求再回到 UI 是怎么流转的?

理解了这些,再去看具体方法实现,就不会那么容易迷路。


二十五、最终总结

一个复杂 AI 聊天模块的核心,不只是“页面怎么画”,而是如何把 UI、状态、业务流程、接口协议、网络请求、数据解析拆清楚。

其中:

MVVM 解决 UI 和状态之间的关系
Controller 解决复杂业务流程编排的问题
Provider 解决不同 AI 平台适配的问题
HttpClient 解决请求统一封装和 SSE 流式响应的问题
Builder 和 Config 解决组件可扩展和业务差异注入的问题
HAR 共享包解决模块级复用的问题

最终形成的结构可以概括为:

通过 ViewModel 打通 UI 和状态
通过 Controller 编排业务流程
通过 Provider 屏蔽平台差异
通过 HttpClient 统一网络请求
通过 Config 和 Builder 保持组件可复用

这就是目前从 AI 聊天模块源码中总结出来的主要架构理解。

HarmonyOS HMRouter 路由库 Demo 练习总结:从路由配置到商品管理增删改查

HarmonyOS HMRouter 路由库 Demo 练习总结:从路由配置到商品管理增删改查

前言

今天主要围绕鸿蒙项目中的 HMRouter 路由库做了一次完整 Demo 练习。

一开始只是一个简单的底部导航 Demo,通过 currentIndex 控制不同组件显示。后来逐步改造成真正的路由跳转,并在此基础上继续练习了路由传参、返回、拦截器、页面组件拆分、商品列表增删改查、搜索和详情页跳转等内容。

这篇文章主要是给自己做一个完整记录,后面如果忘记 HMRouter 的接入流程、push / replace / pop 区别、路由传参、拦截器、商品 Demo 的组件拆分方式,可以直接回来看这篇。


一、今天 Demo 的最终效果

最终 Demo 里主要有几个页面:

首页 TabHome
Home 商品管理页
Setting 设置页
ProductDetail 商品详情页

底部导航通过 HMRouter 实现真实路由跳转:

Index.ets
│
├── HMNavigation 路由容器
│
└── 底部导航栏
    ├── 首页 -> pages/TabHome
    ├── Home -> pages/Home
    └── Setting -> pages/Setting

商品管理页实现了:

商品列表展示
搜索商品
新增商品
删除商品
修改商品名称
点击详情跳转 ProductDetail

详情页实现了:

接收商品 id/title/from 参数
展示参数内容
点击返回 pop 回上一页

二、HMRouter 接入流程

HMRouter 的接入不是只安装一个包就结束,它分成运行时依赖、编译插件、路由扫描配置、运行时初始化几个部分。

整体流程:

安装 @hadss/hmrouter
↓
配置 @hadss/hmrouter-plugin
↓
配置 hmrouter_config.json
↓
EntryAbility 初始化 HMRouterMgr
↓
页面使用 @HMRouter 注册
↓
Index 使用 HMNavigation 承载页面
↓
HMRouterMgr.push / replace / pop 操作路由

三、安装运行库

在项目根目录执行:

ohpm install @hadss/hmrouter

这个包主要提供运行时代码:

HMRouter
HMRouterMgr
HMNavigation

也就是说,页面里能这样写:

import { HMRouter, HMRouterMgr, HMNavigation } from '@hadss/hmrouter'

四、配置编译插件

HMRouter 不是运行时自动扫描页面,而是依赖编译插件扫描 @HMRouter 装饰器,然后生成路由表。

在:

hvigor/hvigor-config.json5

中配置插件依赖:

{
  "modelVersion": "6.1.0",
  "dependencies": {
    "@hadss/hmrouter-plugin": "^1.2.2"
  }
}

注意:

@hadss/hmrouter 是运行库,用 ohpm install
@hadss/hmrouter-plugin 是编译插件,写在 hvigor-config.json5

五、entry 模块启用插件

在:

entry/hvigorfile.ts

中启用插件:

import { hapTasks } from '@ohos/hvigor-ohos-plugin'
import { hapPlugin } from '@hadss/hmrouter-plugin'

export default {
  system: hapTasks,
  plugins: [
    hapPlugin()
  ]
}

如果这一步没配好,运行时容易出现:

not exist pageUrl in store

因为页面没有被扫描到,路由表里没有对应页面。


六、配置路由扫描目录

在 entry 目录下创建:

entry/hmrouter_config.json

内容:

{
  "scanDir": ["src/main/ets/pages"],
  "saveGeneratedFile": true
}

含义:

扫描 entry/src/main/ets/pages 下面的 @HMRouter 页面
并保存生成文件,方便排查路由表是否生成

注意这个文件应该放在:

entry/hmrouter_config.json

而不是项目根目录。


七、EntryAbility 初始化 HMRouter

在:

entry/src/main/ets/entryability/EntryAbility.ets

中初始化 HMRouter:

import { HMRouterMgr } from '@hadss/hmrouter'

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  HMRouterMgr.openLog('INFO')

  HMRouterMgr.init({
    context: this.context
  })
}

如果没初始化,可能会报:

ERR_INIT_NOT_READY
Framework initialization not completed

所以记住:

路由使用前,必须先在 EntryAbility 初始化 HMRouterMgr

八、注册路由页面

每个需要被 HMRouter 跳转的页面,都需要加:

@HMRouter({ pageUrl: 'pages/Home' })

例如 Home 页面:

import { HMRouter } from '@hadss/hmrouter'

@HMRouter({ pageUrl: 'pages/Home' })
@ComponentV2
export struct HomeBuilder {
  build() {
    Column() {
      Text('Home 页面')
    }
  }
}

这里的:

pages/Home

就是这个页面的路由地址。

跳转时也要保持完全一致:

HMRouterMgr.push({
  pageUrl: 'pages/Home'
})

九、Index 页面使用 HMNavigation

入口页面 Index.ets 中使用 HMNavigation 作为路由容器。

import { HMNavigation, HMRouterMgr } from '@hadss/hmrouter'

@Entry
@ComponentV2
struct Index {
  @Local currentTab: string = 'pages/TabHome'

  build() {
    Column() {
      HMNavigation({
        navigationId: 'MainNavigation',
        homePageUrl: 'pages/TabHome'
      })
        .layoutWeight(1)
        .width('100%')

      Row() {
        this.TabItem('首页', 'pages/TabHome')
        this.TabItem('Home', 'pages/Home')
        this.TabItem('Setting', 'pages/Setting')
      }
      .width('100%')
      .height(60)
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  TabItem(title: string, pageUrl: string) {
    Column() {
      Text(title)
        .fontSize(14)
        .fontColor(this.currentTab === pageUrl ? '#007DFF' : '#666666')
    }
    .layoutWeight(1)
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.currentTab = pageUrl

      HMRouterMgr.push({
        pageUrl: pageUrl,
        param: {
          title: title,
          from: '底部导航栏'
        }
      })
    })
  }
}

十、push / replace / pop 的区别

今天练习后,对这三个 API 的理解更清楚了。

1. push

push 是进入一个新页面,会把页面压入路由栈。

适合:

列表页 -> 详情页
商品页 -> 商品详情页
聊天列表 -> 聊天详情页

示例:

HMRouterMgr.push({
  pageUrl: 'pages/ProductDetail',
  param: {
    id: item.id,
    title: item.name,
    from: '商品列表'
  }
})

路由栈类似:

Home
↓ push
ProductDetail

2. pop

pop 是返回上一页。

适合:

详情页返回列表页
二级页面返回上一级

示例:

HMRouterMgr.pop()

路由栈:

Home -> ProductDetail
↓ pop
Home

3. replace

replace 是替换当前页面,不会无限堆栈。

适合底部 Tab 切换:

首页
Home
Setting

这几个页面是平级页面,所以更适合用 replace

HMRouterMgr.replace({
  pageUrl: item.pageUrl,
  param: {
    title: item.title,
    from: '底部导航栏'
  }
})

总结:

Tab 页面切换:replace
详情页跳转:push
页面返回:pop

十一、路由传参

跳转时通过 param 传参:

HMRouterMgr.push({
  pageUrl: 'pages/ProductDetail',
  param: {
    id: item.id,
    title: item.name,
    from: '商品列表'
  }
})

目标页面通过:

HMRouterMgr.getCurrentParam()

接收参数:

interface ProductDetailParam {
  id?: number
  title?: string
  from?: string
}

const param = HMRouterMgr.getCurrentParam() as ProductDetailParam

this.productId = Number(param?.id ?? 0)
this.title = param?.title ?? '商品详情'
this.from = param?.from ?? '默认入口'

今天通过日志验证,传参是正常的:

详情页接收到参数:
{"id":1,"title":"苹果","from":"商品列表"}

十二、路由拦截器

今天还练习了全局路由拦截器,用于避免重复跳转同一个路由。

SameRouteInterceptor.ets

import {
  HMInterceptorAction,
  HMInterceptorInfo,
  IHMInterceptor
} from '@hadss/hmrouter'

export class SameRouteInterceptor implements IHMInterceptor {
  interceptorName: string = 'SameRouteInterceptor'
  priority: number = 100

  handle(info: HMInterceptorInfo): HMInterceptorAction {
    const fromPage: string = info.srcName ?? ''
    const targetPage: string = info.targetName ?? ''

    console.log(`[SameRouteInterceptor] from=${fromPage}, target=${targetPage}`)

    if (fromPage.length > 0 && targetPage.length > 0 && fromPage === targetPage) {
      console.log('[SameRouteInterceptor] 相同路由,拦截跳转')
      return HMInterceptorAction.DO_REJECT
    }

    return HMInterceptorAction.DO_NEXT
  }
}

注册方式:

HMRouterMgr.registerGlobalInterceptor({
  interceptor: new SameRouteInterceptor()
})

整体流程:

发起路由跳转
↓
进入 SameRouteInterceptor
↓
判断当前页面和目标页面是否相同
│
├── 相同:DO_REJECT,拦截跳转
└── 不同:DO_NEXT,继续跳转

十三、路由常量抽取

为了避免到处写死字符串,抽出了路由常量。

RouteConstants.ets

export class RouteConstants {
  static readonly MAIN_NAVIGATION_ID: string = 'MainNavigation'

  static readonly TAB_HOME: string = 'pages/TabHome'
  static readonly HOME: string = 'pages/Home'
  static readonly SETTING: string = 'pages/Setting'
  static readonly PRODUCT_DETAIL: string = 'pages/ProductDetail'
}

export class RouteTitle {
  static readonly TAB_HOME: string = '首页'
  static readonly HOME: string = 'Home'
  static readonly SETTING: string = 'Setting'
}

export class RouteFrom {
  static readonly TAB_BAR: string = '底部导航栏'
}

export class TabRouteItem {
  title: string = ''
  pageUrl: string = ''
}

export const TAB_ROUTES: TabRouteItem[] = [
  {
    title: RouteTitle.TAB_HOME,
    pageUrl: RouteConstants.TAB_HOME
  },
  {
    title: RouteTitle.HOME,
    pageUrl: RouteConstants.HOME
  },
  {
    title: RouteTitle.SETTING,
    pageUrl: RouteConstants.SETTING
  }
]

底部导航可以用 TAB_ROUTES 映射出来:

ForEach(TAB_ROUTES, (item: TabRouteItem) => {
  this.TabItem(item)
}, (item: TabRouteItem) => item.pageUrl)

这样以后新增 Tab,只需要改配置。


十四、商品管理 Demo

今天商品 Demo 实现了基础增删改查和搜索。

功能包括:

初始化商品假数据
展示商品列表
搜索商品
新增商品
删除商品
修改商品名称
点击详情跳转详情页

当前商品管理组件中包含输入状态、搜索状态、商品列表、编辑状态,以及新增、删除、修改、搜索等业务逻辑。:contentReference[oaicite:0]{index=0}


十五、Product 模型

Product.ets

export class Product {
  id: number = 0
  name: string = ''
  price: number = 0
  stock: number = 0
  desc: string = ''
}

字段含义:

id:商品 ID
name:商品名称
price:价格
stock:库存
desc:描述

十六、商品 Store

为了让列表页和详情页共享商品数据,需要把商品列表抽到 Store 中。

ProductStore.ets

import { Product } from '../models/Product'

@ObservedV2
export class ProductStore {
  @Trace products: Product[] = []

  initProducts(): void {
    if (this.products.length > 0) {
      return
    }

    const p1 = new Product()
    p1.id = 1
    p1.name = '苹果'
    p1.price = 5
    p1.stock = 100
    p1.desc = '新鲜红苹果,适合日常购买'

    const p2 = new Product()
    p2.id = 2
    p2.name = '香蕉'
    p2.price = 3
    p2.stock = 80
    p2.desc = '软糯香甜,适合早餐'

    const p3 = new Product()
    p3.id = 3
    p3.name = '牛奶'
    p3.price = 12
    p3.stock = 50
    p3.desc = '早餐搭配,补充蛋白质'

    this.products = [p1, p2, p3]
  }

  addProduct(name: string): void {
    const p = new Product()
    p.id = Date.now()
    p.name = name
    p.price = Math.floor(Math.random() * 20) + 1
    p.stock = Math.floor(Math.random() * 100) + 1
    p.desc = '这是一个新增商品'

    this.products = [...this.products, p]
  }

  deleteProduct(id: number): void {
    this.products = this.products.filter((item: Product) => {
      return item.id !== id
    })
  }

  updateProductName(id: number, name: string): void {
    this.products = this.products.map((item: Product) => {
      if (item.id === id) {
        const p = new Product()
        p.id = item.id
        p.name = name
        p.price = item.price
        p.stock = item.stock
        p.desc = item.desc
        return p
      }
      return item
    })
  }

  findProductById(id: number): Product | undefined {
    return this.products.find((item: Product) => {
      return Number(item.id) === Number(id)
    })
  }
}

export const productStore: ProductStore = new ProductStore()

理解:

ProductStore 是商品数据中心
ProductManager 从这里展示商品
ProductDetail 通过 id 从这里查询商品

十七、ProductManager 商品管理组件

ProductManager 是业务组件,负责商品列表的 UI 和交互。

主要状态:

@Local inputKeyword: string = ''
@Local searchKeyword: string = ''
@Local newName: string = ''
@Local editingId: number = -1
@Local editingName: string = ''

1. 搜索

这里区分了两个状态:

inputKeyword:输入框正在输入的内容
searchKeyword:真正用于搜索的关键词

这样可以实现“点击搜索按钮后再搜索”,而不是输入时自动搜索。

Button('搜索')
  .onClick(() => {
    this.searchKeyword = this.inputKeyword.trim()
  })

过滤逻辑:

private getShowProducts(key: string): Product[] {
  if (key.length === 0) {
    return this.store.products
  }

  return this.store.products.filter((item: Product) => {
    return item.name.includes(key)
  })
}

2. 新增

private addProduct(): void {
  const name = this.newName.trim()

  if (name.length === 0) {
    return
  }

  this.store.addProduct(name)
  this.newName = ''
}

核心思想:

表单输入 newName
点击新增
调用 store.addProduct
清空输入框

3. 删除

private deleteProduct(id: number): void {
  this.store.deleteProduct(id)
}

核心思想:

点击删除
根据 id 删除商品
列表重新渲染

4. 修改

private startEditProduct(item: Product): void {
  this.editingId = item.id
  this.editingName = item.name
}

private saveEditProduct(): void {
  const name = this.editingName.trim()

  if (this.editingId < 0 || name.length === 0) {
    return
  }

  this.store.updateProductName(this.editingId, name)

  this.editingId = -1
  this.editingName = ''
}

核心思想:

点击改名
↓
记录 editingId 和 editingName
↓
显示编辑输入框
↓
点击保存
↓
更新商品名称
↓
清空编辑状态

5. 跳转详情

private openDetail(item: Product): void {
  HMRouterMgr.push({
    pageUrl: RouteConstants.PRODUCT_DETAIL,
    param: {
      id: item.id,
      title: item.name,
      from: '商品列表'
    }
  })
}

流程:

点击详情
↓
传 id/title/from
↓
跳转 ProductDetail

十八、ProductDetail 商品详情页

详情页先做最小版本:只展示路由参数。

ProductDetail.ets

import { HMRouter, HMRouterMgr } from '@hadss/hmrouter'
import { PageShell } from '../components/PageShell'

interface ProductDetailParam {
  id?: number
  title?: string
  from?: string
}

@HMRouter({ pageUrl: 'pages/ProductDetail' })
@ComponentV2
export struct ProductDetail {
  @Local title: string = '商品详情'
  @Local from: string = '默认入口'
  @Local productId: number = 0

  aboutToAppear(): void {
    const param = HMRouterMgr.getCurrentParam() as ProductDetailParam

    console.log('详情页接收到参数:' + JSON.stringify(param))

    this.productId = Number(param?.id ?? 0)
    this.title = param?.title ?? '商品详情'
    this.from = param?.from ?? '默认入口'
  }

  @Builder
  BackButtonBuilder() {
    Text('‹')
      .fontSize(32)
      .width(40)
      .height(40)
      .textAlign(TextAlign.Center)
      .onClick(() => {
        HMRouterMgr.pop()
      })
  }

  @Builder
  ContentBuilder() {
    Column({ space: 16 }) {
      Text('商品详情页')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)

      Text(`商品ID:${this.productId}`)
        .fontSize(18)

      Text(`商品名称:${this.title}`)
        .fontSize(18)

      Text(`来源:${this.from}`)
        .fontSize(18)

      Button('返回')
        .onClick(() => {
          HMRouterMgr.pop()
        })
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Start)
  }

  build() {
    PageShell({
      title: this.title,
      from: this.from,
      leftBuilder: this.BackButtonBuilder,
      contentBuilder: this.ContentBuilder
    })
  }
}

为什么先这样写?

先验证路由传参
再验证根据 id 查询 Store
不要一次把路由、Store、UI 全混在一起排错

十九、PageShell 和 BuilderParam

今天继续练习了公共页面壳组件。

PageShell 结构

PageShell
│
├── leftBuilder
└── contentBuilder

含义:

leftBuilder:左侧区域,比如返回按钮
contentBuilder:页面主体内容

@BuilderParam 本质就是:

把一段 UI 当参数传给子组件

类似:

Vue slot
React children / render props

二十、Home 页面变薄

原来 Home 页面里有很多内容:

路由注册
路由参数
商品模型
商品假数据
搜索
新增
删除
修改
列表 UI

拆分后:

Home.ets
│
├── @HMRouter 注册
├── 接收路由参数
├── 使用 PageShell
└── 挂载 ProductManager

这样页面职责更清晰。


二十一、当前目录结构

entry/src/main/ets/
│
├── pages/
│   ├── Index.ets
│   ├── Home.ets
│   ├── ProductDetail.ets
│   ├── TabHome.ets
│   └── Setting.ets
│
├── components/
│   ├── PageShell.ets
│   └── product/
│       └── ProductManager.ets
│
├── models/
│   └── Product.ets
│
├── stores/
│   ├── ProductStore.ets
│   └── TabState.ets
│
├── constants/
│   └── RouteConstants.ets
│
└── interceptors/
    └── SameRouteInterceptor.ets

理解:

pages:路由页面
components:业务组件 / 公共 UI
models:数据模型
stores:共享状态
constants:常量配置
interceptors:路由拦截器

二十二、今天遇到的问题总结

1. not exist pageUrl in store

含义:

路由表中没有这个 pageUrl

常见原因:

@HMRouter 没写
pageUrl 不一致
插件没执行
scanDir 不对
没 Clean / Rebuild
用 Previewer 跑

解决:

检查 @HMRouter
检查 hmrouter_config.json
检查 entry/hvigorfile.ts
Clean Project
Rebuild Project
Run 到模拟器

2. ERR_INIT_NOT_READY

含义:

HMRouterMgr 还没有初始化

解决:

HMRouterMgr.init({
  context: this.context
})

3. install sign info inconsistent

含义:

模拟器里有旧包残留,签名不一致

解决:

hdc list targets
hdc -t 设备ID uninstall com.example.myapplication

4. 参数正常,但详情页显示商品不存在

排查后发现:

路由参数正常
问题不在 getCurrentParam
而是在根据 id 查询 Store 数据这一层

处理策略:

先简化详情页,只展示路由参数
再逐步增加 Store 查询逻辑

二十三、鸿蒙基础知识点复盘

今天实际用到的鸿蒙基础包括:

@ComponentV2
@Builder
@BuilderParam
@Param
@Local
@ObservedV2
@Trace
build()
aboutToAppear()
Column
Row
Stack
ForEach
TextInput
Button
gesture()
HMRouter
HMNavigation
HMRouterMgr.push
HMRouterMgr.replace
HMRouterMgr.pop
HMRouterMgr.getCurrentParam

二十四、整体流程图

应用启动
│
▼
EntryAbility.onCreate
│
├── HMRouterMgr.openLog
└── HMRouterMgr.init
        │
        ▼
Index.ets
│
├── HMNavigation
│
└── Bottom TabBar
    ├── TabHome
    ├── Home
    └── Setting
          │
          ▼
Home.ets
│
├── 接收路由参数
├── PageShell 外壳
└── ProductManager
      │
      ├── 商品搜索
      ├── 商品新增
      ├── 商品删除
      ├── 商品修改
      └── 点击详情
            │
            ▼
      ProductDetail.ets
      │
      ├── getCurrentParam
      ├── 展示商品参数
      └── pop 返回

二十五、学习心得

今天最大的收获不是简单写了一个商品增删改查 Demo,而是理解了一个鸿蒙业务页面从路由到组件再到状态的组织方式。

以前是:

所有代码写在一个页面里
能跑就行

现在开始尝试拆成:

路由页面
业务组件
模型
Store
常量
拦截器

这更接近公司真实项目的结构。

HMRouter 第一次接入确实有点绕,因为它涉及:

运行库
编译插件
路由扫描
路由表
EntryAbility 初始化
模拟器运行

但跑通一次之后,后面就可以当模板复用。


二十六、后续计划

下一步继续完善:

1. ProductDetail 根据 id 从 ProductStore 获取完整商品数据
2. ProductManager 继续拆成 ProductSearchBar / ProductAddBar / ProductList
3. 练习详情页修改商品后返回列表刷新
4. 继续理解 push / replace / pop 的使用边界
5. 将 Setting 页面改造成简单聊天 Demo
6. 模拟 AI 接口请求、打字机输出、聊天历史记录

总结

今天完成了从普通页面 Demo 到路由化业务 Demo 的完整练习。

掌握了:

HMRouter 接入
路由跳转
路由传参
页面返回
路由拦截器
底部导航
商品 CRUD
组件拆分
Store 思想
日志排查

这次练习让我对鸿蒙项目有了更清晰的认识:

页面不是只写 UI
还要理解路由、状态、组件拆分、工程配置和调试流程

HarmonyOS HMRouter 接入记录:从普通 Tab Demo 到路由跳转

HarmonyOS HMRouter 接入记录:从普通 Tab Demo 到路由跳转

前言

今天在鸿蒙 Demo 中接入了 @hadss/hmrouter 路由库。

一开始只是用 @Local currentIndex 控制底部 Tab 切换:

点击底部按钮
↓
修改 currentIndex
↓
if / else 展示不同组件

这种方式本质是组件切换,不是页面路由。

后来改成使用 HMRouter:

点击底部按钮
↓
HMRouterMgr.push()
↓
通过路由切换页面

这篇文章记录完整接入流程,以及遇到的问题,方便以后排查。


一、最终实现目标

Demo 有三个页面:

首页
Home
Setting

底部有一个导航栏,点击不同 Tab 时,通过 HMRouter 路由跳转到对应页面。

最终结构:

Index.ets
│
├── HMNavigation 路由容器
│
└── 底部 TabBar
     ├── 首页 -> pages/TabHome
     ├── Home -> pages/Home
     └── Setting -> pages/Setting

二、目录结构

示例目录:

MyApplication
├── AppScope
│   └── app.json5
├── entry
│   ├── hvigorfile.ts
│   ├── hmrouter_config.json
│   └── src
│       └── main
│           ├── ets
│           │   ├── entryability
│           │   │   └── EntryAbility.ets
│           │   └── pages
│           │       ├── Index.ets
│           │       ├── TabHome.ets
│           │       ├── Home.ets
│           │       └── Setting.ets
│           └── module.json5
├── hvigor
│   └── hvigor-config.json5
├── hvigorfile.ts
└── oh-package.json5

三、接入 HMRouter 的完整流程

HMRouter 接入主要分 8 步:

1. 安装运行库
2. 配置 hvigor 插件依赖
3. 在 entry/hvigorfile.ts 启用插件
4. 新建 hmrouter_config.json 指定扫描目录
5. EntryAbility 初始化 HMRouterMgr
6. 页面使用 @HMRouter 注册
7. Index 使用 HMNavigation 承载路由页面
8. 点击底部 Tab 使用 HMRouterMgr.push 跳转

四、第一步:安装运行库

在项目根目录执行:

ohpm install @hadss/hmrouter

这个包提供运行时代码:

HMNavigation
HMRouter
HMRouterMgr

如果没有安装,页面里 import 会失败。


五、第二步:配置插件依赖

打开:

hvigor/hvigor-config.json5

添加:

{
  "modelVersion": "6.1.0",
  "dependencies": {
    "@hadss/hmrouter-plugin": "^1.2.2"
  }
}

注意:

@hadss/hmrouter 是运行库,用 ohpm 安装
@hadss/hmrouter-plugin 是编译插件,写在 hvigor-config.json5

不要执行:

ohpm install @hadss/hmrouter-plugin

否则可能 404。


六、第三步:entry/hvigorfile.ts 启用插件

打开:

entry/hvigorfile.ts

写:

import { hapTasks } from '@ohos/hvigor-ohos-plugin'
import { hapPlugin } from '@hadss/hmrouter-plugin'

export default {
  system: hapTasks,
  plugins: [
    hapPlugin()
  ]
}

这一步非常关键。

如果没有启用插件,@HMRouter 不会被扫描,也不会生成路由表。


七、第四步:新建 hmrouter_config.json

entry 目录下新建:

entry/hmrouter_config.json

内容:

{
  "scanDir": ["src/main/ets/pages"],
  "saveGeneratedFile": true
}

作用:

告诉 HMRouter 插件去哪里扫描 @HMRouter 页面

因为页面在:

entry/src/main/ets/pages

所以 scanDir 写:

src/main/ets/pages

注意这个文件位置:

entry/hmrouter_config.json

不是:

项目根目录/hmrouter_config.json

八、第五步:EntryAbility 初始化 HMRouterMgr

打开:

entry/src/main/ets/entryability/EntryAbility.ets

引入:

import { HMRouterMgr } from '@hadss/hmrouter'

onCreate 中初始化:

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  HMRouterMgr.openLog('INFO')
  HMRouterMgr.init({
    context: this.context
  })
}

如果没有初始化,运行时可能报:

ERR_INIT_NOT_READY
Framework initialization not completed

九、第六步:页面使用 @HMRouter 注册

TabHome.ets

import { HMRouter } from '@hadss/hmrouter'

@HMRouter({ pageUrl: 'pages/TabHome' })
@ComponentV2
export struct TabHome {
  build() {
    Column() {
      Text('首页')
        .fontSize(30)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

Home.ets

import { HMRouter } from '@hadss/hmrouter'

@HMRouter({ pageUrl: 'pages/Home' })
@ComponentV2
export struct HomeBuilder {
  build() {
    Column() {
      Text('Home 页面')
        .fontSize(30)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

Setting.ets

import { HMRouter } from '@hadss/hmrouter'

@HMRouter({ pageUrl: 'pages/Setting' })
@ComponentV2
export struct Setting {
  build() {
    Column() {
      Text('Setting 页面')
        .fontSize(30)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

十、第七步:Index.ets 使用 HMNavigation

Index.ets 是入口页面。

import { HMNavigation, HMRouterMgr } from '@hadss/hmrouter'

@Entry
@ComponentV2
struct Index {
  @Local currentTab: string = 'pages/TabHome'

  build() {
    Column() {
      HMNavigation({
        navigationId: 'MainNavigation',
        homePageUrl: 'pages/TabHome'
      })
      .layoutWeight(1)
      .width('100%')

      Row() {
        this.TabItem('首页', 'pages/TabHome')
        this.TabItem('Home', 'pages/Home')
        this.TabItem('Setting', 'pages/Setting')
      }
      .width('100%')
      .height(60)
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  TabItem(title: string, pageUrl: string) {
    Column() {
      Text(title)
        .fontSize(14)
        .fontColor(this.currentTab === pageUrl ? '#007DFF' : '#666666')
    }
    .layoutWeight(1)
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.currentTab = pageUrl
      HMRouterMgr.push({
        pageUrl: pageUrl
      })
    })
  }
}

十一、整体流程图

应用启动
│
▼
EntryAbility.onCreate()
│
├── HMRouterMgr.openLog()
└── HMRouterMgr.init()
        │
        ▼
Index.ets
│
├── HMNavigation
│     └── homePageUrl: pages/TabHome
│
└── 底部 TabBar
      │
      ├── 点击 首页
      │     └── HMRouterMgr.push({ pageUrl: 'pages/TabHome' })
      │
      ├── 点击 Home
      │     └── HMRouterMgr.push({ pageUrl: 'pages/Home' })
      │
      └── 点击 Setting
            └── HMRouterMgr.push({ pageUrl: 'pages/Setting' })

十二、每一步的作用总结

步骤 作用
ohpm install @hadss/hmrouter 安装运行库
hvigor-config.json5 配置 plugin 让 hvigor 能下载编译插件
entry/hvigorfile.ts 启用 hapPlugin() 让插件真正执行
entry/hmrouter_config.json 指定扫描页面目录
EntryAbility.ets 初始化 初始化路由框架
@HMRouter 注册页面
HMNavigation 路由容器
HMRouterMgr.push 跳转页面

十三、常见问题记录

1. ERR_INIT_NOT_READY

错误:

[HMRouter ERROR] ERR_INIT_NOT_READY 40001003
Framework initialization not completed

原因:

HMRouterMgr 没有初始化

解决:

EntryAbility.etsonCreate 中添加:

HMRouterMgr.openLog('INFO')
HMRouterMgr.init({
  context: this.context
})

2. not exist pageUrl in store

错误:

[HMRouter INFO][HMRouterStore] not exist pageUrl in store, pageUrl is pages/Home

原因:

路由表里没有这个页面

常见原因:

1. 页面没有写 @HMRouter
2. pageUrl 不一致
3. hmrouter-plugin 没有执行
4. scanDir 没覆盖页面目录
5. 没有 Clean / Rebuild
6. 在 Previewer 里运行

解决排查:

1. 确认页面有 @HMRouter({ pageUrl: 'pages/Home' })
2. 确认 push 的 pageUrl 完全一致
3. 确认 entry/hvigorfile.ts 配置 hapPlugin()
4. 确认 entry/hmrouter_config.json 位置正确
5. Clean Project + Rebuild Project
6. Run 到模拟器,不要只用 Previewer

3. 搜不到 hm_router_map.json

如果搜不到生成的路由表,说明插件没有成功生成路由映射。

排查:

1. hmrouter_config.json 是否在 entry 目录下
2. scanDir 是否写对
3. entry/hvigorfile.ts 是否启用 hapPlugin()
4. 是否 Rebuild Project

可以在 DevEco 中:

Ctrl + Shift + F

搜索:

pages/Home

如果生成成功,除了自己写的页面代码外,还应该能搜到生成文件中的路由信息。


4. Previewer 运行异常

日志中如果出现:

previewer is a mocked implementation

说明当前是在 Previewer 中运行。

HMRouter 依赖编译产物、rawfile 和运行时能力,建议:

不要用 Previewer 验证 HMRouter
要 Run 到模拟器或真机

5. 安装失败:install sign info inconsistent

错误:

Install Failed: error: failed to install bundle.
code:9568332
error: install sign info inconsistent.

原因:

模拟器里有旧包残留
旧包签名和新包签名不一致

解决:

先查设备:

& "C:\Program Files\Huawei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" list targets

输出类似:

127.0.0.1:5555

卸载旧包:

& "C:\Program Files\Huawei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" -t 127.0.0.1:5555 uninstall com.example.myapplication

然后重新运行。


十四、调试经验总结

这次接入 HMRouter 之后,发现鸿蒙路由库的问题不一定是页面代码问题。

更多时候问题出在:

1. 编译插件没有执行
2. 路由表没有生成
3. pageUrl 不一致
4. 没有初始化路由框架
5. 使用 Previewer 而不是模拟器
6. 模拟器旧包签名冲突

所以以后遇到白屏,优先看:

HiLog 里的 HMRouter 日志
Build Output 里的插件执行日志
是否生成 hm_router_map.json
是否 Run 到模拟器

十五、最终理解

HMRouter 使用起来其实不复杂,真正复杂的是第一次接入时的工程配置。

可以简单记成一句话:

ohpm 装运行库
hvigor 配插件
hmrouter_config 指定扫描目录
EntryAbility 初始化
@HMRouter 注册页面
HMNavigation 承载页面
HMRouterMgr.push 跳转页面

跑通一次之后,后面基本就是复制模板。


十六、组件切换 vs 路由跳转

组件切换

@Local currentIndex
↓
if / else
↓
显示不同组件

优点:

简单,适合练习状态和组件通信

缺点:

不是真正页面路由
没有路由栈
不适合复杂页面跳转

HMRouter 路由跳转

@HMRouter 注册页面
↓
HMNavigation 承载
↓
HMRouterMgr.push 跳转

优点:

是真正页面路由
支持页面栈
适合企业项目

十七、后续计划

下一步可以继续练:

1. 路由传参
2. HMRouterMgr.pop 返回
3. replace 跳转
4. 页面生命周期
5. 路由拦截
6. 结合商品管理 Demo 做详情页

比如:

商品列表页
↓
点击商品
↓
路由跳转到商品详情页
↓
传递商品 id

这样更接近真实业务。

从鸿蒙 AI 聊天 Demo 学习 ArkUI V2:第一天上手记录

从鸿蒙 AI 聊天 Demo 学习 ArkUI V2:第一天上手记录

前言

最近开始正式接触 HarmonyOS NEXT 和 ArkUI V2。

因为公司项目里有一套 AI 聊天业务,所以第一天没有选择直接死啃官方文档,而是采用:

业务驱动 + Demo 验证

的方式学习。

今天主要做了两件事:

  • 拆解 AI 聊天业务整体数据流
  • 自己写 ArkUI V2 Demo 练习核心语法

这篇文章记录一下今天学习过程中理解到的一些内容。


一、先理解 AI 聊天业务整体结构

刚开始接触项目时,最懵的是:

页面很多
Builder很多
状态很多

后来发现其实核心结构并不复杂。

整体大概是:

ChatPage
│
├── Provider(AI接口层)
├── ChatConfig(业务配置)
├── Builder(卡片UI插槽)
│
▼
ChatComponent(聊天组件)
│
├── vm(统一状态中心)
├── InputBar(输入层)
├── MessageList(消息展示层)
└── Controller(业务调度层)

二、真正理解 AI 聊天数据流

今天最大的收获之一:

是把 AI 聊天的数据流跑通了。

整体流程

用户输入
↓
输入框双向绑定状态
↓
点击发送
↓
Controller.sendMessage()
↓
创建用户消息
↓
push 到 chatHistory
↓
请求 AI
↓
流式返回
↓
push AI消息 / 卡片
↓
MessageList 自动刷新

本质上:

还是“状态驱动 UI”

只是聊天场景里:

状态 = chatHistory

三、vm(ViewModel)终于看懂了

以前一直对:

vm
viewModel

这些概念很模糊。

今天终于理解:

vm 本质是统一状态中心。

比如:

输入框

修改:

vm.userInput

而:

消息列表

读取:

vm.chatHistory

Controller:

push 到 vm.chatHistory

然后:

MessageList 自动刷新

这其实就是:

响应式状态共享。


四、Builder 插槽是今天最大的收获之一

聊天项目里有很多:

xxxBuilder

刚开始完全看不懂。

后来发现:

本质上就是“UI 插槽”。

整体逻辑

外层页面
│
├── 普通卡片 Builder
├── 混合卡片 Builder
└── Loading Builder
        │
        ▼
聊天组件根据消息类型
调用不同 Builder 渲染 UI

也就是说:

聊天组件本身
不关心业务UI长什么样

它只负责:

  • 状态管理
  • 消息流
  • 生命周期
  • 消息调度

真正的 UI:

由外部注入。

这个思想其实很像:

  • Vue slot
  • React render props

五、终于理解 @BuilderParam

今天真正搞懂:

@BuilderParam

它本质就是:

“把 UI 当参数传递”。

比如:

@BuilderParam content: () => void

意思:

“外部传一段 UI 给我”

组件内部:

this.content()

等于:

渲染外部传进来的 UI

这个设计对于大型组件非常重要。

因为:

逻辑
和
UI
可以解耦

六、开始真正上手 ArkUI V2

今天正式开始使用:

@ComponentV2

以及:

  • @Local
  • @Param
  • @ObservedV2
  • @Trace
  • @Builder
  • @BuilderParam

七、ArkUI V2 响应式理解

@Local

组件自己的状态:

@Local keyword: string = ''

@Param

父组件传值:

@Param title: string = ''

父组件:

Child({
  title: 'hello'
})

@Builder

抽 UI 片段:

@Builder
ProductRow() {

}

@BuilderParam

传 UI 插槽:

@BuilderParam content: () => void

八、终于理解鸿蒙布局

之前一直觉得:

Column
Row
Stack

很抽象。

后来自己写 Demo 后发现其实很简单。


Column

上下排列

Row

左右排列

Stack

图层叠加
后面的覆盖前面的

比如:

Stack() {
  A()
  B()
}

实际上:

B 会覆盖在 A 上面

九、写了一个商品管理 Demo

为了练习 ArkUI V2。

功能包括:

  • 商品列表
  • 搜索
  • 新增
  • 删除
  • 修改
  • 列表渲染
  • 手势
  • 生命周期

十、第一次真正理解 Diff 和 key

今天一个特别大的收获:

数据变化 ≠ UI一定刷新

比如:

item.name = 'xxx'

有时候 UI不会更新。

后来发现:

和 ForEach 的 key 有关系。


ForEach Diff 机制

原来:

(item) => item.id

修改 name 后:

id 没变

框架会复用旧节点。

于是:

UI不刷新

后来改成:

(item) => `${item.id}-${item.name}`

就好了。

因为:

key变化
=
节点重建

今天第一次真正理解:

Diff 更新机制。


十一、开始接触 gesture

比如:

.gesture(
  LongPressGesture()
)

意思:

长按手势。

例如:

长按删除商品

开始真正接触移动端交互层。


十二、目前已经接触到的知识点

ArkUI V2

  • @ComponentV2
  • @Local
  • @Param
  • @ObservedV2
  • @Trace
  • @Builder
  • @BuilderParam

布局

  • Column
  • Row
  • Stack

列表

  • ForEach
  • Diff
  • key机制

生命周期

  • aboutToAppear
  • aboutToDisappear

手势

  • LongPressGesture

状态驱动

  • vm
  • chatHistory
  • 响应式刷新

十三、今天最大的感受

今天最大的感受其实是:

不要硬啃官方文档。

因为很多时候:

只看文档
很难建立整体理解

更有效的方法是:

从业务出发
↓
遇到不会的
↓
查文档
↓
自己写 Demo 验证
↓
再回到业务

这样成长会快很多。


十四、下一步计划

接下来准备继续深入:

  • 聊天消息列表
  • Drawer
  • transition 动画
  • 生命周期
  • 流式消息更新
  • Builder 复杂嵌套
  • 路由
  • MVVM 结构

以及:

继续完善自己的 ArkUI Demo。


结尾

虽然现在还有很多不会。

但至少:

已经开始知道:

状态怎么流动
UI怎么更新
组件怎么通信
Builder怎么解耦

这已经算真正开始进入 HarmonyOS 开发了。

❌