Flutter IM 桌面端项目架构、聊天窗口布局与 WebSocket 长连接设计

一、先想清楚:你做的不是"聊天页面",而是"聊天客户端"

很多人写 IM,第一步就是先把页面搭出来:

  • 左边会话列表
  • 中间消息区域
  • 右边详情栏
  • 底部输入框

这当然没错,但如果你只停留在这里,那很容易越写越乱。

因为 IM 最终一定会遇到这些问题:

  • 聊天记录越来越多,消息列表会不会卡
  • 掉线之后怎么自动重连
  • 自己发出去的消息,先展示还是等服务端返回后再展示
  • 窗口关闭时到底退出应用,还是隐藏到托盘
  • 应用重启之后,草稿、未读数、历史消息还在不在
  • 多窗口聊天时,主窗口和子窗口的状态怎么同步

你会发现,这些问题已经完全超出了"页面开发"的范围。

它更像一个持续运行的桌面客户端系统。

所以从一开始,思路就应该变成这样:

  • UI 只是外层表现
  • 消息流才是主线
  • 本地存储是基础设施
  • 长连接是神经系统
  • 窗口、托盘、通知是桌面端的外设能力

当你用这个视角去看 Flutter,你会发现它并不是"不适合",而是很适合承担主体层

二、为什么 Flutter 其实很适合做 IM 的主体层?

这个问题不能空谈,我们直接从 IM 的特征出发来看。

1. IM 天然是一个长列表系统

无论是会话列表,还是消息列表,本质上都属于长列表

尤其是聊天记录,它不是静态数据,而是会不断增长、不断插入、不断回滚历史消息的动态列表。如果这里的渲染方式选错,项目中后期体验会明显下滑。

所以 Flutter 在这件事上的优势,首先来自它的列表体系。

你不能把聊天记录写成这种"全量渲染"思路:

dart 复制代码
ListView(
  children: messages.map((e) => MessageBubble(e)).toList(),
)

小数据量时它当然能跑,但消息量一大,就很容易吃力。

更适合聊天场景的写法,是 ListView.builder 这种"按需构建"的方式。因为聊天页的重点从来不是"一次全部画出来",而是"当前屏幕需要谁,就先构建谁"。

下面是一个更适合博客展示的注释版消息列表示例:

dart 复制代码
import 'package:flutter/material.dart';

/// 聊天消息列表
/// 这里使用 ListView.builder,而不是一次性渲染全部消息,
/// 更适合 IM 这种消息持续增长的长列表场景。
class ChatMessageList extends StatelessWidget {
  final List<String> messages;

  const ChatMessageList({
    super.key,
    this.messages = const [
      '你好,最近在忙什么?',
      '在做 Flutter 桌面 IM 客户端。',
      '听起来很有意思。',
      '是的,主要在做会话列表、消息流和长连接。',
    ],
  });

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      // reverse: true 表示列表反向渲染
      // 聊天场景中,通常希望最新消息更靠近底部输入框
      reverse: true,

      // 给消息区加一点内边距,界面更舒展
      padding: const EdgeInsets.all(16),

      // 消息总数
      itemCount: messages.length,

      itemBuilder: (context, index) {
        final msg = messages[index];

        // 这里只是演示:偶数条消息视为自己发送,奇数条视为对方发送
        final isSelf = index.isEven;

        return Align(
          // 自己的消息靠右,对方消息靠左
          alignment: isSelf ? Alignment.centerRight : Alignment.centerLeft,
          child: Container(
            // 每条消息之间增加一点垂直间距
            margin: const EdgeInsets.only(bottom: 10),

            // 气泡内边距
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),

            // 限制气泡最大宽度
            // 桌面屏幕更宽,如果不限制,文本会被拉得过长,阅读体验反而差
            constraints: const BoxConstraints(maxWidth: 420),

            decoration: BoxDecoration(
              // 自己和对方的消息使用不同底色区分
              color: isSelf ? Colors.blue.shade50 : Colors.grey.shade200,
              borderRadius: BorderRadius.circular(12),
            ),
            child: Text(msg),
          ),
        );
      },
    );
  }
}

这段代码看起来简单,但它背后其实代表了一个很重要的工程意识:

聊天页不是"能显示就行",而是必须从第一天开始,就按长列表思维去写。

2. IM 非常依赖状态变化,而 Flutter 很适合状态驱动

