海狸IM 2.0 双端客户端技术实践:Electron 与 Flutter 如何对齐 IM 协议

海狸IM 2.0 双端客户端技术实践:Electron 与 Flutter 如何对齐 IM 协议

接《海狸IM 2.0 服务端技术解析》,46 号文讲了 SendMsg → RocketMQ → WebSocketseq + datasync 离线补消息。客户端这边的问题是:Electron 和 Flutter 两套 UI,怎么保证同一条消息在 PC 和手机上一致?

这篇把双端客户端拆成几个技术点分析:分层架构、乐观发送、增量 sync、WS 落库、本地 DB 设计、2.0 功能对齐。IM 客户端同样能写成一个系列------本篇是总篇,后面可以单拆 Drift 同步、Electron 多窗口、LiveKit 来电等。


1. 双端技术栈:不是两个 IM,是一套协议

维度 PC(beaver-desktop 2.0.0) 移动(beaver-flutter 2.0.1)
UI Vue 3 + TypeScript + Vite Flutter 3.x
Electron 31 iOS / Android
本地 DB Drizzle ORM + better-sqlite3 Drift(SQLite)
状态 Pinia + 主进程 Store BLoC + MessageStore
网络 REST + WebSocket REST + WebSocket
同步 datasync 摘要 + chatSync 区间拉 同左,代码几乎镜像

2.0 起 Flutter 是移动端唯一主线,Uniapp 已停更。双端共用:

  • 同一套 网关 REST
  • 同一套 WS command / type
  • 同一套 seq 语义
  • 同一套 fileUrl 媒体字段

协议一改,双端必须一起改------这是维护双端 IM 的铁律。


2. 客户端四层架构(二次开发先记这个)

无论 Electron 还是 Flutter,海狸客户端都可以抽象成四层:

复制代码
┌─────────────────────────────────────────┐
│  UI 层                                   │
│  会话列表 / ChatWindow / ReplyBar / 设置  │
├─────────────────────────────────────────┤
│  Store 层                                │
│  MessageStore、ConversationStore、User...   │
├─────────────────────────────────────────┤
│  Business 层                             │
│  发消息、handleNewWSMessage、同步调度     │
├─────────────────────────────────────────┤
│  Data 层                                 │
│  SQLite 表 + API Client + WebSocketClient │
└─────────────────────────────────────────┘
           │ REST              │ WebSocket
           └──────── beaver-server ────────┘
你想改... 动哪层
气泡样式、Markdown 渲染 UI
未读数、列表排序 Store + Business
发送失败重试策略 Business
离线拉取频率、批量大小 Data + datasync
表结构、索引 Data(DB schema)

反模式 :在 Vue 组件里直接 axios.post 发消息------短期快,长期和 WS / 本地库状态打架。


3. Electron 双进程:为什么 PC 端多一层

beaver-desktop 是典型 Electron 主进程 + 渲染进程

进程 职责
主进程 SQLite、WS 连接、datasync 调度、系统托盘、窗口管理
渲染进程 Vue UI,通过 IPC 读 Store / 发命令

消息同步器 MessageSync 跑在 主进程 main/datasync/chat/chat-message.ts,和数据库同进程,避免渲染进程卡 UI 时拖慢 sync。

渲染进程通过 IPC 收到 NotificationChatCommand 一类事件刷新界面------数据和 UI 解耦,这是 Electron IM 的常见打法(Slack 桌面版同类思路)。


4. 发消息:乐观 UI + REST 确认

用户点「发送」,双端流程高度一致:

4.1 六步流程

复制代码
1. 生成本地临时 messageId(可选)
2. 插入本地 DB,status = sending(乐观 UI 立刻显示)
3. POST chat send API
4. 成功:写入服务端 seq,status = sent
5. 失败:status = failed,UI 显示重试
6. 其它在线成员:WS 推送;离线成员:下次 sync

为什么乐观 UI? REST 往返 50~300ms,不等服务器就画气泡,体验才跟得上微信。失败再标红重试,比「转圈半秒再出现」好得多。

4.2 2.0 扩展操作

操作 路径
回复 带 replyTo messageId,ReplyBar 组件
编辑 edit API,双端 EditBar
撤回 recall API,本地改状态或插通知消息
转发 选目标会话,再走 send

这些在 2.0 都是 REST 写 + WS/sync 传播,没有单独搞一套「编辑专用 WS」。


5. 增量同步:双端镜像实现(重点)

离线同步是 IM 客户端 最难写对 的部分。海狸 PC 和 Flutter 用了 同一算法,下面用 PC 源码讲(Flutter 几乎一一对应)。

5.1 核心表:chat_sync_status

PC 端 Drizzle 定义:

typescript 复制代码
export const chatSyncStatus = sqliteTable('chat_sync_status', {
  conversationId: text('conversation_id').notNull(),
  module: text('module').notNull(), // 'message' | 'conversation' | ...
  seq: integer('seq').default(0),   // 消息同步进度
  version: integer('version').default(0),
  updatedAt: integer('updated_at'),
})

