
Flutter 3.32.8发布那阵子我正好被一个需求卡住了要做一个同时跑在Android和iOS上的AI对话App用户发一句话模型能像真人聊天一样一个字一个字往外蹦。最开始我连模型厂商的官方SDK都看好了后来发现一个尴尬的事实——官方SDK大多是为后端设计的移动端要么自己封装HTTP要么掏钱买商业组件。转了一圈我决定用最朴素的方式Flutter负责界面和交互硅基流动API负责模型推理两者之间就是一堆HTTP请求加JSON解析。这篇文章就是把这个基础版AI对话从零到跑通的完整记录目标读者是那些想把AI能力快速塞进Flutter项目、又不想被私有SDK绑死的开发者。照着做完你能得到一个能发消息、能流式回复、能多轮对话的聊天界面后面想接Agent、想切多模型代码里也留好了扩展位。1. 选型逻辑为什么偏偏是Flutter 3.32.8加硅基流动API很多人在技术选型上容易陷入参数党的误区非要跑一轮基准测试才肯动手。我的经验是聊天类应用的技术选型应该先看场景特征再看生态成本最后才是跑分数据。下面把我当时的思考过程完整摊开。1.1 Flutter 3.32.8解决了我的哪个痛点先说背景。我之前用原生写过一套简单的客服聊天DemoAndroid和iOS各维护一份一个气泡样式改动要同步改两处版本迭代到第三轮我已经开始怀疑人生。Flutter的跨端能力在聊天这种以列表和表单为主的场景下体验差距其实已经小到可以忽略——毕竟聊天界面90%的时间都在滚列表和渲染气泡又不是在跑游戏引擎。3.32.8这个版本还有一个让我下决心的点Impeller渲染引擎的默认覆盖设备范围又扩大了一批。在老版本上低端Android机用Skia渲染滚动列表时明显掉帧切换到Impeller之后列表滚动的流畅度提升相当直观。如果你以前被老版本Flutter在低端机上的卡顿劝退过这个版本值得再试一次。另外3.32.8对Dart 3.x的GC机制做了进一步调优聊天这种高频创建临时对象比如消息模型的场景内存抖动比早期版本收敛了很多flutter run --profile模式下能明显看到内存曲线更平。1.2 硅基流动API的不可替代性在哪硅基流动API对我这种个人开发者和中小团队来说价值不在于某个模型特别强而在于它把一堆主流开源模型统一封装成了同一个HTTP接口。你不需要为了换模型去重新适配SDK也不需要为了跑一个14B的模型去准备一块大显存的显卡。更关键的是它的接口格式走的是OpenAI兼容协议POST /v1/chat/completions、JSON请求体、SSE流式返回。这意味着什么意味着Dart生态里任何一种通用HTTP客户端都能接生态里能找到的绝大部分AI工具链的对接代码也能直接抄过来改改就用完全不需要引入某个模型厂商专有的SDK。对于Flutter开发者来说这几乎是最低摩擦的接法。1.3 整体技术栈一览我最终确定的方案如下环节选型为什么这么选跨端框架Flutter 3.32.8一套Dart代码通吃Android/iOS聊天类UI性能足够状态管理Provider轻量、无代码生成、中小项目够用且社区资料多网络请求Dio自带拦截器、流式响应、取消请求能力调试方便API服务硅基流动APIOpenAI兼容格式一个Key换模型自由本地存储shared_preferences存API Key和聊天历史简单可靠这套组合最大的好处是没有黑盒。从点击发送到文字上屏每一层数据流转我都能看清楚出了问题也能快速定位。2. 环境准备Flutter 3.32.8安装、新建项目与Gradle构建的连环坑这一步看着基础实际上我见过的项目里十有八九的跑不起来都是环境问题。3.32.8对构建链路做了不少调整尤其是Gradle插件的应用方式老教程大概率会误导你。2.1 三步装好Flutter 3.32.8含国内镜像配置第一步从Flutter官网下载对应操作系统的SDK压缩包。这里有个容易踩的细节Windows用户千万不要把SDK解压到C:\Program Files这类带空格的路径下后续Gradle脚本解析路径会出各种莫名其妙的错误。我习惯放在D:\flutter这种纯英文无空格的根目录。第二步配置环境变量。在用户变量里添加PUB_HOSTED_URLhttps://pub.flutter-io.cn FLUTTER_STORAGE_BASE_URLhttps://storage.flutter-io.cn这两个环境变量会把Pub包和Flutter引擎的下载源切到国内镜像不然flutter pub get拉依赖能卡到你怀疑人生。第三步在终端跑flutter doctor按提示把缺的Android SDK、JDK补上。这里提醒一句Flutter 3.32.8要求JDK 17及以上Android Studio自带的JBRJetBrains Runtime通常没问题但如果你用的是独立安装的旧版JDK记得把JAVA_HOME指到JDK 17。2.2 新建项目后跑不起来的三种典型原因flutter create chat_demo之后flutter run却报错的场景太常见了我归纳成三类第一类是Gradle下载超时。Flutter新建项目默认会去下载对应版本的Gradle发行包国内网络环境经常卡在Downloading https://services.gradle.org/distributions/gradle-8.x-all.zip。解决办法是手动去下载这个zip包放到C:\Users\你的用户名\.gradle\wrapper\dists对应目录下或者直接改gradle-wrapper.properties里的distributionUrl指向本地路径。第二类是依赖拉取失败。如果你发现Android构建阶段卡在Buildgpg或者某个aar下载不动多半是Google Maven仓库访问慢。需要在android/build.gradle里把仓库顺序调整一下优先走阿里云镜像allprojects { repositories { maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/gradle-plugin } maven { url https://maven.aliyun.com/repository/public } google() mavenCentral() } }第三类是运行设备识别不到。模拟器开着但flutter devices里就是没有先把adb kill-server adb start-server跑一遍八成能解决。2.3 Flutter 3.32.8的新旧Gradle写法冲突这个坑我在热搜词里也看到了you are applying flutters main gradle plugin imperatively using the apply...。Flutter 3.29之后新建项目的Android工程结构已经改成声明式插件写法了老教程里那种apply plugin: com.android.application的写法构建时会直接给出警告甚至报错。新项目默认的android/settings.gradle长这样plugins { id dev.flutter.flutter-plugin-loader version 1.0.0 id com.android.application version 8.3.2 apply false id org.jetbrains.kotlin.android version 1.9.22 apply false }而android/app/build.gradle顶部是plugins { id com.android.application id kotlin-android id dev.flutter.flutter-gradle-plugin }如果你参照网上的老文章手动改了Gradle文件遇到这个报错直接把build.gradle恢复成上面这种声明式写法就行。核心区别是新版要求dev.flutter.flutter-gradle-plugin在插件块里声明而不是在后面用apply方法引入。2.4 Dart VM初始化报错的快速定位开发热重载阶段你可能会在控制台看到这样的日志E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: ...这个错误本身不是原因而是结果Dart VM在初始化时收到了未捕获的异常只是Flutter引擎把这个日志统一打到了dart_vm_initializer.cc这个入口。真正的异常内容在冒号后面的堆栈里。我的排查习惯是第一步往上翻日志找到Unhandled Exception:那行后面跟的实际异常类型和完整堆栈。第二步看堆栈第一条是不是某个插件的registerWith方法如果是基本可以断定是插件和当前Flutter版本不兼容去pubspec.yaml里把插件升到最新试试。第三步如果堆栈指向的是你写的代码那就是异步函数里抛异常没接住给入口函数加个全局兜底void main() { runZonedGuarded(() { runApp(const ChatApp()); }, (error, stack) { debugPrint(全局异常: $error\n$stack); }); }这样处理后至少崩溃时你能拿到完整堆栈而不是面对一行干巴巴的E/flutter日志。3. 硅基流动API接入从申请密钥到第一个可以对话的接口环境通了我第一件事就是去把API打通因为只有接口通了后面UI和状态管理才有真实数据可以调试。这一步比想象中快核心就三件事拿Key、看文档、发请求。3.1 注册、密钥申请与额度检查去硅基流动官网注册账号个人开发者身份就行。登录后进入控制台左侧找到API密钥页面点创建密钥把生成的sk-开头的字符串复制保存好。这个Key相当于你访问所有模型的通行证泄露了别人就能用你的额度所以千万别写死在代码里开发阶段可以先用--dart-define传参后面再考虑放到服务端做代理。这里有个容易忽略的点硅基流动的模型列表页会标注每个模型的价格和上下文长度接入前先看一眼你选的模型支持多长的上下文这直接决定了后面多轮对话的策略。比如模型的上下文是32K你硬塞200K的历史消息进去调用直接报错。3.2 OpenAI兼容协议到底兼容了什么理解硅基流动API之前你要先理解这套OpenAI兼容的协议骨架一共就三部分请求地址固定为https://api.siliconflow.cn/v1/chat/completions请求头固定为Authorization: Bearer 你的API_KEY请求体是标准JSON结构{ model: Qwen/Qwen2.5-7B-Instruct, messages: [ {role: system, content: 你是一个友好的聊天助手}, {role: user, content: 你好介绍一下你自己} ], stream: false }messages数组是整个对话的核心每一条消息都有rolesystem、user、assistant和content。多轮对话的本质就是把这个数组越拼越长每次都把全部历史发给模型让模型记得之前说了什么。3.3 用Dio发送第一个非流式对话请求在pubspec.yaml里加上dio: ^5.4.0然后写一个最简的调用import package:dio/dio.dart; class ChatApi { static const _baseUrl https://api.siliconflow.cn/v1/chat/completions; static const _apiKey String.fromEnvironment(API_KEY); static FutureString sendMessage({ required String apiKey, required ListMapString, String messages, }) async { final dio Dio(BaseOptions( baseUrl: _baseUrl, headers: {Authorization: Bearer $apiKey}, )); final response await dio.post( , data: { model: Qwen/Qwen2.5-7B-Instruct, messages: messages, stream: false, }, ); final data response.data as MapString, dynamic; return data[choices][0][message][content] as String; } }注意choices是一个数组因为接口设计允许一次返回多个候选结果我们取下标0就行。response.data在这里会被Dio自动解析成Map因为服务端返回的Content-Type是application/json。我第一次跑通时打印了完整返回结构发现里面除了content还有reasoning_content部分推理模型的思考过程和usagetoken消耗统计这些字段后面做调试和计费统计时很有用。4. 真正的对话体验SSE流式解析与Provider状态管理非流式接口虽然能跑通但体验太差了发一句话要等两三秒才一次性蹦出完整回答这期间界面只能干等着。真正像样的聊天体验必须走流式也就是模型每生成几个字就推给我们一段像打字机一样逐字上屏。这一步同时也是全网问得最多的flutter provider怎么用和flutter组件通信的落地点。4.1 为什么聊天必须用流式响应用户体验层面不用多说没人愿意盯着一个菊花转三秒。技术层面还有个更现实的原因长回答的场景下非流式请求容易在移动网络环境里超时一旦连接断开整段回答就丢了。流式响应则不同模型已经吐出来的字都是即时收到的断线最多是后面内容没接上前面内容已经渲染出来了重试成本低很多。从API设计的角度理解流式就是HTTP响应体变成了一个持续推送的数据流。服务端把每个分片包装成一个data:开头的文本行最后用data: [DONE]标记结束。这种格式就是SSEServer-Sent Events。4.2 用Dio处理SSE流一段可复用的解析器Dio对流式响应有两个层级的支持底层是ResponseType.stream适合处理极端场景更实用的是onReceiveProgress回调配合responseTransformer可以实时拿到每个分片。我用的方案是后者代码更直观。class ChatApi { static Futurevoid sendMessageStream({ required String apiKey, required ListMapString, String messages, required void Function(String delta) onDelta, required void Function() onDone, }) async { final dio Dio(BaseOptions( baseUrl: _baseUrl, headers: {Authorization: Bearer $apiKey}, )); // 取消关联用于用户在回复过程中主动停止 final cancelToken CancelToken(); await dio.post( , data: { model: Qwen/Qwen2.5-7B-Instruct, messages: messages, stream: true, }, options: Options(responseType: ResponseType.stream), cancelToken: cancelToken, ).then((response) async { // SSE数据是text/event-stream需要逐行读取 final stream response.data.stream; // 关键按换行符切分过滤空行和注释行 await for (final chunk in stream.transform(utf8.decoder)) { final lines chunk.split(\n); for (final line in lines) { if (!line.startsWith(data:)) continue; final data line.substring(5).trim(); if (data [DONE]) { onDone(); return; } if (data.isEmpty) continue; final json jsonDecode(data); final delta json[choices][0][delta][content]; if (delta ! null) { onDelta(delta as String); } } } }); } }这里有几个实战细节第一个ResponseType.stream模式下response.data是一个StreamUint8List必须自己解码成字符串不能像JSON模式那样直接response.data[choices]。第二个流式数据分片是不稳定的一个中文字符可能被拆成两个分片传输如果你直接用utf8.decoder解码整个分片流基本不会出问题但如果你手工按字节去拼很容易在中文边界上解码出乱码。Dart的utf8.decoder内部维护了跨分片的状态可以放心用。第三个await for循环里遇到[DONE]要立刻return别忘了这个标记之后可能跟着换行和空数据不return的话解析器会一直空转。4.3 Provider怎么用聊天状态的ChangeNotifier设计流式回调拿到了文字增量接下来要把这些增量不断塞进UI里。如果直接用setState消息列表和输入框耦合在一起业务逻辑稍微一多代码就烂了。这里用Provider做一层状态管理把聊天状态和界面组件彻底解耦。先定义一个消息数据类class ChatMessage { final String role; // user / assistant final String content; final bool isStreaming; // 是否为流式生成中 ChatMessage({ required this.role, required this.content, this.isStreaming false, }); }再定义聊天状态管理器class ChatState extends ChangeNotifier { final ListChatMessage _messages []; ListChatMessage get messages List.unmodifiable(_messages); bool _isSending false; bool get isSending _isSending; Futurevoid send(String text) async { if (text.trim().isEmpty || _isSending) return; // 1. 把用户消息加入列表 _messages.add(ChatMessage(role: user, content: text)); _isSending true; notifyListeners(); // 2. 创建一条空的assistant消息用于流式追加 final assistantMsg ChatMessage( role: assistant, content: , isStreaming: true, ); _messages.add(assistantMsg); notifyListeners(); // 3. 组装历史消息发送给API final history _messages .where((m) !m.isStreaming || m ! assistantMsg) .map((m) {role: m.role, content: m.content}) .toList(); // 4. 流式接收增量并更新 await ChatApi.sendMessageStream( apiKey: _apiKey, messages: history, onDelta: (delta) { assistantMsg.content delta; notifyListeners(); }, onDone: () { assistantMsg.isStreaming false; _isSending false; notifyListeners(); }, ); } }核心思路是ChatState是一个ChangeNotifier消息列表的每一次增改都调用notifyListeners()所有监听这个状态的界面组件自动刷新。你在UI层只需要做一件事——用Consumer包住需要响应状态变化的组件。4.4 组件通信链路输入框、Provider与列表的完整闭环这里顺便把flutter组件通信这个高频问题讲透。聊天页面的组件通信本质是一条单向数据流链路界面输入框捕获用户输入 → 调用ChatState.send()→ 状态管理器更新_messages并触发通知 → 消息列表组件ConsumerChatState收到通知自动重建。对应的UI写法是class ChatPage extends StatelessWidget { override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) ChatState(), child: Scaffold( appBar: AppBar(title: const Text(AI助手)), body: Column( children: [ Expanded( child: ConsumerChatState( builder: (context, chat, _) { return ListView.builder( itemCount: chat.messages.length, itemBuilder: (context, index) { final msg chat.messages[index]; return MessageBubble(message: msg); }, ); }, ), ), ChatInputBar(onSend: (text) { context.readChatState().send(text); }), ], ), ), ); } }注意Consumer的builder参数当ChatState调用notifyListeners()时只有Consumer那一块的UI会重建ChatInputBar如果不依赖聊天消息就不会被重建这比全页面setState高效得多。5. 聊天界面搭建消息列表、气泡样式与键盘处理的细节接口通了状态管理也跑通了接下来是让界面看起来像个正经聊天工具。这一步没有太多高深技术但细节很多直接决定App的质感。5.1 页面骨架输入框和消息列表怎么布局聊天页面的布局几乎固定是顶部标题栏、中间消息列表、底部输入栏用Column组合Expanded让消息列表占满剩余空间。输入栏我这里做了三层结构一个文本框、一个发送按钮、左边加了一个清空会话的图标按钮。文本框要处理两个事件点发送按钮发送以及键盘的提交动作。用TextField的onSubmitted回调注意需要设置textInputAction: TextInputAction.send。还有一个所有聊天App都躲不过的细节键盘弹起时会不会挡住输入框。默认Scaffold的resizeToAvoidBottomInset是true键盘会把整个页面顶上去输入框自然可见但消息列表底部会被键盘遮住一部分。我的处理办法是在ListView收到新消息时自动滚动到底部。final _scrollController ScrollController(); void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); }在Consumer的builder里检测消息数量变化触发_scrollToBottom()即可。这里有个坑流式输出过程中消息内容一直在变但ListView的itemCount没变maxScrollExtent不会自动更新所以滚动触发要放在onDelta更新内容之后而不是放在itemCount变化时。5.2 消息气泡与流式光标气泡组件我设计成三种状态用户消息右对齐蓝底白字助手消息左对齐浅灰底黑字流式输出中的助手消息右下角加一个闪烁的竖线光标模拟打字效果。class MessageBubble extends StatelessWidget { final ChatMessage message; override Widget build(BuildContext context) { final isUser message.role user; return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.75, ), decoration: BoxDecoration( color: isUser ? Colors.blue : Colors.grey[200], borderRadius: BorderRadius.circular(14), ), child: Text( message.content (message.isStreaming ? ▍ : ), style: TextStyle( fontSize: 15, height: 1.4, color: isUser ? Colors.white : Colors.black87, ), ), ), ); } }这个组件同时处理了历史消息和流式消息message.content的增量更新由上层状态管理器负责气泡组件只做渲染。加一个▍字符作为流式光标简单可靠不需要额外引入动画组件。5.3 多轮对话的上下文拼接策略流式聊天跑通之后你会遇到第二个问题模型失忆。原因前面提过每次请求只发了当前这一轮的消息模型根本没有上文可看。解决办法是把历史消息都拼进messages数组final history chat.messages .map((m) {role: m.role, content: m.content}) .toList(); // 最后一条assistant消息还在流式生成中拼历史时要排除它但这里立刻出现一个现实约束模型上下文长度有限。你把100轮历史全塞进去请求直接超长报错。我的策略是系统提示词永远保留在最前面消息总条数超过一定阈值比如20条时丢弃最旧的一半历史计算所有消息内容的字符总数超过模型上下文一半长度时依次丢弃最早的非系统消息。这个裁剪逻辑虽然粗暴但适合MVP阶段。后面如果要做更精细的上下文管理可以考虑实现摘要压缩——把超过窗口的旧消息送给模型生成一段摘要再拼回历史里。6. 实测踩坑记录模型上下文溢出、R8混淆与低端机滚动卡顿从能跑到好用中间隔着一堆实际使用中才能遇见的坑。把我在真机调试中记录的关键问题列出来这些如果你提前看到能省出至少一个下午的排错时间。6.1 上下文太长被模型拒答表现连续对话十几轮后发送消息直接返回HTTP 400响应体里写着context length exceeded之类的错误。原因前面说过的历史消息无脑拼接最终超出了模型支持的token上限。修复方案分两步。第一步在发送前做一个本地估算。英文字符大约每4个字符算1个token中文大约是每1个字符算1个token按这个粗粒度估算当前消息数组的总长度超标就裁剪。第二步给聊天页面加一个上下文长度条实时显示当前会话占模型窗口的比例。这个设计非常实用用户自己能看到快满了同时自动触发裁剪逻辑比报错了再处理体验好得多。6.2 Android打包时R8和API Key的坑flutter build apk --release的时候默认会开启R8代码压缩。R8会把没用的代码和资源删掉再对类名方法名做混淆。本人踩过的坑有两个。第一个是如果你把API Key直接写死在Dart代码里构建后别人用jadx反编译APK能直接在字符串常量池里翻到你的sk-开头的Key。正确的处理方式是用--dart-define在构建时注入并且绝对不能把Key提交到Git仓库。第二个坑是有些第三方库的类会被R8误删导致运行时崩溃。解决办法是在android/app/proguard-rules.pro里加keep规则或者最简单粗暴的minifyEnabled false关掉压缩。但关闭R8会让APK体积变大我的建议是先用默认配置构建一次跑一遍全流程测试如果没崩溃就保持开崩了就针对具体崩溃点加keep规则而不是一刀切关掉。6.3 Impeller渲染下低端机的表现3.32.8默认启用Impeller后绝大多数设备渲染正常但我在一台很老的测试机上遇到了文字模糊和列表闪烁的问题。这类问题的通用解法是临时回退到Skia验证是否是渲染引擎的锅在AndroidManifest.xml的application标签里加一行meta-data android:nameio.flutter.embedding.android.EnableImpeller android:valuefalse /如果加上这行后问题消失基本可以断定是Impeller在该设备上的兼容性问题记录下来并提交issue即可。实际使用中Impeller在绝大多数主流设备上的表现都比Skia好不建议全局回退只针对出问题的旧机型特殊处理。还有一个滚动性能的优化点聊天列表里每条消息都包含Text组件消息多的时候每次重建开销不小。可以用const构造函数避免不必要的重建再对列表项做懒加载。实测下来1000条消息的会话在低端机上滚动依然能保持60帧。7. 下一步可以怎么玩多AI协作、会话持久化与Agent雏形基础版跑通后你会发现这个架构的扩展性相当好。因为所有模型都走同一个OpenAI兼容接口加模型、换模型只是一次请求参数的变化而已。7.1 多模型路由与多AI协作在ChatState里增加一个modelProvider字段记录当前选用的模型。界面上做一个底部弹窗让用户切换模型切换后新消息就发到新模型上下文历史可以共享也可以按模型隔离——看你产品怎么定义。多AI协作就是更进阶的玩法定义三个不同的ChatState实例分别绑定不同的系统提示词和模型比如一个负责文案、一个负责代码审查、一个负责翻译。用户发一条消息三个实例同时发起请求界面用Tab或分栏展示各自的结果。这个功能架构上实现起来非常快因为你的数据流转已经是完全解耦的。7.2 会话持久化当前聊天记录只存在内存里App一关就没了。用shared_preferences存聊天记录注意把ChatMessage转成JSON字符串数组加载时再解析回来。历史消息体积大了之后建议换sqflite或者hive。我自己的习惯是MVP阶段先用shared_preferences超过100条再考虑上数据库。7.3 Agent雏形想让这个基础版更接近智能体可以做一个最简单的工具调用循环。定义一个函数列表把模型返回的tool_calls解析出来执行对应函数比如查天气、计算器再把执行结果以tool角色消息发回给模型模型根据结果生成最终回答。这种循环在OpenAI兼容协议里已经有标准字段Dart端实现也只是解析和回调的事情。等这个基础版聊天项目跑熟再往上做Agent你会发现自己已经绕过了最大的坑——AI应用的核心不是那个模型而是围绕模型的数据流转和应用架构。我在实际项目里踩了一圈之后最大的体会是Flutter加硅基流动API这套组合真正厉害的地方在于它把接入AI这件事变成了纯粹的HTTP请求处理没有私有SDK的束缚也没有复杂的原生桥接。只要把流式解析和状态管理这两块地基打好后面无论你想换更大的模型、做多轮复杂对话还是往Agent方向演进代码都撑得住。如果你也正在做类似的东西建议先把这一篇里的基础链路完整跑通——它能帮你省下的时间绝对比刷十篇教程多得多。