IM 客户端的典型特点,就是状态变化非常频繁。

比如:

  • 新消息来了
  • 连接断开了
  • 正在重连
  • 未读数变了
  • 某条消息从 sending 变成了 success
  • 会话草稿更新了
  • 当前窗口激活状态变化了

这意味着 IM 很不适合那种"页面点一下、接口调一下、局部改一下"的松散式开发方式。

它更适合状态驱动式架构

Flutter 在这里的优势,不是"自带某个神奇状态管理框架",而是它本身就非常适合围绕状态去刷新 UI。只要你的状态边界划分得合理,IM 这类高频状态应用写起来其实很顺。

3. IM 的通信模型,和 Dart 的 WebSocket 也天然契合

做 IM,离不开长连接。

而 Dart 的 WebSocket 本身就是双向通信对象,不只可以发送消息,也可以持续接收服务端推送。对 IM 来说,这种模式非常自然。

但这里要强调一句:

WebSocket 在 IM 项目里,绝对不是"连上就行"。

真正的 IM 连接层,要关心的事情包括:

  • 当前连接状态
  • 心跳机制
  • 掉线感知
  • 自动重连
  • 手动退出和异常断开的区分
  • 消息去重
  • ACK 回执

所以连接层从一开始,就不能写成一个只有 connect()send() 的小工具类。

三、项目结构别从页面开始堆,要先把边界立住

很多 Flutter 项目,第一版目录都长这样:

text 复制代码
lib/
├─ pages/
├─ services/
├─ models/
└─ utils/

这种结构在小项目里问题不大,但 IM 不是小项目。

一旦你开始加:

  • 会话索引
  • 消息缓存
  • 失败重发
  • 撤回消息
  • 托盘通知
  • 多窗口
  • 草稿同步

你就会发现,页面、网络、数据库、桌面能力会越来越缠。

所以更合理的拆法应该像这样:

text 复制代码
lib/
├─ app/
│  ├─ app.dart
│  ├─ router/
│  └─ theme/
├─ common/
│  ├─ constants/
│  ├─ utils/
│  └─ widgets/
├─ domain/
│  ├─ entity/
│  ├─ repository/
│  └─ usecase/
├─ infrastructure/
│  ├─ network/
│  │  ├─ websocket/
│  │  └─ http/
│  ├─ storage/
│  │  ├─ database/
│  │  └─ cache/
│  └─ native/
│     ├─ window/
│     ├─ tray/
│     └─ notification/
├─ features/
│  ├─ session/
│  ├─ chat/
│  ├─ contacts/
│  └─ settings/
└─ main.dart

这套结构最核心的价值,不在于"层次感很强",而在于:

它让每一类能力都有明确归属。

比如:

  • features 只负责页面和交互
  • domain 负责消息、会话、已读、撤回等业务规则
  • infrastructure/network 负责 WebSocket 和 HTTP
  • infrastructure/storage 负责 SQLite、本地缓存
  • infrastructure/native 专门处理窗口、托盘、通知这类桌面能力

当你后面需要调整托盘逻辑、补原生能力、增加多窗口时,这种分层的价值会越来越明显。

四、桌面 IM 首页怎么布局,才像真正的客户端?

桌面端和移动端最大的差异之一,就是屏幕空间更大。

所以桌面 IM 最适合的布局,不是"一个页面切一个页面",而是三栏式并列布局:

  • 左边:会话列表
  • 中间:聊天主区
  • 右边:详情区

这样用户在一个窗口里,就能同时完成:

  • 切换聊天对象
  • 查看当前消息流
  • 查看会话上下文信息

这才是桌面软件该有的味道。

下面是一个适合博客展示的三栏布局骨架,代码我也补上了中文注释。

dart 复制代码
import 'package:flutter/material.dart';

/// IM 桌面端首页
/// 典型三栏布局:左侧会话列表、中间聊天区、右侧详情区
class ImHomePage extends StatelessWidget {
  const ImHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: const [
          // 左侧:会话列表区域,固定宽度更符合桌面端习惯
          SizedBox(
            width: 300,
            child: SessionSidebar(),
          ),

          // 左中分割线
          VerticalDivider(width: 1),

          // 中间:聊天主区域,占据主要空间
          Expanded(
            child: ChatPanel(),
          ),

          // 中右分割线
          VerticalDivider(width: 1),

          // 右侧:详情区,可用于展示群成员、公告、共享文件等
          SizedBox(
            width: 280,
            child: ConversationDetailPanel(),
          ),
        ],
      ),
    );
  }
}