每个会话一条 module=message 的记录,表示「这个会话我同步到第几条 seq 了」。

5.2 三步同步算法

Step 1 --- 拉摘要

typescript 复制代码
const serverResponse = await datasyncGetSyncChatMessagesApi({
  since: lastSyncTime,  // 本地游标:上次同步时间戳
})
// 返回:各 conversationId 的 serverMaxSeq

Step 2 --- 对比 seq

typescript 复制代码
for (const [conversationId, serverSeq] of serverConversationMap) {
  const localSeq = localVersionMap.get(conversationId) || 0
  if (serverSeq > localSeq) {
    needSyncConversations.push({ conversationId, serverSeq })
  }
}

Step 3 --- 区间拉消息

typescript 复制代码
await chatSyncApi({
  conversationId,
  fromSeq: localSeq + 1,
  toSeq: serverSeq,
  limit: 100,
})
// batchUpsert 到本地 messages 表
// 更新 chat_sync_status.seq = serverSeq

Flutter 端 lib/core/datasync/chat/message.dart 逻辑相同,只是语言换成 Dart。

5.3 为什么用「时间戳摘要 + seq 拉取」两层?

只用 seq 只用时间戳
不知道哪个会话变了,要扫全库 时钟漂移会漏消息
摘要告诉你会话列表 seq 保证会话内顺序和增量

两层合用:摘要 O(变更会话数) ,拉取 O(缺口消息数),千群用户也不会每次全量拉。

5.4 和 46 号文服务端如何配合

服务端 sendmsg 后更新 datasync 摘要;客户端 since 游标推进。闭环


6. WebSocket:在线快车道

6.1 下行包处理(Flutter 示例)

MessageBusiness.handleNewWSMessage 大致步骤:

  1. 解析 WS:messageIdconversationIdseqmsg JSON
  2. Upsert 本地 Drift(防重复:messageId 唯一)
  3. MessageStore.addMessage 若当前正在该会话,立刻渲染
  4. ConversationBusiness.notifyConversationUpdate 更新列表预览、未读

PC 主进程收到 MQ 转 WS 后,同样落 SQLite,再 IPC 通知 Vue。

6.2 WS 与 sync 会不会重复?

会收到重复包,靠 messageId / seq upsert 去重。UI 层只读 DB,不维护「WS 专用列表」和「HTTP 专用列表」两套状态------这是稳定性关键。

6.3 tableUpdates 结构

46 号文提到:服务端 WS 包可能带 tableUpdates(messages / conversations / user_conversations)。客户端解析后 按表批量 upsert,一次推送更新消息 + 会话未读 + 列表排序,减少 UI 抖动。


7. 本地数据库设计要点

7.1 PC:Drizzle + WAL

typescript 复制代码
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('synchronous = NORMAL')

WAL 模式读写并发更好,聊天窗口滑动时 sync 写库不容易卡 UI。

7.2 Flutter:Drift + 批量写入

同步拉到的消息 batchCreate / upsert,单会话上百条一次事务提交。Flutter 端列表用 ListView.builder,长历史不会一次 build 万条。

7.3 消息表核心字段(概念)

字段 用途
messageId 全局唯一,去重
conversationId 所属会话
seq 排序、sync
msgType 文本/图/语音...
msg JSON 消息体
sendStatus 本地 sending/sent/failed

8. 2.0 功能点技术拆解

8.1 Markdown 文本

文本消息服务端存 HTML 或 Markdown(以实现为准),双端渲染组件支持代码块、链接。PC 大窗阅读体验更好。

8.2 语音消息与媒体态

  • 录制 → file 上传 → 消息体带 fileUrl + duration
  • 「未听」不是改 msg JSON,而是 markMessageMedia API
  • 另一端 datasync 拉媒体变更,更新图标

2.0.1 修过跨端不同步,说明 媒体态必须走独立同步 ,别在文本字段里塞 listened: true

8.3 回复 / 编辑 UI 组件

组件
ReplyBar 双端
EditBar 双端

引用消息存 replyToMessageId,编辑走 edit API。UI 层要处理「编辑中禁用发送」「编辑后替换原气泡」。

8.4 PC 独立设置中心

2.0 Electron 把设置从聊天窗拆出来:

功能 技术
改密 user API
设备列表 / 踢下线 user 设备 RPC
快捷键 user Settings API,JSON 存服务端
关于 / 更新 Electron updater

换电脑登录,快捷键跟着账号走------这是 成品 IM 和 demo 的差别。

8.5 Flutter 2.0 模块地图

模块 路径/技术
增量 sync lib/core/datasync/chat/
发消息/WS lib/core/business/chat/message.dart
状态 lib/store/message/
动态 Moment moment 相关 store + API
表情 系统/收藏/商店
音视频 LiveKit + IncomingCallBanner

8.6 群助手 Webhook(PC 配置)

