LanChat 局域网即时通讯软件 --- 技术文档
起初做这个项目是我工作中有两台电脑工作,另一台又是外网,不方便下载聊天工具,那么我就突然奇想用AI辅助开发一个局域网聊天工具,方便复制和发送文件,而且关闭聊天框后会自动清楚聊天记录,方便上班发悄悄话!!!
项目概述
LanChat 是一款基于 Python 的局域网即时通讯软件,支持文本聊天、文件传输、在线用户自动发现,采用 TCP + UDP 混合协议实现 P2P 通信。
点击发送就可以发送任何文件


右击气泡即可复制内容,和保存文件
目录结构
lan_message/
├── main.py # 程序入口
├── network.py # 网络层(UDP 发现 + TCP 通信)
├── ui.py # Tkinter 图形界面
├── LanChat.spec # PyInstaller 打包配置
├── dist/ # 构建产物
│ ├── main.exe
│ └── main.rar
└── docs/
└── 技术文档.md # 本文
架构设计
整体架构

通信架构

网络层 --- network.py
核心数据类
| 类 | 字段 | 说明 |
|---|---|---|
Peer |
name, ip, last_seen, online |
对等节点信息 |
ChatMessage |
sender, content, timestamp, is_file, file_name, file_size, file_path, is_self |
聊天消息 |
常量
| 常量 | 值 | 说明 |
|---|---|---|
DISCOVERY_PORT |
9876 | UDP 广播发现端口 |
TCP_PORT |
9877 | TCP 消息传输端口 |
BROADCAST_ADDR |
255.255.255.255 | 子网广播地址 |
DISCOVERY_INTERVAL |
3s | 心跳广播间隔 |
PEER_TIMEOUT |
12s | 对等节点超时阈值 |
BUFFER_SIZE |
65536 | 网络缓冲区大小 |
CHUNK_SIZE |
32768 | 文件传输分块大小 |
NetworkManager 类
线程模型
start()
├── _udp_listener() [daemon] ← 共享 UDP socket,持续监听 "hello" 广播
├── _udp_broadcaster() [daemon] ← 共享 UDP socket,每 3s 发送 "hello" 心跳
├── _tcp_server_thread() [daemon] ← 监听 TCP 端口,处理消息/文件接收
└── _queue_processor() [daemon] ← 消费消息队列,回调 UI 层
启动流程
NetworkManager.__init__()
└─ 注册回调 (on_message, on_file_progress, on_peers_changed)
NetworkManager.start()
├─ 创建单例 UDP socket (SO_BROADCAST | SO_REUSEADDR)
├─ bind 0.0.0.0:9876
│ ├─ 成功 → 启动 _udp_listener
│ └─ 失败 → close socket, self.udp_sock = None (仅发送广播)
├─ 启动 _udp_broadcaster
├─ 启动 _tcp_server_thread
└─ 启动 _queue_processor
对等发现机制

