当前位置: 首页 > news >正文

HarmonyOS AI 聊天模块架构复盘:从 UI、状态、Controller 到 Provider、SSE 与业务卡片

HarmonyOS AI 聊天模块架构复盘:从 UI、状态、Controller 到 Provider、SSE 与业务卡片

本文是一次 AI 聊天模块源码学习后的架构复盘。内容已做脱敏处理,不涉及公司项目名、内部接口、真实业务参数、内部库名、服务地址、鉴权字段等敏感信息。文中只保留通用组件架构、工程化分层思想和可复用的学习总结。

一、为什么要写这篇复盘

最近在学习一个 HarmonyOS AI 聊天模块。刚开始看源码时,很容易被大量文件和目录绕晕:页面入口、聊天组件、ViewModel、Controller、Provider、HttpClient、Parser、MessageList、InputBar、业务卡片、会话抽屉等全部出现。

如果只是一行行看代码,很容易出现一种感觉:

当时好像看懂了,但隔一会儿就忘了。

后来把项目拆成多个小节分析,才逐渐发现它的主线其实很清楚:

用户输入 ↓ InputBar ↓ ChatViewModel ↓ ChatController ↓ AgentProvider / PlatformProvider ↓ HttpClient.stream ↓ SSE 流式返回 ↓ CardParser / MessageConverter ↓ ChatItem / AgentCard ↓ MessageList / BotBubble ↓ 业务卡片组件

这篇文章就是把这个主线重新整理一遍,重点记录其中涉及到的架构思想:

MVVM Controller 业务编排 Provider 适配器 请求统一封装 SSE 流式响应 数据转换层 组件化 Builder 扩展点 解耦 HAR 共享包 防御式编程

二、整体架构地图

一个完整的 AI 聊天模块,通常不是一个简单页面,而是一套完整的聊天能力封装。它大致可以拆成这些层:

pages/ 页面入口,负责路由注册、Provider 创建、配置注入 view/ UI 组件层,负责聊天页面展示 viewmodel/ 状态管理层,负责保存页面状态 controller/ 业务流程编排层,负责发送消息、会话切换、停止生成等流程 api/ AI 平台适配层,负责把统一能力转换成具体平台接口 utils/ 工具层,负责请求封装、卡片解析、消息转换等 model/ 数据模型层,负责定义消息、会话、卡片、配置等结构 view/cards/ 业务卡片展示层,负责把结构化数据渲染成业务 UI

可以先记一个口诀:

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

这也是后面分析所有文件时的主线。


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

页面入口层可以理解成“组装器”。它一般负责:

注册路由 创建 Provider 配置 ChatConfig 传入业务卡片 Builder 传入 loading / error UI 渲染聊天组件

入口页面通常会做类似这样的事情:

@ComponentV2exportstruct ChatPage{@Localprovider:AgentProvider|null=nullaboutToAppear():void{constconfig=newProviderConfig()config.userId='current_user_id'this.provider=newSomeAIProvider(config)}build(){AgentChatComp({provider:this.provider,chatConfig:this.buildChatConfig(),cardsBuilder:this.cardsBuilder,mixedCardsBuilder:this.mixedCardsBuilder,loadingBuilder:this.loadingBuilder})}}

入口页的重点不是“处理聊天”,而是“把聊天模块需要的能力传进去”。

它不应该直接做:

发送 AI 请求 解析 SSE 维护 chatHistory 处理会话分页 上传附件 解析业务卡片 JSON

这些能力应该放到后面的 Controller、Provider、HttpClient、Parser 等层里。

一句话总结:

页面入口负责装配能力,不负责聊天核心流程。

四、聊天组件总入口:接参数、建状态、启 Controller、搭 UI

真正的聊天 UI 容器一般是一个类似AgentChatComp的组件。

它接收入口页传进来的:

provider chatConfig cardsBuilder mixedCardsBuilder loadingBuilder networkErrorBuilder 背景配置 自定义卡片类型