/// 左侧会话列表区域
class SessionSidebar extends StatelessWidget {
  const SessionSidebar({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: const [
        SizedBox(height: 12),

        // 实际项目中,这里通常会放搜索框 + 会话列表
        Expanded(
          child: SessionListView(),
        ),
      ],
    );
  }
}

/// 中间聊天主区
class ChatPanel extends StatelessWidget {
  const ChatPanel({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: const [
        // 顶部聊天标题栏,例如显示会话名称、在线状态、工具按钮
        ChatHeader(),

        Divider(height: 1),

        // 消息区使用 Expanded 撑满剩余空间
        Expanded(
          child: ChatMessageList(),
        ),

        Divider(height: 1),

        // 底部输入区域
        ChatInputPanel(),
      ],
    );
  }
}

/// 右侧详情区
class ConversationDetailPanel extends StatelessWidget {
  const ConversationDetailPanel({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('会话详情'),
    );
  }
}

这段代码最关键的价值,其实不是 UI,而是它天然帮你建立了页面边界:

  • 左边只关心会话索引
  • 中间只关心当前消息流
  • 右边只关心辅助信息

等你后面要加会话搜索、共享文件、群成员列表时,这个结构会非常稳。

五、消息模型别随便定义,localIdserverId 一开始就要分开

很多人刚开始做 IM,会把消息模型写得非常简单:

  • 一个 id
  • 一个 content
  • 一个 senderId

这种写法早期当然能用,但很快你就会发现不够了。

因为一条消息的生命周期通常是这样的:

  1. 用户点击发送
  2. 客户端先本地生成一条"发送中"的消息
  3. UI 立即显示这条消息
  4. WebSocket 把消息发给服务端
  5. 服务端返回 ACK 和正式消息 ID
  6. 客户端再把本地状态改成"发送成功"

这意味着,一个成熟一点的消息模型里,至少应该区分:

  • localId:客户端先生成的本地消息 ID
  • serverId:服务端最终确认后的正式消息 ID

下面是一个更合理的消息实体示例:

dart 复制代码
/// 消息类型
enum MessageType {
  text,   // 文本消息
  image,  // 图片消息
  file,   // 文件消息
  system, // 系统消息
}

/// 发送状态
enum MessageSendStatus {
  sending, // 发送中
  success, // 发送成功
  failed,  // 发送失败
}

/// 聊天消息实体
class ChatMessage {
  /// 本地消息 ID
  /// 用户点击发送时,客户端立即生成
  final String localId;

  /// 服务端消息 ID
  /// 服务端确认接收后返回
  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,
    );
  }
}

这里最重要的,不是字段多少,而是这套模型背后体现出的客户端思维:

消息不是"发出去就完了",而是有完整生命周期的。

六、WebSocket 长连接怎么设计,才不只是一个 demo?

这部分是整个 IM 客户端的核心之一。

很多示例代码里,WebSocket 会被写成这样:

  • 一个 connect()
  • 一个 send()
  • 一个监听回调

这当然可以跑起来,但它更像一个演示,不像一个真正的客户端连接层。

因为客户端连接层真正需要做的,是管理连接生命周期

也就是说,它至少要解决这些事情:

  • 当前是不是已连接
  • 掉线时是不是要重连
  • 用户主动退出时不要误重连
  • 服务端长时间没响应时,如何判断连接失效
  • 外部页面怎样感知连接状态变化

下面这个版本,是更适合写进博客里的注释版长连接管理器。

dart 复制代码
import 'dart:async';
import 'dart:convert';
import 'dart:io';

/// 连接状态枚举
/// 用于让 UI 或状态管理层感知当前连接状态
enum ImConnectionState {
  idle,          // 初始状态
  connecting,    // 正在连接
  connected,     // 已连接
  reconnecting,  // 正在重连
  disconnected,  // 已断开
}

/// IM WebSocket 客户端
/// 负责连接建立、消息接收、状态通知、心跳、自动重连等能力
class ImSocketClient {
  WebSocket? _socket;

  /// 消息流控制器
  /// broadcast 表示允许多个监听者同时订阅
  final _messageController = StreamController<Map<String, dynamic>>.broadcast();