群详情 → 群助手 → 通知机器人 → 复制 Webhook URL。外部 POST,服务端写群消息,双端 sync/WS 正常收

配置在 PC,手机只 收消息------45 号文有产品说明。

8.7 音视频 LiveKit

移动端 IncomingCallBanner 横幅;PC 有通话 UI。信令走 call 模块,不和 chat MQ 混。二次开发通话别改 sendmsg。


9. PC vs Flutter:场景分工

场景 推荐端 原因
办公大窗、群管理 PC 多窗口、群助手
移动、弱网 Flutter Drift 离线
音视频来电 Flutter 体验完整 横幅 + 后台
群 Webhook 配置 PC 2.0 入口在群助手
OAuth 扫码 PC 展示 + 手机扫 标准流程

同一账号、同一 seq、同一 fileUrl------不是两个半成品拼盘。


10. 双端对齐检查清单(发版前自用)

检查项 PC Flutter
新发文本 seq 正确
离线 8h 后 sync 无漏
图片 fileUrl 双端可开
语音未听态 cross-device
撤回后双端一致
编辑后双端一致
群 @ 全体成员(若有)
WS 断线重连 + sync

IM 回归就测这些,比「点一遍所有菜单」有效。


11. 性能:IM 客户端容易踩的坑

11.1 列表

  • 必须 虚拟列表(ListView.builder / 等价)
  • 图片 缓存(cached_network_image 等)
  • 会话列表 不要 每次 WS 全量 sort 全库

11.2 数据库

  • 批量 upsert 用事务
  • conversationId + seq 索引
  • 历史消息分页加载,进聊天窗再拉旧 seq

11.3 WebSocket

  • 心跳 + 指数退避重连
  • 重连成功后 trigger checkAndSync(),补断线期间缺口

11.4 Electron 特有

  • 大文件下载放主进程或 worker
  • 多窗口 共享同一份 DB 连接(海狸在主进程)

12. 和 Uniapp 旧版对比(为何 2.0 换 Flutter)

维度 Uniapp 旧版 Flutter 2.0
渲染 WebView 为主 原生渲染
长列表 易卡 60fps 可预期
本地库 Drift 完整 SQL
音视频 受限 LiveKit 原生集成
维护 停更 主线

43~41 号文章讲过 Flutter 重构故事;这篇从 协议对齐 角度补充:换框架不换协议,服务端 seq/datasync 不用推倒重来


13. 二次开发:改哪里最快出效果

想改 UI 主题

→ desktop src/render / flutter lib/ui

想加消息类型

→ 服务端 proto(46 号文)+ 双端渲染组件 + send 逻辑

想改离线策略

MessageSync.checkAndSync 双端各一份,保持行为一致

想接推送

→ notification 模块 + FCM/APNs(2.1 在补 Flutter Token 注册)


14. 常见问题

Q:PC 有消息,手机没有?

看手机 sync 游标和 seq;是否 WS 在线只影响实时,最终应 sync 到。

Q:重复消息两条?

查 upsert 是否按 messageId 唯一;WS 和 sync 是否都去重。

Q:发送一直 sending?

查 REST 是否 401、网关是否通、本地是否没处理失败态。

Q:能不能只做 Web 不做 Electron?

协议支持,但 2.0 官方成品是 Electron + Flutter;Web 需自研或后续版本。


15. 后续系列:客户端也能拆很多篇

计划篇目 内容
Drift 消息表设计与索引 Flutter 专项
Electron 主进程 DB 与 IPC desktop 专项
ReplyBar / EditBar 实现细节 双端 UI
LiveKit 来电与通话状态机 call + Flutter Banner
表情商店与收藏同步 emoji 模块
动态 Moment 社交链 moment
客户端全局搜索实现 desktop 强于 flutter

16. 小结

海狸 IM 2.0 客户端的技术含金量:

  1. Electron + Flutter 双端,共用 REST + WS + seq
  2. datasync 摘要 + chatSync 区间拉 ,PC/Flutter 镜像实现
  3. 乐观发送 + WS/sync 去重,生产级体验
  4. fileUrl 统一媒体,语音媒体态独立同步
  5. 2.0 消息能力全:回复、编辑、撤回、Markdown、语音、动态

做 IM 客户端的同学,建议 双仓库对照读 MessageSyncsendmsglogic------服务端怎么写 seq,客户端就怎么拉 seq,对上号就通了。


相关链接

项目源码:

学习资源:

核心教学视频:


上一篇:海狸IM 2.0 服务端技术解析:go-zero 微服务与消息链路(46.海狸IM 2.0 服务端技术解析:go-zero 微服务与消息链路.md)

系列回顾:

  • 海狸IM 2.0 发版记录:六端工程拆分与主要变更说明(44.海狸IM 2.0 正式发布:全端能力大升级.md)
  • 海狸IM 2.0 开放能力说明:OAuth2 接入与群推送机器人(45.海狸IM 2.0:开放平台、OAuth与Bot,让IM不止于聊天.md)