它内部会创建一个共享的 ViewModel:

@Localvm:ChatViewModel=newChatViewModel()

然后把这个vm分发给所有子组件:

MessageList InputBar ConversationDrawer LoadingOverlay VoiceMaskOverlay FloatingButtons QuickQuestionsCard

它还会初始化页面级 Controller,例如:

new PageController(vm, chatConfig) pageController.start(context, provider)

UI 结构大概是:

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

所以聊天组件总入口的职责是:

接收外部参数 创建 ViewModel 初始化 Controller 组合聊天 UI 处理安全区、键盘、抽屉、蒙层等页面级结构

一句话记忆:

AgentChatComp = 接参数 + 建 vm + 启 controller + 搭 UI。

五、MVVM:UI 和业务之间的状态桥梁

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

MVVM 可以拆成:

View:负责 UI 展示和用户交互 ViewModel:负责状态和交互入口 Model:负责数据结构

在聊天模块里可以对应为:

View: ChatPage AgentChatComp MessageList InputBar BotBubble UserBubble ConversationDrawer ViewModel: ChatViewModel Model: ChatItem AgentCard ConversationInfo ChatConfig AgentResult AgentAttachment

需要特别注意:

Model 不是 Controller。

Model 是数据结构,比如一条消息长什么样、一个卡片有哪些字段、一个会话有哪些字段。

Controller 是业务流程,比如发送消息、切换会话、停止生成。

可以这样记:

View = 页面长什么样 ViewModel = 页面现在是什么状态 Model = 数据本身长什么样 Controller = 业务流程怎么跑

六、ChatViewModel:当前聊天模块的状态中心

ChatViewModel是整个聊天模块的状态中心。

它通常保存:

userInput:当前输入框内容 chatHistory:聊天消息列表 loading:AI 是否正在生成 conversationId:当前会话 ID conversations:会话列表 quickPhrases:推荐问题 pendingAttachments:待发送附件 showDrawer:会话抽屉是否显示 initialLoaded:首屏是否加载完成 loadFailed:是否加载失败

UI 组件通过它读取状态:

InputBar 读取和修改 userInput MessageList 读取 chatHistory ConversationDrawer 读取 conversations LoadingOverlay 读取 initialLoaded / loadFailed BotBubble 读取 ChatItem.content / ChatItem.cards

Controller 通过它回写状态:

发送消息后更新 chatHistory 开始请求时设置 loading = true 结束请求后设置 loading = false 切换会话后更新 conversationId 和 chatHistory

完整关系是:

用户操作 UI ↓ UI 调用 vm 方法 ↓ vm 转发给 Controller ↓ Controller 执行业务流程 ↓ Controller 更新 vm 状态 ↓ UI 根据 vm 状态自动刷新

一句话:

UI 调 vm,Controller 改 vm,vm 变了 UI 刷新。

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

标准 MVVM 中,ViewModel 可能会承担一部分业务逻辑。但在复杂聊天模块里,如果把所有发送、会话、语音、附件、重试、停止生成都写进 ViewModel,ViewModel 会非常膨胀。

所以项目中会额外拆出 Controller 层。

常见 Controller:

ChatController: 负责发送消息、停止生成、重试、流式回复、错误收尾 ConversationController: 负责会话列表、切换会话、加载历史、分页、删除会话 VoiceInputController: 负责语音输入、录音状态、语音识别

一句话:

ViewModel 管状态,Controller 管流程。

1. ChatController 发送流程

用户点击发送后,大致流程是:

InputBar 调用 vm.sendMessage() ↓ ViewModel 转给 ChatController.sendMessage() ↓ 读取 vm.userInput ↓ 创建用户消息 ChatItem ↓ 先写入 vm.chatHistory,让用户消息立即上屏 ↓ 清空 vm.userInput ↓ 设置 vm.loading = true ↓ 创建 AI 回复占位消息 ↓ 调用 provider.sendMessage() ↓ 接收 onDelta / onMessage / onReplyComplete ↓ 持续更新 AI 占位消息 ↓ 回复完成后恢复 loading

