欢迎光临
我们一直在努力

鸿蒙 + 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 [],
});
}

几个设计要点:

  • 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 逻辑混成一团

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

    关键代码位置

    文件

    作用

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

  • 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)
    未经允许不得转载:171主机测评 » 鸿蒙 + Flutter 下 AI 页面的状态协同设计
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址