举报企业网站用个人信息备案社区网站建设资金申请
举报企业网站用个人信息备案,社区网站建设资金申请,深圳东门老街游玩攻略推荐,公司网站建设调研一、IM 发送消息#xff0c;千万别理解成“调一次 send() 就结束”
很多人刚开始做聊天#xff0c;会把发送消息想得很简单#xff1a;
socket.send({content: 你好});从“网络动作”的角度看#xff0c;这当然没错。 但从“客户端体验”的角度看…一、IM 发送消息千万别理解成“调一次 send() 就结束”很多人刚开始做聊天会把发送消息想得很简单socket.send({content:你好});从“网络动作”的角度看这当然没错。但从“客户端体验”的角度看这远远不够。因为用户点击发送后客户端至少要同时完成四件事UI 里立刻出现一条“正在发送”的消息这条消息要先写入本地缓存WebSocket 真正把消息发给服务端等服务端 ACK 到来后再把这条消息改成“发送成功”也就是说发送消息从来不是一个动作而是一条完整链路。在 Dart 里WebSocket.add()发送的数据只能是String或Listint这意味着你最终发出去的消息通常还是 JSON 字符串或二进制包而pingInterval则可以帮你做心跳和断线感知。换句话说WebSocket已经给了你“发消息”和“保连接”的基础但“消息状态管理”仍然是客户端自己的职责。(api.dart.dev)所以一个像样的 IM 发送流程至少应该长这样用户点击发送 → 本地生成 localId → SQLite 插入一条 sending 状态消息 → UI 立刻展示这条消息 → WebSocket 发出消息包 → 服务端返回 ACK携带 localId / serverId → 本地更新消息状态为 success → 会话表更新最后一条消息预览这套流程的重点只有一句话先本地后网络再确认。二、为什么localId和serverId必须分开这是很多 IM 第一版最容易省略的点但它偏偏很关键。如果你只给消息一个id那发送时就会面临两个问题这个id是本地先生成还是等服务端返回如果服务端还没回UI 里这条消息用什么标识所以更靠谱的做法是从一开始就把两个 ID 分开localId客户端点击发送时立即生成serverId服务端真正接收、入库、确认后返回下面是一个更适合放进博客的消息实体定义代码我依然补了中文注释。/// 消息类型enumMessageType{text,// 文本消息image,// 图片消息file,// 文件消息system,// 系统消息}/// 发送状态enumMessageSendStatus{sending,// 发送中success,// 发送成功failed,// 发送失败}/// 聊天消息实体classChatMessage{/// 本地消息 ID/// 点击发送时由客户端立即生成finalStringlocalId;/// 服务端消息 ID/// 由服务端 ACK 返回finalString?serverId;/// 所属会话 IDfinalStringconversationId;/// 发送者 IDfinalStringsenderId;/// 消息类型finalMessageTypetype;/// 消息内容finalStringcontent;/// 发送时间毫秒时间戳finalint sendTime;/// 是否为自己发送finalbool isSelf;/// 发送状态finalMessageSendStatussendStatus;constChatMessage({requiredthis.localId,this.serverId,requiredthis.conversationId,requiredthis.senderId,requiredthis.type,requiredthis.content,requiredthis.sendTime,requiredthis.isSelf,requiredthis.sendStatus,});/// 用于更新局部字段/// 例如收到 ACK 后把 sending 改成 successChatMessagecopyWith({String?serverId,MessageSendStatus?sendStatus,}){returnChatMessage(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 包可以这样设计{event:message.send,payload:{localId:l_10001,conversationId:c_2001,type:text,content:你好}}服务端 ACK{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它负责承接会话标题最后一条消息预览最后一条消息时间未读数草稿是否置顶CREATETABLEconversations(idTEXTPRIMARYKEY,titleTEXTNOTNULL,avatarTEXT,last_message_previewTEXT,last_message_timeINTEGER,unread_countINTEGERNOTNULLDEFAULT0,draft_textTEXT,is_pinnedINTEGERNOTNULLDEFAULT0);2. 消息表messages它负责承接本地 ID服务端 ID所属会话发送者消息类型消息内容时间发送状态CREATETABLEmessages(local_idTEXTPRIMARYKEY,server_idTEXT,conversation_idTEXTNOTNULL,sender_idTEXTNOTNULL,typeINTEGERNOTNULL,contentTEXTNOTNULL,send_timeINTEGERNOTNULL,is_selfINTEGERNOTNULL,send_statusINTEGERNOTNULL);如果你后面要支持图片和文件消息再补一张attachments表会更清晰。但第一版即使先不拆附件表这两张表也已经足够你把消息链路跑起来。六、Flutter 桌面端初始化 SQLite应该怎么写下面给你一个适合技术博客展示的sqflite_common_ffi初始化示例代码我照样加了中文注释。importdart:io;importpackage:path/path.dartasp;importpackage:path_provider/path_provider.dart;importpackage:sqflite_common_ffi/sqflite_ffi.dart;/// 数据库服务classAppDatabase{staticDatabase?_db;/// 获取数据库实例staticFutureDatabaseinstance()async{if(_db!null)return_db!;// 初始化 FFIsqfliteFfiInit();// 指定 desktop 环境下的 databaseFactorydatabaseFactorydatabaseFactoryFfi;// 获取应用文档目录finaldirawaitgetApplicationDocumentsDirectory();// 拼接数据库文件路径finaldbPathp.join(dir.path,flutter_im.db);// 打开数据库_dbawaitdatabaseFactory.openDatabase(dbPath,options:OpenDatabaseOptions(version:1,onCreate:(db,version)async{// 创建会话表awaitdb.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 ); );// 创建消息表awaitdb.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 状态消息先写 SQLiteUI 立即显示再通过 WebSocket 发给服务端下面这个代码块就体现了这种思路。importdart:convert;classSendMessageUseCase{finalDatabasedb;finalImSocketClientsocketClient;SendMessageUseCase({requiredthis.db,requiredthis.socketClient,});/// 发送文本消息FuturevoidsendTextMessage({requiredStringconversationId,requiredStringsenderId,requiredStringtext,})async{// 1. 本地生成 localIdfinallocalIdlocal_${DateTime.now().microsecondsSinceEpoch};// 2. 生成本地消息对象先标记为 sendingfinalmessageChatMessage(localId:localId,serverId:null,conversationId:conversationId,senderId:senderId,type:MessageType.text,content:text,sendTime:DateTime.now().millisecondsSinceEpoch,isSelf:true,sendStatus:MessageSendStatus.sending,);// 3. 先写入本地消息表awaitdb.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. 更新会话表中的最后一条消息预览awaitdb.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看代码会更直观。classAckHandler{finalDatabasedb;AckHandler(this.db);/// 处理服务端 ACKFuturevoidhandleAck(MapString,dynamicevent)async{finalpayloadevent[payload]asMapString,dynamic;finallocalIdpayload[localId]asString;finalserverIdpayload[serverId]asString;// 根据 localId 更新本地消息状态awaitdb.update(messages,{server_id:serverId,send_status:MessageSendStatus.success.index,},where:local_id ?,whereArgs:[localId],);}}这一步做完以后UI 层如果是基于数据库查询结果或状态层更新就可以自然把这条消息从“发送中”切换到“发送成功”。如果 ACK 超时一直没回来呢那就要考虑把状态改成failed并给用户一个“重试发送”的入口。这就是 ACK 真正的价值它不是为了让协议更复杂而是为了给消息状态一个可靠的落点。九、收到服务端新消息时别直接 append 到界面这也是新手项目很常见的坑。不少人收到服务端推送后直接就把消息 append 到当前列表里。这样做短期看很快但会埋下两个问题去重难做重启恢复难做更稳妥的做法应该是先解析消息包先做去重判断先写 SQLite再驱动 UI 更新也就是说数据库是状态源UI 是状态投影。你可以把处理流程理解成这样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做一层本地去重。第四步重建会话预览和未读数不要只更新消息表不更新会话表。不然重连后虽然消息是对的但左侧会话列表会显得不对劲。十二、重连后如何处理“发送中”消息下面给你一个适合博客展示的恢复策略示例。classMessageRecoveryService{finalDatabasedb;MessageRecoveryService(this.db);/// 连接恢复后处理本地仍为 sending 状态的消息FuturevoidrecoverPendingMessages()async{finalrowsawaitdb.query(messages,where:send_status ?,whereArgs:[MessageSendStatus.sending.index],);for(finalrowinrows){finallocalIdrow[local_id]asString;// 这里先演示一种保守策略// 连接恢复后先把所有长时间处于 sending 的消息标记为 failed// 然后由用户手动点击重试awaitdb.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 就会从“能聊天”开始变成“像一个真正的软件”。