这里有一个关键点:

用户消息是先进入 chatHistory,不是等 AI 请求成功后再显示。

AI 回复则通过一个占位消息逐步更新。

2. ConversationController 会话流程

会话切换流程大致是:

用户打开会话抽屉 ↓ 点击某个历史会话 ↓ vm.switchConversation(conversationId) ↓ ConversationController 处理切换 ↓ 更新 vm.conversationId ↓ 清空旧 chatHistory ↓ Provider 拉历史消息 ↓ MessageConverter 转成 ChatItem[] ↓ 写入 vm.chatHistory ↓ MessageList 展示历史消息

一句话:

ChatController 管消息,ConversationController 管会话。

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

AI 聊天模块可能接入不同 AI 平台。

如果聊天组件直接依赖具体平台,例如:

constprovider=newSomeAIProvider()

那么组件就被某个平台绑定死了。

更好的做法是抽象一个统一 Provider:

exportabstractclassAgentProvider{abstractgetName():stringabstractsendMessage(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>}

具体平台实现它:

exportclassPlatformProviderextendsAgentProvider{getName():string{return'SomePlatform'}asyncsendMessage(...):Promise<AgentResult>{// 具体平台请求逻辑}}

上层组件只依赖抽象:

@Paramprovider:AgentProvider|null=null

这样就实现了:

聊天组件不关心底层是哪个 AI 平台 只关心传入对象是否满足 AgentProvider 规范

这就是解耦和面向接口编程。

一句话:

组件依赖抽象,不依赖具体实现。

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

在 Provider 抽象中会用到abstract

abstractclassAgentProvider{abstractgetName():stringabstractsendMessage(...):Promise<AgentResult>}

abstract表示:

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

抽象类不能直接 new:

constprovider=newAgentProvider()// 不允许

它的作用是约束子类:

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

具体怎么发送,由子类自己实现。

一句话:

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

十、Provider 和 HttpClient 的区别

这一点很容易混。

可以直接背:

Provider 管协议,HttpClient 管网络。

Provider 管什么?

Provider 是平台适配层,负责:

创建会话 拼接某个平台需要的请求体 处理附件 fileId 调用 HttpClient.stream 发起请求 解析平台返回的 event 类型 把 delta / completed / error 转成项目内部结果 通过 onDelta / onMessage 回调给 ChatController 拉会话列表 拉历史消息 取消生成

Provider 知道某个平台的返回事件是什么意思。

HttpClient 管什么?

HttpClient 是底层网络工具,负责:

get post put upload stream abortStream SSE event/data 基础解析 请求中断 日志脱敏 基础错误处理

HttpClient 不关心某个平台的 event 是业务文本、卡片还是错误。

它只负责把网络数据拆出来。


十一、HttpClient 与 SSE 流式响应

普通 HTTP 通常是:

客户端请求一次 服务端返回一次完整结果 请求结束

SSE 是:

客户端发起一次请求 服务端在这条连接里持续推送 event/data 客户端每收到一段就处理一段 直到完成事件返回

AI 回复能一点点显示,就是因为服务端把完整回复拆成很多小片段返回。

SSE 常见格式:

event: message.delta data: {"content":"你好"} event: message.delta data: {"content":",我是 AI 助手"} event: message.completed data: {"finish_reason":"stop"}

前端处理流程:

HttpClient.stream 发起流式请求 ↓ 不断收到二进制数据块 ↓ 转成字符串 ↓ 放入 sseBuffer ↓ 按 \n\n 拆完整 SSE 事件 ↓ 解析 event 和 data ↓ 回调给 Provider

为什么要 buffer?

因为网络数据块不一定刚好是一条完整 SSE 事件。

可能服务端发的是:

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

客户端实际收到的是:

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

所以必须拼包。

一句话:

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

十二、停止生成:不只是把 loading 改成 false

AI 聊天里经常有“停止生成”。

完整流程不是:

loading=false

而是:

用户点击停止生成 ↓ InputBar 调用 vm.stopGenerate() ↓ ChatController.stopGenerate() ↓ Provider.cancelChat() ↓ HttpClient.abortStream() 中断本地 SSE ↓ Provider 通知服务端取消当前生成 ↓ 保留已生成内容 ↓ 恢复 loading 状态

还要区分:

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

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

所以可以定义类似:

StreamAbortedError

用于区分主动取消和真实异常。


十三、CardParser 与 MessageConverter:数据解析和转换层

AI 返回的不一定都是普通文本,也可能是结构化 JSON。

例如:

{"cardType":"poi","title":"示例地点","data":{"address":"示例地址"}}

如果直接展示,用户会看到 JSON。

所以需要解析层。

CardParser: JSON → AgentCard MessageConverter: ServerMessage → ChatItem

它们的意义是:

让 UI 不直接依赖服务端原始数据结构。

完整转换链路:

服务端原始内容 ↓ CardParser 解析结构化卡片 ↓ AgentCard[] ↓ MessageConverter 转成 ChatItem ↓ 写入 chatHistory ↓ MessageList 统一渲染

一句话:

CardParser 管卡片解析,MessageConverter 管消息转换。

十四、MessageList 与 BotBubble:消息渲染层

当数据已经进入:

vm.chatHistory

UI 层就开始渲染。

MessageList负责:

读取 vm.chatHistory 遍历 ChatItem 根据 role 分发到不同气泡组件

伪代码:

ForEach(this.vm.chatHistory,(item:ChatItem)=>{if(item.role==='user'){UserBubble({item})}elseif(item.role==='assistant'){BotBubble({item})}else{SystemBubble({item})}})

BotBubble负责 AI 回复展示:

展示普通文本 content 展示流式生成中的内容 展示状态 展示 AgentCard[] 调用 cardsBuilder / mixedCardsBuilder 渲染业务卡片

一句话:

MessageList 管列表,BotBubble 管 AI 气泡。

十五、InputBar:输入和发送入口

InputBar是用户直接操作的输入栏。

它负责:

绑定 vm.userInput 点击发送调用 vm.sendMessage() 根据 vm.loading 切换发送 / 停止按钮 管理附件入口 接入语音入口 处理键盘避让 处理底部安全区

它不负责真正发送请求。

完整链路是:

用户输入文字 ↓ InputBar 更新 vm.userInput ↓ 用户点击发送 ↓ InputBar 调用 vm.sendMessage() ↓ ChatController 执行发送流程

一句话:

InputBar 触发发送,ChatController 真正发送。

十六、ConversationController 与 ConversationDrawer:会话管理

聊天模块不只是发一条消息,还需要管理历史会话。

ConversationDrawer是 UI:

展示会话列表 展示当前选中会话 点击历史会话 点击新建会话 点击删除会话

ConversationController是流程:

同步会话列表 切换会话 加载历史消息 分页加载更多 新建会话 删除会话 清空会话

会话切换流程:

用户点击历史会话 ↓ ConversationDrawer 调 vm.switchConversation(conversationId) ↓ ConversationController 处理切换 ↓ 更新 vm.conversationId ↓ 清空旧 chatHistory ↓ Provider 拉历史消息 ↓ MessageConverter 转 ChatItem[] ↓ 写入 vm.chatHistory ↓ MessageList 展示新会话

一个重要细节是异步防护:

请求返回时,要判断返回的 conversationId 是否仍然是当前会话。

防止旧请求晚返回,覆盖新会话数据。

一句话:

ChatController 管消息,ConversationController 管会话。

十七、view/cards:业务卡片展示层

AI Agent 和普通聊天机器人最大的区别是:

普通聊天机器人主要返回文本 AI Agent 可以返回文本 + 结构化数据 + 可执行业务动作

业务卡片就是这个结构化数据的 UI 展示。

完整链路:

AI 返回 JSON ↓ CardParser.parse() ↓ AgentCard ↓ ChatItem.cards ↓ BotBubble ↓ cardsBuilder / mixedCardsBuilder ↓ RouteCard / PoiCard / TicketCard / UnknownCard

业务卡片层一般会有:

地点卡片 路线卡片 票务卡片 服务卡片 推荐卡片 未知卡片兜底

卡片组件只负责展示和抛出点击事件:

Card 负责 UI Handler / 回调负责跳转或业务动作

一句话:

JSON → AgentCard → Card UI → 业务跳转。

十八、HAR 共享包与模块复用

HarmonyOS 中可以把这类聊天能力做成 HAR 共享包。

HAR 可以理解成:

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

它不是 exe,也不是独立运行程序。

更像:

前端 npm package Android AAR Java JAR

HAR 可以封装:

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

其他模块通过依赖和 import 使用。

可以这样理解:

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

一句话:

HAR 负责复用,解耦负责降低依赖。

十九、Builder 与 Config:通用组件的扩展点

通用聊天组件不能把所有业务都写死。

所以它会通过:

ChatConfig cardsBuilder mixedCardsBuilder loadingBuilder networkErrorBuilder onLinkClick onTrackEvent onCardClick

把业务差异交给外部页面。

例如:

@BuildercardsBuilder(cards:AgentCard[]){PoiCardList({cards:cards.filter(item=>item.cardType==='poi'),onCardClick:this.onCardClick})}

这样聊天组件只负责通用聊天能力,业务页面决定具体卡片怎么展示、点击后怎么跳转。

一句话:

通用组件提供插槽,业务页面注入差异。

二十、解耦思想总结

解耦不是简单地把代码拆成多个文件。

真正的解耦是:

每一层只知道自己必须知道的东西。

例如:

InputBar 不知道 AI 请求怎么发 MessageList 不知道 SSE 怎么解析 BotBubble 不知道平台协议 Provider 不知道 UI 怎么画 HttpClient 不知道业务事件含义 Card 组件不直接解析原始 JSON

每层职责:

Page:入口和配置 View:展示 ViewModel:状态 Controller:流程 Provider:平台协议 HttpClient:网络请求 Parser:解析转换 Card:业务展示

一句话:

把 UI、状态、流程、协议、请求、解析、展示拆开,各自负责自己的事情。

二十一、最容易混淆的几个点

1. Model 不是 Controller

Model = 数据结构 Controller = 业务流程

例如:

ChatItem / AgentCard / ConversationInfo 是 Model ChatController / ConversationController 是 Controller

2. Provider 不是 HttpClient

Provider 管平台协议 HttpClient 管底层网络

3. UI 不是业务流程

InputBar 触发发送,但不负责完整发送 ConversationDrawer 触发切换,但不负责完整切换

4. SSE 不是持续创建会话

SSE 是一次流式请求中,服务端持续推送数据片段。

5. 用户消息先上屏

用户消息先写入 chatHistory AI 回复通过占位消息逐步更新

二十二、最终总流程图

ChatPage 页面入口 / Provider 创建 / ChatConfig 配置 ↓ AgentChatComp UI 总容器 / 创建 vm / 启动 controller ↓ ChatViewModel 状态中心 / userInput / chatHistory / loading ↓ InputBar 用户输入 / 点击发送 / 调用 vm.sendMessage ↓ ChatController 创建用户消息 / AI 占位 / 调用 Provider / 更新状态 ↓ AgentProvider / PlatformProvider 平台适配 / 请求体 / SSE 事件解析 ↓ HttpClient get / post / upload / stream / abort ↓ SSE event/data 流式返回 ↓ CardParser / MessageConverter JSON → AgentCard ServerMessage → ChatItem ↓ ChatViewModel.chatHistory 状态更新 ↓ MessageList / BotBubble 按 role 渲染气泡 content 展示文本 cards 展示卡片 ↓ view/cards 具体业务卡片 UI

二十三、最终一句话总结

这套 AI 聊天模块的核心不是“页面怎么画”,而是如何把 UI、状态、业务流程、平台协议、网络请求、数据解析和业务卡片展示拆清楚。

最终可以概括为:

通过 ChatPage 做入口装配, 通过 AgentChatComp 搭建聊天 UI, 通过 ChatViewModel 管理响应式状态, 通过 ChatController 编排发送流程, 通过 Provider 屏蔽 AI 平台差异, 通过 HttpClient 统一网络请求和 SSE, 通过 CardParser / MessageConverter 转换数据, 通过 MessageList / BotBubble / Card 组件完成展示。

再压缩一句:

用户操作 UI,UI 调 ViewModel,ViewModel 转 Controller,Controller 调 Provider,Provider 调 HttpClient,结果回写 ViewModel,UI 自动刷新。

这就是当前阶段对 HarmonyOS AI 聊天模块架构的完整理解。

http://www.cnnetsun.cn/news/2570275.html

相关文章:

  • 秋冬服装越来越难卖?AI或许才是真正突破口
  • 安卓6老设备救星:手把手教你用Termux v0.79离线版跑起Linux(附避坑源配置)
  • AI智能体记忆漂移难题:向量检索+知识图谱协同架构实战
  • C语言位运算完全指南:从代数公理到工程实践
  • Unity UGUI遮罩性能深度解析:RectMask2D与Mask原理对比
  • Python generator实战:用懒加载对抗大数据OOM
  • 如何快速激活Adobe全家桶:终极Adobe-GenP激活工具完整指南
  • Redis分布式锁进阶第二十一篇
  • 构建无头会计API:REST/GraphQL双接口与MCP集成实践
  • Unity IL2CPP游戏BepInEx启动失败的底层原因与修复方案
  • MEM: Multi-Scale Embodied Memory for Vision Language Action Models
  • App安全加固与Frida检测原理科普
  • Routiform:构建模块化路由器框架,实现深度自定义与稳定性的平衡
  • 手把手教你用 Gitee 替代 DDNS:家庭 IP 自动更新 + 本地快捷访问
  • 云 PACS 系统全院级影像数字化落地方案
  • 构建数据管道深度监控体系:从质量契约到工程实践
  • Python TDD实战入门:从red-green-refactor到高覆盖率测试套件
  • 从一次CAN总线‘丢帧’排查说起:深入理解扩展帧过滤器的‘列表模式’与‘掩码模式’到底怎么选
  • 用51单片机和MJ-8000模块,做个自己的扫码小助手(附完整代码和接线图)
  • 低成本AI网站审计工具架构:批处理与纯函数设计实现0.03美元单次成本
  • 保姆级教程:用STM32F103驱动TM1620数码管,从看懂手册到点亮第一个数字
  • DeepSeek评估被90%团队忽略的关键漏洞:上下文长度突变下的稳定性崩塌(附自动化检测脚本)
  • Excel时间计算底层原理:序列号机制与[h]:mm格式解析
  • 硬件在环(HIL)测试入门:如何用自制的60通道万能BOB盒搭建你的第一个汽车ECU测试台架?
  • AArch64虚拟化调试:HDFGWTR2_EL2寄存器原理与应用
  • Godot4节点生命周期与GDScript交互开发入门
  • AMD Ryzen处理器深度调优解决方案:SMUDebugTool实战指南与原理剖析
  • 为什么架构师越老越值钱?越陈越香的IT界茅台
  • 基于RAG与向量数据库构建代码库智能问答系统
  • C#游戏物理引擎的SIMD向量加速实战