适合谁看

  • 正在写 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,职责边界清晰。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