一、先想清楚:你做的不是"聊天页面",而是"聊天客户端"
很多人写 IM,第一步就是先把页面搭出来:
- 左边会话列表
- 中间消息区域
- 右边详情栏
- 底部输入框
这当然没错,但如果你只停留在这里,那很容易越写越乱。
因为 IM 最终一定会遇到这些问题:
- 聊天记录越来越多,消息列表会不会卡
- 掉线之后怎么自动重连
- 自己发出去的消息,先展示还是等服务端返回后再展示
- 窗口关闭时到底退出应用,还是隐藏到托盘
- 应用重启之后,草稿、未读数、历史消息还在不在
- 多窗口聊天时,主窗口和子窗口的状态怎么同步
你会发现,这些问题已经完全超出了"页面开发"的范围。
它更像一个持续运行的桌面客户端系统。
所以从一开始,思路就应该变成这样:
- UI 只是外层表现
- 消息流才是主线
- 本地存储是基础设施
- 长连接是神经系统
- 窗口、托盘、通知是桌面端的外设能力
当你用这个视角去看 Flutter,你会发现它并不是"不适合",而是很适合承担主体层。
二、为什么 Flutter 其实很适合做 IM 的主体层?
这个问题不能空谈,我们直接从 IM 的特征出发来看。
1. IM 天然是一个长列表系统
无论是会话列表,还是消息列表,本质上都属于长列表。
尤其是聊天记录,它不是静态数据,而是会不断增长、不断插入、不断回滚历史消息的动态列表。如果这里的渲染方式选错,项目中后期体验会明显下滑。
所以 Flutter 在这件事上的优势,首先来自它的列表体系。
你不能把聊天记录写成这种"全量渲染"思路:
dart
ListView(
children: messages.map((e) => MessageBubble(e)).toList(),
)
小数据量时它当然能跑,但消息量一大,就很容易吃力。
更适合聊天场景的写法,是 ListView.builder 这种"按需构建"的方式。因为聊天页的重点从来不是"一次全部画出来",而是"当前屏幕需要谁,就先构建谁"。
下面是一个更适合博客展示的注释版消息列表示例:
dart
import 'package:flutter/material.dart';
/// 聊天消息列表
/// 这里使用 ListView.builder,而不是一次性渲染全部消息,
/// 更适合 IM 这种消息持续增长的长列表场景。
class ChatMessageList extends StatelessWidget {
final List<String> messages;
const ChatMessageList({
super.key,
this.messages = const [
'你好,最近在忙什么?',
'在做 Flutter 桌面 IM 客户端。',
'听起来很有意思。',
'是的,主要在做会话列表、消息流和长连接。',
],
});
@override
Widget build(BuildContext context) {
return ListView.builder(
// reverse: true 表示列表反向渲染
// 聊天场景中,通常希望最新消息更靠近底部输入框
reverse: true,
// 给消息区加一点内边距,界面更舒展
padding: const EdgeInsets.all(16),
// 消息总数
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
// 这里只是演示:偶数条消息视为自己发送,奇数条视为对方发送
final isSelf = index.isEven;
return Align(
// 自己的消息靠右,对方消息靠左
alignment: isSelf ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
// 每条消息之间增加一点垂直间距
margin: const EdgeInsets.only(bottom: 10),
// 气泡内边距
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
// 限制气泡最大宽度
// 桌面屏幕更宽,如果不限制,文本会被拉得过长,阅读体验反而差
constraints: const BoxConstraints(maxWidth: 420),
decoration: BoxDecoration(
// 自己和对方的消息使用不同底色区分
color: isSelf ? Colors.blue.shade50 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Text(msg),
),
);
},
);
}
}
这段代码看起来简单,但它背后其实代表了一个很重要的工程意识:
聊天页不是"能显示就行",而是必须从第一天开始,就按长列表思维去写。
2. IM 非常依赖状态变化,而 Flutter 很适合状态驱动
IM 客户端的典型特点,就是状态变化非常频繁。
比如:
- 新消息来了
- 连接断开了
- 正在重连
- 未读数变了
- 某条消息从
sending变成了success - 会话草稿更新了
- 当前窗口激活状态变化了
这意味着 IM 很不适合那种"页面点一下、接口调一下、局部改一下"的松散式开发方式。
它更适合状态驱动式架构。
Flutter 在这里的优势,不是"自带某个神奇状态管理框架",而是它本身就非常适合围绕状态去刷新 UI。只要你的状态边界划分得合理,IM 这类高频状态应用写起来其实很顺。
3. IM 的通信模型,和 Dart 的 WebSocket 也天然契合
做 IM,离不开长连接。
而 Dart 的 WebSocket 本身就是双向通信对象,不只可以发送消息,也可以持续接收服务端推送。对 IM 来说,这种模式非常自然。
但这里要强调一句:
WebSocket 在 IM 项目里,绝对不是"连上就行"。
真正的 IM 连接层,要关心的事情包括:
- 当前连接状态
- 心跳机制
- 掉线感知
- 自动重连
- 手动退出和异常断开的区分
- 消息去重
- ACK 回执
所以连接层从一开始,就不能写成一个只有 connect() 和 send() 的小工具类。
三、项目结构别从页面开始堆,要先把边界立住
很多 Flutter 项目,第一版目录都长这样:
text
lib/
├─ pages/
├─ services/
├─ models/
└─ utils/
这种结构在小项目里问题不大,但 IM 不是小项目。
一旦你开始加:
- 会话索引
- 消息缓存
- 失败重发
- 撤回消息
- 托盘通知
- 多窗口
- 草稿同步
你就会发现,页面、网络、数据库、桌面能力会越来越缠。
所以更合理的拆法应该像这样:
text
lib/
├─ app/
│ ├─ app.dart
│ ├─ router/
│ └─ theme/
├─ common/
│ ├─ constants/
│ ├─ utils/
│ └─ widgets/
├─ domain/
│ ├─ entity/
│ ├─ repository/
│ └─ usecase/
├─ infrastructure/
│ ├─ network/
│ │ ├─ websocket/
│ │ └─ http/
│ ├─ storage/
│ │ ├─ database/
│ │ └─ cache/
│ └─ native/
│ ├─ window/
│ ├─ tray/
│ └─ notification/
├─ features/
│ ├─ session/
│ ├─ chat/
│ ├─ contacts/
│ └─ settings/
└─ main.dart
这套结构最核心的价值,不在于"层次感很强",而在于:
它让每一类能力都有明确归属。
比如:
features只负责页面和交互domain负责消息、会话、已读、撤回等业务规则infrastructure/network负责 WebSocket 和 HTTPinfrastructure/storage负责 SQLite、本地缓存infrastructure/native专门处理窗口、托盘、通知这类桌面能力
当你后面需要调整托盘逻辑、补原生能力、增加多窗口时,这种分层的价值会越来越明显。
四、桌面 IM 首页怎么布局,才像真正的客户端?
桌面端和移动端最大的差异之一,就是屏幕空间更大。
所以桌面 IM 最适合的布局,不是"一个页面切一个页面",而是三栏式并列布局:
- 左边:会话列表
- 中间:聊天主区
- 右边:详情区
这样用户在一个窗口里,就能同时完成:
- 切换聊天对象
- 查看当前消息流
- 查看会话上下文信息
这才是桌面软件该有的味道。
下面是一个适合博客展示的三栏布局骨架,代码我也补上了中文注释。
dart
import 'package:flutter/material.dart';
/// IM 桌面端首页
/// 典型三栏布局:左侧会话列表、中间聊天区、右侧详情区
class ImHomePage extends StatelessWidget {
const ImHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: const [
// 左侧:会话列表区域,固定宽度更符合桌面端习惯
SizedBox(
width: 300,
child: SessionSidebar(),
),
// 左中分割线
VerticalDivider(width: 1),
// 中间:聊天主区域,占据主要空间
Expanded(
child: ChatPanel(),
),
// 中右分割线
VerticalDivider(width: 1),
// 右侧:详情区,可用于展示群成员、公告、共享文件等
SizedBox(
width: 280,
child: ConversationDetailPanel(),
),
],
),
);
}
}
/// 左侧会话列表区域
class SessionSidebar extends StatelessWidget {
const SessionSidebar({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: const [
SizedBox(height: 12),
// 实际项目中,这里通常会放搜索框 + 会话列表
Expanded(
child: SessionListView(),
),
],
);
}
}
/// 中间聊天主区
class ChatPanel extends StatelessWidget {
const ChatPanel({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: const [
// 顶部聊天标题栏,例如显示会话名称、在线状态、工具按钮
ChatHeader(),
Divider(height: 1),
// 消息区使用 Expanded 撑满剩余空间
Expanded(
child: ChatMessageList(),
),
Divider(height: 1),
// 底部输入区域
ChatInputPanel(),
],
);
}
}
/// 右侧详情区
class ConversationDetailPanel extends StatelessWidget {
const ConversationDetailPanel({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: Text('会话详情'),
);
}
}
这段代码最关键的价值,其实不是 UI,而是它天然帮你建立了页面边界:
- 左边只关心会话索引
- 中间只关心当前消息流
- 右边只关心辅助信息
等你后面要加会话搜索、共享文件、群成员列表时,这个结构会非常稳。
五、消息模型别随便定义,localId 和 serverId 一开始就要分开
很多人刚开始做 IM,会把消息模型写得非常简单:
- 一个
id - 一个
content - 一个
senderId
这种写法早期当然能用,但很快你就会发现不够了。
因为一条消息的生命周期通常是这样的:
- 用户点击发送
- 客户端先本地生成一条"发送中"的消息
- UI 立即显示这条消息
- WebSocket 把消息发给服务端
- 服务端返回 ACK 和正式消息 ID
- 客户端再把本地状态改成"发送成功"
这意味着,一个成熟一点的消息模型里,至少应该区分:
localId:客户端先生成的本地消息 IDserverId:服务端最终确认后的正式消息 ID
下面是一个更合理的消息实体示例:
dart
/// 消息类型
enum MessageType {
text, // 文本消息
image, // 图片消息
file, // 文件消息
system, // 系统消息
}
/// 发送状态
enum MessageSendStatus {
sending, // 发送中
success, // 发送成功
failed, // 发送失败
}
/// 聊天消息实体
class ChatMessage {
/// 本地消息 ID
/// 用户点击发送时,客户端立即生成
final String localId;
/// 服务端消息 ID
/// 服务端确认接收后返回
final String? serverId;
/// 会话 ID
final String conversationId;
/// 发送者 ID
final String senderId;
/// 消息类型
final MessageType type;
/// 消息内容
final String content;
/// 发送时间,通常使用毫秒时间戳
final int sendTime;
/// 是否为自己发送
final bool isSelf;
/// 消息发送状态
final MessageSendStatus sendStatus;
const ChatMessage({
required this.localId,
this.serverId,
required this.conversationId,
required this.senderId,
required this.type,
required this.content,
required this.sendTime,
required this.isSelf,
required this.sendStatus,
});
/// 局部拷贝更新
/// 例如服务端 ACK 返回后,把 sending 改成 success
ChatMessage copyWith({
String? serverId,
MessageSendStatus? sendStatus,
}) {
return ChatMessage(
localId: localId,
serverId: serverId ?? this.serverId,
conversationId: conversationId,
senderId: senderId,
type: type,
content: content,
sendTime: sendTime,
isSelf: isSelf,
sendStatus: sendStatus ?? this.sendStatus,
);
}
}
这里最重要的,不是字段多少,而是这套模型背后体现出的客户端思维:
消息不是"发出去就完了",而是有完整生命周期的。
六、WebSocket 长连接怎么设计,才不只是一个 demo?
这部分是整个 IM 客户端的核心之一。
很多示例代码里,WebSocket 会被写成这样:
- 一个
connect() - 一个
send() - 一个监听回调
这当然可以跑起来,但它更像一个演示,不像一个真正的客户端连接层。
因为客户端连接层真正需要做的,是管理连接生命周期。
也就是说,它至少要解决这些事情:
- 当前是不是已连接
- 掉线时是不是要重连
- 用户主动退出时不要误重连
- 服务端长时间没响应时,如何判断连接失效
- 外部页面怎样感知连接状态变化
下面这个版本,是更适合写进博客里的注释版长连接管理器。
dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// 连接状态枚举
/// 用于让 UI 或状态管理层感知当前连接状态
enum ImConnectionState {
idle, // 初始状态
connecting, // 正在连接
connected, // 已连接
reconnecting, // 正在重连
disconnected, // 已断开
}
/// IM WebSocket 客户端
/// 负责连接建立、消息接收、状态通知、心跳、自动重连等能力
class ImSocketClient {
WebSocket? _socket;
/// 消息流控制器
/// broadcast 表示允许多个监听者同时订阅
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
/// 连接状态流控制器
final _stateController = StreamController<ImConnectionState>.broadcast();
/// 对外暴露消息流
Stream<Map<String, dynamic>> get messages => _messageController.stream;
/// 对外暴露连接状态流
Stream<ImConnectionState> get states => _stateController.stream;
String? _url;
/// 用于区分"手动关闭"还是"异常断开"
bool _manualClose = false;
/// 当前重连次数
int _reconnectCount = 0;
/// 建立 WebSocket 连接
Future<void> connect(String url) async {
_url = url;
_manualClose = false;
// 通知外部:开始连接
_stateController.add(ImConnectionState.connecting);
try {
// 建立 WebSocket 连接
_socket = await WebSocket.connect(url);
// 设置心跳间隔
// 会定时发送 ping,若长时间收不到 pong,会自动断开
_socket!.pingInterval = const Duration(seconds: 20);
// 通知外部:连接成功
_stateController.add(ImConnectionState.connected);
// 连接成功后,重连次数归零
_reconnectCount = 0;
// 监听服务端推送
_socket!.listen(
(event) {
// 这里只处理字符串消息
if (event is String) {
final map = jsonDecode(event) as Map<String, dynamic>;
_messageController.add(map);
}
},
// 连接关闭时触发
onDone: _handleDisconnect,
// 出错时也进入断开处理流程
onError: (_) => _handleDisconnect(),
// 出错后终止当前监听
cancelOnError: true,
);
} catch (_) {
// 建连失败时进入重连流程
_scheduleReconnect();
}
}
/// 发送消息
/// 这里把 Map 转成 JSON 字符串后发送给服务端
void send(Map<String, dynamic> data) {
_socket?.add(jsonEncode(data));
}
/// 处理断开逻辑
void _handleDisconnect() {
// 如果是用户主动关闭,就不要继续重连
if (_manualClose) {
_stateController.add(ImConnectionState.disconnected);
return;
}
// 如果不是手动关闭,说明可能是网络问题或服务端断开
_scheduleReconnect();
}
/// 自动重连
void _scheduleReconnect() {
if (_url == null) return;
_stateController.add(ImConnectionState.reconnecting);
_reconnectCount++;
// 简单退避策略:
// 前几次逐步增加等待时间,避免在异常情况下疯狂重试
final delay = Duration(
seconds: _reconnectCount > 5 ? 10 : _reconnectCount * 2,
);
Future.delayed(delay, () {
// 如果这段时间用户已经手动关闭,就不要继续重连
if (_manualClose || _url == null) return;
connect(_url!);
});
}
/// 手动关闭连接
Future<void> close() async {
_manualClose = true;
await _socket?.close();
}
/// 释放资源
Future<void> dispose() async {
await close();
await _messageController.close();
await _stateController.close();
}
}
这段代码和普通 demo 最大的区别,不在于它更长,而在于它已经开始具备"客户端"的意识了:
- 有状态流
- 有心跳
- 有重连
- 有手动关闭和异常断开的区分
- 有对外广播能力
也就是说,它已经不只是一个"网络工具类",而是一个真正的连接管理器。
七、桌面能力不能直接进业务层,必须收口
Flutter 做桌面 IM,还有一个很容易被忽视的点:
桌面能力和业务逻辑,必须分开。
比如:
- 窗口显示与隐藏
- 最小化到托盘
- 桌面通知
- 多窗口管理
这些都属于桌面环境能力,而不是聊天业务本身。
如果你把这些调用直接散在页面里,后面会非常难维护。
比如下面这种写法就不太理想:
dart
windowManager.hide();
trayManager.setIcon(...);
trayManager.popUpContextMenu();
为什么说它不好?
因为它会导致:
- 页面直接依赖具体插件
- 平台差异散落在业务代码中
- 后面一旦要改插件或补原生实现,重构成本很高
更好的方式,是统一抽象出一个桌面壳层。
dart
/// 桌面壳层抽象
/// 用于统一封装窗口、托盘、通知等桌面能力
/// 业务层不要直接调用具体插件,而是依赖这个抽象接口
abstract class DesktopShell {
/// 初始化桌面环境
Future<void> init();
/// 显示主窗口
Future<void> showMainWindow();
/// 隐藏到托盘
Future<void> hideToTray();
/// 显示桌面通知
Future<void> showNotification(String title, String body);
}
这层抽象的意义在于:
- 聊天业务层不用知道具体用了什么插件
- 后面要换实现,只改壳层
- 需要补原生代码时,不会影响上层页面结构
在真正的桌面客户端项目里,这种收口几乎是必须的。
八、主窗口初始化,也应该单独封装
桌面应用不像移动端,窗口本身就是一个重要对象。
包括:
- 初始大小
- 是否居中
- 是否获取焦点
- 标题怎么设置
这些都不应该散在 main.dart 各个角落,而应该有一个统一初始化入口。
dart
import 'package:window_manager/window_manager.dart';
import 'dart:ui';
/// 初始化主窗口
/// 一般在应用启动阶段调用
Future<void> initMainWindow() async {
// 确保 window_manager 初始化完成
await windowManager.ensureInitialized();
// 配置窗口参数
const options = WindowOptions(
// 初始窗口尺寸
size: Size(1280, 860),
// 启动时居中显示
center: true,
// 窗口标题
title: 'Flutter IM',
// 标题栏样式
titleBarStyle: TitleBarStyle.normal,
);
// 等待窗口准备完成后显示
await windowManager.waitUntilReadyToShow(options, () async {
// 显示窗口
await windowManager.show();
// 获取焦点,让窗口置于前台
await windowManager.focus();
});
}
这类代码在技术博客里非常加分,因为它会让读者感觉到:
这不是在讲一个抽象概念,而是在讲一个真正准备落地的桌面项目。
九、本地存储为什么第一版就应该考虑?
很多人做 IM 时,会把本地数据库放到"以后再说"。
但在桌面端,这个决定往往会让你后面很被动。
因为这些功能都离不开本地存储:
- 历史消息缓存
- 会话索引
- 草稿保存
- 未读数恢复
- 文件消息状态记录
如果没有数据库,体验会非常虚:
- 应用一重启,状态全没
- 会话列表不能秒开
- 草稿丢失
- 未读数不稳
所以哪怕第一版不做复杂搜索,也应该至少把这些表的边界先想清楚:
conversationsmessagesdraftsattachments
这一步虽然不直观,但它决定的是整个客户端的稳定性。
十、最后总结:Flutter 不是不能做 IM,而是不能"随便做"
说到底,Flutter 做 IM 桌面端这件事,真正的关键不在框架本身,而在于你怎么用它。
如果你只是想:
- 快速画个聊天页
- 接个 WebSocket
- 把消息显示出来
那当然也能跑。
但那更像一个演示样例,而不是一个真正的桌面 IM 客户端。
真正正确的方式应该是:
- 用 Flutter 承担跨平台 UI 主体
- 用合理的项目分层隔离页面、业务、基础设施
- 用
ListView.builder和局部刷新思维处理长列表 - 用有状态、有心跳、有重连的连接管理器管理 WebSocket
- 用本地数据库承接消息、会话和草稿
- 用桌面壳层封装窗口、托盘、通知等能力