news 2026/6/13 2:12:51

鸿蒙 + Flutter 下 AI 页面的状态协同设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
鸿蒙 + Flutter 下 AI 页面的状态协同设计

适合谁看

  • 正在写 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 状态

这些状态更像:

状态

性质

为什么留在页面层

_history

页面展示数据

控制消息渲染顺序,和 AI 会话本体是两个概念

_lastStreamingText

渲染防重标记

纯粹是为了防止同一轮回复被重复归档

_hasSubmittedInitial

一次性标记

只在页面初始化时用一次,和会话无关

_isSpeaking

按钮 UI 状态

控制"语音播报/停止播报"按钮的显示

_scrollController

滚动控制

纯 UI 交互,和 AI 逻辑无关

_inputFocusNode

焦点控制

纯 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 [], }); }

几个设计要点:

  1. dishes只在 AI 消息中有— 用户消息不需要关联菜品卡片

  2. dishes是不可变列表— 归档时用List<Dish>.unmodifiable()包装,防止后续修改影响历史

  3. _ChatEntry是页面私有类— 只在AiAssistantScreen内部使用,不暴露给协调器或服务层

这意味着如果以后要支持"消息收藏"、"消息分享"等功能,只需要在页面层处理_ChatEntry,不需要改动协调器或服务层。

十、为什么 AI 页面特别容易把状态写乱

因为它会天然跨越很多维度:

维度

示例状态

该放哪层

对话过程

status, streamingText

会话状态(协调器)

工具调用过程

matchedDishes

会话状态(协调器)

页面渲染过程

_history, _lastStreamingText

页面局部状态

语音输入过程

listening 状态

会话状态(协调器)

语音播报过程

_isSpeaking(按钮)+ speaking(会话)

各管各的

错误处理

errorMessage

会话状态(协调器)

UI 辅助

_hasSubmittedInitial, _scrollController

页面局部状态

如果没有主动拆层,最常见的结果就是:

  • 所有状态都放进一个大 Provider → 协调器越来越重,什么都管

  • 或者所有状态都塞回一个 StatefulWidget → AI 逻辑和 UI 逻辑混成一团

这两种方式都容易在后期变得难维护。食界探味当前这套拆法的价值,正是在于它已经把中间那层协调器立起来了。

关键代码位置

文件

作用

app/lib/core/ai/models/ai_session_state.dart

会话级状态定义

app/lib/core/ai/ai_explore_coordinator.dart

协调器,管理会话状态

app/lib/features/ai_assistant/screens/ai_assistant_screen.dart

页面层,管理局部状态

app/lib/core/platform/speech_recognition_channel.dart

鸿蒙语音识别通道

app/lib/core/platform/text_to_speech_channel.dart

鸿蒙 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 对话过程中切到其他应用再回来。此时:

  1. Riverpod 的autoDispose会自动销毁 coordinator → 会话状态丢失

  2. 页面重建时,_history也丢失(因为是 StatefulWidget 的局部状态)

  3. 鸿蒙 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管理,控制渲染细节

关键的拆分原则:

  1. streamingText_history必须分开— 正在生成的文本和已完成的历史消息不能混在一起

  2. 协调器的speaking和页面的_isSpeaking各管各的— 同主题不同层级

  3. _history不过早塞进AiSessionState— 它是展示数据,不是会话本体

  4. 归档逻辑用_lastStreamingText防重复— 三个条件同时满足才归档

这样一来,不管后面继续加工具调用、鸿蒙语音输入还是播报能力,状态结构都更容易稳住。在鸿蒙设备上,这种分层让原生能力的接入变得干净——协调器负责和鸿蒙 Channel 对齐状态,页面层只管 UI,职责边界清晰。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/13 2:12:45

3个Python-Skill Bridge核心技巧:实现EDA开发效率的革命性提升

3个Python-Skill Bridge核心技巧&#xff1a;实现EDA开发效率的革命性提升 【免费下载链接】skillbridge A seamless python to Cadence Virtuoso Skill interface 项目地址: https://gitcode.com/gh_mirrors/sk/skillbridge 在电子设计自动化&#xff08;EDA&#xff0…

作者头像 李华
网站建设 2026/6/13 2:11:48

不止于抓包:用Ubiqua的Network Explorer和Graphic View透视你的Zigbee网络拓扑

透视Zigbee网络&#xff1a;Ubiqua高级诊断功能实战指南在智能家居和工业物联网场景中&#xff0c;Zigbee网络的稳定性和性能直接影响着整个系统的可靠性。当设备出现间歇性掉线、响应延迟或通信异常时&#xff0c;传统的抓包工具往往只能提供原始数据&#xff0c;而无法直观呈…

作者头像 李华
网站建设 2026/6/13 2:08:55

【AI Agent 第十二期:Gemini CLI 使用指南】

Gemini CLI 使用指南 作者&#xff1a;Choiyon | 关键词&#xff1a;Gemini CLI、Google AI、命令行工具、cch代理、AI编程助手 &#x1f680; 前言&#xff1a;AI编程助手新选择 在AI编程助手领域&#xff0c;除了大家熟知的GitHub Copilot、Cursor之外&#xff0c;现在又多了…

作者头像 李华