  /// 连接状态流控制器
  final _stateController = StreamController<ImConnectionState>.broadcast();

  /// 对外暴露消息流
  Stream<Map<String, dynamic>> get messages => _messageController.stream;

  /// 对外暴露连接状态流
  Stream<ImConnectionState> get states => _stateController.stream;

  String? _url;

  /// 用于区分"手动关闭"还是"异常断开"
  bool _manualClose = false;

  /// 当前重连次数
  int _reconnectCount = 0;

  /// 建立 WebSocket 连接
  Future<void> connect(String url) async {
    _url = url;
    _manualClose = false;

    // 通知外部:开始连接
    _stateController.add(ImConnectionState.connecting);

    try {
      // 建立 WebSocket 连接
      _socket = await WebSocket.connect(url);

      // 设置心跳间隔
      // 会定时发送 ping,若长时间收不到 pong,会自动断开
      _socket!.pingInterval = const Duration(seconds: 20);

      // 通知外部:连接成功
      _stateController.add(ImConnectionState.connected);

      // 连接成功后,重连次数归零
      _reconnectCount = 0;

      // 监听服务端推送
      _socket!.listen(
        (event) {
          // 这里只处理字符串消息
          if (event is String) {
            final map = jsonDecode(event) as Map<String, dynamic>;
            _messageController.add(map);
          }
        },

        // 连接关闭时触发
        onDone: _handleDisconnect,

        // 出错时也进入断开处理流程
        onError: (_) => _handleDisconnect(),

        // 出错后终止当前监听
        cancelOnError: true,
      );
    } catch (_) {
      // 建连失败时进入重连流程
      _scheduleReconnect();
    }
  }

  /// 发送消息
  /// 这里把 Map 转成 JSON 字符串后发送给服务端
  void send(Map<String, dynamic> data) {
    _socket?.add(jsonEncode(data));
  }

  /// 处理断开逻辑
  void _handleDisconnect() {
    // 如果是用户主动关闭,就不要继续重连
    if (_manualClose) {
      _stateController.add(ImConnectionState.disconnected);
      return;
    }

    // 如果不是手动关闭,说明可能是网络问题或服务端断开
    _scheduleReconnect();
  }

  /// 自动重连
  void _scheduleReconnect() {
    if (_url == null) return;

    _stateController.add(ImConnectionState.reconnecting);
    _reconnectCount++;

    // 简单退避策略:
    // 前几次逐步增加等待时间,避免在异常情况下疯狂重试
    final delay = Duration(
      seconds: _reconnectCount > 5 ? 10 : _reconnectCount * 2,
    );

    Future.delayed(delay, () {
      // 如果这段时间用户已经手动关闭,就不要继续重连
      if (_manualClose || _url == null) return;

      connect(_url!);
    });
  }

  /// 手动关闭连接
  Future<void> close() async {
    _manualClose = true;
    await _socket?.close();
  }

  /// 释放资源
  Future<void> dispose() async {
    await close();
    await _messageController.close();
    await _stateController.close();
  }
}

这段代码和普通 demo 最大的区别,不在于它更长,而在于它已经开始具备"客户端"的意识了:

  • 有状态流
  • 有心跳
  • 有重连
  • 有手动关闭和异常断开的区分
  • 有对外广播能力

也就是说,它已经不只是一个"网络工具类",而是一个真正的连接管理器

七、桌面能力不能直接进业务层,必须收口

Flutter 做桌面 IM,还有一个很容易被忽视的点:

桌面能力和业务逻辑,必须分开。

比如:

  • 窗口显示与隐藏
  • 最小化到托盘
  • 桌面通知
  • 多窗口管理

这些都属于桌面环境能力,而不是聊天业务本身。

如果你把这些调用直接散在页面里,后面会非常难维护。

比如下面这种写法就不太理想:

dart 复制代码
windowManager.hide();
trayManager.setIcon(...);
trayManager.popUpContextMenu();

为什么说它不好?

因为它会导致:

  • 页面直接依赖具体插件
  • 平台差异散落在业务代码中
  • 后面一旦要改插件或补原生实现,重构成本很高

更好的方式,是统一抽象出一个桌面壳层。

dart 复制代码
/// 桌面壳层抽象
/// 用于统一封装窗口、托盘、通知等桌面能力
/// 业务层不要直接调用具体插件,而是依赖这个抽象接口
abstract class DesktopShell {
  /// 初始化桌面环境
  Future<void> init();

