Flutter IM 桌面端消息发送、ACK 回执、SQLite 本地缓存与断线重连设计

一、IM 发送消息,千万别理解成"调一次 send() 就结束"

很多人刚开始做聊天,会把发送消息想得很简单:

dart 复制代码
socket.send({"content": "你好"});

从"网络动作"的角度看,这当然没错。

但从"客户端体验"的角度看,这远远不够。

因为用户点击发送后,客户端至少要同时完成四件事:

  • UI 里立刻出现一条"正在发送"的消息
  • 这条消息要先写入本地缓存
  • WebSocket 真正把消息发给服务端
  • 等服务端 ACK 到来后,再把这条消息改成"发送成功"

也就是说,发送消息从来不是一个动作,而是一条完整链路。

在 Dart 里,WebSocket.add() 发送的数据只能是 StringList<int>,这意味着你最终发出去的消息通常还是 JSON 字符串或二进制包;而 pingInterval 则可以帮你做心跳和断线感知。换句话说,WebSocket 已经给了你"发消息"和"保连接"的基础,但"消息状态管理"仍然是客户端自己的职责。(api.dart.dev)

所以一个像样的 IM 发送流程,至少应该长这样:

text 复制代码
用户点击发送
→ 本地生成 localId
→ SQLite 插入一条 sending 状态消息
→ UI 立刻展示这条消息
→ WebSocket 发出消息包
→ 服务端返回 ACK(携带 localId / serverId)
→ 本地更新消息状态为 success
→ 会话表更新最后一条消息预览

这套流程的重点只有一句话:

先本地,后网络,再确认。

二、为什么 localIdserverId 必须分开?

这是很多 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 回执到底在解决什么问题?

很多人第一次做聊天,会有一个误区:

"只要我把消息发出去了,就算成功。"

但真实情况不是这样。

从客户端视角看,"消息发出去"至少分三层:

  1. 客户端已经调用了 socket.add()
  2. 服务端已经收到这条消息
  3. 服务端已经把这条消息处理成功,并返回确认

只有第三层,才是真正适合把消息标记为"发送成功"的时机。

所以 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 后至少要做三件事:

  1. 通过 localId 找到本地那条 sending 消息
  2. 写入 serverId
  3. sendStatussending 改成 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 就会从"能聊天"开始变成"像一个真正的软件"。

相关推荐
Hello.Reader2 小时前
Flutter IM 桌面端项目架构、聊天窗口布局与 WebSocket 长连接设计
websocket·flutter·架构
前端不太难2 小时前
Flutter Web / Desktop 为什么“能跑但不好用”?
前端·flutter·状态模式
前端不太难2 小时前
Flutter 国际化和主题系统如何避免后期大改?
flutter·状态模式
小雨凉如水2 小时前
flutter 基础组件学习
学习·flutter
Swift社区2 小时前
Flutter 适合长期大型项目 - 真实边界在哪里
flutter
嘉琪0013 小时前
Flutter 实战经验(场景 + 落地)——0309
flutter
海边的Kurisu3 小时前
范进说八股 | Redis篇
数据库·redis·缓存
難釋懷3 小时前
Redis主从-主从同步优化
数据库·redis·缓存
LSL666_4 小时前
8 Redis 高可用进阶(主从容灾→选举机制→哨兵机制)
数据库·redis·缓存