聊天消息的「状态」该怎么存从一堆 boolean 到一个状态机项目MyApplicationAI 打车对话 demo目标文件chat/src/main/ets/models/MessageStatus.ets新建models/chatModel.etscomponents/ChatListComp.ets一句话把「这条消息现在是什么状态」从散落的几个 boolean 往正文里拼字符串升级成一个枚举状态机。这是本系列状态三部曲的第一篇专讲数据建模。〇、先看一个你每天都在用的场景打开微信发消息你会看到三种样子消息旁边转个小圈圈—— 正在发送圈圈消失 —— 发出去了变成一个红色感叹号—— 没发出去点一下重发。这背后其实就是一个问题一条消息要怎么记录它现在处于哪一步AI 对话比微信还多两步 —— AI 要思考、要一个字一个字往外蹦、你还能中途喊停。状态更多记录方式就更容易写乱。这篇就从我 demo 里一段看着能跑、其实埋雷的旧代码讲起。一、旧写法一个全局isLoading 往正文里拼字符串最早我的消息模型长这样只有内容没有状态这个概念ObservedV2exportclassChatMessage{Tracecontent:string// 正文role:string// user | assistant}正在生成靠 ViewModel 上一个全局布尔TraceisLoading:booleanfalse// 整个会话共用一个然后所有跟状态有关的事都用很土的方式硬塞// 停止生成时把已停止直接拼到正文后面 stopGeneration():void{if(this.activeAiMessage){constoldthis.activeAiMessage.contentthis.activeAiMessage.contentold.length0?old\n\n[已停止]// ← 状态被当成正文写进去了:[已停止]}this.vm.isLoadingfalse}// 失败时把正文整个改成一句错误话术onError:(){this.activeAiMessage.content生成失败请稍后重试// ← 同样是状态混进正文}能跑。但只要多想一层问题全是窟窿 你想做的事旧写法为什么做不到持久化后还原这条是被停止的存进数据库的只有content里面混着[已停止]读回来分不清是 AI 真说了这四个字还是状态给已停止单独配个灰色分割线样式它就是正文的一部分没法单独挑出来加样式把界面文案换成英文 / 改措辞[已停止]、生成失败...散在 Controller 各处改一个漏一个区分用户消息发送中和AI 思考中只有一个全局isLoading它俩共用分不开同时有发送中和已失败isLoading是会话级的根本不是某条消息的状态核心病灶状态meta和内容content是两种东西被搅在了一起。正文应该只装 AI 说的话状态要单独有个字段。二、第一反应那就多加几个 boolean 呗很自然的下一步是给消息加一排开关生产里不少代码也是这么写的ObservedV2exportclassChatMessage{Tracecontent:stringTraceisLoading:booleanfalse// 思考中TraceisStreaming:booleanfalse// 流式输出中TraceisFailed:booleanfalse// 失败了TracestoppedMessage:string// 停止提示非空就显示}比第一版强多了 —— 至少状态从正文里分出来了。但它有个隐患有个专门的名字叫布尔陷阱 / 布尔汤boolean soup4 个 boolean 2⁴ 16 种组合但合法的状态其实只有 5、6 种。剩下的全是非法状态编译器却拦不住你写出来msg.isLoadingtruemsg.isFailedtrue// 又在思考、又失败了这是什么状态msg.isStreamingtruemsg.isLoadingtrue// 既在思考又在流式自相矛盾这些组合在类型上完全合法能编译、能赋值但语义上是坏数据。一旦哪段逻辑漏改一个开关UI 就会进入一个谁都没设计过的中间态 —— 这类 bug 最难查因为它本不该存在。⚠️ 多 boolean 的本质问题它允许你表达不可能发生的状态。状态越多非法组合越多维护时全靠人脑约束这俩不能同时为 true迟早出错。三、正解一个枚举一次只能是一个状态一条消息在任意时刻只会处于一个状态 —— 那就用一个字段、一个枚举来表达它。这在软件设计里有句口号叫“让非法状态无法被表示”make illegal states unrepresentable// chat/src/main/ets/models/MessageStatus.etsexportenumMessageStatus{SENDINGsending,// 用户消息已发出等待服务端受理THINKINGthinking,// AI已受理等首个字思考中STREAMINGstreaming,// AI正在逐字输出DONEdone,// 终态正常完成STOPPEDstopped,// 终态用户主动停止FAILEDfailed,// 终态失败user 可重发 / assistant 可重新生成}消息模型也就干净了 —— 一个status取代一排 booleanObservedV2exportclassChatMessage{Tracecontent:string// 只装正文role:stringTracestatus:MessageStatusMessageStatus.DONE// 状态独立成字段}对比一下三版的差距维度① 全局 isLoading 拼字符串② 多 boolean③ 单一枚举 ✅状态和正文分离❌ 混在一起✅✅能否写出非法状态——❌ 能16 选 6✅ 不能天然互斥区分 user / AI 各自状态❌ 共用一个✅✅加新状态到处改 if再加一个 boolean组合爆炸枚举里加一个值switch是否能穷举检查——❌✅ 一眼看全 判断该用 boolean 还是 enum的土办法这些标志位会不会同时为真会 → 它们是独立维度用多个 boolean互斥同一时刻只有一个成立→ 用一个 enum。消息状态显然是后者。四、状态怎么流转画出来就清楚了枚举的另一个好处是所有合法的状态迁移可以画成一张图照着图写代码不容易漏用户消息 SENDING ──首个 chunk 到达──► DONE 送达AI 开始回 └──发不出去 / 服务端报错──► FAILED 红叹号可重发 AI 消息 THINKING ──首个 chunk──► STREAMING ──流结束──► DONE 正常收完 │ ├─用户点停止─► STOPPED 独立已停止条 │ └─中途断网───► FAILED 留半截可重新生成 └──一个字都没来 / 报错──────────────────► FAILED对照需求每个状态都有了明确归宿产品需求落到哪个状态用户消息发送中user →SENDINGAI正在流式输出ai →THINKING→STREAMING网络失败 重新发送user →FAILEDAI 消息重新生成assistant 终态 → 点「重新生成」停止后显示独立状态ai →STOPPED一个枚举把五条需求一网打尽。剩下怎么发请求推进这些状态失败/重发的编排是下一篇的事这篇只聚焦建模。五、把停止做成独立状态而不是拼进正文这是这次最想纠正的一个坏习惯。回看旧代码// ❌ 旧状态拼进正文this.activeAiMessage.contentold\n\n[已停止]// ✅ 新状态归状态正文归正文this.activeAiMessage.statusMessageStatus.STOPPED// content 保持用户停止前已经收到的那部分一个字不动正文干净了UI 就能单独为已停止渲染一条分割线 灰字而不用去正文里抠[已停止]四个字// ChatListComp.ets —— assistant 气泡内if(this.msg.statusMessageStatus.STOPPED){Text(ChatText.STOPPED)// 已停止生成.fontSize(12).fontColor(this.theme.textTertiary).padding({top:4}).border({width:{top:0.5},color:this.theme.divider})// 顶部一条分割线} 一个朴素但好用的判断标准如果一段文字将来要单独配样式 / 单独翻译 / 单独存取它就不该和正文拼在一个字符串里。“已停止”生成失败都属于这一类它们是 UI 状态不是对话内容。顺手把所有界面文案收口到一个常量类告别魔法字符串散落// chat/src/main/ets/constants/ChatConstants.etsexportclassChatText{staticreadonlyTHINKING:string思考中staticreadonlySTOPPED:string已停止生成staticreadonlyRESEND:string重新发送staticreadonlyREGENERATE:string重新生成// ...}六、UI 按状态渲染ArkUI V2 有个必须在 build 顶层读的坑有了status气泡就是一个纯函数给定status渲染对应形态。但 ArkUI V2 这里有个新手必踩的坑 ——响应式字段Trace必须在build()的顶层被读到依赖才会被追踪到。什么叫顶层就是直接写在build()里的if/表达式中而不是把字段当参数塞进Builder函数。后者会让 V2 丢掉依赖状态变了 UI 不刷新build(){Column(){// ✅ 直接在 build 里读 this.msg.status依赖被追踪状态一变就重渲染if(this.msg.statusMessageStatus.THINKING){Row({space:8}){LoadingProgress().width(16).height(16).color(this.theme.primary)Text(ChatText.THINKING).fontColor(this.theme.textSecondary)}}elseif(this.msg.content.length0){// 流式光标用一个 Span 拼仍然不进 contentText(){Span(this.msg.content)Span(this.msg.statusMessageStatus.STREAMING? ▌:).fontColor(this.theme.primary)}}}}⚠️ 反例MyBuilder(this.msg.status)把响应式字段当Builder入参传进去 —— V2 收不到依赖status变了这块 UI 纹丝不动。记住结构性的if分支直接读字段别绕一层函数参数。注意那个流式光标▌的小技巧我没有把它拼进content那样又脏了正文而是另起一个Span靠status STREAMING决定它是 ▌还是空串。流结束status变DONE光标自己就消失了 —— 状态驱动 UI正文始终干净。七、一句话心智模型正文只装说了什么状态单独一个字段装现在哪一步。 互斥的状态 → 一个 enum别用一堆 booleanboolean soup 会放进非法组合。 让非法状态无法被表示编译器替你挡掉又在思考又失败。 已停止失败是 UI 状态不是正文要能单独配样式 / 翻译 / 存取。 ArkUI V2响应式字段在 build 顶层读别塞进 Builder 参数。八、顺口溜正文状态要分家别往 content 里硬拼塞 boolean 多了汤一锅非法组合挡不住。 一个 enum 管到底互斥状态它最配 停止失败独立态单挑样式随你裁。 V2 刷新有讲究字段顶层 build 里读 塞进 Builder 当参数依赖一丢界面木。九、参考ObservedV2 / Trace状态管理 V2 —— 本文气泡实时刷新的底层机制ComponentV2 / Param状态管理总览ArkTSTS→ArkTS 迁移枚举与严格类型本系列上一篇 25-arkts-rdb-chat-persistence下一篇 28-arkts-resend-regenerate-idempotency重发 / 重新生成 / 幂等防重、29-arkts-message-status-rdb-persistence状态入库与历史还原
聊天消息的「状态」该怎么存?从一堆 boolean 到一个状态机
聊天消息的「状态」该怎么存从一堆 boolean 到一个状态机项目MyApplicationAI 打车对话 demo目标文件chat/src/main/ets/models/MessageStatus.ets新建models/chatModel.etscomponents/ChatListComp.ets一句话把「这条消息现在是什么状态」从散落的几个 boolean 往正文里拼字符串升级成一个枚举状态机。这是本系列状态三部曲的第一篇专讲数据建模。〇、先看一个你每天都在用的场景打开微信发消息你会看到三种样子消息旁边转个小圈圈—— 正在发送圈圈消失 —— 发出去了变成一个红色感叹号—— 没发出去点一下重发。这背后其实就是一个问题一条消息要怎么记录它现在处于哪一步AI 对话比微信还多两步 —— AI 要思考、要一个字一个字往外蹦、你还能中途喊停。状态更多记录方式就更容易写乱。这篇就从我 demo 里一段看着能跑、其实埋雷的旧代码讲起。一、旧写法一个全局isLoading 往正文里拼字符串最早我的消息模型长这样只有内容没有状态这个概念ObservedV2exportclassChatMessage{Tracecontent:string// 正文role:string// user | assistant}正在生成靠 ViewModel 上一个全局布尔TraceisLoading:booleanfalse// 整个会话共用一个然后所有跟状态有关的事都用很土的方式硬塞// 停止生成时把已停止直接拼到正文后面 stopGeneration():void{if(this.activeAiMessage){constoldthis.activeAiMessage.contentthis.activeAiMessage.contentold.length0?old\n\n[已停止]// ← 状态被当成正文写进去了:[已停止]}this.vm.isLoadingfalse}// 失败时把正文整个改成一句错误话术onError:(){this.activeAiMessage.content生成失败请稍后重试// ← 同样是状态混进正文}能跑。但只要多想一层问题全是窟窿 你想做的事旧写法为什么做不到持久化后还原这条是被停止的存进数据库的只有content里面混着[已停止]读回来分不清是 AI 真说了这四个字还是状态给已停止单独配个灰色分割线样式它就是正文的一部分没法单独挑出来加样式把界面文案换成英文 / 改措辞[已停止]、生成失败...散在 Controller 各处改一个漏一个区分用户消息发送中和AI 思考中只有一个全局isLoading它俩共用分不开同时有发送中和已失败isLoading是会话级的根本不是某条消息的状态核心病灶状态meta和内容content是两种东西被搅在了一起。正文应该只装 AI 说的话状态要单独有个字段。二、第一反应那就多加几个 boolean 呗很自然的下一步是给消息加一排开关生产里不少代码也是这么写的ObservedV2exportclassChatMessage{Tracecontent:stringTraceisLoading:booleanfalse// 思考中TraceisStreaming:booleanfalse// 流式输出中TraceisFailed:booleanfalse// 失败了TracestoppedMessage:string// 停止提示非空就显示}比第一版强多了 —— 至少状态从正文里分出来了。但它有个隐患有个专门的名字叫布尔陷阱 / 布尔汤boolean soup4 个 boolean 2⁴ 16 种组合但合法的状态其实只有 5、6 种。剩下的全是非法状态编译器却拦不住你写出来msg.isLoadingtruemsg.isFailedtrue// 又在思考、又失败了这是什么状态msg.isStreamingtruemsg.isLoadingtrue// 既在思考又在流式自相矛盾这些组合在类型上完全合法能编译、能赋值但语义上是坏数据。一旦哪段逻辑漏改一个开关UI 就会进入一个谁都没设计过的中间态 —— 这类 bug 最难查因为它本不该存在。⚠️ 多 boolean 的本质问题它允许你表达不可能发生的状态。状态越多非法组合越多维护时全靠人脑约束这俩不能同时为 true迟早出错。三、正解一个枚举一次只能是一个状态一条消息在任意时刻只会处于一个状态 —— 那就用一个字段、一个枚举来表达它。这在软件设计里有句口号叫“让非法状态无法被表示”make illegal states unrepresentable// chat/src/main/ets/models/MessageStatus.etsexportenumMessageStatus{SENDINGsending,// 用户消息已发出等待服务端受理THINKINGthinking,// AI已受理等首个字思考中STREAMINGstreaming,// AI正在逐字输出DONEdone,// 终态正常完成STOPPEDstopped,// 终态用户主动停止FAILEDfailed,// 终态失败user 可重发 / assistant 可重新生成}消息模型也就干净了 —— 一个status取代一排 booleanObservedV2exportclassChatMessage{Tracecontent:string// 只装正文role:stringTracestatus:MessageStatusMessageStatus.DONE// 状态独立成字段}对比一下三版的差距维度① 全局 isLoading 拼字符串② 多 boolean③ 单一枚举 ✅状态和正文分离❌ 混在一起✅✅能否写出非法状态——❌ 能16 选 6✅ 不能天然互斥区分 user / AI 各自状态❌ 共用一个✅✅加新状态到处改 if再加一个 boolean组合爆炸枚举里加一个值switch是否能穷举检查——❌✅ 一眼看全 判断该用 boolean 还是 enum的土办法这些标志位会不会同时为真会 → 它们是独立维度用多个 boolean互斥同一时刻只有一个成立→ 用一个 enum。消息状态显然是后者。四、状态怎么流转画出来就清楚了枚举的另一个好处是所有合法的状态迁移可以画成一张图照着图写代码不容易漏用户消息 SENDING ──首个 chunk 到达──► DONE 送达AI 开始回 └──发不出去 / 服务端报错──► FAILED 红叹号可重发 AI 消息 THINKING ──首个 chunk──► STREAMING ──流结束──► DONE 正常收完 │ ├─用户点停止─► STOPPED 独立已停止条 │ └─中途断网───► FAILED 留半截可重新生成 └──一个字都没来 / 报错──────────────────► FAILED对照需求每个状态都有了明确归宿产品需求落到哪个状态用户消息发送中user →SENDINGAI正在流式输出ai →THINKING→STREAMING网络失败 重新发送user →FAILEDAI 消息重新生成assistant 终态 → 点「重新生成」停止后显示独立状态ai →STOPPED一个枚举把五条需求一网打尽。剩下怎么发请求推进这些状态失败/重发的编排是下一篇的事这篇只聚焦建模。五、把停止做成独立状态而不是拼进正文这是这次最想纠正的一个坏习惯。回看旧代码// ❌ 旧状态拼进正文this.activeAiMessage.contentold\n\n[已停止]// ✅ 新状态归状态正文归正文this.activeAiMessage.statusMessageStatus.STOPPED// content 保持用户停止前已经收到的那部分一个字不动正文干净了UI 就能单独为已停止渲染一条分割线 灰字而不用去正文里抠[已停止]四个字// ChatListComp.ets —— assistant 气泡内if(this.msg.statusMessageStatus.STOPPED){Text(ChatText.STOPPED)// 已停止生成.fontSize(12).fontColor(this.theme.textTertiary).padding({top:4}).border({width:{top:0.5},color:this.theme.divider})// 顶部一条分割线} 一个朴素但好用的判断标准如果一段文字将来要单独配样式 / 单独翻译 / 单独存取它就不该和正文拼在一个字符串里。“已停止”生成失败都属于这一类它们是 UI 状态不是对话内容。顺手把所有界面文案收口到一个常量类告别魔法字符串散落// chat/src/main/ets/constants/ChatConstants.etsexportclassChatText{staticreadonlyTHINKING:string思考中staticreadonlySTOPPED:string已停止生成staticreadonlyRESEND:string重新发送staticreadonlyREGENERATE:string重新生成// ...}六、UI 按状态渲染ArkUI V2 有个必须在 build 顶层读的坑有了status气泡就是一个纯函数给定status渲染对应形态。但 ArkUI V2 这里有个新手必踩的坑 ——响应式字段Trace必须在build()的顶层被读到依赖才会被追踪到。什么叫顶层就是直接写在build()里的if/表达式中而不是把字段当参数塞进Builder函数。后者会让 V2 丢掉依赖状态变了 UI 不刷新build(){Column(){// ✅ 直接在 build 里读 this.msg.status依赖被追踪状态一变就重渲染if(this.msg.statusMessageStatus.THINKING){Row({space:8}){LoadingProgress().width(16).height(16).color(this.theme.primary)Text(ChatText.THINKING).fontColor(this.theme.textSecondary)}}elseif(this.msg.content.length0){// 流式光标用一个 Span 拼仍然不进 contentText(){Span(this.msg.content)Span(this.msg.statusMessageStatus.STREAMING? ▌:).fontColor(this.theme.primary)}}}}⚠️ 反例MyBuilder(this.msg.status)把响应式字段当Builder入参传进去 —— V2 收不到依赖status变了这块 UI 纹丝不动。记住结构性的if分支直接读字段别绕一层函数参数。注意那个流式光标▌的小技巧我没有把它拼进content那样又脏了正文而是另起一个Span靠status STREAMING决定它是 ▌还是空串。流结束status变DONE光标自己就消失了 —— 状态驱动 UI正文始终干净。七、一句话心智模型正文只装说了什么状态单独一个字段装现在哪一步。 互斥的状态 → 一个 enum别用一堆 booleanboolean soup 会放进非法组合。 让非法状态无法被表示编译器替你挡掉又在思考又失败。 已停止失败是 UI 状态不是正文要能单独配样式 / 翻译 / 存取。 ArkUI V2响应式字段在 build 顶层读别塞进 Builder 参数。八、顺口溜正文状态要分家别往 content 里硬拼塞 boolean 多了汤一锅非法组合挡不住。 一个 enum 管到底互斥状态它最配 停止失败独立态单挑样式随你裁。 V2 刷新有讲究字段顶层 build 里读 塞进 Builder 当参数依赖一丢界面木。九、参考ObservedV2 / Trace状态管理 V2 —— 本文气泡实时刷新的底层机制ComponentV2 / Param状态管理总览ArkTSTS→ArkTS 迁移枚举与严格类型本系列上一篇 25-arkts-rdb-chat-persistence下一篇 28-arkts-resend-regenerate-idempotency重发 / 重新生成 / 幂等防重、29-arkts-message-status-rdb-persistence状态入库与历史还原