花了三个周末,终于把一套基于 FastAPI + PyQt5 的聊天软件系统从零到跑通完整捋了一遍。整体效果做出来之后,第一反应是“这界面终于没有浏览器里那种违和感了”:左侧会话列表、好友列表,中间聊天窗口,消息气泡、表情、图片都能传,登录注册、掉线重连都收拾妥当。这套系统没有用任何现成的 IM SDK,后端从协议到接口全自己搭,前端从窗口到控件全用 PyQt5 手写,算是一次比较彻底的练手。
如果你正打算自己复刻一款类似 QQ 风格的桌面聊天软件,或者纠结后端到底是用 Flask 还是 FastAPI、界面到底是用 PyQt 还是 Electron,这篇实战拆解应该能帮你省下不少试错时间。我不打算只讲“我做了个什么”,而是会把整个技术选型、架构设计、关键代码思路和踩过的坑一起倒出来。
1. 为什么偏偏选 FastAPI + PyQt5,而不是其他方案
1.1 需求定位:要的是一款“像样”的桌面 IM
动手之前我先把自己的需求写得很明确:不是做个玩具 Demo,而是尽量接近真实桌面 IM 的使用体验。具体一点说,需要满足这几点:
- 有完整的登录注册流程,密码不能明文乱存。
- 登录后能看到自己的好友列表与会话列表,并显示未读消息数。
- 点击会话可以打开聊天窗口,支持文本、表情、图片消息。
- 新消息到达时,如果当前不在对应会话页,要有未读提醒。
- 客户端退出后再登录,聊天记录还在,不能每次登录都是空城。
- 整体视觉风格上,刻意往传统桌面 IM 靠拢:左侧功能栏,中部会话列表,右侧聊天面板。
这套需求其实隐含了一个关键点:客户端是“胖客户端”,不是网页套壳。所以技术选型必须在“能写出复杂桌面 UI”和“能快速搭出稳定后端”这两个方向同时发力。最终落在 PyQt5 + FastAPI 上,并不是偶然,而是对比之后的结果。
1.2 后端选型:FastAPI 赢在异步、数据校验和自动文档
后端最开始有个候选名单:Flask、Django、FastAPI。我最后选了 FastAPI。原因很实际:
第一,FastAPI 的异步能力对 IM 这种长连接场景非常重要。聊天软件的服务端不只是处理“请求-响应”,还要担起 WebSocket 消息推送。FastAPI 对 WebSocket 的支持是原生的,一个路由装饰器就能挂一个 WebSocket 端点,配合异步读写,写起来非常干净。
第二, Pydantic 做请求体校验太省事了。客户端传上来的登录信息、聊天内容、好友操作,我不需要写一堆手动的字段校验代码,直接在请求模型里声明字段类型和约束,接口层自动完成校验,非法请求直接返回 422。这一点在接口数量多起来之后省下的时间非常可观。
第三,FastAPI 自带 OpenAPI 文档。虽然桌面客户端不需要浏览器看文档,但在联调阶段这个功能救了我好几次,哪条接口参数不对,打开文档页一测就知道,省去了对着 Postman 建一堆 collection 的功夫。
1.3 客户端选型:PyQt5 为何胜过 Tkinter 和 Electron
客户端这边我比较过三个方向:Tkinter、Electron、PyQt5。
Tkinter 最大的问题是控件风格太老,想要做出现代桌面 IM 那种“圆角头像、气泡消息、卡片列表”的观感,需要自己用 Canvas 画大量东西。画一个气泡可以接受,但画一整套会话列表、右键菜单、弹窗提示就非常痛苦了。
Electron 的问题是资源占用与打包体积。一个聊天客户端要用 Chromium 撑起整个界面,内存占用轻松到几百 MB,对于这类桌面小工具来说颗粒度太粗。Electron 的优势是前端生态丰富,但我要的是“原生桌面体验”,不是“套着浏览器外壳”的网页。
PyQt5 在这两者之间取了挺好的平衡。Qt 的 QSS 机制完全可以做到现代界面的视觉定制,ListView、TreeView、StackedWidget 这些控件稍加组合就能撑起一个完整的 IM 主窗口;同时它是原生渲染,内存占用明显低于 Electron。再加上 PyQt5 的信号槽机制天然适合界面和业务解耦,新消息到达、网络断开、UI 刷新这些都是典型的事件驱动场景,用 Qt 的 signal/slot 来表达会非常顺。
可能有朋友会问,为什么不用 Qt6。这里说句实话,PyQt5 的教程、第三方案例、踩坑记录比 PyQt6 多不少,而且本项目用到的控件和特性 PyQt5 完全覆盖。对一个以练手为主要目标的项目来说,选一个社区资料更厚实的版本,遇到问题自己排查时底气更足。
1.4 前期选型的结论
我最终定下的技术栈组合是:
| 分层 | 技术 | 用途 |
|---|---|---|
| 客户端 GUI | PyQt5 | 登录、主窗口、聊天界面、消息气泡 |
| 服务端框架 | FastAPI | HTTP 接口、WebSocket 推送 |
| 数据库 | SQLite | 用户、好友、会话、消息存储 |
| ORM | SQLAlchemy | 数据库访问层 |
| 通信协议 | HTTP + WebSocket | 请求响应 + 实时推送 |
SQLite 作为初版数据库完全够用,单文件部署,不需要额外装数据库服务。真要做到服务端多进程部署、跨机器读写的时候再迁移 PostgreSQL 也不迟。
2. 项目整体架构与数据库设计
2.1 客户端与服务端的职责边界
这个项目里我最注意的一件事,就是别把业务逻辑塞进界面控件里。很多 PyQt5 初学者喜欢把请求、解析、渲染全写在一个窗口类里,最终文件几百上千行,改一个功能要翻半天。我这里把客户端拆成了 UI、网络、工具三层:
- UI 层:只负责控件搭建、界面刷新、事件响应。
- 网络层:封装 HTTP 请求和 WebSocket 连接,对外只暴露回调函数和信号。
- 工具层:负责头像生成、图片压缩、时间格式化等杂活。
服务端这边就按 FastAPI 的惯例分成两块:路由入口和业务处理。路由入口只处理请求参数解析与结果返回,业务操作如密码校验、好友关系维护、消息落库都抽到独立的函数或类里。这样整个目录结构就很清晰:
app/ ├── main.py # FastAPI 实例与路由注册 ├── api/ │ ├── auth.py # 登录注册接口 │ ├── friend.py # 好友增删、列表接口 │ ├── message.py # 历史消息、会话列表接口 │ └── ws.py # WebSocket 通道 ├── models/ # SQLAlchemy 数据模型 │ ├── user.py │ ├── friend.py │ ├── conversation.py │ └── message.py ├── core/ │ ├── database.py # 数据库引擎与会话管理 │ └── security.py # Token 签发与密码哈希 └── schemas/ # Pydantic 请求/响应模型客户端目录:
client/ ├── main.py # 程序入口 ├── ui/ │ ├── LoginWindow.py # 登录窗口 │ ├── MainWindow.py # 主窗口 │ └── ChatWidget.py # 聊天面板 ├── net/ │ ├── http_client.py # HTTP 请求封装 │ └── ws_client.py # WebSocket 客户端 ├── widgets/ │ ├── MessageBubble.py # 消息气泡控件 │ └── SessionListItem.py # 会话列表条目 └── utils/ └── avatar.py # 默认头像绘制这样拆分之后,每个文件职责单一,改起来也容易定位。比如聊天界面想换一种气泡样式,只动 MessageBubble.py 就够,不会波及网络层。
2.2 数据库表设计:用户、好友、会话、消息四张核心表
聊天系统最核心的数据就是用户关系和消息记录。我这边设计了四张表:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| user | id、username、password_md5、nickname、avatar_path、created_at | 用户基本信息 |
| friend | id、user_id、friend_id、remark、created_at | 好友关系 |
| conversation | id、type、name、owner_id、created_at | 会话,type 区分单聊/群聊 |
| message | id、conversation_id、sender_id、content_type、content、sent_at | 聊天消息 |
好友表本质上是一张关系表。最常见的坑是大家会把“加好友”理解成只插一行记录,其实应该在 friend 表里维护双向关系,或者通过条件查询:(user_id=A and friend_id=B) or (user_id=B and friend_id=A)。我这里采用了前者,加好友时一次插入两条记录,方便后期查询。
conversation 表这里需要特别说一嘴。最早我以为会话表可以省,直接根据 sender_id 和 receiver_id 反查聊天记录就行。后来发现这是太天真了:用户点开一个会话时,他需要知道这个会话中有哪些人、会话的名字怎么显示、最后一条消息是什么、有没有未读数。这些如果不抽象成会话表,每次都得去动态查询所有好友然后两两计算最后消息时间,查询逻辑会越来越绕。预先建一张 conversation 表,每次打开会话列表就只查这张表,表情、群组、置顶这类后续功能也都有地方挂数据。
message 表的 content_type 我预留了 text、image、emoji 三种,后面想扩展文件、语音等类型,只需在表里加类型值,不必改动表结构。
2.3 消息实时性设计:为什么我主用 WebSocket
聊天软件最难的一点就是“消息怎么从服务端到客户端”。这其实有两条路:
- 轮询:客户端每隔几秒主动拉取一次新消息。
- WebSocket 长连接:客户端和服务端保持一条持久通道,服务端一旦有消息就主动推过来。
我们项目最终以 WebSocket 为主,但不是全盘否定轮询。在 WebSocket 建立失败的降级场景下,轮询可以作为保底方案。服务端在每一条 WebSocket 连接建立后,把连接对象按用户 ID 存到一个连接管理器里;当某个用户发了一条消息,服务端先落库,然后从连接管理器找到接收者的连接,直接把消息推送过去。客户端收到推送后,更新对应会话的列表项、气泡和未读角标。这个流程的核心在 FastAPI 上写出来很简单:
@app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): uid = int(websocket.query_params.get("uid")) token = websocket.query_params.get("token") if not verify_token(uid, token): await websocket.close(code=4001) return await websocket.accept() WSManager.connect(uid, websocket) try: while True: data = await websocket.receive_text() await WSManager.send_to_user(uid, data) except WebSocketDisconnect: WSManager.disconnect(uid, websocket)注意,WebSocket 建立前我在 query 参数里校验了 Token,这一步不能省。很多人都只做 HTTP 接口的登录校验,把 WebSocket 通道当后门,结果别人随便构造一个 URL 就能偷听消息流。
3. 从登录到消息收发:核心链路的具体实现
3.1 登录注册与 Token 机制
登录这块看起来简单,但牵扯的东西不少。先看服务端的核心接口设计:
| 接口 | 方法 | 说明 |
|---|---|---|
| /api/register | POST | 注册新用户 |
| /api/login | POST | 登录,返回 token |
| /api/logout | POST | 注销登录 |
| /api/conversations | GET | 获取会话列表 |
| /api/messages/{cid} | GET | 获取某个会话的历史记录 |
| /ws | WebSocket | 实时消息通道 |
密码不存明文,我用了加盐的 MD5。我知道有人说 MD5 强度不够,但对一个练手项目来说,核心是“不存储明文密码”这条底线。业务上真要上线,可以换成 bcrypt,接口逻辑不用改。
服务端登录接口的伪代码如下:
@app.post("/api/login") async def login(body: LoginBody, db: Session = Depends(get_db)): user = db.query(User).filter(User.username == body.username).first() if not user: return {"code": 1, "msg": "用户不存在"} if user.password_md5 != md5_with_salt(body.password, user.salt): return {"code": 1, "msg": "密码错误"} token = create_token(user.id) return {"code": 0, "data": {"token": token, "user": user.to_dict()}}客户端拿到 token 之后会保存在内存里,同时写入本地配置文件,下次启动如果检测到 token 未过期,可以自动登录。这一步用 PyQt5 写起来也不复杂:
class LoginWindow(QMainWindow): def login_request(self): username = self.username_input.text().strip() password = self.password_input.text() if not username or not password: QMessageBox.warning(self, "提示", "用户名和密码不能为空") return self.http_thread.run(self.api.login, username=username, password=password, done_callback=self.handle_login_result)登录成功之后,窗口跳转的逻辑要稍微注意一下。不要在登录窗口里直接把自己 close 掉就万事大吉,而是应该先创建主窗口,再在合适时机隐藏登录窗口。否则主窗口弹出时如果登录窗口还没完全销毁,就可能出现两个窗口短暂重叠或者任务栏图标错乱的问题。
3.2 会话列表与好友列表:怎么让 ListView 听话
会话列表是主界面的灵魂,也是 PyQt5 最容易写出“呆板感”的地方。我这里用的是 QListWidget 自绘 Item,没有用默认的普通文字条目。每一个会话条目都是一个独立的 Widget,里面包含头像、昵称、最后一条消息摘要、时间、未读角标。这意味着我需要实现一个自定义 ItemWidget 类,然后用 setItemWidget 把它挂到 ListWidgetItem 上。
一开始我在 QListWidget 里塞了上千条消息,发现滚动时明显掉帧。排查下来,发现是我每次刷新会话列表时把所有 Item 全清空重建了。这个操作在数据量小的时候没感觉,到后来会话多了就非常卡顿。优化方案是增量更新:只更新有变化的会话条目,不重建整个列表。比如新消息到达时,把对应会话的“最后消息”文字和“未读数”改一下,并把它移动到列表顶部,其他条目原样保持。
好友列表的实现思路类似,但这里要额外处理“分组”的问题。传统桌面 IM 都喜欢把好友分成“我的好友”“黑名单”之类,我这边用 QTreeWidget 来实现分组展开效果,每个分组对应一个顶级节点,好友是子节点。点击某个好友时触发聊天窗口的创建或切换到对应会话。
3.3 聊天窗口与消息气泡的实现
聊天窗口是整个系统里 UI 复杂度最高的一块。主要涉及消息气泡左对齐/右对齐、多种消息类型、自动滚动到底部这几个能力。
我的实现方式是:用一个 QScrollArea 作为聊天内容容器,容器内部放一个垂直布局“气泡控件”。每条消息根据方向决定是左对齐还是右对齐,同时根据消息类型选择不同的渲染逻辑:
- 文本消息:直接用 QLabel 显示,设置样式和换行策略。
- 表情消息:使用一个字符映射表,把 [微笑] [大哭] 之类的占位文本替换为对应的表情图片。
- 图片消息:动态创建 QLabel 并设置 QPixmap,同时限制最大显示宽度,防止大图把窗口撑爆。
气泡的实现不能只靠 QSS 的 border-radius 就完事,因为真实的聊天气泡往往带一个“小三角”指向头像。这个三角我用 QPainter 在 paintEvent 里画出来:
class MessageBubble(QLabel): def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) # 画圆角矩形背景 painter.drawRoundedRect(self.rect().adjusted(6, 2, -6, -2), 6, 6) # 按方向画小三角新消息到达后,聊天区要自动滚动到底部。这里有个小细节:直接调用 QScrollArea.verticalScrollBar().setValue(maximum()) 在消息还没完全渲染完时可能失效。正确做法是延迟一下,比如用 QTimer.singleShot(0, ...),等 Qt 完成一次事件循环再滚动,效果才稳定。
3.4 发送消息与历史消息加载
发送消息流程是:用户在输入框写完内容,点击发送按钮后,客户端先把消息内容追加到聊天区,再通过 HTTP 接口 POST 到服务端,服务端落库后通过 WebSocket 推给接收方。同时也要构造一条本地消息,直接渲染在发送方窗口。这个“先本地显示,再服务端确认”的顺序非常关键。如果不先本地显示,而是等服务器返回成功才显示,网络稍微慢一点,用户就会觉得消息“卡住”了,体验非常差。
历史消息加载我采用了“进入会话时一次性拉取最近 50 条,往上滚动到顶部时再加载更早的 50 条”的策略。往下拉加载更多的体验适合朋友圈,聊天软件更适合一进会话就展示最近消息,把“翻旧账”变成用户主动触发。
PyQt5 这边处理分页加载时要注意:滚动条到达顶部的事件触发加载以后,要防止加载多次导致消息顺序错乱。我在加载前加了一个 loading_more 标志位,等新消息插入完成并且滚动条位置调整好之后再解除标志。
4. 开发过程中踩过的几个坑
4.1 PyQt5 主线程与网络请求的冲突:UI 假死问题
这个坑基本每个 PyQt5 写网络应用的人都会遇到,而且第一次遇到时特别容易一脸懵。我最初在主线程里直接写requests.post(...),结果一登录,界面就白屏卡住,转圈转十几秒,有时直接无响应。原因很简单:PyQt5 的 UI 事件循环在主线程里,而网络请求是阻塞操作,在等待服务端响应时,主线程整个被占住了,控件自然无法重绘、事件无法派发。
解决办法是:把耗时的网络操作放到工作线程里执行,完成后通过信号把结果传给主线程去刷新界面。我封装了一个基于 QThreadPool 的工具类:
class HttpWorker(QRunnable): finished = pyqtSignal(object) def __init__(self, fn, *args, **kwargs): super().__init__() self.fn = fn self.args = args self.kwargs = kwargs self._finished = Signal(object) def run(self): result = self.fn(*self.args, **self.kwargs) self._finished.emit(result)注意,QRunnable 里不能直接用类变量finished = pyqtSignal(object)跨线程发射,必须为每个实例动态创建信号对象。我一开始就是踩了这个坑,所有线程的信号互相串线,A 会话加载的请求结果跑到 B 会话里去了。后来把它改成实例变量才真正解决。
4.2 FastAPI 异步接口中的数据库连接管理
FastAPI 的接口是 async 的,但 SQLAlchemy 的 Session 默认是同步操作。如果在 async 函数里直接用同步查询,查询过程中会阻塞事件循环,其他在线用户的 WebSocket 推送就会跟着受影响。我当时聊天室里两个人同时发消息,明显感觉有一个人要等另一个人的数据库查询完成才有回应。
解决思路有两个方向:一是把数据库操作放到run_in_executor线程池里去执行;二是使用支持异步的数据库驱动。我这个项目为了简洁,选择了第一个方案。封装一个异步数据库会话依赖是关键:
def get_db(): db = SessionLocal() try: yield db finally: db.close()注意不要在 fastapi 的 async 路由里直接使用这个生成器依赖,因为它会是同步执行。最好把路由函数定义为普通 def,FastAPI 会自动把同步定义的接口放到线程池中执行,避免阻塞事件循环。这一点很容易查不到原因,因为问题不是“报错”,而是“偶发卡顿”,不少人会以为是网络问题。
4.3 WebSocket 断开与重连:静默掉线的处理
开发到一半发现一个问题:客户端开着聊天界面不动,过一段时间再发消息,对方收不到,而且服务端也没有报错。排查之后才知道是网络层把空闲连接断掉了,而服务端的receive_text()一直在等消息,双方就这么“熬”着。
解决方案是定期发送心跳包。客户端每 30 秒发送一个{"type": "ping"},服务端收到后不落库,直接回{"type": "pong"}。服务端也加了一个超时机制:超过 60 秒没有收到任何客户端消息,就主动断开连接。这样双向都有明确的生命周期管理,死连接不会一直挂着。
重连逻辑也很重要。客户端检测到 WebSocket 断开后,不能直接放弃,应该进入一个指数退避的重连循环:1秒、2秒、4秒、8秒……最多重试 5 次,每次重连前先检查网络状态。重新连上之后,要重新拉取一次未读会话列表,把掉线期间漏掉的消息补齐。
4.4 中文字体下的消息气泡高度计算
有一段时间消息气泡的文本总是被截断。英文和数字显示正常,中文长句就会把气泡撑得不对。原因是 QLabel 的 sizeHint 在有中文时可能对换行宽度估算不准,尤其是带标点符号的中文文本。我在计算气泡最小高度时,用了fontMetrics().boundingRect()去做宽度估算,让文本换行策略和气泡的布局高度保持一致,才算基本解决。
更稳妥的做法是自定义消息气泡控件,在sizeHint()里手动计算文本实际渲染需要的行数和高度,而不是完全依赖 QLabel 的自动计算。
4.5 图片发送:体积控制和宽高限制
图片消息发送时如果直接发原图,一张手机照片动辄几 MB,经过 base64 编码后体积膨胀 33%,不管是入库还是网络传输都是很大的负担。我在发送前用了两步处理:
- 先读取图片的像素尺寸,如果最长边超过 1200px 就等比缩小。
- 然后用 QPixmap 重新保存为 JPEG 格式,质量设为 85,这样既能明显减小体积,又不会影响查看。
服务端存储时,我把图片数据当作image类型的消息内容,以 base64 字符串存进数据库。初版这样处理很简单,但要注意数据库文件会快速增长。很现实的是,SQLite 存几 MB 的 base64 字符串虽然能跑,但性能堪忧。如果消息量真的很大,建议还是把图片保存到文件系统或对象存储,数据库只存路径。
5. 界面效果展示与后续可以扩展的地方
5.1 界面效果与截图说明
项目做完后我截了几张图,可以比较直观地看到最终效果。
登录页:我做了青色渐变背景,中间放一个白色圆角卡片,卡片包含头像占位、用户名输入框、密码输入框、登录按钮和注册链接。输入框的 focus 样式做了蓝色边框高亮,登录按钮在 hover 时有颜色过渡,手感比较接近现代桌面软件。
主窗口:左侧是一个窄功能栏,放了头像、会话、好友、设置四个图标按钮;中间是会话列表,每个条目有头像、昵称、最后一条消息、时间、未读数角标;右侧是聊天面板,顶部显示会话标题,中间消息区,底部输入框。整个布局用 QSplitter 分割,用户可以自由拖动调整各列宽度。
聊天区:消息气泡根据发送方向分为左右两侧,我的消息靠右、绿色背景,对方消息靠左、白色背景。消息时间在气泡下方用小号灰色文字显示,图片消息在气泡内等比例显示,表情能够正确渲染,整体观感已经很接近常见的桌面 IM 了。
好友列表:通过 QTreeWidget 展示“我的好友”分组,每个好友节点显示头像和昵称。右键好友项弹出菜单,包含发起聊天、删除好友、查看资料等操作。双击好友则立即切换或创建对应的单聊会话。
5.2 后续可以马上动手的方向
这一版跑通之后,可以继续做的东西其实非常多,我列一下优先级较高的几项:
- 群聊支持:当前会话表已经预留了 type 字段,但群聊的成员关系、群公告、@ 提醒都还没有做,需要新增 group_member 表。
- 未读消息数量汇总:可以让会话列表顶部出现总未读角标,逻辑上只要把所有会话未读数累加即可。
- 消息撤回与已读回执:撤回需要服务端记录消息状态并推送更新,已读回执则需要客户端在聊天窗口聚焦时上报最后已读消息 ID。
- 消息加密:初版的明文存储到生产环境肯定不行,传输层可以用 TLS,库里敏感字段做应用层加密。
- 多端登录:现在一个用户只允许一个 WebSocket 连接,想支持手机和电脑同时在线,需要把连接管理器的数据结构改成“用户 ID 到多个连接的映射”。
如果只是练手,做到现在这样已经能给你很多信心了:FastAPI 后端顺手了,PyQt5 的各类自绘控件也摸清了,整个系统的行为逻辑基本完整。但如果想让这个项目成为作品集里的亮点,我建议在群聊和已读回执这两个方向再深挖一下,它们最能体现对复杂聊天系统的理解深度。
最后再分享一个小技巧:项目里的所有接口约定,最好在一开始就用一个文档记录下来,哪怕只是 Markdown 里列个表格。我在开发中前后改了三次消息字段,因为没有统一文档,每次都要同时翻服务端路由和客户端代码,半天时间就这么浪费掉了。后来把接口文档补上,联调效率高了一大截。这一点看起来不起眼,但对这种前后端都自己写的项目来说,反而是最值得养成的习惯。