鸿蒙 + 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 状态
这些状态更像:
|
状态 |
性质 |
为什么留在页面层 |
|---|---|---|
|
|
页面展示数据 |
控制消息渲染顺序,和 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,职责边界清晰。
更多推荐


所有评论(0)