适合谁看
正在写 AI 聊天页状态层的人
页面状态已经开始变乱的人
想知道哪些状态该进 Provider,哪些该留页面层的人
想理解鸿蒙原生能力接入时状态如何对齐的人
问题背景
AI 页面一旦稍微真实一点,就会同时长出很多状态:
当前输入框内容
历史消息
正在流出的文本
工具搜索状态
语音监听状态
播报状态
错误提示
鸿蒙语音识别引擎状态
鸿蒙 TTS 引擎状态
如果这些状态全塞到同一个地方,页面很快会变成"大状态堆场"。
所以真正难的不是"有没有状态管理库",而是:
哪些状态属于对话会话,哪些状态属于页面局部交互,哪些状态属于鸿蒙原生能力——它们各自该放在哪一层,又怎么协同。
项目中的真实场景
食界探味当前把 AI 状态拆成了三层:
第一层:会话级状态(AiSessionState)
status— 当前会话阶段inputText— 用户输入streamingText— 正在生成的文本errorMessage— 错误信息matchedDishes— 工具调用找到的菜品
第二层:协调器内部状态(AiExploreCoordinator)
_isSpeaking— 是否正在播报_agentInitialized— agent 是否已初始化
第三层:页面局部状态(AiAssistantScreen)
_history— 对话历史_lastStreamingText— 防止重复归档_hasSubmittedInitial— 初始 query 是否已提交_isSpeaking— 播报按钮的 UI 状态
中间再通过AiExploreCoordinator把会话编排接起来。
这正好可以用来说明"状态协同"而不是"状态堆叠"的思路。
核心实现
先说结论:
在 AI 应用里,最稳的状态设计通常不是把所有状态都塞进一个 Provider,而是区分"会话状态"和"页面局部状态",再用协调器把两者接起来。在鸿蒙端,还需要考虑原生能力的状态如何融入这个体系。
一、AiSessionState负责什么——会话级状态
当前AiSessionState里收的主要是会话级状态:
enum AiSessionStatus { idle, // 空闲 listening, // 正在语音识别 parsing, // 正在理解用户意图 searching, // 正在搜索菜品(工具调用中) responding, // 正在流式生成回复 speaking, // 正在 TTS 播报 error, // 出错 } class AiSessionState { final AiSessionStatus status; // 当前会话阶段 final String? inputText; // 用户输入 final String streamingText; // 正在生成的文本 final String? errorMessage; // 错误信息 final List<Dish> matchedDishes; // 工具调用找到的菜品 bool get isLoading => status == AiSessionStatus.parsing || status == AiSessionStatus.searching; }这类状态的共同特点是:
特点 | 说明 |
|---|---|
和一轮 AI 会话强相关 | 跨越多次用户交互,贯穿整个对话过程 |
需要被协调器主动推进 | 不是用户直接操作触发,而是 AI 流程驱动 |
不是纯 UI 临时状态 | 页面销毁后可能还需要恢复 |
可被多个组件消费 | 页面、气泡、卡片列表都需要读取 |
例如"当前是不是在 searching"、"当前流式文本是什么"、"最近一次工具调用找到了哪些菜品"——这些都更适合收进会话状态,而不是散在页面组件里。
二、页面层自己保留了哪些状态——局部交互状态
AiAssistantScreen里并没有把所有东西都塞进 Provider:
class _AiAssistantScreenState extends ConsumerState<AiAssistantScreen> { final _scrollController = ScrollController(); // 滚动控制 final _inputFocusNode = FocusNode(); // 输入框焦点 final List<_ChatEntry> _history = []; // 对话历史 String? _lastStreamingText; // 防重复归档 bool _hasSubmittedInitial = false; // 初始 query 标记 bool _isSpeaking = false; // 播报按钮 UI 状态这些状态更像:
状态 | 性质 | 为什么留在页面层 |
|---|---|---|
| 页面展示数据 | 控制消息渲染顺序,和 AI 会话本体是两个概念 |
| 渲染防重标记 | 纯粹是为了防止同一轮回复被重复归档 |
| 一次性标记 | 只在页面初始化时用一次,和会话无关 |
| 按钮 UI 状态 | 控制"语音播报/停止播报"按钮的显示 |
| 滚动控制 | 纯 UI 交互,和 AI 逻辑无关 |
| 焦点控制 | 纯 UI 交互 |
这说明页面层在主动控制自己的"展示策略",而不是把所有东西都推给协调器。
三、协调器在中间承担了什么——会话编排层
AiExploreCoordinator的作用,正是把会话推进和页面展示之间的边界顶住:
class AiExploreCoordinator extends StateNotifier<AiSessionState> { final AgentService _agentService; final FoodRepository _foodRepository; bool _isSpeaking = false; // 协调器侧的播报状态 bool _agentInitialized = false; // agent 初始化标记 // 负责:改 AiSessionStatus // 负责:维护 streamingText // 负责:回填 matchedDishes // 负责:管语音输入 // 负责:管 TTS 播报 // 不负责:聊天气泡组件怎么渲染 // 不负责:页面历史消息何时插入 // 不负责:滚动条如何滚到底 }协调器的职责边界:
协调器管什么: ✅ 状态流转(idle → parsing → searching → responding → idle) ✅ 流式文本累积(streamingText) ✅ 工具调用结果回填(matchedDishes) ✅ 语音输入(startVoiceInput → SpeechRecognitionChannel) ✅ TTS 播报(speakText → TextToSpeechChannel) ✅ 会话重置(reset) 协调器不管什么: ❌ 聊天气泡组件怎么渲染 ❌ 页面历史消息何时插入 ❌ 滚动条如何滚到底 ❌ 输入框焦点管理 ❌ 播报按钮的 UI 状态这说明当前结构已经在主动区分:会话编排状态vs页面表现状态。
四、为什么_history没有直接塞进AiSessionState
这点很值得单独讲。当前页面不是直接把所有历史消息都交给协调器管理,而是由页面内部维护_history:
class _ChatEntry { final bool isUser; // 是否是用户消息 final String text; // 消息文本 final List<Dish> dishes; // 关联的菜品卡片(仅 AI 消息有) }这是一个很务实的选择。因为当前历史消息承担的更多是:
页面展示顺序
用户消息 / AI 消息交替显示
已完成流式回复的归档
它目前更像页面展示状态,而不是底层 AI 会话本体。
如果过早把它全部并入AiSessionState,反而会让协调器开始承担太多展示职责。比如协调器需要知道"消息列表该用什么 Widget 渲染"、"新消息来了要不要自动滚动"——这些显然是页面层的事。
五、为什么streamingText和_history必须分开
这是整个状态协同设计中最关键的拆分。当前设计是:
AI 正在回复时,内容先放在
streamingText(协调器管理)回复结束后,再归档进
_history(页面管理)
归档逻辑:
// ai_assistant_screen.dart → build() // 触发条件:流式完成 + 有内容 + 内容变了 if (sessionState.status == AiSessionStatus.idle && sessionState.streamingText.isNotEmpty && _lastStreamingText != sessionState.streamingText) { final capturedDishes = List<Dish>.unmodifiable( sessionState.matchedDishes, ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { _history.add( _ChatEntry( isUser: false, text: sessionState.streamingText, dishes: capturedDishes, ), ); _lastStreamingText = sessionState.streamingText; }); } }); }这就让页面能很清楚地区分:
流式进行中: _history = [用户消息1, AI回复1, 用户消息2] streamingText = "为你找到了3道牛肉吃法:红烧牛腩、日式..." → 页面渲染:历史消息 + 临时流式气泡 流式完成后: _history = [用户消息1, AI回复1, 用户消息2, AI回复2] streamingText = "为你找到了3道牛肉吃法:红烧牛腩、日式..." → 页面渲染:所有历史消息(含刚归档的回复)这类拆分在 AI 页面里非常重要。不然你很容易遇到:文本重复、历史闪动、最终消息和流中消息混在一起。
六、语音状态为什么是"同主题,不同层级"
页面当前有_isSpeaking,协调器里也有AiSessionStatus.speaking。这看起来像重复,其实承担的层级不完全一样:
// 协调器侧 — 会话阶段 enum AiSessionStatus { // ... speaking, // 表示"会话当前处于播报阶段" } // 页面侧 — 按钮 UI 状态 bool _isSpeaking = false; // 表示"这个页面的播报按钮当前显示什么" void _toggleSpeak(String text) async { if (_isSpeaking) { await TextToSpeechChannel.stop(); setState(() => _isSpeaking = false); // 按钮从"停止播报"变回"语音播报" } else { setState(() => _isSpeaking = true); // 按钮从"语音播报"变成"停止播报" await TextToSpeechChannel.speak(text); } }协调器侧的speaking状态更像"会话当前处于什么阶段"——它影响的是状态机流转和其他状态的判断逻辑。
页面侧的_isSpeaking则更像"这个页面的播报按钮当前显示逻辑"——它只影响按钮的图标和文字。
这就是页面状态和会话状态协同时很常见的一种现象:同主题,不同层级。它们语义相关,但不一定必须压成一份状态。
七、一个完整的状态协同时序
以用户发送一条消息为例,完整展示各层状态的变化:
用户点击发送按钮 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [页面层] _handleSubmit() → _history.add(用户消息) ← 页面局部状态 → _lastStreamingText = null ← 页面局部状态 → _scrollToBottom() ← 页面 UI 操作 → coordinator.submitQuery(text) ← 委托给协调器 [协调器] submitQuery() → state = state.copyWith(status: parsing) ← 会话状态 → state = state.copyWith(streamingText: '') ← 会话状态 → agentService.chatWithToolsStream(...) [页面层] ref.watch 触发 rebuild → sessionState.status == parsing → _buildStatusBubble() 返回 "正在理解你的需求..." ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [协调器] onToolCall 回调 → state = state.copyWith(status: searching) ← 会话状态 [页面层] ref.watch 触发 rebuild → sessionState.status == searching → _buildStatusBubble() 返回 "正在探索全球美食..." ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [协调器] onContent 回调(多次) → buffer.write(chunk) → state = state.copyWith( status: responding, streamingText: buffer.toString(), ) ← 会话状态 [页面层] ref.watch 触发 rebuild(多次) → sessionState.status == responding → 渲染临时气泡 + 流式文本 + loading 圈 → 同时渲染 matchedDishes 卡片(如果有的话) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [协调器] 流式结束 → state = state.copyWith( status: idle, streamingText: buffer.toString(), ) ← 会话状态 [页面层] build() 中的归档逻辑触发 → _history.add(AI 回复 + dishes) ← 页面局部状态 → _lastStreamingText = streamingText ← 页面局部状态 → 临时气泡消失,正式历史消息出现 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [用户] 点击"语音播报" → _toggleSpeak(text) → _isSpeaking = true ← 页面局部状态 → TextToSpeechChannel.speak(text) ← 鸿蒙原生 [页面层] setState 触发 rebuild → 气泡底部按钮从"语音播报"变成"停止播报" [用户] 点击"停止播报" → _toggleSpeak(text) → TextToSpeechChannel.stop() ← 鸿蒙原生 → _isSpeaking = false ← 页面局部状态八、状态数据流向图
┌─────────────────────────────────────────────────────┐ │ 协调器层 │ │ │ │ AiExploreCoordinator (StateNotifier) │ │ │ │ │ ├─ AiSessionState (会话级状态) │ │ │ ├─ status: AiSessionStatus │ │ │ ├─ inputText: String? │ │ │ ├─ streamingText: String │ │ │ ├─ errorMessage: String? │ │ │ └─ matchedDishes: List<Dish> │ │ │ │ │ ├─ _isSpeaking: bool (协调器内部) │ │ └─ _agentInitialized: bool (协调器内部) │ │ │ │ → 通过 Riverpod 暴露给页面层 │ │ │ ├──────────────────────────────────────────────────────┤ │ │ │ 页面层 │ │ │ │ AiAssistantScreen (ConsumerStatefulWidget) │ │ │ │ │ ├─ ref.watch(coordinator) → sessionState │ │ │ → 用于渲染状态提示、流式气泡、菜品卡片 │ │ │ │ │ ├─ _history: List<_ChatEntry> (页面局部) │ │ │ → 对话历史渲染 │ │ │ │ │ ├─ _lastStreamingText: String? (页面局部) │ │ │ → 防止重复归档 │ │ │ │ │ ├─ _hasSubmittedInitial: bool (页面局部) │ │ │ → 初始 query 一次性标记 │ │ │ │ │ ├─ _isSpeaking: bool (页面局部) │ │ │ → 播报按钮 UI 状态 │ │ │ │ │ ├─ _scrollController (页面局部) │ │ │ → 滚动控制 │ │ │ │ │ └─ _inputFocusNode (页面局部) │ │ → 输入框焦点 │ │ │ ├──────────────────────────────────────────────────────┤ │ │ │ 鸿蒙原生层(间接) │ │ │ │ SpeechRecognitionChannel → SpeechRecognitionPlugin │ │ TextToSpeechChannel → TextToSpeechPlugin │ │ │ │ 状态影响: │ │ - listening 状态 → 语音识别进行中 │ │ - speaking 状态 → TTS 播报进行中 │ │ - 页面退出 → 必须停止鸿蒙原生引擎 │ │ │ └──────────────────────────────────────────────────────┘九、_ChatEntry数据结构的设计
页面局部维护的_ChatEntry也很值得看一下:
class _ChatEntry { final bool isUser; final String text; final List<Dish> dishes; const _ChatEntry({ required this.isUser, required this.text, this.dishes = const [], }); }几个设计要点:
dishes只在 AI 消息中有— 用户消息不需要关联菜品卡片dishes是不可变列表— 归档时用List<Dish>.unmodifiable()包装,防止后续修改影响历史_ChatEntry是页面私有类— 只在AiAssistantScreen内部使用,不暴露给协调器或服务层
这意味着如果以后要支持"消息收藏"、"消息分享"等功能,只需要在页面层处理_ChatEntry,不需要改动协调器或服务层。
十、为什么 AI 页面特别容易把状态写乱
因为它会天然跨越很多维度:
维度 | 示例状态 | 该放哪层 |
|---|---|---|
对话过程 | status, streamingText | 会话状态(协调器) |
工具调用过程 | matchedDishes | 会话状态(协调器) |
页面渲染过程 | _history, _lastStreamingText | 页面局部状态 |
语音输入过程 | listening 状态 | 会话状态(协调器) |
语音播报过程 | _isSpeaking(按钮)+ speaking(会话) | 各管各的 |
错误处理 | errorMessage | 会话状态(协调器) |
UI 辅助 | _hasSubmittedInitial, _scrollController | 页面局部状态 |
如果没有主动拆层,最常见的结果就是:
所有状态都放进一个大 Provider → 协调器越来越重,什么都管
或者所有状态都塞回一个 StatefulWidget → AI 逻辑和 UI 逻辑混成一团
这两种方式都容易在后期变得难维护。食界探味当前这套拆法的价值,正是在于它已经把中间那层协调器立起来了。
关键代码位置
文件 | 作用 |
|---|---|
| 会话级状态定义 |
| 协调器,管理会话状态 |
| 页面层,管理局部状态 |
| 鸿蒙语音识别通道 |
| 鸿蒙 TTS 通道 |
鸿蒙侧与状态协同的关系
虽然这篇主要讨论 Flutter 侧状态协同,但状态设计会直接影响鸿蒙原生能力的接入体验:
语音识别的状态对齐
协调器的listening状态对应鸿蒙侧的语音识别引擎:
协调器:state = listening → 页面显示"正在聆听..." → SpeechRecognitionChannel.startListening() → 鸿蒙 SpeechRecognitionPlugin 创建引擎、开始识别 → 用户说话... → 鸿蒙识别完成,返回文本 → 协调器收到文本,状态从 listening → parsing → 页面显示"正在理解你的需求..."如果协调器的状态和鸿蒙引擎的状态不对齐(比如鸿蒙引擎还在识别,协调器已经切到 parsing),就会出现"页面显示在理解,但语音还在录入"的问题。
TTS 播报的状态对齐
协调器的speaking状态对应鸿蒙侧的 TTS 引擎:
协调器:state = speaking → 页面显示"停止播报"按钮 → TextToSpeechChannel.speak() → 鸿蒙 TextToSpeechPlugin 创建引擎、开始播报 → 播报完成 → 协调器状态 speaking → idle → 页面显示"语音播报"按钮如果用户在 TTS 播报中退出页面:
@override void dispose() { if (_isSpeaking) { TextToSpeechChannel.stop().catchError((_) {}); // 停止鸿蒙 TTS } super.dispose(); }这个 dispose 必须在页面层执行,因为_isSpeaking是页面局部状态。协调器的 dispose 也会停止 TTS,但页面层的 dispose 更及时(页面销毁时立即触发)。
鸿蒙前后台切换的状态恢复
鸿蒙设备上,用户可能在 AI 对话过程中切到其他应用再回来。此时:
Riverpod 的
autoDispose会自动销毁 coordinator → 会话状态丢失页面重建时,
_history也丢失(因为是 StatefulWidget 的局部状态)鸿蒙 TTS 引擎可能还在后台播放
当前的处理方式是:页面退出时停止 TTS,页面重建时重新初始化。这意味着对话历史不会跨页面保持——对当前产品来说是合理的(每次进 AI 助手都是新对话),但如果以后要做"对话历史持久化",就需要在协调器层加本地存储。
常见坑
所有 AI 状态都塞进一个大 Provider→ 协调器越来越重,什么都管,最后变成上帝对象
所有页面交互状态也都塞进会话状态→ 滚动位置、焦点状态、按钮 UI 状态不属于会话
流式文本和历史文本不分层→ 导致文本重复、历史闪动、半成品消息和正式消息混在一起
语音状态和页面按钮状态混着处理→ 协调器的
speaking状态和页面的_isSpeaking应该各管各的_history过早塞进AiSessionState→ 协调器开始承担展示职责,违反单一职责归档逻辑没有防重复→
_lastStreamingText必须用来做去重鸿蒙原生引擎状态和协调器状态不对齐→ 导致"页面显示在理解,但语音还在录入"
页面退出时不停止鸿蒙 TTS→ 后台播放声音,用户体验极差
可复用模板
状态分层原则
会话级状态(放协调器 / Provider) ├─ status: 当前会话阶段 ├─ streamingText: 正在生成的文本 ├─ errorMessage: 错误信息 └─ matchedDishes: 工具调用结果 页面局部状态(放 StatefulWidget) ├─ _history: 对话历史渲染数据 ├─ _lastStreamingText: 防重复归档 ├─ _isSpeaking: 播报按钮 UI 状态 ├─ _hasSubmittedInitial: 一次性标记 ├─ _scrollController: 滚动控制 └─ _inputFocusNode: 焦点控制 协调器内部状态(放 StateNotifier) ├─ _isSpeaking: 会话级播报状态 └─ _agentInitialized: agent 初始化标记协调器状态管理模板
class AiCoordinator extends StateNotifier<AiSessionState> { AiCoordinator() : super(const AiSessionState()); Future<void> submitQuery(String text) async { // 1. 切到 parsing state = state.copyWith( status: AiSessionStatus.parsing, streamingText: '', ); // 2. 流式输出 → responding final buffer = StringBuffer(); await agentService.chatWithToolsStream( message: text, onContent: (chunk) { buffer.write(chunk); state = state.copyWith( status: AiSessionStatus.responding, streamingText: buffer.toString(), ); }, onToolCall: (toolCall) { state = state.copyWith(status: AiSessionStatus.searching); }, ); // 3. 完成 → idle state = state.copyWith( status: AiSessionStatus.idle, streamingText: buffer.toString(), ); } }页面状态消费模板
class AiScreen extends ConsumerStatefulWidget { @override ConsumerState<AiScreen> createState() => _AiScreenState(); } class _AiScreenState extends ConsumerState<AiScreen> { final List<ChatEntry> _history = []; String? _lastStreamingText; @override Widget build(BuildContext context) { final sessionState = ref.watch(coordinatorProvider); // 归档逻辑 if (sessionState.status == AiSessionStatus.idle && sessionState.streamingText.isNotEmpty && _lastStreamingText != sessionState.streamingText) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { _history.add(ChatEntry( isUser: false, text: sessionState.streamingText, )); _lastStreamingText = sessionState.streamingText; }); } }); } // 渲染 return ListView.builder( itemCount: _history.length + (isStreaming ? 1 : 0), itemBuilder: (context, index) { if (index == _history.length && isStreaming) { return StatusBubble(status: sessionState.status); } return MessageBubble(entry: _history[index]); }, ); } }本篇总结
AI 应用里,Flutter 页面状态和对话状态最怕混成一团。食界探味当前的做法之所以值得借鉴,是因为它已经把三层拆开了:
会话级状态—
AiSessionState,由协调器管理,控制 AI 流程协调器层—
AiExploreCoordinator,推进状态流转,对接 AgentService 和鸿蒙 Channel页面局部状态—
_history、_isSpeaking等,由AiAssistantScreen管理,控制渲染细节
关键的拆分原则:
streamingText和_history必须分开— 正在生成的文本和已完成的历史消息不能混在一起协调器的
speaking和页面的_isSpeaking各管各的— 同主题不同层级_history不过早塞进AiSessionState— 它是展示数据,不是会话本体归档逻辑用
_lastStreamingText防重复— 三个条件同时满足才归档
这样一来,不管后面继续加工具调用、鸿蒙语音输入还是播报能力,状态结构都更容易稳住。在鸿蒙设备上,这种分层让原生能力的接入变得干净——协调器负责和鸿蒙 Channel 对齐状态,页面层只管 UI,职责边界清晰。