心跳与超时检测
-
每个节点每 3s 广播一次
{"type": "hello", "name": hostname} -
接收方记录
peer.last_seen = time.time(), 设置peer.online = True -
广播线程每轮循环检查
now - peer.last_seen > 12s,超时则标记online = False -
离线节点仍然保留在列表中(红色标识),可手动刷新清除
时间线:
t=0 t=3 t=6 t=9 t=12 t=15
│ │ │ │ │ │
hello──► hello──► hello──► hello──► (hello 停止)
↑ ↑ ↑ ↑
last_seen=3 last_seen=6 last_seen=9 last_seen=9
now - 9 = 6 < 12 → 仍在线
t=21
│
now - 9 = 12 ≥ 12 → offline
消息发送 (TCP)
send_message(peer_ip, content)
├─ 创建 TCP 连接 → peer_ip:9877
├─ 4字节小端长度前缀 + JSON 消息体
│ {"type":"msg", "sender", "content", "time"}
├─ sendall
└─ close
文件发送 (TCP)
send_file(peer_ip, file_path)
├─ TCP 连接 → peer_ip:9877
├─ 发送文件头: {"type":"file_offer", "name","size","sender","time"}
├─ 等待接收方 ACK (1字节 0x01)
├─ 分块发送 (CHUNK_SIZE = 32KB)
│ └─ 每块发送后推送进度到队列
├─ close
└─ 创建本地 ChatMessage (is_self=True)
文件接收
_handle_tcp_client(conn, peer_ip)
├─ 4字节长度前缀 → JSON 解析
├─ msg_type == "file_offer"
│ ├─ 创建接收目录: ~/Downloads/LanChat/
│ ├─ 处理文件名冲突 (添加 (1), (2)...)
│ ├─ 发送 ACK
│ ├─ 分块接收写入文件
│ │ └─ 每块推送进度到消息队列
│ └─ 创建 ChatMessage (is_file=True)
└─ msg_type == "msg"
└─ 创建 ChatMessage → 入队
线程安全设计
peers字典由peers_lock(threading.Lock) 保护- 所有 UI 更新通过
root.after(0, callback)委托到主线程 - 网络线程通过
queue.Queue异步传递消息到 UI 层 _queue_processor单线程消费消息队列,避免竞态
UI 层 --- ui.py
窗口层级
MainWindow (DnDTk)
├── PanedWindow
│ ├── 左侧面板 (Frame, 220px)
│ │ ├── Label "在线用户"
│ │ ├── Button "🔄 刷新"
│ │ └── Listbox (peer_listbox)
│ │
│ └── 右侧容器 (Frame, weight=1)
│ ├── welcome_frame (初始欢迎页)
│ └── ChatView (聊天视图)
│ ├── 顶部导航 (← 返回 + 标题)
│ ├── Canvas + Scrollbar (消息区域)
│ │ └── msg_frame
│ │ └── ChatBubbleFrame ×N
│ ├── 拖拽提示条
│ └── 底部输入区
│ ├── Text (多行输入)
│ ├── Button "发送"
│ ├── Button "📎 发送文件"
│ └── Label (进度提示)
ChatBubbleFrame --- 聊天气泡
渲染流程
__init__(parent, msg)
├─ 创建 Canvas
├─ 创建 content Frame (Canvas 的子控件)
├─ 组装内容 (时间 + 文本/文件控件)
├─ 临时 pack + create_window 测量尺寸
│ ├─ content.winfo_width()
│ └─ content.winfo_height()
├─ 删除临时窗口
├─ 配置 Canvas 最终尺寸 (宽度 + RADIUS, 高度 + 4)
├─ pack 到父容器 (anchor=E/W + 边距)
├─ 绘制圆角矩形背景 (polygon + smooth)
└─ create_window 居中嵌入 content
圆角矩形算法
_round_rect(c, x1, y1, x2, y2, r=10)
输入: Canvas 对象, 矩形左上角(x1,y1), 右下角(x2,y2), 圆角半径r
输出: Canvas polygon ID
控制点:
(x1+r,y1) ──────────── (x2-r,y1)
│ │
│ ┌────────────────┐ │
│ │ content │ │
│ └────────────────┘ │
│ │
(x1,y1+r) (x2,y1+r)
│ │
│ │
(x1,y2-r) (x2,y2-r)
│ │
(x1+r,y2) ──────────── (x2-r,y2)
→ 使用 create_polygon(points, smooth=True)
smooth 自动在角点生成贝塞尔曲线
气泡布局
| 条件 | 对齐 | padx_left | padx_right | 背景色 |
|---|---|---|---|---|
is_self=True |
tk.E (右) |
40 | 0 | #dcf8c6 |
is_self=False |
tk.W (左) |
0 | 40 | #ffffff |
定时刷新机制
| 机制 | 触发方式 | 间隔 |
|---|---|---|
| UDP 事件驱动 | on_peers_changed 回调 |
实时 (收到 hello 时) |
| 定时器兜底 | _start_peer_refresh_timer |
2s |
| 手动刷新 | 用户点击 "🔄 刷新" | --- |
拖拽文件发送
- 依赖
tkinterdnd2库(基于 TkDnD 原生扩展) MainWindow使用DnDTk()替代tk.Tk()作为根窗口,使所有子控件继承拖放能力ChatView._setup_drag_drop()注册DND_FILES类型- 拖放时解析
event.data,提取文件路径,调用network.send_file()
右键菜单
| 消息类型 | 菜单项 |
|---|---|
| 文本消息 | 复制文本、复制消息内容 |
| 文件消息 | 打开文件、另存文件...、复制消息内容 |
数据流
文本消息发送
用户输入 → Enter / 点击发送
→ ChatView._send_message()
→ 创建 ChatMessage (is_self=True)
→ _add_message() 本地显示
→ 线程: network.send_message(peer_ip, text)
→ TCP 连接 → JSON 编码 → sendall
→ 接收方 _handle_tcp_client()
→ ChatMessage 入队
→ _queue_processor()
→ on_message() callback
→ MainWindow._on_message()
→ root.after(0, ...)
→ ChatView.receive_message()
→ _add_message() 显示
文件发送
用户选择文件 / 拖拽
→ network.send_file(peer_ip, path)
→ TCP 连接 → 发送文件头
→ 接收方 ACK
→ 分块发送 (每块32KB)
→ 每块发完后推送进度到队列
→ on_file_progress()
→ ChatView.update_progress()
→ 发送完成 → 创建 ChatMessage (is_self=True)
→ on_message() → 显示文件气泡
接收方:
TCP 接收 → 文件头解析
→ 创建 ~/Downloads/LanChat/
→ 分块写入文件
→ 每块推送进度 → UI 进度条
→ 完整接收 → ChatMessage (is_file=True) → 显示文件气泡
配置与打包
PyInstaller 打包
打包配置见 LanChat.spec:
python
# 关键配置
datas = ['tkinterdnd2/tkdnd'] # 需要打包 TkDnD 原生扩展库
hiddenimports = ['tkinterdnd2']
console = False # 无控制台窗口
依赖
| 依赖 | 用途 | 安装 |
|---|---|---|
| Python ≥ 3.10 | 运行时 | --- |
| tkinter | GUI 框架 | Python 内置 |
| tkinterdnd2 | 文件拖拽 | pip install tkinterdnd2 |
| PyInstaller | 打包 exe | pip install pyinstaller |
构建命令
bash
pyinstaller LanChat.spec
端口占用
| 端口 | 协议 | 用途 |
|---|---|---|
| 9876 | UDP | 对等发现 (广播 + 监听) |
| 9877 | TCP | 消息传输、文件传输 |
关键设计决策
为什么使用单例 UDP socket?
最初 _udp_listener 和 _udp_broadcaster 各自创建独立的 UDP socket,但 Windows 上多 socket 绑定同一端口行为不可靠(SO_REUSEADDR 无法保证所有平台都能接收广播)。改为在 start() 中创建单个 socket (同时设置 SO_BROADCAST 和 SO_REUSEADDR),两个线程共享此 socket 分别执行 sendto 和 recvfrom,避免了端口冲突问题。
为什么用 Canvas 绘制气泡背景?
Tkinter 原生控件不支持圆角。采用 Canvas.create_polygon(smooth=True) 绘制圆角矩形,通过 12 个控制点生成平滑贝塞尔曲线,替代了原始的 Frame 背景方案,实现聊天气泡的圆角效果。
为什么使用 create_window 要求子控件关系?
Tkinter 的 canvas create window 要求嵌入的控件必须是 Canvas 的直接子控件(或同一窗口树中的后代)。将 content Frame 的父控件设置为 self.canvas(而非 self)才能正确通过 create_window 嵌入并显示。
线程模型设计考量
| 线程 | 职责 | 为什么独立 |
|---|---|---|
_udp_listener |
持续接收广播 | recvfrom 是阻塞调用 |
_udp_broadcaster |
定时发送心跳 + 超时检测 | 需要精确计时循环 |
_tcp_server_thread |
接受 TCP 连接 | accept 是阻塞调用 |
_queue_processor |
消费消息队列 | 串行化网络 → UI 消息 |
| TCP handler ×N | 处理单个连接 | recv 可能长时间阻塞(尤其文件传输) |
所有网络线程使用 daemon=True,主窗口关闭时自动退出。UI 更新通过 root.after(0, callback) 线程安全地调度到主线程。
错误处理策略
| 场景 | 处理方式 |
|---|---|
| UDP 端口被占用 | 关闭 socket,设置 udp_sock = None,仅发送广播不接收 |
| TCP 端口被占用 | tcp_server = None,跳过接收(不能发送消息和文件) |
| 发送消息超时/失败 | send_message 返回 False,UI 弹出警告 |
| 文件接收目录创建失败 | 静默处理,文件可能无法保存 |
| 网络线程异常 | 通用 except: continue 保持线程运行 |
| tkinterdnd2 未安装 | 拖拽功能降级为文字提示,文件发送走按钮 |
版本构建
当前版本通过 PyInstaller 打包为单个 main.exe,位于 dist/ 目录。TkDnD 原生扩展库 (tkdnd/) 需随程序分发,已在 .spec 中通过 datas 配置。