IM 聊天软件是最容易写出 demo、最难写出稳定成品的方向,尤其到了桌面端,坑比移动端多一个量级。我之前用 Flutter 做了一款支持 Windows 和 macOS 的桌面端 IM 客户端,把消息发送、ACK 回执、SQLite 本地缓存、断线重连这几条链路完整捋了一遍,中间踩了不少坑,也沉淀了一套可以直接复用的方案。这篇文章就是把这套设计从头到尾拆开讲,适合已经能跑通 Flutter 基础工程、正准备做 IM 或消息类应用的开发者,也适合那些在移动端做过 IM、想迁移到桌面端的朋友——桌面端的生命周期、网络、数据库和输入法生态都和手机不一样,不能简单平移。看完你至少能搞清楚三件事:消息从输入框到“已送达”到底经过哪几步、SQLite 在桌面端怎么用才不会卡 UI、断线重连为什么不是“掉了就重连”这么简单。
1. 项目整体拆解:一条消息的完整旅途
1.1 桌面端 IM 和移动端 IM 的三个显著差异
很多人把 Flutter IM 从移动端搬到桌面端时,第一反应是“代码复用就行”,实际上桌面的运行环境差异非常大,我总结三个最影响架构的点。
第一是生命周期。移动端的 App 有 onPause、onStop、onResume 这一套,切后台就是“可以安心断线”的信号。桌面端没有严格的后台概念,窗口最小化之后进程还在跑,socket 还挂着,这就导致断线重连不能依赖生命周期回调,而要完全基于网络状态和心跳结果来判断。
第二是输入法。移动端 TextField 基本都是系统输入法接管。桌面端在 Windows 上奔着搜狗、微软拼音这类 IME 走,组合输入过程中 Flutter 的文本合成事件和原生 IM 很容易打架,尤其是消息快速连续发送时,候选框还没确定,消息就飞出去了,会在发送层出现半截文本。
第三是数据库和文件系统。移动端 SQLite 路径统一,桌面端每个系统的用户目录结构都不同,Windows 得用 appData,macOS 要走 Application Support,目录没处理好,用户升级换机器后历史消息全没了。这个我在后面缓存章节详细说。
这三点的共同结论是:桌面端 IM 客户端,必须把“网络连接管理”和“消息本地化”当成一等公民来设计,而不是挂在页面生命周期下面。
1.2 消息从输入到“已读”的生命周期划分
为了让团队成员别在状态上互相踩脚,我把一条消息拆成了六个阶段,每个阶段都有一个明确状态,贯穿 UI、发送队列和数据库三端:
- 草稿阶段:还在输入框里,不属于消息,不落库。
- sending:点击发送后立刻生成消息实体,先写 SQLite 状态为 sending,再塞进发送队列。
- sent:服务端 TCP 层收到并返回应用层 ACK,确认服务端已持久化,此时状态置为 sent。
- acked:对端已读,通过已读回执更新状态,这是手动 ACK 的部分。
- failed:超时或连接断开重发失败,UI 上显示红点,允许手动重试。
- 撤回/删除:属于消息生命周期外的扩展,可以基于 msg_id 做。
这个状态机的好处在于,任何时刻崩溃重启,客户端启动时扫一遍 SQLite 里状态为 sending 的消息,直接重新投递,不会丢消息,UI 也不会出现“消息发出去了但界面不知道”的断档。
1.3 为什么选 Flutter + SQLite + 自研 ACK 这套组合
先从技术选型说起。当时桌面端 IM 的可选方案其实不少,Electron、Qt、原生 Win32、Flutter 各有拥趸。我选 Flutter 不是因为跨端 UI 好看,而是三个现实原因:一是消息列表这种高频滚动场景,Flutter 的列表渲染性能比 WebView 方案稳定;二是 Dart 的单线程模型配合 Isolate 做 SQLite 操作足够简单,不用像 C++ 那样手写线程同步;三是 Flutter 桌面端已经支持多窗口插件,后续做独立聊天窗口很顺畅。
缓存层选 SQLite 而不是内存 Map 或文件存储,理由是:内存 Map 一崩全丢,文件存储要自己处理索引、追加、碎片回收,而 SQLite 自带了事务、索引、崩溃恢复,十万条消息的量级它完全扛得住。至于 ACK,我一开始想过完全依赖 TCP 可靠传输,不做应用层 ACK,结果联调时发现一个致命问题:TCP 只保证字节流到达,不保证“对端应用已经处理完成”。服务端进程 crash 前收了数据,但业务没落库,重启后这个数据就丢了,客户端这边还显示已发送。所以 ACK 必须做在业务层,这在架构上是没有商量余地的。
2. 消息发送与 ACK 回执的核心设计
2.1 消息 ID 和 seq 序列号怎么设计不容易出乱子
做 IM 第一件事不是写界面,是把消息唯一标识定下来。我用的是两层 ID:
- msg_id:全局唯一消息 ID,客户端生成,格式为 “clientId_时间戳_自增序号”,用于消息去重和 UI 定位。
- seq:会话内单调递增序号,用于排序和服务端增量同步。注意 seq 是会话维度的,不是全局的,因为拉取历史消息时只需要会话内有序。
为什么不直接用时间戳当排序字段?因为桌面端消息经常是批量同步补拉回来的,服务端时间戳和本地时间戳存在时钟偏差,几条消息之间的时间差很小,直接按时间排可能乱序。seq 由服务端统一分配,客户端在收到新消息时以 seq 定序,写入 DB 时单独建索引,查询分页也走 seq,这样排序就完全脱离了物理时钟。
客户端发送消息时,本地先分配一个负数 seq(例如 -local_auto_increment),发送成功拿到服务端确认后,再用服务端分配的真实 seq 更新这条记录。这样即便服务端在极端情况下调整顺序,本地也保证有据可依,不会把 UI 搞错乱。
2.2 发送队列与状态机的代码落地
发送这块我只用一个 FIFO 队列 + 定时 flush 的模型,不搞复杂的优先级队列。为什么?IM 消息本质上是按用户发送顺序投递的,插队会造成会话内时序错乱,服务端也不会承认乱序的消息。每一条消息进队列前都先写库,确保进程被杀也能恢复。
核心代码长这样:
enum MessageStatus { sending, // 0 sent, // 1 acked, // 2 failed, // 3 } class MessageEntity { final String msgId; final String sessionId; int seq; // 本地暂用负值,服务端确认后更新 final String content; MessageStatus status; final DateTime createdAt; } final Queue<MessageEntity> _outbox = Queue<MessageEntity>(); void enqueueMessage(MessageEntity msg) { // 1. 先落库 _db.insertMessage(msg); // 2. 入队 _outbox.add(msg); // 3. 触发发送 _flushOutbox(); } Future<void> _flushOutbox() async { if (_isFlushing) return; _isFlushing = true; while (_outbox.isNotEmpty) { final msg = _outbox.first; try { await _sendMessage(msg); _outbox.removeFirst(); // 发送成功,出队 } on TimeoutException { msg.status = MessageStatus.failed; await _db.updateStatus(msg.msgId, MessageStatus.failed); _outbox.removeFirst(); // UI 提示重试 } } _isFlushing = false; }这里关键是_isFlushing这个标志位。如果不加这个锁,多条消息同时触发enqueueMessage时会产生多个并发发送协程,同一个会话的消息顺序就会乱。我初期就踩过这个坑,表现为“偶尔两人消息互换位置”,排查半天才发现是 flush 并发。
2.3 服务端 ACK 与本地确认的时序细节
服务端 ACK 的格式我定义为:
{ "type": "msg_ack", "ackId": "ack_123456", "msgId": "client_10001_1699999999999_1", "serverSeq": 10086, "ts": 1699999999999 }客户端收到 ACK 后,做两件事:第一,按 msgId 更新本地消息状态为 sent;第二,用 serverSeq 覆盖本地 seq,如果发现本地会话的 max_seq 小于这个值,就更新本地的同步水位。这里有一个很容易忽略的坑:ACK 消息本身也会在网络里丢去重,所以客户端要维护一个 ack 去重集合,防止同一条 ACK 被处理多次引起状态回退。
去重我用的方案是:收到 ACK 后先查本地 messages 表,如果该 msgId 的状态已经是 sent 或 acked,说明 ACK 重复,直接忽略;只有 sending 状态下才执行更新。这个“状态比对”比单独维护 dedupe 集合更可靠,因为重启后 dedupe 会丢,但数据库不会。
2.4 手动 ACK 与自动 ACK 的选择逻辑
ACK 分两种:自动 ACK 是服务端收到消息后自动回执,代表“我收到了”;手动 ACK 是对端读过消息后主动上报的已读回执,代表“我看过了”。这两者必须分开处理,否则会闹出“消息刚发出去就显示已读”的乌龙。
我的设计是:自动 ACK 由服务端即时返回,客户端收到后只把状态从 sending 改成 sent,用来驱动 UI 把“发送中”的转圈熄灭。手动 ACK 通过单独的信令通道传递,由对方客户端在会话处于前台、消息进入可视区域时上报,收到后本地把状态改成 acked,并更新会话列表的未读数。
这里要特意提醒:不要用自动 ACK 去刷新“已读”状态。我见过有团队为了省事,把服务端确认和已读回执合在一个字段里,结果消息发出去两秒就显示“已读”,用户骂声一片。协议设计上这两个字段一定要明确分离。
3. SQLite 本地缓存:表结构、索引和十万条消息的性能实践
3.1 为什么缓存一定要落在 SQLite
桌面端 IM 如果没有本地缓存,每次开客户端都要把所有历史消息从服务端拉一遍,体验非常差。选 SQLite 有三个理由:第一,事务机制能保证“消息落库”和“状态更新”的原子性,崩溃后不会出现半条消息;第二,SQL 查询能力做分页、按时间过滤很方便,不用自己写一堆 Map 操作;第三,Flutter 官方生态里有 sqflite_common_ffi 支持桌面端,调用方式和移动端几乎一致,不用换抽象层。
我在桌面端测试过 SQLite 的稳定性,单库写入十万条以上消息完全没问题,只要索引设计合理,查询基本在毫秒级。真正容易翻车的是并发访问——桌面端多个 isolate 或平台通道同时开库,会碰到数据库锁的问题,这个后面细说。
3.2 核心表结构设计与迁移方案
消息表我设计得比较精简,运维和维护都方便:
CREATE TABLE messages ( local_id INTEGER PRIMARY KEY AUTOINCREMENT, msg_id TEXT NOT NULL UNIQUE, session_id TEXT NOT NULL, seq INTEGER NOT NULL DEFAULT 0, content TEXT NOT NULL, status INTEGER NOT NULL DEFAULT 0, read_status INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX idx_messages_session_seq ON messages(session_id, seq DESC); CREATE INDEX idx_messages_msg_id ON messages(msg_id);会话表单独建一个:
CREATE TABLE sessions ( session_id TEXT PRIMARY KEY, session_type INTEGER NOT NULL, last_seq INTEGER NOT NULL DEFAULT 0, unread_count INTEGER NOT NULL DEFAULT 0, last_message TEXT, updated_at INTEGER NOT NULL );这里要强调seq的设计:它承担着“增量同步水位”功能。客户端本地维护每个会话的 last_seq,重连或下拉刷新时,拿着 last_seq 向服务端要增量数据,服务端返回 seq 大于 last_seq 的消息。这套方案比基于时间戳的同步方案更稳,因为 seq 是单调的,不会有时间回拨问题。
数据库迁移我用的是手动版本管理:建一个schema_version表,每次升级比对版本号,执行对应的 ALTER 语句。桌面端为什么要特别强调迁移?因为应用是安装在用户机器上的,不像 Web 端每次刷新都是最新代码,SQLite 的 schema 一旦和代码预期不一致,crash 都不给提示,直接 “no such column” 而且不可恢复。我用 db4s(DB Browser for SQLite)手动检查过这种问题,最快的恢复办法是从备份文件重建库。
3.3 十万条消息场景下的性能实测
我专门在 Windows 和 macOS 上做了压测,插入 10 万条消息,记录不同查询的耗时。这里列一个实测数据,供参考:
| 场景 | 无索引 | 加索引 | 优化后 |
|---|---|---|---|
| 按会话分页查询(每页 50 条) | 380ms~600ms | 15ms~25ms | 2ms~5ms |
| 按 msg_id 精确查询 | 120ms~200ms | 1ms~3ms | 1ms |
| 未读数统计 | 450ms | 20ms | 6ms |
无索引阶段查询慢的原因不是 SQLite 本身,而是全表扫描。加了(session_id, seq DESC)复合索引后,由于查询条件是“session_id 等值 + seq 排序”,这个索引能被直接利用,性能提升非常明显。
但索引也不是越多越好。每一条索引都会拖慢写入速度,尤其 IM 场景是高频写、低频读,所以我在“会话维度的 seq 查询”上只保留一个复合索引,read 状态查询直接查内存缓存,不落到数据库。实测下来 10 万条消息的数据库文件大小在 80MB 左右,完全可接受。
3.4 WAL 模式、事务和并发写库的实战处理
桌面端 SQLite 默认的 rollback journal 模式在“多进程同时读、单进程写”时表现还行,但同一进程内多个 isolate 并发写,很容易出现database is locked。我的处理方案是两件事:
第一,打开数据库时启用 WAL 模式:
PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;WAL 模式下读和写可以并发,写操作之间才互斥,非常适合 IM 这种“读列表 + 写新消息”同时发生的场景。同步级别用 NORMAL,性能比 FULL 好很多,而且 WAL 本身具备崩溃恢复能力,断电最多丢最后几条,不会损坏整个库。
第二,所有写操作走同一个事务序列。我在 Dart 层封装了一个DatabaseBatchExecutor,内部用一个队列把写请求串行化,但读取不受影响。这样既避开了 SQLite 的锁竞争,又不会因为并发事务导致主键冲突或状态覆盖。
第三,不要把 SQLite 操作放在 UI 线程。Flutter 的 UI isolate 和 IO 是同一个事件循环,如果直接同步执行一次大查询,界面会卡顿几百毫秒。我用compute或Isolate.run把查询扔到后台 isolate,查询结果再回传主 isolate 刷新列表。实测在 10 万条消息下列表滑动依然流畅,这个方案在桌面端完全够用。
4. 断线重连设计:心跳、退避和消息补偿
4.1 断线不是断开才重连,靠的是心跳
桌面端网络和移动端有一个很大的不同:Wi-Fi 切换、锁屏、休眠这类事件很常见,socket 不会立刻报错,而是表现为“静默断开”。如果只靠 socket 的 close 事件,你根本不会发现连接已经死了。
所以断线判定必须双通道:第一,TCP 层 detect viaSocket读写异常,即时触发;第二,应用层心跳。我一开始用的是固定 15 秒心跳,后来发现一个问题:用户把电脑合盖再打开,网络栈恢复后,老的 socket 还残留着,心跳超时后直接新建连接是合理的,但如果心跳间隔太短,网络抖动就会疯狂重连。
最终方案是 30 秒心跳 + 3 次未收到 pong 判定超时,整体判定周期约 90 秒。心跳包独立于消息通道发送,用clientPing/serverPong两个信令,携带当前本地最大的 seq 水位,服务端通过 pong 回复最新水位,这个水位对后面的增量同步很有用。
4.2 指数退避算法:重连不是越快越好
刚开始做重连时我踩过一个典型坑:断线后立即重连,如果服务端正在重启,客户端就会陷入“连接失败 → 立刻重试 → 又失败 → 又立刻重试”的死循环,大量无效握手机器,这就是热词里常说的连接风暴,服务端被客户端雪崩式打挂。
我最后用的方案是带随机抖动的指数退避:
int currentAttempt = 0; int maxAttempt = 8; int nextDelay() { const int base = 1000; // 起始 1 秒 const int cap = 30000; // 上限 30 秒 final int exponential = min(base * (1 << currentAttempt), cap); final int jitter = Random().nextInt(500); // 0~500ms 随机抖动 currentAttempt++; return exponential + jitter; }具体效果是:第一次失败等 1 秒重连,第二次 2 秒,第三次 4 秒……最多第 8 次后稳定在 30 秒一次,直到连上为止。抖动的作用是防止多客户端同时重连造成同步冲击,这在群发场景尤其关键。
还有一个细节:重连成功后要重置 currentAttempt。如果用户已经恢复网络,还按 30 秒的间隔去重连,用户会明显感觉到“卡了好一会儿”。
4.3 重连后的消息补偿:补拉和重发要一起做
断线期间,本地会积压两类消息:一类是用户在线时发出去但没收到 ACK 的,状态停留在 sending;另一类是服务端有新消息,但客户端断线没收到。重连后必须把这两类消息一起处理。
我的处理策略是三步:
- 重连握手时发送 sync 请求,携带每个会话的 last_seq,服务端返回 seq 大于 last_seq 的全部增量消息。
- 客户端收到增量消息后,先按 msg_id 去重,再写入 messages 表,更新对应会话的 last_seq。
- 扫描本地 sending 状态的消息,重新投递。这里必须带上原来的 msg_id,服务端根据 msg_id 判断是否已收到,如果已收到就直接回 ACK 而不重复入库。
顺序上严格先补拉增量,再重发本地消息。如果反过来,本地重发的消息和增量同步的消息会交错写入,可能把 UI 时序搞乱。
4.4 桌面端窗口生命周期对连接的影响
桌面端的窗口最小化、锁屏、系统休眠都会影响网络连接,但 Flutter 移动端的生命周期 API 在桌面端不适用。我采用的做法是监听系统电源事件和窗口焦点事件。
具体来说:窗口失焦(用户切到别的应用)时不主动断开连接,因为会话可能在后台挂机收消息;但系统休眠事件会立即冻结 socket,所以我监听到休眠信号后主动关闭连接并标记为“离线”,等系统恢复唤醒后再触发重连逻辑。这个事件在 Windows 上通过WM_POWERBROADCAST获取,在 macOS 上通过NSWorkspace.didWakeNotification获取,Flutter 侧用 MethodChannel 桥接给 Dart。
没做这个处理前,用户的电脑睡眠再唤醒后,客户端界面显示在线,实际上消息已经收不到了,直到下次心跳超时才发现。加了休眠监听之后,唤醒瞬间就能恢复连接,体感从“一直没消息”变成“一解锁就收到一堆历史消息”。
5. 常见问题速查与实测经验复盘
5.1 高频问题排查表
直接整理一个排查表,都是我实际遇到过的场景,按“现象 → 原因 → 解法”组织,遇到类似问题可以照着检查:
| 现象 | 可能原因 | 排查与解法 |
|---|---|---|
| 消息发出后一直显示“发送中” | 服务端 ACK 未回,或连接静默断开 | 查看心跳超时日志,确认是不是心跳先挂了;再查服务端是否有 ACK 积压 |
| 消息发出去 2 秒就显示“已读” | 手动 ACK 和自动 ACK 共用状态位 | 拆分成两个独立状态字段,分别存储服务端回执和已读回执 |
| 重启客户端后历史消息丢了一部分 | 数据库路径没有持久化到用户目录 | 检查 sqflite_common_ffi 的 databasePath,Windows 用 hi 下的 AppData,macOS 用 Application Support |
| 连续快速发消息偶发乱序 | 发送队列 flush 并发,未加互斥锁 | 确认只有一个发送协程在批量处理 outbox,加_isFlushing标志 |
| 十万条消息后启动变慢 | 索引缺失或查询不带分页 | 添加 session_id + seq 复合索引,分页加载不要一次拉全量 |
| SQLite 报 database is locked | 多 isolate 并发写 | 打开 WAL 模式,写操作串行化,读操作走独立连接 |
| 系统休眠唤醒后一直收不到消息 | socket 已断但客户端未知 | 监听休眠/唤醒事件,唤醒后强制重连并拉取增量 |
| 桌面端 TextField 中文输入法联想起不来 | Flutter 桌面 IME 接管问题 | 升级 Flutter 版本,或引入原生输入法插件辅助 |
| Windows 上列表滚动掉帧 | 查询打在了 UI isolate | 用 Isolate.run 做 DB 查询,再回调到 UI 线程刷新 |
5.2 几个值得单独说说的“软问题”
第一,未捕获的异步异常会把整个客户端瞬间干掉。我在桌面端遇到过一次e/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)] unhandled exception,查下来是某条数据库操作抛了空指针异常,直接搞崩整个 isolate。IM 客户端本来就容易在各式各样的边缘情况下崩,所以我在入口处用runZonedGuarded全局兜底,同时所有异步发送、落库操作都显式 try-catch,真正做到不稳定代码“不死机”。
第二,手动 ACK 的去重机制不能只依赖内存。桌面端消息多,已读回执也会高频出现。我的处理是已读回执直接更新 messages 表的 read_status,从 sending 改 sent 只允许状态“正向迁移”,不允许从 acked 回退成 sent。这个状态方向的约束看似简单,实际上解决了大量重复回执、乱序回执导致的 UI 状态闪跳。
第三,sqflite 桌面端调试效率提升技巧。建议把数据库文件路径直接打印到日志里,开发时用 db4s 实时打开看表结构和数据,排查问题速度能翻倍。我经常是 UI 上消息状态不对,直接打开 sqlite db 看那条消息的 status 和 read_status 分别是多少,立刻能定位是协议问题还是 UI 问题。
5.3 从这套方案还能延伸出什么
这套设计做稳定之后,我很自然地开始在这个基础上扩展别的能力。比如消息撤回:本质上就是把 messages 表加一个 recall_status 字段,撤回时通过信令通道下发,收到后更新状态并清理本地内容。再比如多端同步:因为已经有了 last_seq 水位,天然支持“手机发了消息,桌面端重连后自动拉增量”,不需要额外造轮子。
对群聊场景来说,只需要把 session_id 从单聊 ID 换成群 ID,消息表结构不需要动,seq 排序逻辑完全复用。对数据库性能要求更高的,可以考虑按月份拆表,把历史数据分到messages_202501、messages_202502这种按月分表里,查询时按时间路由,能进一步控制单表体积。
最后说一个我在整个项目里体会最深的一点:IM 的核心从来不是“把消息发出去”,而是“在不可靠的网络、不可靠的进程、不可靠的时序下,保证消息最终不丢不重不乱序”。舍得在设计上多花时间的部分,恰恰是那些用户感知不到、但是出了问题就藏不住的部分。希望这套方案能帮你少踩几个我踩过的坑。