  /// 显示主窗口
  Future<void> showMainWindow();

  /// 隐藏到托盘
  Future<void> hideToTray();

  /// 显示桌面通知
  Future<void> showNotification(String title, String body);
}

这层抽象的意义在于:

  • 聊天业务层不用知道具体用了什么插件
  • 后面要换实现,只改壳层
  • 需要补原生代码时,不会影响上层页面结构

在真正的桌面客户端项目里,这种收口几乎是必须的。

八、主窗口初始化,也应该单独封装

桌面应用不像移动端,窗口本身就是一个重要对象。

包括:

  • 初始大小
  • 是否居中
  • 是否获取焦点
  • 标题怎么设置

这些都不应该散在 main.dart 各个角落,而应该有一个统一初始化入口。

dart 复制代码
import 'package:window_manager/window_manager.dart';
import 'dart:ui';

/// 初始化主窗口
/// 一般在应用启动阶段调用
Future<void> initMainWindow() async {
  // 确保 window_manager 初始化完成
  await windowManager.ensureInitialized();

  // 配置窗口参数
  const options = WindowOptions(
    // 初始窗口尺寸
    size: Size(1280, 860),

    // 启动时居中显示
    center: true,

    // 窗口标题
    title: 'Flutter IM',

    // 标题栏样式
    titleBarStyle: TitleBarStyle.normal,
  );

  // 等待窗口准备完成后显示
  await windowManager.waitUntilReadyToShow(options, () async {
    // 显示窗口
    await windowManager.show();

    // 获取焦点,让窗口置于前台
    await windowManager.focus();
  });
}

这类代码在技术博客里非常加分,因为它会让读者感觉到:

这不是在讲一个抽象概念,而是在讲一个真正准备落地的桌面项目。

九、本地存储为什么第一版就应该考虑?

很多人做 IM 时,会把本地数据库放到"以后再说"。

但在桌面端,这个决定往往会让你后面很被动。

因为这些功能都离不开本地存储:

  • 历史消息缓存
  • 会话索引
  • 草稿保存
  • 未读数恢复
  • 文件消息状态记录

如果没有数据库,体验会非常虚:

  • 应用一重启,状态全没
  • 会话列表不能秒开
  • 草稿丢失
  • 未读数不稳

所以哪怕第一版不做复杂搜索,也应该至少把这些表的边界先想清楚:

  • conversations
  • messages
  • drafts
  • attachments

这一步虽然不直观,但它决定的是整个客户端的稳定性。

十、最后总结:Flutter 不是不能做 IM,而是不能"随便做"

说到底,Flutter 做 IM 桌面端这件事,真正的关键不在框架本身,而在于你怎么用它。

如果你只是想:

  • 快速画个聊天页
  • 接个 WebSocket
  • 把消息显示出来

那当然也能跑。

但那更像一个演示样例,而不是一个真正的桌面 IM 客户端。

真正正确的方式应该是:

  • 用 Flutter 承担跨平台 UI 主体
  • 用合理的项目分层隔离页面、业务、基础设施
  • ListView.builder 和局部刷新思维处理长列表
  • 用有状态、有心跳、有重连的连接管理器管理 WebSocket
  • 用本地数据库承接消息、会话和草稿
  • 用桌面壳层封装窗口、托盘、通知等能力
相关推荐
前端不太难2 小时前
Flutter Web / Desktop 为什么“能跑但不好用”?
前端·flutter·状态模式
山顶望月2 小时前
OpenClaw 架构与设计思路分析
人工智能·架构
前端不太难2 小时前
Flutter 国际化和主题系统如何避免后期大改?
flutter·状态模式
小雨凉如水2 小时前
flutter 基础组件学习
学习·flutter
老迟聊架构2 小时前
完全基于对象存储的数据库引擎:SlateDB
数据库·后端·架构
Swift社区2 小时前
Flutter 适合长期大型项目 - 真实边界在哪里
flutter
嘉琪0013 小时前
Flutter 实战经验(场景 + 落地)——0309
flutter
娇娇yyyyyy3 小时前
C++ 网络编程(22) beast网络库实现websocket服务器
网络·c++·websocket
天远云服3 小时前
PHP微服务风控架构:无缝接入天远劳动仲裁信息查询API排查用工黑产
大数据·微服务·架构·php