ARTICLE DETAIL

资讯详情

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

Flutter桌面端IM消息可靠性设计:发送、ACK、SQLite与断线重连

Flutter桌面端IM消息可靠性设计:发送、ACK、SQLite与断线重连 1. 项目定位与整体设计一条消息的可靠性链路做 Flutter 桌面端 IM 客户端最烦人的不是 UI 写不出来而是消息发出去了到底有没有送达、断网重连之后会不会丢消息、本地缓存能不能撑住几十万条聊天记录。这几件事看起来各自独立实际上是一条完整的可靠性链路发送、ACK 回执、SQLite 本地缓存、断线重连缺一环都会让用户骂娘。拿最常见的场景来说用户在聊天框里敲了一句话点发送消息先进入本地数据库UI 上立刻显示带“发送中”状态的气泡与此同时客户端通过 WebSocket 把消息包推给服务器服务器落库后返回一个 ACK客户端收到 ACK 才把这条消息标记成“已送达”。如果这中间网络断了客户端得在重连之后把没收到 ACK 的消息重新发一遍或者根据服务器回传的同步数据补拉漏掉的消息。整个过程涉及的不只是网络请求还有一套状态机和本地缓存策略。这篇文章就围绕这条链路展开适合正在做 Flutter 桌面端 IM、或者已经在写但被各种边界情况折磨的开发者。你会发现桌面端和移动端最大的差别不是 UI而是:移动端你还能靠系统的生命周期回调勉强兜底桌面端窗口一关、网络一断、机器一休眠所有不可靠的细节都会被放大。我的经验是先把发送、ACK、本地缓存、重连这四个模块当成整体来设计后面会省掉大量返工。1.1 一条消息从发送到已读到底经历了什么先画一条完整链路后面所有设计都围绕它展开。用户发消息时客户端生成本地唯一的local_id先写进 SQLite状态是sending同时把消息渲染成气泡。然后通过 WebSocket 发送{ type: message.send, local_id: 3fa4c8e2-..., conversation_id: conv_1024, content: 今晚开会吗, client_ts: 1715856000000 }服务器收到后给这条消息分配正式的message_id和服务端时间戳存储后回 ACK{ type: message.send.ack, local_id: 3fa4c8e2-..., message_id: m_887231, server_ts: 1715856000123 }客户端收到 ACK把本地消息状态改成sent同时记录message_id。如果 3 秒内没收到 ACK客户端会按一定策略重发。这里有一个关键点重发的不是一条新消息而是携带相同local_id的同一业务消息服务器通过local_id做幂等重复收到同一个local_id时直接返回旧的 ACK而不是再次入库。这是发送链路。接收链路同样需要回执接收端客户端收到消息后需要向服务器上报已读回执比如{type: receipt.send, conversation_id: conv_1024, up_to_message_id: m_887231}。服务器再把已读状态广播给发送方发送方据此把气泡从“已送达”更新成“已读”。许多人把 TCP 层的 ACK 和业务层的 ACK 混为一谈其实完全是两回事TCP ACK 只保证字节流到达而业务 ACK 保证的是“服务器已经处理了这条消息”这在弱网环境下尤为重要。1.2 为什么是 WebSocket SQLite ACK 这套组合先说 WebSocket。IM 是强实时场景HTTP 轮询有两个问题一是每次请求都有完整的 HTTP 头浪费带宽二是消息到达的延迟取决于轮询间隔做不到真正的秒达。WebSocket 是一条长连接服务端可以主动推送双向通信都走一条 TCP 连接在 Flutter 里用web_socket_channel这个包就能跨平台使用桌面端和移动端行为一致。再说 SQLite。本地缓存最大的需求是离线可查、启动秒开、滚动历史消息不卡顿。SQLite 是嵌入式数据库不需要单独的服务进程单文件存储事务完整桌面端直接读写文件即可。和 Hive、SharedPreferences 这类轻量 KV 相比SQLite 有真正的 SQL 查询、索引和事务适合做几十万条消息的检索和排序。需要说明的是这里并不指望 SQLite 扛住几十万并发那也不是一个客户端该干的事它只服务单用户所以只要表结构和索引合理十万条数据对 SQLite 来说是很容易消化的量。最后说 ACK 回执。为什么要额外做一层业务 ACK因为 WebSocket 只保证连接是通的不保证服务器“处理成功”。举个例子客户端发出message.send服务器入库并广播给接收方但在回 ACK 的瞬间网络断了。客户端这边会认为消息没发出去重连后重发如果服务器没做幂等接收方就会看到两条一模一样的消息。加了local_id ACK这套机制之后客户端可以根据 ACK 确定服务器处理到了哪一步服务器也能根据local_id去重两边对齐状态。1.3 核心难点拆解状态一致性、可靠性、性能这个项目实际开发中最能拖慢进度的不是网络请求怎么写而是“状态”怎么管理。消息在 UI、本地数据库、服务器三处都有自己的状态三者的同步一旦做不好就会出现“消息显示已发送但数据库里还是发送中”“重连后消息重复”“会话列表里的最后一条消息和数据库对不上”这类问题。可靠性方面要考虑 ACK 丢失、超时重传、重复消息、断线期间消息堆积、重连后的补拉与去重。任何一个环节掉链子用户体感都是“消息丢了”或“消息重了”。性能方面桌面端最容易被吐槽的就是滚动历史消息卡顿和启动时加载太慢。这通常不是 SQLite 本身的锅而是查询方式问题比如无脑OFFSET翻页、没有索引、每一条消息都触发 UI 刷新。合理的分页策略和批量刷新机制比任何数据库调优都重要。所以我的建议是开工之前先做一个简单的状态图和消息流文档哪怕就是一张纸也要把发送、ACK、重发、同步、已读这五个状态跳转画清楚。代码是状态机的表达状态没想清楚代码写再多也是在打补丁。2. 消息发送与 ACK 回执从“发出”到“已送达”之间有哪些讲究消息发送链路的核心是状态机。Flutter 里很多人喜欢用全局状态管理库但消息状态这种高频变化的数据我建议先落到 SQLite再通过ChangeNotifier或Stream通知 UI 刷新避免内存里一套状态、数据库里一套状态最后两边不一致。2.1 消息状态机设计我给消息定义的范围如下状态字段取值含义status0发送中本地已暂存等待服务器 ACKstatus1已发送收到服务器 ACK但未读status2发送失败超过重试次数read_status0未读read_status1已读收到阅读回执或本地上报后确认注意发送失败并不是从sending直接变过去的而是先经历若干次超时重发最后一次仍然超时才标记为失败。这个过程中 UI 上的表现可以是“发送中”旁边多一个感叹号用户点击后手动重发。手动重发的本质是把消息的状态重置为sending重新走发送链路而不是新建一条消息。设计状态机时有三个容易踩的坑第一个坑是只用一个布尔值表示“已读”。实际业务里消息有“已发送”“已送达”“已读”三层语义布尔值根本装不下。第二个坑是发送失败后直接清空本地消息用户一刷新历史记录就看不到这条消息了正确的做法是保留消息内容只是标记失败允许重发。第三个坑是收到 ACK 时直接覆盖整条记录把本地排序列、重试次数等字段弄丢应该用局部更新。2.2 发送队列与本地暂存发送前先把消息写入messages表这一步是 IM 客户端可靠性的地基。很多新手直接ws.send()之后就在内存里等结果一旦 App 被杀或者断网这条消息就消失了。正确做法是“先落库、再发送、后改状态”。这里有一个很容易忽略的点落库时就要把status初始化为 0而不是空字符串或者默认值因为 App 启动时要把所有status 0的消息捞出来重新发送。也就是说messages表天然承担了 outbox发送队列的职责不需要单独建一张发送任务表除非你的业务有草稿、定时消息这类额外需求。我把启动恢复逻辑写成这样Futurevoid recoverPendingMessages() async { final db await DatabaseHelper.instance.database; final pending await db.query( messages, where: status ?, whereArgs: [0], orderBy: created_at ASC, ); for (final row in pending) { _sendPacket(row); // 重新发送不重新落库 } }这条查询在启动时执行一次数据量很小不会卡 UI。真正的关键点是发送时的消息体必须带上原始created_at服务器端要用这个时间戳来保证排序不能让重发后的时间变成发送时间否则会话顺序会乱。2.3 ACK 超时重传与去重ACK 超时重传是链路中最容易出 bug 的部分。超时时间设多少太短容易造成无效重发太长用户会觉得“消息怎么一直没发出去”。我实测下来内网环境 RTT 一般在几十毫秒公网一般 200~500 毫秒弱网或服务器高延迟时可能到 2 秒左右所以默认设 3 秒是一个相对稳妥的起始值。更讲究的做法是把每台终端最近的 RTT 做一个滑动平均值超时时间取2 * RTT 1s动态调整。重传次数和间隔也要控制。我通常采用依次递增的间隔3s、5s、10s、20s最多重试 4 次总耗时大约 38 秒。如果第 4 次仍然超时直接标记失败并弹一条本地通知或红点提示。这里面有个原则重发不能无限进行否则弱网情况下消息会堆积重连后瞬间打爆服务器。实现时需要给每个local_id维护一个Timer避免重发逻辑互相覆盖。收到 ACK 时取消对应 Timer并删除映射。重发逻辑要判断消息是否还处于sending状态如果已经被标记为失败就不再重发class MessageSender { static const maxRetry 4; final MapString, Timer _timers {}; void send(TextMessage msg) { _persistAndSend(msg); _armTimer(msg.localId, retryCount: 0); } void _armTimer(String localId, {required int retryCount}) { _timers[localId]?.cancel(); _timers[localId] Timer(Duration(seconds: _retryDelay(retryCount)), () { final msg _loadPending(localId); if (msg null || msg.status ! MessageStatus.sending) return; if (retryCount maxRetry) { _markFailed(localId); _timers.remove(localId); } else { _sendPacket(msg); _armTimer(localId, retryCount: retryCount 1); } }); } void onAck(String localId, String serverMessageId) { _timers[localId]?.cancel(); _timers.remove(localId); _markSent(localId, serverMessageId); } }服务端幂等是这套机制的前提。我建议服务器用 Redis 或内存表维护最近一段时间内的local_id - message_id映射收到message.send时先查这个映射如果存在就直接返回旧 ACK不再走插入流程。映射需要设置 TTL比如保留 7 天覆盖客户端可能的重试窗口即可。另外注意一件事重传的包不能改local_id也不能改client_ts。很多人为了“保证服务器不判重”重传时重新生成一个 ID这恰恰是雪崩的根源——服务器会以为这是两条新消息重复入库接收端看到的是两条重复消息。2.4 已读回执怎么处理已读回执和发送 ACK 是两个维度。发送 ACK 保证“服务器收到了”已读回执保证“接收端看了”。处理已读回执要克制高频地发单条回执是性能毒药群聊场景尤其严重。我见过比较稳妥的做法是:接收端进入会话时记录当前会话已加载的最大message_id然后批量上报{ type: receipt.send, conversation_id: conv_1024, up_to_message_id: m_887231 }服务器收到后把conversation_id下小于等于这个message_id且是发给当前用户的消息全部标记为已读再把这些消息的发送方列入待通知队列统一推送一个回执事件。发送方收到后更新本地数据库UPDATE messages SET read_status 1 WHERE conversation_id ? AND status 1 AND read_status 0 AND created_at ?这条 SQL 用到了conversation_id和created_at的联合索引即使数据量大也不会慢。UI 层的刷新不要一条一条地改气泡直接刷新整个会话列表或当前聊天气泡区域减少不必要的重建。3. SQLite 本地缓存桌面端离线可查、启动秒开的底气移动端 Flutter 首选sqflite但桌面端不能直接这么玩因为sqflite依赖 Android/iOS 的原生 SQLite 接口。桌面端Windows、Linux、macOS要走sqflite_common_ffi它通过 Dart FFI 直接调用 SQLite 的 C 接口接口风格和sqflite几乎完全一致迁移成本很低。3.1 桌面端 SQLite 方案选型sqflite_common_ffi初始化的时候在main()里判断平台把全局databaseFactory替换成 FFI 实现import dart:io; import package:sqflite_common_ffi/sqflite_ffi.dart; void main() { if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { databaseFactory databaseFactoryFfi; } runApp(MyApp()); }数据库文件的存放路径我建议用path_provider的getApplicationSupportDirectory()而不是getDocumentsDirectory()。原因是应用支持目录属于应用的私有数据域不容易被用户误删也更符合“缓存数据库”的定位。Windows 上它通常落在%APPDATA%\..\Local\应用名macOS 上在~/Library/Application Support/应用名。final dir await getApplicationSupportDirectory(); final dbPath p.join(dir.path, chat_cache.db);打开数据库时有几条 PRAGMA 值得设置PRAGMA journal_mode WAL; PRAGMA synchronous NORMAL; PRAGMA cache_size -20000; PRAGMA foreign_keys ON;为什么要用 WAL 模式桌面端场景下UI 线程可能要读数据后台 isolate 可能要写数据WAL 允许多个读操作和一个写操作并发读不会被写阻塞写也不会被读阻塞。synchronous NORMAL配合 WAL 在可靠性上足够写入性能会好不少。cache_size -20000表示给 SQLite 分配 20MB 内存页缓存对某人几十万条消息的查询很友好。3.2 表结构设计与索引规划消息表是核心我会这样建CREATE TABLE IF NOT EXISTS conversations ( conversation_id TEXT PRIMARY KEY, type INTEGER NOT NULL DEFAULT 0, display_name TEXT, last_message_preview TEXT, last_message_at INTEGER NOT NULL DEFAULT 0, unread_count INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS messages ( local_id TEXT PRIMARY KEY, server_message_id TEXT, conversation_id TEXT NOT NULL, sender_id TEXT NOT NULL, content_type INTEGER NOT NULL, content TEXT NOT NULL, status INTEGER NOT NULL DEFAULT 0, read_status INTEGER NOT NULL DEFAULT 0, retry_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_messages_conv_created ON messages(conversation_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_messages_conv_status ON messages(conversation_id, status);聊几句为什么这么设计local_id做主键是因为每条消息在客户端产生时就需要一个唯一标识服务器分配message_id是在网络往返之后太晚了。收到的消息local_id可以用srv_ message_id来构造保证接收和发送两路消息都能走到同一个主键去重。索引(conversation_id, created_at DESC)是历史记录查询的关键它让 SQLite 能走索引直接按会话倒序扫描不需要临时排序。另一个会话状态索引用于启动恢复 outbox 时快速捞status 0的消息以及会话列表页刷新未读状态时使用。conversations表里的last_message_preview和last_message_at是冗余字段每次插入消息后用事务更新。一开始我试图“动态聚合”最后一条消息结果会话列表每次刷新都要做一次子查询几百个会话时明显掉帧。冗余几个字段换来的是会话列表的秒开。3.3 十万条消息场景下的查询优化很多人担心 SQLite 扛不住十万条数据实际测试下来只要走对索引常规的历史记录查询在几十毫秒到百毫秒级别。关键是分页方式。最忌讳的是这种写法SELECT * FROM messages WHERE conversation_id ? ORDER BY created_at DESC LIMIT 50 OFFSET 1000;OFFSET的代价是 SQLite 必须扫描并丢弃前 1000 行翻到几十页之后性能会明显劣化。正确做法是键集分页也叫游标分页。记住当前页最后一条消息的created_at下一查询直接从这里继续SELECT * FROM messages WHERE conversation_id ? AND created_at ? ORDER BY created_at DESC LIMIT 50;配合(conversation_id, created_at DESC)索引SQLite 会定位到 ?的第一条记录然后顺序扫描 50 条扫描行数就是返回行数性能恒定。另一个容易犯的错是频繁SELECT COUNT(*)。会话列表的未读数已经在conversations.unread_count里维护了不需要每次重新数消息表。如果某些统计界面确实需要总数也只在后台 isolate 里跑别在 UI 线程做。插入性能方面批量写入消息时要开启事务。比如断线重连后从服务器补拉了 200 条消息用db.batch()包在一个事务里提交而不是循环单条插入性能差一个数量级。我在十万条数据规模下实测过按上述表结构和键集分页单页 50 条完成一次查询大约 20~60 毫秒配合 prefetch用户滚动历史记录基本感觉不到加载延迟。这个数字会因为机器磁盘 IO 有浮动但无论如何不该是页面卡死。3.4 缓存库调试DB Browser for SQLite 的妙用调试本地缓存我最常用的工具是 DB Browser for SQLite也就是网上常说的db4s一个开源跨平台图形化管理工具。它可以直接打开 SQLite 文件浏览表结构、执行 SQL、查看索引和查询计划。有几个特别实用的场景一是检查消息状态卡住的问题。用户反馈“消息一直显示发送中”直接用 db4s 打开数据库执行SELECT local_id, status, retry_count, created_at FROM messages WHERE status 0;如果发现大量status 0说明要么客户端没有启动恢复逻辑要么 ACK 处理器在某个环节抛了异常日志里应该有端倪。二是验证查询是否走了索引。在查询语句前面加EXPLAIN QUERY PLAN比如EXPLAIN QUERY PLAN SELECT * FROM messages WHERE conversation_id conv_1024 AND created_at 1715856000000 ORDER BY created_at DESC LIMIT 50;结果里出现SEARCH messages USING INDEX idx_messages_conv_created就说明索引生效如果出现SCAN messages就要检查索引字段和查询条件是否匹配。三是造测试数据。往本地库里批量插入十万条消息验证 UI 滚动性能和分页逻辑比手动点界面快得多。4. 断线重连设计消息风暴、弱网与状态恢复断线重连是 IM 客户端里最锻炼人的部分。很多实现能做到“断线后重连”但做不到“重连后数据不错不乱不重”。下面是我梳理的四个模块。4.1 心跳与连接探测快速感知“假死”WebSocket 连接看起来还开着实际上可能已经死了家用路由器 NAT 超时、公司出口设备静默断开、手机从 Wi-Fi 切到蜂窝网络、桌面端休眠后网络栈被回收。客户端如果一直等可能要几十秒甚至几分钟才能发现连接失效。我的做法是应用层心跳每 30 秒发一个{type:ping}服务器收到后回{type:pong}。如果客户端在 10 秒内没收到 pong视为一次丢失连续丢失 2 次主动断开 WebSocket进入重连流程。心跳报文要轻量不要带业务字段否则服务器解析开销大。桌面端还要监听系统网络状态。Flutter 里可以用connectivity_plus获取网络接口变化但要注意connectivity_plus上报的只是“有可用网络接口”不代表互联网真的通。所以网络状态变化可以作为一个“提前触发重连”的信号真正的成功标准永远是 WebSocket 连上且完成一次协议握手。一个实用的设计是把连接状态抽成枚举enum ConnectionState { connecting, // 正在连接 connected, // 已连接且握手完成 reconnecting, // 断线后重连中 disconnected, // 主动断开或彻底失败 }UI 顶部可以显示对应状态排查问题时会方便很多。4.2 指数退避与随机抖动不让服务器雪崩断线后不能立即重连也不能固定间隔重连。想象一个场景公司网络断了几分钟恢复瞬间所有客户端同时重连服务器直接被打爆。这就要用指数退避加随机抖动。退避公式我常用import dart:math; Duration nextBackoff(int attempt) { final maxMs Duration(seconds: 30).inMilliseconds; final baseMs Duration(seconds: 1).inMilliseconds; final expMs min(maxMs, baseMs * pow(2, attempt).toInt()); final jitterMs Random().nextInt(300); return Duration(milliseconds: expMs jitterMs); }第 0 次重连立即执行之后间隔大约1s、2s、4s、8s、16s、30s并且每次叠加 0~300ms 随机抖动。抖动看起来是个小细节却能有效避免“所有客户端在同一秒发起重连”的刺穿效应。还需要注意重连期间业务消息的处理。我的经验是断线瞬间把 WebSocket 上所有发送中的 Timer 取消避免 Timer 回调里往已经断开的 socket 写数据产生大量异常日志同时把连接状态置为reconnecting。此时用户看到的消息气泡仍然是“发送中”但客户端内部要清楚当前不发送任何包只做重连尝试。4.3 重连后的数据恢复与补拉重连成功不等于状态恢复必须先做数据同步。我的同步方案基于服务端维护的递增序号seq每一条消息在服务端落库时分配一个全局递增的seq客户端本地单独维护一张sync_state表记录当前已收到的最大seq。重连握手完成后客户端上报{ type: sync.req, last_seq: 5200 }服务器返回所有seq 5200的消息按正序排列。客户端收到后逐条入库。入库时用local_id srv_ server_message_id做主键配合INSERT OR IGNORE这样即使同步和推送之间有重叠也不会产生重复消息。同步完成之后紧接着处理 outbox。这里有个容易踩的坑不能先补拉再发 outbox也不能先发 outbox 再补拉必须按顺序来。正确流程是先同步服务端增量再根据同步结果确认哪些本地消息已被服务器收到最后处理本地尚未收到 ACK 的消息。打个比方你离线期间发出去 5 条消息其中前 2 条可能已经到达服务器只是 ACK 没回。如果重连后直接重发这 5 条服务器即使按local_id幂等也可能因为缓存过期而产生重复。所以同步响应里我建议服务器把最近一段时间的local_id - message_id映射也一并返回客户端拿到后先把匹配到映射的本地消息标记为已发送剩余的才是真正需要重发的消息。4.4 重连后的发送队列合并策略补拉和 outbox 处理完客户端才真正恢复到可用状态。此时如果积压了几十条待发消息直接并行全发很不明智会造成瞬时带宽挤占和服务器压力。我把发送窗口限制为 5 条并发即最多同时有 5 条消息在等待 ACK。每收到一个 ACK就按下一条待发消息。这个滑动窗口还能天然限制重试风暴。const inFlightLimit 5; int _inFlight 0; final QueueOutboxItem _pendingQueue Queue(); void _drainOutbox() { while (_inFlight inFlightLimit _pendingQueue.isNotEmpty) { final item _pendingQueue.removeFirst(); _sendPacket(item.message); _inFlight; } } void onAck(String localId) { _inFlight--; _drainOutbox(); }对用户来说积压消息在断线重连后一条一条“飞出去”而不是一瞬间刷屏观感也更好。5. 常见问题与排查实录这部分是实际操作中遇到的比较典型的问题我整理成一个速查性质的小节。桌面端 IM 的坑往往不是单点技术难题而是多个模块交叉出来的边界情况。5.1 上线后最频繁的几类问题第一类是日志里的e/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)]后面跟着一堆 unhandled exception。很多人看到这串日志就心里发毛其实它只是 Flutter 默认打印未捕获异常的入口。真正要处理的不应该是让日志“消失”而是在顶层配置全局错误捕获把堆栈、当前连接状态、当前消息状态一并记录下来。IM 这类有状态应用光看堆栈往往不够需要“堆栈 上下文”才能复现问题。第二类是“数据库 is locked”。桌面端如果多个 isolate 同时写 SQLite或者重连补拉时主线程和后台线程同时插入就会频繁遇到。解决思路是统一用一个数据库入口类所有写操作串行执行同时把数据库打开为 WAL 模式。如果还是锁检查是不是有事务没有正确关闭。第三类是“消息重复了”。排查时先看服务端是否对local_id幂等再看客户端补拉和推送两条通道是否都用了INSERT OR IGNORE最后看乱序到达的消息是否因为主键设计不合理而被重复插入。第四类是“会话列表的最后一条消息和聊天页对不上”。常见原因是没有把conversations.last_message_at和消息表的created_at用同一个时钟源。客户端本地发送的消息用本地时间服务端补拉的消息用服务器时间两者如果不做对齐排序就会错乱。我的做法是发送消息时带上client_ts服务器回 ACK 时返回server_ts客户端以server_ts为准回写最后消息时间。5.2 排查手段与日志设计IM 调试最怕“偶现”所以你需要在设计阶段就埋好可观测性。我在项目里加了一个调试面板实时显示连接状态、心跳 RTT、在途消息数、待发送队列长度、本地数据库路径。排查断线问题时它比什么都直观。网络包日志要增加方向标识比如[SEND] message.send local_id...和[RECV] message.send.ack local_id...。重连之后把本地的待发送队列和服务器同步回来的 offset 打出来。定位重复消息问题时只要对比同一local_id被发送了几次、每次的服务器返回值即可。我还用一个本地脚本模拟不可靠网络用一个简单 Socket 服务器随机丢弃ACK包、随机断开连接。跑一轮混沌测试能暴露大量边界问题。这在打磨阶段比手动反复测试高效得多。5.3 几句真心话踩过几次坑之后我的体会是IM 客户端的核心不是网络库选型也不是哪条 SQL 写得漂亮而是“状态机画清楚”。消息发送、ACK、重传、补拉、已读每一步都指向某一类状态迁移。如果你发现代码里充满了if (connected status sending)这种叠罗汉式判断说明状态建模已经失控了回到白板重新定义状态才是最快的修复方式。另外一个小建议把消息状态、回执状态分开存字段不要用布尔值拼。把本地缓存、网络连接、UI 展示三者的更新顺序固定下来无论是谁都可以按同一套顺序排查问题。这个项目做完以后我最庆幸的是把 ACK、SQLite 缓存、断线重连当成一个整体来设计而不是各做各的模块。以上经验能帮你少走一些弯路。
返回列表