一、IM 发送消息,千万别理解成"调一次 send() 就结束"
很多人刚开始做聊天,会把发送消息想得很简单:
dart
socket.send({"content": "你好"});
从"网络动作"的角度看,这当然没错。
但从"客户端体验"的角度看,这远远不够。
因为用户点击发送后,客户端至少要同时完成四件事:
- UI 里立刻出现一条"正在发送"的消息
- 这条消息要先写入本地缓存
- WebSocket 真正把消息发给服务端
- 等服务端 ACK 到来后,再把这条消息改成"发送成功"
也就是说,发送消息从来不是一个动作,而是一条完整链路。
在 Dart 里,WebSocket.add() 发送的数据只能是 String 或 List<int>,这意味着你最终发出去的消息通常还是 JSON 字符串或二进制包;而 pingInterval 则可以帮你做心跳和断线感知。换句话说,WebSocket 已经给了你"发消息"和"保连接"的基础,但"消息状态管理"仍然是客户端自己的职责。(api.dart.dev)
所以一个像样的 IM 发送流程,至少应该长这样:
text
用户点击发送
→ 本地生成 localId
→ SQLite 插入一条 sending 状态消息
→ UI 立刻展示这条消息
→ WebSocket 发出消息包
→ 服务端返回 ACK(携带 localId / serverId)
→ 本地更新消息状态为 success
→ 会话表更新最后一条消息预览
这套流程的重点只有一句话:
先本地,后网络,再确认。
二、为什么 localId 和 serverId 必须分开?
这是很多 IM 第一版最容易省略的点,但它偏偏很关键。
如果你只给消息一个 id,那发送时就会面临两个问题:
- 这个
id是本地先生成,还是等服务端返回? - 如果服务端还没回,UI 里这条消息用什么标识?
所以更靠谱的做法,是从一开始就把两个 ID 分开:
localId:客户端点击发送时,立即生成serverId:服务端真正接收、入库、确认后返回
下面是一个更适合放进博客的消息实体定义,代码我依然补了中文注释。
dart
/// 消息类型
enum MessageType {
text, // 文本消息
image, // 图片消息
file, // 文件消息
system, // 系统消息
}
/// 发送状态
enum MessageSendStatus {
sending, // 发送中
success, // 发送成功
failed, // 发送失败
}
/// 聊天消息实体
class ChatMessage {
/// 本地消息 ID
/// 点击发送时由客户端立即生成
final String localId;
/// 服务端消息 ID
/// 由服务端 ACK 返回
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,
);
}
}
这个模型最大的价值,不是"字段更全",而是它天然适配了 IM 的真实发送过程。
你后面要做这些功能时,它都会变得顺很多:
- 发送中动画
- 发送失败重试
- ACK 对账
- 掉线重连后的消息恢复
- 去重
三、ACK 回执到底在解决什么问题?
很多人第一次做聊天,会有一个误区:
"只要我把消息发出去了,就算成功。"
但真实情况不是这样。
从客户端视角看,"消息发出去"至少分三层:
- 客户端已经调用了
socket.add() - 服务端已经收到这条消息
- 服务端已经把这条消息处理成功,并返回确认
只有第三层,才是真正适合把消息标记为"发送成功"的时机。
所以 ACK 的本质,是让客户端知道这条消息到底有没有被服务端正式确认。
一个最简单的发送包和 ACK 包,可以这样设计:
json
{
"event": "message.send",
"payload": {
"localId": "l_10001",
"conversationId": "c_2001",
"type": "text",
"content": "你好"
}
}
服务端 ACK:
json
{
"event": "message.ack",
"payload": {
"localId": "l_10001",
"serverId": "m_90001",
"conversationId": "c_2001",
"ackTime": 1741240000
}
}
这里 localId 的意义非常大。
因为客户端收到 ACK 后,不需要猜测是哪条消息成功了,而是可以直接通过 localId 精确找到那条"发送中"的消息,然后更新状态。
也就是说,ACK 不是为了"让协议更完整",而是为了让客户端具备状态闭环。
四、发送消息时,为什么一定要先写 SQLite?
这个问题,其实可以反过来问:
桌面 IM 为什么不能只靠内存状态?
因为只靠内存,你几乎一定会遇到这些问题:
- 应用一重启,会话和消息全丢
- 正在发送中的消息没法恢复
- 草稿丢失
- 会话列表不能秒开
- 历史消息不能分页拉取
- 掉线重连后,没有本地基线状态可参考
所以 SQLite 对桌面 IM 来说,不是"以后再加的缓存层",而是非常基础的能力。
在 Flutter 桌面侧,sqflite_common_ffi 明确支持 Linux、macOS、Windows 上的 Flutter 和 Dart VM;而 sqflite 自己也说明了 Linux / Windows / Dart VM 的支持通常通过 sqflite_common_ffi 来完成。与此同时,drift_flutter 可以帮助 Flutter 应用更方便地打开 drift 数据库,并且在启用选项时可以创建专用数据库 isolate。也就是说,在桌面 IM 里,用 SQLite 并不是问题,重点只是你选"轻量直接"还是"更强建模"。(Dart packages)
如果你现在处于第一版落地阶段,我的建议很简单:
- 想快速稳妥:先用
sqflite_common_ffi - 想要更强建模和更舒服的响应式查询:后续再考虑
drift/drift_flutter
五、数据库表应该怎么设计,才像个聊天客户端?
对于 IM 桌面端,第一版最少应该有两张核心表。
1. 会话表 conversations
它负责承接:
- 会话标题
- 最后一条消息预览
- 最后一条消息时间
- 未读数
- 草稿
- 是否置顶
sql
CREATE TABLE conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
avatar TEXT,
last_message_preview TEXT,
last_message_time INTEGER,
unread_count INTEGER NOT NULL DEFAULT 0,
draft_text TEXT,
is_pinned INTEGER NOT NULL DEFAULT 0
);
2. 消息表 messages
它负责承接:
- 本地 ID
- 服务端 ID
- 所属会话
- 发送者
- 消息类型
- 消息内容
- 时间
- 发送状态
sql
CREATE TABLE messages (
local_id TEXT PRIMARY KEY,
server_id TEXT,
conversation_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
type INTEGER NOT NULL,
content TEXT NOT NULL,
send_time INTEGER NOT NULL,
is_self INTEGER NOT NULL,
send_status INTEGER NOT NULL
);
如果你后面要支持图片和文件消息,再补一张 attachments 表会更清晰。
但第一版即使先不拆附件表,这两张表也已经足够你把消息链路跑起来。
六、Flutter 桌面端初始化 SQLite,应该怎么写?
下面给你一个适合技术博客展示的 sqflite_common_ffi 初始化示例,代码我照样加了中文注释。
dart
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// 数据库服务
class AppDatabase {
static Database? _db;
/// 获取数据库实例
static Future<Database> instance() async {
if (_db != null) return _db!;
// 初始化 FFI
sqfliteFfiInit();
// 指定 desktop 环境下的 databaseFactory
databaseFactory = databaseFactoryFfi;
// 获取应用文档目录
final dir = await getApplicationDocumentsDirectory();
// 拼接数据库文件路径
final dbPath = p.join(dir.path, 'flutter_im.db');
// 打开数据库
_db = await databaseFactory.openDatabase(
dbPath,
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, version) async {
// 创建会话表
await db.execute('''
CREATE TABLE conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
avatar TEXT,
last_message_preview TEXT,
last_message_time INTEGER,
unread_count INTEGER NOT NULL DEFAULT 0,
draft_text TEXT,
is_pinned INTEGER NOT NULL DEFAULT 0
);
''');
// 创建消息表
await db.execute('''
CREATE TABLE messages (
local_id TEXT PRIMARY KEY,
server_id TEXT,
conversation_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
type INTEGER NOT NULL,
content TEXT NOT NULL,
send_time INTEGER NOT NULL,
is_self INTEGER NOT NULL,
send_status INTEGER NOT NULL
);
''');
},
),
);
return _db!;
}
}
这段代码的重点不是"怎么开库",而是它说明了一件事:
桌面 IM 从第一版开始,就应该有自己的本地数据根。
不然你后面所有关于消息恢复、会话恢复、掉线恢复、草稿恢复的能力,都会很被动。
七、发送消息的正确姿势:先写本地,再发网络
这一段很关键。
如果用户点击发送后,你要等服务端响应回来再插入消息,体验会很"假"。
成熟一点的 IM 客户端通常都会这样做:
- 用户点击发送
- 本地立刻生成一条 sending 状态消息
- 先写 SQLite
- UI 立即显示
- 再通过 WebSocket 发给服务端
下面这个代码块,就体现了这种思路。
dart
import 'dart:convert';
class SendMessageUseCase {
final Database db;
final ImSocketClient socketClient;
SendMessageUseCase({
required this.db,
required this.socketClient,
});
/// 发送文本消息
Future<void> sendTextMessage({
required String conversationId,
required String senderId,
required String text,
}) async {
// 1. 本地生成 localId
final localId = 'local_${DateTime.now().microsecondsSinceEpoch}';
// 2. 生成本地消息对象,先标记为 sending
final message = ChatMessage(
localId: localId,
serverId: null,
conversationId: conversationId,
senderId: senderId,
type: MessageType.text,
content: text,
sendTime: DateTime.now().millisecondsSinceEpoch,
isSelf: true,
sendStatus: MessageSendStatus.sending,
);
// 3. 先写入本地消息表
await db.insert('messages', {
'local_id': message.localId,
'server_id': message.serverId,
'conversation_id': message.conversationId,
'sender_id': message.senderId,
'type': message.type.index,
'content': message.content,
'send_time': message.sendTime,
'is_self': message.isSelf ? 1 : 0,
'send_status': message.sendStatus.index,
});
// 4. 更新会话表中的最后一条消息预览
await db.update(
'conversations',
{
'last_message_preview': text,
'last_message_time': message.sendTime,
},
where: 'id = ?',
whereArgs: [conversationId],
);
// 5. 再通过 WebSocket 发送给服务端
socketClient.send({
"event": "message.send",
"payload": {
"localId": localId,
"conversationId": conversationId,
"type": "text",
"content": text,
}
});
}
}
这里最有价值的部分,不是代码本身,而是顺序:
先本地落盘,再发网络。
这样做之后:
- UI 不用等服务端也能立即展示消息
- 即使发送过程中掉线,本地也知道有一条 sending 状态消息
- 重启应用后,也能恢复这条消息的存在
这就是为什么桌面 IM 看起来"稳不稳",很多时候不是看页面,而是看发送链路是不是先本地化。
八、收到 ACK 后,到底该更新什么?
收到 ACK,不是"打印一下日志就结束"。
一个正常的客户端,在收到 ACK 后至少要做三件事:
- 通过
localId找到本地那条 sending 消息 - 写入
serverId - 把
sendStatus从sending改成success
看代码会更直观。
dart
class AckHandler {
final Database db;
AckHandler(this.db);
/// 处理服务端 ACK
Future<void> handleAck(Map<String, dynamic> event) async {
final payload = event['payload'] as Map<String, dynamic>;
final localId = payload['localId'] as String;
final serverId = payload['serverId'] as String;
// 根据 localId 更新本地消息状态
await db.update(
'messages',
{
'server_id': serverId,
'send_status': MessageSendStatus.success.index,
},
where: 'local_id = ?',
whereArgs: [localId],
);
}
}
这一步做完以后,UI 层如果是基于数据库查询结果或状态层更新,就可以自然把这条消息从"发送中"切换到"发送成功"。
如果 ACK 超时一直没回来呢?
那就要考虑把状态改成 failed,并给用户一个"重试发送"的入口。
这就是 ACK 真正的价值:
它不是为了让协议更复杂,而是为了给消息状态一个可靠的落点。
九、收到服务端新消息时,别直接 append 到界面
这也是新手项目很常见的坑。
不少人收到服务端推送后,直接就把消息 append 到当前列表里。
这样做短期看很快,但会埋下两个问题:
- 去重难做
- 重启恢复难做
更稳妥的做法应该是:
- 先解析消息包
- 先做去重判断
- 先写 SQLite
- 再驱动 UI 更新
也就是说:
数据库是状态源,UI 是状态投影。
你可以把处理流程理解成这样:
text
Socket 收到 message.new
→ 解析 payload
→ 用 serverId 做去重判断
→ SQLite 插入消息
→ 更新 conversations 表最后一条消息
→ 若当前会话未激活,则 unread +1
→ UI 根据数据变化刷新
这种做法虽然看起来"绕了一层",但它会让客户端非常稳。
十、断线重连为什么最容易把消息搞乱?
掉线不可怕,最可怕的是掉线恢复后状态不一致。
IM 一旦进入重连场景,最容易出现的就是这三类问题:
1. 同一条消息重复显示
比如:
- 本地有一条 sending 消息
- 网络断开时服务端其实已经收到了
- 重连后服务端又把正式消息推了一次
- 客户端没有正确做
localId / serverId对账 - 结果列表里出现两条内容相同的消息
2. 本地 sending 状态永远不消失
因为客户端不知道:
- 这条消息到底是没发出去
- 还是已经发出去但 ACK 丢了
- 还是服务端成功了但回执没回来
3. 会话列表最后一条消息错乱
因为断网期间的消息补偿和本地预览更新顺序没理顺。
所以,断线重连不是"重新 connect 一下"这么简单,而是要有恢复策略。
十一、比较稳妥的断线恢复策略是什么?
我比较建议这样处理。
第一步:连接恢复后,先同步服务端增量消息
也就是说,不要盲目相信本地内存状态,而是让服务端告诉你:
- 你上次同步到哪了
- 这次应该补哪些消息
第二步:扫描本地仍处于 sending 的消息
找到这些消息后,逐条判断:
- 如果服务端已经确认过,更新为 success
- 如果服务端没有收到,允许用户重试或自动重发
- 如果状态不确定,标记为 failed,交给用户确认
第三步:用 serverId 或服务端序列做去重
对于已经有 serverId 的消息,以 serverId 作为主去重键最稳。
如果没有 serverId,那至少也要通过 localId + conversationId 做一层本地去重。
第四步:重建会话预览和未读数
不要只更新消息表,不更新会话表。
不然重连后虽然消息是对的,但左侧会话列表会显得不对劲。
十二、重连后如何处理"发送中"消息?
下面给你一个适合博客展示的恢复策略示例。
dart
class MessageRecoveryService {
final Database db;
MessageRecoveryService(this.db);
/// 连接恢复后,处理本地仍为 sending 状态的消息
Future<void> recoverPendingMessages() async {
final rows = await db.query(
'messages',
where: 'send_status = ?',
whereArgs: [MessageSendStatus.sending.index],
);
for (final row in rows) {
final localId = row['local_id'] as String;
// 这里先演示一种保守策略:
// 连接恢复后,先把所有长时间处于 sending 的消息标记为 failed
// 然后由用户手动点击重试
await db.update(
'messages',
{
'send_status': MessageSendStatus.failed.index,
},
where: 'local_id = ?',
whereArgs: [localId],
);
}
}
}
为什么我这里先给的是"保守策略",而不是"自动重发"?
因为自动重发虽然看起来更聪明,但如果你没有做完整的幂等处理,很容易把消息发重复。
所以实际项目里更稳的策略通常是:
- 第一版:先失败标记 + 用户手动重试
- 第二版:有幂等键和去重机制后,再引入自动重发
这个节奏更安全。
十三、为什么桌面客户端比移动端更需要"本地基线状态"?
因为桌面客户端往往有两个特点:
- 生命周期更长
- 用户更期待"像软件一样稳定"
移动端用户对"重新打开重新拉一遍数据"有一定容忍度。
但桌面端不一样。
桌面 IM 更像一个长期驻留的软件。
用户会天然期待它具备这些能力:
- 打开就能看到上次会话
- 会话列表秒开
- 历史消息能快速恢复
- 草稿不能丢
- 掉线恢复后状态尽量连续
而这些体验,最终都建立在一个前提上:
本地必须有可靠的数据基线。
也就是说,SQLite 在桌面 IM 中不是"优化项",而更像"地基"。
十四、这篇文章最重要的结论,不是代码,而是顺序
如果你把这篇文章的重点压缩成一句话,那就是:
IM 客户端设计里,顺序比动作更重要。
不是"有没有发消息",而是:
- 是不是先本地生成状态
- 是不是先写 SQLite
- 是不是有 ACK 闭环
- 是不是能在重连后恢复这条链路
同样,也不是"有没有重连",而是:
- 重连后是不是知道哪些消息处于不确定状态
- 是不是有去重依据
- 是不是会话预览和未读数也同步恢复
所以真正成熟一点的桌面 IM,核心从来不是"页面多好看",而是这条消息链路是不是完整闭环。
十五、最后总结
做到这里,你应该会发现一件事:
Flutter 做 IM 桌面端,真正难的从来不是 UI,而是消息状态治理。
而一旦你把下面这几件事理顺:
localId / serverId分离- 发送先本地落盘,再发网络
- ACK 负责闭环状态确认
- SQLite 作为本地基线状态源
- 重连后先恢复状态,再恢复交互
那你的桌面 IM 就会从"能聊天"开始变成"像一个真正的软件"。