十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter聊天应用开发实战:从架构选型到消息可靠性保障

Flutter聊天应用开发实战:从架构选型到消息可靠性保障 简介这份基于Flutter与Dart开发的聊天应用教学源码包面向刚入门跨平台移动开发或想系统掌握实时聊天功能实现的开发者。项目包含聊天界面、消息收发、用户列表等核心模块通过具体代码展示StatefulWidget/StatelessWidget组件、Dart异步编程、JSON解析及WebSocket通信思路同时保留Android/iOS双端工程配置适合边读边练。压缩包共82个文件以Dart源码、PNG图片资源、Xcode构建文件、XML与JSON数据配置为主整体仅738KB体量轻、结构清晰便于快速检索与学习。目前已有134人学习浏览可配合README及主要页面代码理解聊天应用从界面搭建到数据交互的完整实现链路对提升Flutter实战能力有直接帮助。 聊到 Flutter 聊天应用很多人第一反应是不就是列表加输入框吗真做起来才发现一个能上线、能稳定跑的 chat app里面全是细节。这个chat_flutter_app项目我断断续续折腾了一个多月从当初只想着把界面画出来到最后把连接管理、消息可靠性、本地存储、未读体系全部捋顺整个过程踩过的坑绝对比写过的代码多。这篇博文不聊虚的直接把我在这个项目里的架构选型、关键代码实现、性能优化思路和排查记录摊开来讲。适合两类人看一是 Flutter 基础已经掌握、想找个完整项目练手的人二是已经在做即时通讯或客服类应用、想看看别人怎么处理消息时序、数据库选型这些硬骨头的人。1. 项目初始化与架构选型1.1 状态管理和路由的选型思路聊天应用的状态管理和普通业务应用有本质区别。普通应用的状态是页面级的聊天应用的状态是会话级且跨页面共享的——会话列表页要显示最后一条消息和未读数聊天页要维护整个消息列表这两处必须同时感知新消息的到来。我最终选了 provider ChangeNotifier没上 riverpod也没上 bloc。理由很简单这个项目体量下riverpod 的代码生成和编译期检查反而拖慢迭代速度bloc 的事件驱动模型用在聊天这种高频状态变更场景下样板代码会淹没核心逻辑。provider 的 ListenableBuilder 配合 ChangeNotifier足以支撑会话列表和聊天页的状态同步而且团队里任何人接手都能快速看懂。路由方面直接用的官方 Navigator 2.0没引入 go_router。原因是我这个项目里聊天页的入参是会话 ID 和会话类型单聊/群聊这两个参数在路由栈里要一直保留方便从会话列表跳转后返回时还能拿到上下文。go_router 的声明式路由在这种场景下反而要把参数编码进 URL多一道转换没必要。依赖管理用了一个非常关键的国内镜像配置在pubspec.yaml旁边建一个.pub-cache环境变量指向国内镜像源否则pub get在拉取 dio、web_socket_channel 这些常用包时会卡到怀疑人生。这个配置说实话不算技术难点但没配置好的话项目根本跑不起来。1.2 项目目录结构怎么划分才不乱聊天项目的目录划分我建议按功能模块走不要按技术类型走。按技术类型划分models、pages、widgets、services在项目初期看着清爽一旦加了新功能模块比如群公告消息撤回你会发现每个目录下都要动一刀文件分布越来越散。我实际采用的结构是这样的lib/ ├── core/ # 全局配置、路由表、常量、网络客户端封装 ├── features/ │ ├── chat/ │ │ ├── models/ # 会话模型、消息模型 │ │ ├── providers/ # 会话状态、消息状态 │ │ ├── pages/ # 会话列表页、聊天页 │ │ └── widgets/ # 消息气泡、输入栏、日期分隔线 │ ├── contacts/ # 通讯录模块 │ └── profile/ # 我的模块 └── shared/ # 通用组件、工具方法、主题这个结构的核心逻辑是每个 feature 内部自成体系model、provider、page、widget 都收拢在一起。比如以后要加一个朋友圈模块直接新建一个features/moments/文件夹不影响任何已有模块。shared 目录放的则是真正的通用组件比如网络状态监听、自定义弹窗这类与业务无关的东西。1.3 主题和基础组件的统一处理聊天应用在视觉上有一个很容易被忽视的点消息气泡和输入框的主题色必须从统一的设计 token 里取不能散落着硬编码颜色值。我在项目里用了一个AppColors类统一管理所有颜色会话列表的未读红点、聊天气泡的背景色、输入框的边框色等等全部集中定义。这样做的直接好处是当产品经理说聊天气泡颜色稍微调浅一点的时候只改一个常量全 App 生效不会出现改了发送气泡忘了改接收气泡的尴尬情况。同样的道理字体大小、圆角半径、间距这些也都抽成了常量聊天页里同一套参数在不同设备上表现一致。2. 网络层与实时通信设计2.1 WebSocket 连接管理的完整方案聊天 App 网络层最核心的链路不是 HTTP 接口而是 WebSocket 长连接。我用的 web_socket_channel 包但它只提供了最基本的连接能力连接建立后的心跳、断线重连、消息确认、网络状态切换处理全都要自己封装。我先说连接建立策略。App 启动时不立刻建立长连接而是先调 HTTP 接口拿到 token 和服务端分配的长连接地址然后再用这个地址建立 WebSocket。这种做法的好处是如果用户的 token 已经过期HTTP 层就会直接返回 401省去一次无效的 WebSocket 握手。连接建立后客户端要做的第一件事是发一个auth消息把 token 带上去。服务端校验通过后才开始转发业务消息。这一步不能省因为 WebSocket 的 URL 上带 token 有日志泄露风险放在首条消息里更安全。心跳机制这块我踩过的坑特别有代表性。最开始我设的是每 30 秒发一次 ping但服务端那边 60 秒没收到任何消息就断开。看起来有 30 秒余量但移动网络下 TCP 层有重传机制一旦 Wi-Fi 切换到蜂窝网络旧连接的 TCP 重传会卡住新数据ping 发出去迟迟到不了服务端结果就是连接被服务端误杀。后来我把心跳间隔调到 15 秒同时监听 connectivity_plus 的网络变化事件网络切换时主动调用socket.sink.add()发送一次即时心跳问题才彻底解决。断线重连这里有一个非常重要的细节不是所有断线都要立刻重连。如果是用户主动切到后台超过一定时间我设的是 5 分钟就不要在后台重连了等回到前台再连。否则 App 在后台会被系统频繁唤醒耗电不说还容易被系统判定为异常。回到前台时的重连策略是指数退避第一次等 1 秒第二次 2 秒第三次 4 秒最多 30 秒上限是 5 次。加上随机抖动避免大量用户同时断网恢复后集中重连打爆服务端。2.2 消息模型的字段设计消息模型是整个聊天系统的地基字段设计一定要把扩展性考虑进去。我见过很多聊天项目把消息类型写成枚举然后在 model 里写switch (type)做分发结果每次加一种消息类型图片、语音、视频、文件、位置就要改一堆地方。我采用的方案是消息模型只保留公共字段具体内容放在一个content字符串字段里用 JSON 承载。公共字段包括消息 ID、会话 ID、发送者 ID、消息类型、发送时间、状态发送中、已发送、已读、引用消息 ID。content字段里再根据不同消息类型存不同的 JSON 结构。class ChatMessage { final String messageId; final String conversationId; final String senderId; final MessageType type; final String content; final DateTime createdAt; final MessageStatus status; }这样做的直观好处是加一种合并转发消息类型时只改 UI 层的渲染逻辑model、数据库表结构、网络协议全都不用动。消息类型这种高频变化的字段用 JSON 字符串而不是强类型模型来承载灵活性高出一个量级。2.3 消息发送流程中的可靠性保障消息发送不是客户端发出去就完了至少要经历四个状态流转发送中、已发送、对方已读、发送失败。这个状态流转在 UI 上的体现就是那条消息右侧的小图标在变化。发送流程里最容易出问题的是消息 ID 的生成。这个 ID 必须由客户端生成不能等服务端返回因为消息一发出就要插入本地数据库、显示在列表里如果等服务端返回 ID 再入库消息列表会出现闪现先显示临时消息再被正式消息替换。我用 UUID 保证全局唯一发送时把完整消息对象发给服务端服务端存储后返回 ACK客户端把状态从发送中改成已发送。消息 ACK 这块有个微妙的问题如果客户端发完消息就断网了服务端其实收到了但 ACK 发不回来客户端这边消息状态一直是发送中。这种情况我在本地做了兜底消息发送前先入库状态为发送中收到 ACK 后更新状态如果重连后发现有发送中状态的消息自动重发一次。配合消息 ID 的幂等性服务端可以根据 ID 去重不会出现同一条消息存两遍的情况。3. 聊天界面的三个硬骨头3.1 消息列表的性能优化聊天列表的渲染性能直接影响用户体验这里有一个常见的误解以为用 ListView.builder 就万事大吉了。实际上消息列表里每条消息的高度不是固定的文字消息有长有短、图片消息有固定宽高比而且可能包含富文本、引用卡片、头像、昵称等元素复杂的 item 布局会让列表滑动时的 build 操作非常频繁。我的优化思路是分层处理的。第一层是消息内容本身要尽量轻量头像用缓存过的 CachedNetworkImage文字内容用 Text 组件但设置maxLines和overflow防止极端的长文本把布局撑爆。第二层是消息列表的 item 高度在首次构建后不再变化所以可以放心用itemExtent的变体——但前提是每条消息的高度是确定的。对于纯文字消息我根据文字长度和屏幕宽度预计算高度存入一个MapString, double缓存这样 ListView 不再需要动态测量滚动时性能提升非常明显。第三层是对图片消息的特殊处理。图片消息在列表里显示的是缩略图点开才是原图。缩略图在消息到达时由服务端生成好客户端只负责加载不在客户端做压缩避免阻塞 UI 线程。实测下来这个方案能让列表在 1000 条消息的会话里保持 60 帧滚动。3.2 输入框与键盘弹起的适配方案聊天页输入框的键盘适配是所有 Flutter 聊天应用开发者都会遇到的问题。iOS 和 Android 的差异在这里体现得特别明显iOS 的键盘弹起是遮挡式底部输入框会被键盘挡住Android 默认是调整式键盘弹起时整个窗口会被压缩。Flutter 里的标准做法是监听MediaQuery.of(context).viewInsets.bottom来获取键盘高度然后给输入框的底部 padding 加上这个高度。但这里有一个新手很容易踩的坑直接把这个 padding 加在输入框外层容器上会导致键盘弹起时聊天列表被压缩出现列表底部消息跳动的现象。我在这个项目里用的是Scaffold的resizeToAvoidBottomInset属性配合手动 padding。resizeToAvoidBottomInset设为 false让整个页面不被键盘压缩然后通过 AnimatedPadding 给输入框添加底部间距同时给列表添加一个跟随视图的滚动行为组合。另一个高频问题出现在聊天页里嵌入了 TextField 的底部弹窗场景。比如更多面板里有一个搜索框或者转发消息弹窗里有搜索选择器。如果弹窗里的 TextField 聚焦时键盘弹起而页面本身已经做了键盘适配两层适配叠加会导致弹窗位置错乱。我的处理方式是底部弹窗用showModalBottomSheet时设置isScrollControlled: true弹窗内容底部再按viewInsets.bottom做一次 padding并且弹窗内部使用Padding包裹而不是SafeArea避免双重安全区偏移。3.3 消息加载与位置恢复分页加载的历史消息和刚收到的实时消息在同一个列表里展示时如果处理不好用户会看到新消息把历史消息挤下去了的错乱场景。工程上的做法是ListView 使用reverse: true列表底部朝上最新的消息始终在视觉最下方。reverse: true还有一个额外的好处当消息数量超过一屏时列表默认展示的是最底部即最新消息这正好符合聊天场景的需求。加载历史消息时记录当前的maxScrollExtent新数据插入列表头部后用ScrollController.jumpTo()恢复到加载前的位置。这里有几个参数需要根据实际场景调加载历史消息的触发时机我是在列表滚动到顶部position.pixels 200时触发分页大小设为 20 条加载过程中显示一个加载中的旋转指示器但要让用户感觉不到页面卡住所以加载过程是异步的UI 不阻塞。3.4 底部弹窗内 TextField 的焦点问题这个点本来想放在刚才的键盘适配里但实际写代码时发现它值得单独拿出来说。聊天页的更多面板里如果有搜索框或者发消息时想某人会弹出搜索用户的面板。这个面板如果是showModalBottomSheet打开后 TextField 自动聚焦键盘弹出此时如果用户点击面板外关闭键盘收起和面板关闭的动画同时进行很容易出现面板关了键盘还在或键盘关了面板还在的闪烁。我的处理方案是弹窗打开时使用FocusScope.of(context).requestFocus()主动聚焦关闭时先FocusScope.of(context).unfocus()收起键盘等 200ms 键盘动画结束后再Navigator.pop()关闭面板。实测下来这个时序能保证动画不打架视觉上顺滑很多。4. 本地存储与未读消息体系4.1 数据库选型sqflite 还是 Hive聊天消息的本地存储要存什么消息本身、会话列表、草稿、已读位置这些都是高频读写的。数据库选型上我最终选了 sqflite理由有三个。第一聊天消息的数据结构是二维表结构最合适的会话表、消息表、联系人表彼此有外键关系。sqflite 的关系查询能力JOIN在会话列表页要显示最后一条消息 发送时间这类场景下非常好用。Hive 虽然是纯 NoSQLBox 的嵌套结构处理关系数据反而要自己拼装。第二sqflite 支持事务。消息批量入库、会话未读数批量变更这些操作必须放在事务里保证原子性。Hive 的事务能力非常弱大批量写入时很难保证一致性。第三sqflite 可以直接在 Dart 层做复杂的查询条件。比如拉取某个会话在某条消息之前的 20 条消息一个 SQL 语句就搞定了用 Hive 得先取整个 Box 再在内存里过滤数据量大了之后性能完全不在一个量级。表结构上消息表最核心的字段是message_id主键、conversation_id索引、sender_id、type、content、created_at、status。特别注意conversation_id和created_at要建联合索引因为分页查询的 WHERE 条件是这两个字段的组合。4.2 消息同步策略聊天 App 的离线消息同步是一个容易做得看似能用实则漏洞百出的部分。我用的方案是时间戳增量同步。本地数据库保存一个last_sync_time每次 App 从后台回到前台或者 WebSocket 重连成功时调一个 HTTP 接口传这个时间戳服务端返回这个时间戳之后的所有消息。客户端收到后先按message_id去重再批量插入本地数据库最后更新last_sync_time。这个方案的关键在于时间戳的粒度。如果用毫秒时间戳同一毫秒内到达的多条消息可能漏掉。我实际用的是服务端生成的自增序列号类似全局消息序号每次拉取时传我已知的最大序号服务端返回序号更大的消息用序号而不是时间戳作为同步游标彻底避免时间精度问题。4.3 会话列表未读数的维护未读数听起来简单做起来极其考究。会话列表每一行要显示未读消息数这个数字要随消息接收、会话进入、会话已读三种动作实时变化。我的做法是收到新消息时如果用户当前正在这个会话的聊天页里不增加未读数否则对应会话的未读数加一。进入会话页时把所有该会话的消息标记为已读未读数清零。这里有一个容易被忽略的边界如果用户在会话 A 的聊天页里而此时会话 B 来了新消息会话 B 的未读数要正常增加。未读数的存储我放在 sqflite 的会话表里不单独建表。每次更新未读数都走事务避免并发写入导致会话列表页显示的数字和实际消息数不一致。UI 层则用 ValueNotifier 监听未读总数在底部 Tab 的角标上实时展示。5. 打包发布与第三方接入5.1 接入微信登录的流程整理聊天类 App 基本都绕不开微信登录Flutter 端接入微信登录的流程主要分三步在微信开放平台注册应用拿到 AppID、在pubspec.yaml引入fluwx包并配置 URL Scheme、调起微信授权并处理回调。实际开发中最容易出问题的点是 URL Scheme 的配置。iOS 端需要在Info.plist里注册weixin开头的 URL Scheme同时配置LSApplicationQueriesSchemes让 App 能检测到微信是否已安装。Android 端的回调配置在build.gradle里设置manifestPlaceholders而且要保证包名和开放平台注册时一致否则微信授权时直接报应用签名不正确。微信登录成功后拿到的 code要交给后端换 access_token 和用户信息客户端不要用官方 API 直接换因为应用密钥存放在客户端有泄露风险。整个登录态的管理我用了一个简单的AuthManager单例内存中保存用户信息本地持久化 token每次 HTTP 请求时自动在 header 里带上。5.2 应用打包与发布注意事项打包之前有两类问题必须提前处理。一是权限声明聊天应用要申请存储权限保存图片/视频、相机权限拍照发送、麦克风权限语音消息。Android 12 以后存储权限的声明方式和以前不一样需要用MANAGE_EXTERNAL_STORAGE的话还要走特殊流程这个在提审时很容易被应用商店驳回建议打包前就把权限模型理清楚。二是代码混淆。Flutter 的 release 包默认的混淆规则有时候会把反射调用的代码搞掉比如 JSON 反序列化。我在proguard-rules.pro里保留了项目里所有 model 类的序列化方法-keep class com.example.chat_flutter_app.models.** { *; }另外Android 打包生成 APK 时一定要做分架构处理abiFilters里按需保留arm64-v8a和armeabi-v7a不需要打包x86_64否则包体会明显变大。release 包建议用flutter build apk --split-per-abi分开打减小每个包的体积。5.3 地图定位与位置消息的接入聊天里经常会发位置消息Flutter 里接入高德地图定位也是很多人的痛点。我的经验是定位权限的申请要放在聊天上下文中不要一进 App 就弹权限框。用户真正要发位置消息时弹出权限请求用户授权后直接调起高德 SDK 的定位方法。高德 SDK 接入的核心是 Android 和 iOS 的 key 配置不一样Android 用发布版 SHA1 签名iOS 用 Bundle ID两边的 key 不能混用。定位成功后把经纬度和逆地理编码的地址信息组装成位置消息发送。这个过程中最耗时的部分是逆地理编码需要网络请求必须放在异步任务里不要在 UI 线程操作。6. 常见问题与排查技巧实录6.1 WebSocket 连接被服务端断开且无错误码我遇到过好几次连接正常但服务端就是不转发消息的情况。排查过程走了不少弯路最后发现是因为客户端的ping消息格式不符合服务端预期。服务端要求ping必须是 JSON 格式{type: ping}而 web_socket_channel 默认的 ping 是二进制帧服务端根本识别不了。建议在开始对接前就和服务端约定好心跳消息的格式和业务消息的格式统一用 JSON 文本帧不要依赖 WebSocket 协议层的 ping/pong。这个约定最好写进接口文档里否则两边各按各的理解实现联调时就是一场灾难。6.2 聊天列表出现消息乱序消息乱序在不同场景下的原因不同。出现在弱网环境下两条消息间隔太近第二条先到服务端、第一条后到客户端收到后按其各自到达时间插入本地库顺序就乱了。解决思路是本地消息列表的排序字段不应该用到达时间而应该用服务端消息序号客户端在收到新消息时按序号插入到正确位置。出现在数据库查询时消息表没有按created_at建立合适的索引查询结果顺序不稳定。这个好排查给conversation_id created_at建联合索引然后 ORDER BYcreated_at就稳定了。6.3 Android 打包提示 Gradle 插件版本冲突这个报错坑了不少人搜索热词里就出现了好几次。根因是 Flutter 项目里settings.gradle声明的插件版本和build.gradle里的 AGP 版本不兼容。我遇到的具体报错是dev.flutter.flutter-plugin-loader解析失败后来把com.android.application版本改为7.3.0、org.jetbrains.kotlin.android版本改为1.7.10之后同步通过。经验是Flutter 版本和 Gradle 插件版本有对应关系不能盲目用最新版。fluter 官方的兼容表要常备升级 Flutter 版本时同步检查这几个插件的版本。另外项目里的gradle-wrapper.properties里的 Gradle 发行版版本也要和插件版本匹配不匹配会在同步阶段直接报错。6.4 收不到离线推送聊天类 App 通常要接入厂商推送小米、华为、OPPO、vivo来实现离线消息提醒。我的经验是离线推送的消息体里不要放完整的聊天消息内容只放会话 ID、发送者昵称、消息摘要和消息类型。用户点击通知栏后App 根据会话 ID 进入对应聊天页再通过消息同步接口拉取完整消息。这样设计的好处是安全——通知栏里不会显示过长的消息内容——同时还能避免离线推送内容和实际消息不一致的问题。如果服务端同时发了多条离线消息通知栏应该合并显示您有 N 条新消息而不是一条一条弹不然用户的手机通知栏会被刷屏。6.5 调试时抓包失败聊天应用的调试经常要抓包看 WebSocket 的帧内容。iOS 上 Charles 的 SSL 代理对 Flutter 的 HttpClient 无效因为 Flutter 默认不走系统代理。我后面用了一个曲线方案调试模式下在 App 里配置一个ProxyInfo把 WebSocket 的地址手动指向 Charles 的代理端口同时在代码里临时信任 Charles 的证书。这个方法只用于开发阶段release 包会关掉。Android 端的抓包相对简单但也需要注意 Android 7.0 之后默认不信任用户证书要么把 Charles 证书装到系统证书目录需要 root要么在network_security_config.xml里给 debug 包单独配置信任用户证书。写在最后的一些体会chat_flutter_app这个项目做完我最大的感受是聊天应用的技术难度不在某个单点而在所有环节加在一起之后的工程复杂度。状态管理、网络层、本地存储、UI 渲染、第三方 SDK 适配每一块单独拿出来都不难但组合起来需要你对整个数据流有非常清晰的认识。从架构上讲我最满意的是把消息模型和消息类型解耦的设计这让项目在后期加新消息类型时几乎没有改造成本。从工程实践上讲最有价值的是断线重连和消息可靠性保障的那套方案这直接决定了应用在真实网络环境下的可用性。如果你也想做一个 Flutter 聊天应用我的建议是不要一上来就追求功能多先把两个人互发一条文字消息并能稳定落地这个最小闭环跑通然后再去扩展图片消息、会话列表、未读数、离线推送这些进阶功能。每一步迭代都基于一条真实跑通的消息链路这样出了问题才好定位。最后分享一个调试小技巧开发阶段把 WebSocket 的收发消息全部打印到控制台加一个LogPrint封装的的 jsonEncode 输出联调时能省一半的时间。本文还有配套的精品资源点击获取
返回列表