文章目录
需求
实现局域网P2P通信
场景:你和同事在同一个 WiFi 下,你们想互相发送消息、图片、文件,但没有互联网,也没有公司服务器。手机和手机,手机和电脑之间的交互

分析
1. LanChat
LanChat 就是解决这个问题的 —— 它让同一局域网内的手机和电脑之间直接通信,不需要经过任何第三方服务器。
┌──────────┐ WiFi/局域网 ┌──────────┐
│ 手机 A │ ←────────────────────────→ │ 电脑 B │
│ │ 直接连接,不经过云端 │ │
└──────────┘ └──────────┘
2. 核心原理(用大白话说)
2.1 设备发现 —— 就像在黑屋子里喊一嗓子
想象一下:你在一栋黑漆漆的大楼里找人,你不知道他在哪个房间。你会怎么做?
- 喊一嗓子(UDP 广播)—— 你大喊"张三你在哪?",整栋楼都能听到
- 对方回应(UDP 回复)—— 张三听到后喊"我在这!304 房间!"
- 走过去敲门(TCP 连接)—— 你走到 304 房间门口,敲门建立连接
- 后面说话就不用喊了(TCP 通信)—— 进了房间,两个人正常聊天
2.2 两个协议的分工
| 协议 | 作用 | 像什么 | 特点 |
|---|---|---|---|
| UDP | 发现设备、打招呼 | 对讲机 | 只喊一声不管对方收到没,速度快 |
| TCP | 传消息、传文件 | 电话 | 确保对方完整收到,可靠但稍慢 |
UDP = User Datagram Protocol(用户数据报协议)—— 不可靠但快
TCP = Transmission Control Protocol(传输控制协议)—— 可靠但稍慢
3. 整体架构
┌─────────────────────────────────────────────┐
│ UI 层 (Vue) │
│ 设备列表 · 聊天界面 · 群聊 · 文件传输 │
│ contacts/index.vue │
└──────────────────┬──────────────────────────┘
│ 调用
┌──────────────────▼──────────────────────────┐
│ 桥接层 (TypeScript) │
│ lan-chat.ts · useLanChat.ts │
│ ↑ 封装成简单 API ↑ │
└──────────────────┬──────────────────────────┘
│ Capacitor 插件桥接
┌──────────────────▼──────────────────────────┐
│ 原生层 (Java) │
│ LanChatService.java │
│ UDP发现 · TCP管理 · 帧协议 · 文件传输 │
└─────────────────────────────────────────────┘
4. 通信协议详解(附 Python 代码)
这一节是你自己搭建 P2P 通信的核心。我会从零开始写 Python 代码,每一行都解释干什么。
环境要求:两台电脑(或一台电脑开两个终端)都能运行 Python 3。
4.1 UDP 设备发现
原理
UDP 多播就像在群里发消息 —— 你发一条消息,所有加入这个群的人都能收到。
技术细节:
- 多播地址:
239.255.0.1(这是一个保留给局域网用的组播地址) - 端口:
45678 - 每 3 秒发送一次心跳
- 12 秒没收到心跳,就认为对方离线了
代码
import socket
import json
import time
import threading
import uuid
# ======== 配置 ========
MULTICAST_GROUP = '239.255.0.1'
UDP_PORT = 45678
TCP_PORT = 9876
DEVICE_TIMEOUT = 12 # 秒
# ======== 生成唯一设备ID ========
def generate_device_id():
"""生成一个唯一的设备标识"""
return f"dev_{uuid.uuid4().hex[:8]}"
# ======== UDP 广播 —— 让别人发现我 ========
def start_broadcaster(udp_socket, device_info, stop_event):
"""
每3秒广播一次自己的存在
- udp_socket: UDP套接字
- device_info: 自己的设备信息(dict)
- stop_event: 停止信号
"""
while not stop_event.is_set():
message = json.dumps(device_info).encode('utf-8')
# 1. 发往多播组(标准方式)
udp_socket.sendto(message, (MULTICAST_GROUP, UDP_PORT))
# 2. 发往广播地址(兜底,有些路由器不支持多播)
udp_socket.sendto(message, ('255.255.255.255', UDP_PORT))
time.sleep(3) # 等3秒再发下一次
# ======== UDP 监听 —— 发现别人 ========
def start_udp_listener(udp_socket, online_devices, lock):
"""
持续监听UDP端口,收到广播就更新设备列表
- udp_socket: UDP套接字
- online_devices: 在线设备字典
- lock: 线程锁,防止同时修改数据
"""
while True:
try:
# 接收数据(最多4096字节)
data, addr = udp_socket.recvfrom(4096)
message = json.loads(data.decode('utf-8'))
device_type = message.get('type', '')
sender_ip = addr[0]
# 如果是本机发的,忽略
if message.get('deviceId') == my_device_id:
continue
if device_type == 'online':
# 新设备上线了!
dev_id = message['deviceId']
dev_name = message.get('userName', '未知')
platform = message.get('platform', 'unknown')
with lock:
# 更新设备信息
online_devices[dev_id] = {
'deviceId': dev_id,
'userId': message.get('userId', ''),
'userName': dev_name,
'ip': sender_ip,
'port': message.get('port', TCP_PORT),
'platform': platform,
'lastSeen': time.time() # 记录最后一次看到的时间
}
print(f" [发现] {dev_name} ({platform}) @ {sender_ip}")
elif device_type == 'offline':
# 设备下线了
dev_id = message.get('deviceId', '')
with lock:
if dev_id in online_devices:
removed = online_devices.pop(dev_id)
print(f" [离线] {removed['userName']} 离开了")
except Exception as e:
print(f" UDP接收错误: {e}")
# ======== 超时检查 —— 长时间没消息就算离线 ========
def start_timeout_checker(online_devices, lock, stop_event):
"""
每5秒检查一次设备是否超时
- 距离上次收到心跳超过12秒 → 判定离线
"""
while not stop_event.is_set():
time.sleep(5)
now = time.time()
expired = []
with lock:
for dev_id, info in online_devices.items():
if now - info['lastSeen'] > DEVICE_TIMEOUT:
expired.append(dev_id)
for dev_id in expired:
removed = online_devices.pop(dev_id)
print(f" [超时] {removed['userName']} 已离线(超过{DEVICE_TIMEOUT}秒无心跳)")
4.2 TCP 消息收发
原理
发现设备后,两边建立 TCP 连接,所有消息走统一的帧格式:
┌────────────┬──────────────┬──────────────────┐
│ 1 byte │ 4 bytes │ N bytes │
│ 帧类型 │ 数据长度 │ 数据内容 │
│ (0x01等) │ (大端整数) │ (JSON或原始字节) │
└────────────┴──────────────┴──────────────────┘
帧类型:
| 值 | 名字 | 用途 |
|---|---|---|
0x01 | FRAME_HELLO | 握手(交换身份) |
0x02 | FRAME_TEXT | 文本消息 |
0x03 | FRAME_IMAGE | 图片 |
0x04 | FRAME_FILE_START | 文件传输开始 |
0x05 | FRAME_FILE_CHUNK | 文件数据块 |
0x06 | FRAME_FILE_END | 文件传输结束 |
代码
import struct
import threading
import socket
# ======== 帧类型常量 ========
FRAME_HELLO = 0x01
FRAME_TEXT = 0x02
FRAME_IMAGE = 0x03
FRAME_FILE_START = 0x04
FRAME_FILE_CHUNK = 0x05
FRAME_FILE_END = 0x06
# ======== 发送一帧数据 ========
def send_frame(sock, frame_type, data):
"""
发送一帧数据
- sock: TCP socket
- frame_type: 帧类型(整数)
- data: 字节数据
"""
# 打包:1字节类型 + 4字节长度(大端) + 数据
# struct.pack 按指定格式把数据打包成二进制
# '!B' = 大端字节序的一个字节
# '!I' = 大端字节序的一个无符号整数(4字节)
header = struct.pack('!B', frame_type) # 帧类型
header += struct.pack('!I', len(data)) # 数据长度
sock.sendall(header + data) # 发送完整的一帧
# ======== 接收一帧数据 ========
def recv_exactly(sock, n):
"""从socket读取精确的n个字节"""
data = b''
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise ConnectionError("连接已断开")
data += chunk
return data
def recv_frame(sock):
"""
接收一帧数据
返回: (帧类型, 数据内容)
"""
# 先读取头部(5字节 = 1字节类型 + 4字节长度)
header = recv_exactly(sock, 5)
frame_type = struct.unpack('!B', header[0:1])[0] # 第1个字节:帧类型
data_len = struct.unpack('!I', header[1:5])[0] # 第2-5字节:数据长度
# 读取数据体
data = recv_exactly(sock, data_len)
return frame_type, data
# ======== TCP 服务端 —— 等待别人连接我 ========
def start_tcp_server(local_info, online_devices, lock, connections, stop_event):
"""
启动TCP服务器,等待其他设备连接
- 监听 0.0.0.0:9876(所有网卡的9876端口)
"""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', TCP_PORT))
server.listen(10)
server.settimeout(1.0) # 1秒超时,方便检查stop_event
print(f" [TCP服务] 监听端口 {TCP_PORT},等待别人连接...")
while not stop_event.is_set():
try:
client_sock, client_addr = server.accept()
print(f" [TCP连接] 收到来自 {client_addr[0]} 的连接")
# 为每个新连接创建一个线程处理
thread = threading.Thread(
target=handle_tcp_connection,
args=(client_sock, client_addr, local_info, online_devices,
lock, connections, stop_event),
daemon=True
)
thread.start()
except socket.timeout:
continue
except Exception as e:
if not stop_event.is_set():
print(f" TCP服务错误: {e}")
server.close()
# ======== 处理 TCP 连接 ========
def handle_tcp_connection(sock, addr, local_info, online_devices,
lock, connections, stop_event):
"""
处理一个TCP连接(无论是主动连接还是被动接受的)
1. 如果是主动连别人的:先发HELLO
2. 如果是被别人连的:等对方发HELLO
3. HELLO之后开始收发消息
"""
remote_ip = addr[0]
remote_dev_id = None
try:
# 先接收对方的 HELLO 帧(包含身份信息)
frame_type, data = recv_frame(sock)
if frame_type != FRAME_HELLO:
print(f" [错误] 来自 {remote_ip} 的第一个消息不是HELLO")
sock.close()
return
hello = json.loads(data.decode('utf-8'))
remote_dev_id = hello['deviceId']
remote_name = hello.get('userName', '未知')
remote_user_id = hello.get('userId', '')
# 更新设备列表
with lock:
online_devices[remote_dev_id] = {
'deviceId': remote_dev_id,
'userId': remote_user_id,
'userName': remote_name,
'ip': remote_ip,
'port': TCP_PORT,
'platform': hello.get('platform', 'unknown'),
'lastSeen': time.time()
}
# 回复 HELLO,让对方知道我的信息
send_frame(sock, FRAME_HELLO,
json.dumps(local_info).encode('utf-8'))
# 保存连接
with lock:
connections[remote_dev_id] = sock
print(f" [握手完成] 与 {remote_name} ({remote_ip}) 建立了连接")
# 进入消息收发循环
while not stop_event.is_set():
frame_type, data = recv_frame(sock)
if frame_type == FRAME_TEXT:
# 收到文本消息
msg = json.loads(data.decode('utf-8'))
print(f"\n 💬 {remote_name}: {msg.get('content', '')}")
elif frame_type == FRAME_IMAGE:
print(f"\n 🖼️ {remote_name} 发来一张图片 ({len(data)} 字节)")
elif frame_type == FRAME_HELLO:
# 对方又发HELLO(二次握手),忽略
pass
else:
print(f" [其他] 收到帧类型 0x{frame_type:02x} ({len(data)} 字节)")
except ConnectionError:
print(f" [断开] {remote_name if remote_dev_id else remote_ip} 连接断开")
except Exception as e:
if not stop_event.is_set():
print(f" [错误] {remote_ip}: {e}")
finally:
# 清理连接
with lock:
if remote_dev_id and remote_dev_id in connections:
del connections[remote_dev_id]
try:
sock.close()
except:
pass
# ======== 主动连接发现的设备 ========
def connect_to_device(dev_id, ip, port, local_info, connections,
online_devices, lock, stop_event):
"""
主动连接到另一个设备
- 会发送 HELLO 帧自我介绍
- 连接成功后进入消息循环
"""
if dev_id in connections:
return # 已经连上了
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5.0)
sock.connect((ip, port))
sock.settimeout(None)
# 发送 HELLO 自我介绍
send_frame(sock, FRAME_HELLO,
json.dumps(local_info).encode('utf-8'))
# 连接建立,把socket传给handler处理
# (handler会先等对方回HELLO,然后进入消息循环)
thread = threading.Thread(
target=handle_tcp_connection,
args=(sock, (ip, port), local_info, online_devices,
lock, connections, stop_event),
daemon=True
)
thread.start()
except Exception as e:
print(f" [连接失败] {dev_id}@{ip}:{port} — {e}")
# ======== 连接管理 —— 发现有新设备就主动连 ========
def connection_manager(local_info, online_devices, lock, connections, stop_event):
"""
监视在线设备列表,对每个新设备发起TCP连接
"""
connected_before = set()
while not stop_event.is_set():
with lock:
current_devices = set(online_devices.keys())
# 对还没连接的设备发起连接
to_connect = current_devices - connected_before
for dev_id in to_connect:
with lock:
info = online_devices.get(dev_id)
if info:
print(f" [连接管理器] 尝试连接 {info['userName']} @ {info['ip']}")
connect_to_device(
dev_id, info['ip'], info['port'],
local_info, connections, online_devices, lock, stop_event
)
connected_before = current_devices
time.sleep(1)
4.3 发送消息
# ======== 发送文本消息 ========
def send_text(connections, target_dev_id, text):
"""
给指定的设备发一条文本消息
"""
if target_dev_id not in connections:
print(f" [错误] 设备 {target_dev_id} 不在线")
return False
message = {
'from': my_user_id,
'fromName': my_user_name,
'type': 'text',
'content': text,
'time': int(time.time() * 1000) # 毫秒时间戳
}
try:
sock = connections[target_dev_id]
send_frame(sock, FRAME_TEXT, json.dumps(message).encode('utf-8'))
return True
except Exception as e:
print(f" [发送失败] {e}")
return False
4.4 组合起来 —— 完整的可运行程序
下面是一个完整的 Python 程序,复制到任何有 Python 3 的电脑上都能运行。
用法:
- 两台电脑连同一个 WiFi
- 分别运行这个程序
- 它们会自动发现对方,然后在终端里聊天
"""
LanChat 演示程序 —— 局域网 P2P 通信
纯 Python 实现,不需要任何第三方库
使用方法:
1. 两台电脑连同一个 WiFi
2. 分别运行: python lanchat_demo.py
3. 输入你的名字
4. 程序会自动发现对方设备
5. 输入消息按回车发送
"""
import socket
import json
import time
import threading
import struct
import uuid
# ======== 配置 ========
MULTICAST_GROUP = '239.255.0.1'
UDP_PORT = 45678
TCP_PORT = 9876
DEVICE_TIMEOUT = 12 # 秒
# ======== 帧类型 ========
FRAME_HELLO = 0x01
FRAME_TEXT = 0x02
# ======== 工具函数 ========
def recv_exactly(sock, n):
data = b''
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise ConnectionError("连接已断开")
data += chunk
return data
def send_frame(sock, frame_type, data):
header = struct.pack('!B', frame_type) + struct.pack('!I', len(data))
sock.sendall(header + data)
def recv_frame(sock):
header = recv_exactly(sock, 5)
frame_type = struct.unpack('!B', header[0:1])[0]
data_len = struct.unpack('!I', header[1:5])[0]
data = recv_exactly(sock, data_len)
return frame_type, data
# ======== UDP 设备发现 ========
def udp_listener(udp_socket, online_devices, lock, stop_event):
while not stop_event.is_set():
try:
data, addr = udp_socket.recvfrom(4096)
msg = json.loads(data.decode('utf-8'))
if msg.get('deviceId') == my_device_id:
continue
if msg.get('type') == 'online':
dev_id = msg['deviceId']
with lock:
online_devices[dev_id] = {
'deviceId': dev_id,
'userName': msg.get('userName', '未知'),
'ip': addr[0],
'port': msg.get('port', TCP_PORT),
'platform': msg.get('platform', ''),
'lastSeen': time.time()
}
if len(online_devices) == 1:
print(f"\n ✅ 发现设备: {online_devices[dev_id]['userName']}")
elif msg.get('type') == 'offline':
dev_id = msg.get('deviceId', '')
with lock:
if dev_id in online_devices:
name = online_devices.pop(dev_id)['userName']
print(f"\n ❌ {name} 离线了")
except socket.timeout:
continue
except:
pass
def udp_broadcaster(udp_socket, device_info, stop_event):
while not stop_event.is_set():
data = json.dumps(device_info).encode('utf-8')
try:
udp_socket.sendto(data, (MULTICAST_GROUP, UDP_PORT))
udp_socket.sendto(data, ('255.255.255.255', UDP_PORT))
except:
pass
time.sleep(3)
def timeout_checker(online_devices, lock, stop_event):
while not stop_event.is_set():
time.sleep(5)
now = time.time()
expired = []
with lock:
for dev_id, info in online_devices.items():
if now - info['lastSeen'] > DEVICE_TIMEOUT:
expired.append(dev_id)
for dev_id in expired:
online_devices.pop(dev_id)
if expired:
print(f" ⏰ {len(expired)} 台设备因超时离线")
# ======== TCP 通信处理 ========
def handle_connection(sock, addr, remote_dev_id, remote_name):
"""处理已建立的TCP连接,持续接收消息"""
try:
while True:
frame_type, data = recv_frame(sock)
if frame_type == FRAME_TEXT:
msg = json.loads(data.decode('utf-8'))
print(f"\n 💬 {remote_name}: {msg.get('content', '')}")
print(f" 📝 输入消息 (回车发送) > ", end='', flush=True)
elif frame_type in (FRAME_HELLO,):
pass # 握手帧,忽略
except:
pass
finally:
print(f"\n 🔌 与 {remote_name} 的连接已断开")
try:
sock.close()
except:
pass
def tcp_server(local_info, stop_event):
"""TCP服务端,等待别人连接"""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', TCP_PORT))
server.listen(10)
server.settimeout(1.0)
while not stop_event.is_set():
try:
sock, addr = server.accept()
# 读HELLO
frame_type, data = recv_frame(sock)
if frame_type != FRAME_HELLO:
sock.close()
continue
hello = json.loads(data.decode('utf-8'))
dev_id = hello['deviceId']
name = hello.get('userName', '未知')
# 回复HELLO
send_frame(sock, FRAME_HELLO,
json.dumps(local_info).encode('utf-8'))
print(f"\n 🤝 {name} 已连接 (来自 {addr[0]})")
print(f" 📝 输入消息 (回车发送) > ", end='', flush=True)
# 存入全局连接
with lock:
connections[dev_id] = sock
# 开线程处理消息
threading.Thread(
target=handle_connection,
args=(sock, addr, dev_id, name),
daemon=True
).start()
except socket.timeout:
continue
except Exception as e:
if not stop_event.is_set():
print(f"\n [TCP服务错误] {e}")
server.close()
def connect_to_device(dev_id, ip, port, local_info):
"""主动连接设备"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5.0)
sock.connect((ip, port))
sock.settimeout(None)
# 发HELLO
send_frame(sock, FRAME_HELLO,
json.dumps(local_info).encode('utf-8'))
# 读HELLO回复
frame_type, data = recv_frame(sock)
if frame_type != FRAME_HELLO:
sock.close()
return
hello = json.loads(data.decode('utf-8'))
name = hello.get('userName', '未知')
print(f"\n 🤝 已连接 {name} @ {ip}")
print(f" 📝 输入消息 (回车发送) > ", end='', flush=True)
with lock:
connections[dev_id] = sock
threading.Thread(
target=handle_connection,
args=(sock, (ip, port), dev_id, name),
daemon=True
).start()
except Exception as e:
print(f" [连不上 {ip}] {e}")
def connection_manager(stop_event):
"""监控新设备并主动连接"""
connected = set()
while not stop_event.is_set():
with lock:
for dev_id, info in list(online_devices.items()):
if dev_id not in connected:
print(f" 🔗 正在连接 {info['userName']}...")
connect_to_device(dev_id, info['ip'], info['port'], local_info)
connected.add(dev_id)
# 清理已离线的
connected &= set(online_devices.keys())
time.sleep(2)
def send_message(target_name, text):
"""给指定名字的设备发消息"""
with lock:
for dev_id, info in online_devices.items():
if info['userName'] == target_name:
target_id = dev_id
break
else:
print(f" ❌ 没找到叫 {target_name} 的在线设备")
return
sock = connections.get(target_id)
if not sock:
print(f" ❌ {target_name} 虽然在线但TCP还没连上,稍等几秒")
return
msg = {
'from': my_user_id,
'fromName': my_user_name,
'type': 'text',
'content': text,
'time': int(time.time() * 1000)
}
try:
send_frame(sock, FRAME_TEXT, json.dumps(msg).encode('utf-8'))
print(f" ✅ 已发送给 {target_name}")
except Exception as e:
print(f" ❌ 发送失败: {e}")
# ======== 主程序 ========
if __name__ == '__main__':
print("=" * 50)
print(" LanChat 局域网 P2P 通信演示")
print("=" * 50)
# 输入你的昵称
my_user_name = input("\n 请输入你的昵称: ").strip() or "用户"
my_device_id = f"dev_{uuid.uuid4().hex[:8]}"
my_user_id = f"user_{uuid.uuid4().hex[:8]}"
local_info = {
'type': 'online',
'deviceId': my_device_id,
'userId': my_user_id,
'userName': my_user_name,
'port': TCP_PORT,
'platform': 'pc'
}
# 共享数据
online_devices = {}
connections = {}
lock = threading.Lock()
stop_event = threading.Event()
# 创建 UDP socket
udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
udp_socket.bind(('', UDP_PORT))
# 加入多播组
mreq = struct.pack('4sl', socket.inet_aton(MULTICAST_GROUP), socket.INADDR_ANY)
udp_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
udp_socket.settimeout(1.0)
# 启动后台线程
threads = []
threads.append(threading.Thread(target=udp_listener, args=(udp_socket, online_devices, lock, stop_event), daemon=True))
threads.append(threading.Thread(target=udp_broadcaster, args=(udp_socket, local_info, stop_event), daemon=True))
threads.append(threading.Thread(target=timeout_checker, args=(online_devices, lock, stop_event), daemon=True))
threads.append(threading.Thread(target=tcp_server, args=(local_info, stop_event), daemon=True))
threads.append(threading.Thread(target=connection_manager, args=(stop_event,), daemon=True))
for t in threads:
t.start()
print("\n ✅ 服务已启动,正在等待发现其他设备...")
print(" ℹ️ 输入 help 查看命令\n")
# 主线程:处理用户输入
try:
while True:
cmd = input("\n 📝 > ").strip()
if cmd.lower() == 'exit':
break
elif cmd.lower() == 'help':
print("\n 可用命令:")
print(" list - 查看在线设备")
print(" send <名字> - 给指定设备发消息(先输入send命令,再输入消息内容)")
print(" exit - 退出")
elif cmd.lower() == 'list':
with lock:
if not online_devices:
print(" 📭 没有在线设备")
else:
print(f" 📱 在线设备 ({len(online_devices)}台):")
for dev_id, info in online_devices.items():
platform_icon = {'pc': '💻', 'phone': '📱', 'tablet': '📟'}
icon = platform_icon.get(info.get('platform', ''), '📱')
print(f" {icon} {info['userName']} ({info['ip']})")
elif cmd.lower().startswith('send '):
target = cmd[5:].strip()
if not target:
print(" ❌ 用法: send <设备名>")
continue
print(f" 现在输入要发给 {target} 的消息:")
msg_text = input(" 💬 > ").strip()
if msg_text:
send_message(target, msg_text)
else:
print(" ❌ 消息不能为空")
elif cmd:
# 直接输入文字的处理:如果只有1个在线设备,直接发给他
with lock:
if len(online_devices) == 1:
target_id = list(online_devices.keys())[0]
target_name = online_devices[target_id]['userName']
msg = {
'from': my_user_id,
'fromName': my_user_name,
'type': 'text',
'content': cmd,
'time': int(time.time() * 1000)
}
sock = connections.get(target_id)
if sock:
send_frame(sock, FRAME_TEXT, json.dumps(msg).encode('utf-8'))
print(f" ✅ 已发送给 {target_name}")
else:
print(f" ❌ {target_name} TCP 还没连上,稍等...")
elif len(online_devices) > 1:
print(f" ❓ 有多个在线设备,请用 send <设备名> 指定发送给谁")
else:
print(" ❌ 没有在线设备")
except KeyboardInterrupt:
print("\n\n 正在退出...")
finally:
stop_event.set()
# 发送离线广播
offline_msg = json.dumps({
'type': 'offline',
'deviceId': my_device_id,
'userId': my_user_id
}).encode('utf-8')
try:
udp_socket.sendto(offline_msg, (MULTICAST_GROUP, UDP_PORT))
except:
pass
udp_socket.close()
time.sleep(0.5)
print(" 👋 已退出")
5. 如何运行演示程序
步骤
- 把上面的完整代码保存为
lanchat_demo.py - 两台电脑连同一个 WiFi
- 两台电脑都运行:
python lanchat_demo.py
- 输入你的昵称(比如"张三"和"李四")
- 程序会自动发现对方
- 输入
list查看在线设备 - 输入
send 李四然后输入消息内容 - 对方会收到消息
自己跟自己测试
如果只有一台电脑,开两个终端同时运行:
# 终端 1
python lanchat_demo.py
# 昵称: 张三
# 终端 2
python lanchat_demo.py
# 昵称: 李四
6. 通信流程总结
┌────────────────────────────────────────────────────┐
│ 设备A │
│ ┌─────────────┐ UDP广播(每3秒) │
│ │ UDP广播线程 │─────→ "我是A,platform:pc" │
│ └─────────────┘ │
│ ┌─────────────┐ UDP监听 │
│ │ UDP监听线程 │←──── "我是B,platform:phone" │
│ └─────────────┘ │
│ ┌─────────────┐ TCP连接 │
│ │ TCP连接管理器│─────→ 连B:9876 │
│ └─────────────┘ HELLO A → B │
│ HELLO B → A │
│ ┌─────────────┐ TEXT("你好") │
│ │ 消息收发 │←────────────────────────────────→ │
│ └─────────────┘ │
│ ┌─────────────┐ 超时12秒检查 │
│ │ 超时检查 │─────→ 移除去线设备 │
│ └─────────────┘ │
└────────────────────────────────────────────────────┘
7. 和项目中 LanChat 的关系
| 概念 | 本教程 Python 代码 | 项目中 Android 实现 |
|---|---|---|
| UDP 发现 | ✅ 完整实现 | LanChatService.java UDP 部分 |
| TCP 帧协议 | ✅ 完整实现 | LanChatService.java TCP 部分 |
| HELLO 握手 | ✅ 完整实现 | handleIncomingConnection / connectToDevice |
| 文本消息 | ✅ FRAME_TEXT | sendMessage / dispatchText |
| 图片传输 | 框架支持,代码略 | FRAME_IMAGE + base64 |
| 文件传输 | 框架支持,代码略 | FRAME_FILE_START/CHUNK/END |
| 群聊 | × | useLanChat.ts 群聊编码 |
| 未读消息 | × | useLanChat.ts unreadCounts (§9) |
| 消息持久化 | × | localStorage 存消息历史 (§9.4) |
| UI 界面 | 命令行 | Vue 界面(contacts/index.vue) |
协议完全兼容——这个 Python 程序发送的 UDP 广播和 TCP 帧,Android 端的 LanChat 可以识别。把 platform 设为 "pc" 后,Android 端列表里就会显示电脑图标。
8. 当前项目的完整代码实现
前面用 Python 演示了协议原理,这一节讲真实项目(Android WebView App)中每一层代码是怎么写的。
项目结构:
map-h5/ ├── android/.../lanchat/LanChatService.java # ① 原生层:UDP + TCP ├── src/plugins/lan-chat.ts # ② 桥接层:接口定义 ├── src/hooks/useLanChat.ts # ② 桥接层:Vue 封装 └── src/page/contacts/index.vue # ③ UI层:界面
8.1 原生层(Java)—— 网络通信核心
文件:android/app/.../lanchat/LanChatService.java
这一层直接操作 socket,做真正的网络收发。
UDP 设备发现
// 加入多播组 239.255.0.1:45678
MulticastSocket udpSocket = new MulticastSocket(45678);
udpSocket.joinGroup(InetAddress.getByName("239.255.0.1"));
// 每3秒广播一次自己的信息
JSONObject msg = new JSONObject();
msg.put("type", "online");
msg.put("deviceId", deviceId);
msg.put("userName", userName);
msg.put("platform", localPlatform); // "phone" | "tablet" | "pc"
byte[] data = msg.toString().getBytes("UTF-8");
// 发往多播组
udpSocket.send(new DatagramPacket(data, data.length, multicastGroup, 45678));
// 同时发往广播地址(兜底)
udpSocket.send(new DatagramPacket(data, data.length, broadcastAddr, 45678));
TCP 帧协议
// 发送一帧:类型 + 长度 + 数据
void sendFrame(byte type, byte[] data) {
output.writeByte(type); // 1字节帧类型
output.writeInt(data.length); // 4字节数据长度(大端)
output.write(data); // N字节数据
output.flush();
}
HELLO 握手(被动接受连接时)
// 1. 读取对方的 HELLO 帧
byte type = input.readByte(); // 应为 0x01
int len = input.readInt(); // payload 长度
byte[] payload = new byte[len];
input.readFully(payload); // 读取完整 payload
JSONObject hello = new JSONObject(new String(payload));
// 2. 拿到对方身份信息
String devId = hello.getString("deviceId");
String userName = hello.optString("userName");
// 3. 回复自己的 HELLO
JSONObject resp = new JSONObject();
resp.put("deviceId", myDeviceId);
resp.put("userName", myUserName);
resp.put("platform", "phone");
conn.sendFrame(0x01, resp.toString().getBytes());
// 4. 进入消息循环
readFrames(conn);
消息分发循环
void readFrames(Connection conn) {
while (running) {
byte type = conn.input.readByte(); // 帧类型
int len = conn.input.readInt(); // 长度
byte[] payload = new byte[len];
conn.input.readFully(payload); // 数据
switch (type) {
case 0x01: /* HELLO → 握手阶段已处理,跳过 */ break;
case 0x02: /* TEXT → 文本消息 */ dispatchText(...); break;
case 0x03: /* IMAGE → 图片 */ dispatchImage(...); break;
case 0x04: /* FILE_START → 文件头 */ break;
case 0x05: /* FILE_CHUNK → 文件块 */ break;
case 0x06: /* FILE_END → 文件结束 */ break;
}
}
}
8.2 桥接层(TypeScript)—— 连接原生和界面
这一层有两个文件,分工不同:
接口定义 lan-chat.ts
定义 TypeScript 和 Java 原生之间的"约定"——双方都要遵守的数据格式:
// 设备信息(Java → JS)
export interface DeviceInfo {
deviceId: string
userId: string
userName: string
ip: string
port: number
platform?: string // "phone" | "tablet" | "pc"
}
// 消息格式(JS → Java 发送,Java → JS 接收)
export interface LanChatMessage {
from: string
fromName: string
type: string // "text" | "image"
content: string
time: number
fromDeviceId?: string
fromUserName?: string
fileData?: string // 图片的 base64
groupId?: string // 群聊消息使用
}
// 插件接口(Capacitor 会自动把这里的调用翻译成 Java 方法调用)
export interface LanChatPlugin {
startService(options: {...}): Promise<{...}>
stopService(): Promise<void>
sendMessage(options: { targetDeviceId, text }): Promise<void>
sendImage(options: { targetDeviceId, base64Data }): Promise<void>
getOnlineDevices(): Promise<{ devices: DeviceInfo[] }>
addListener(eventName, listener): void // 监听 Java 层的事件
}
Vue 封装 useLanChat.ts
把原生插件包装成 Vue 响应式 API,让 UI 组件可以直接用:
// —— 响应式数据(界面会自动更新) ——
const onlineDevices = ref<DeviceInfo[]>([]) // 在线设备列表
const messages = ref<Record<string, LanChatMessage[]>>({}) // 消息记录
const unreadCounts = ref<Record<string, number>>({}) // 未读计数
// —— 监听 Java 发来的事件 ——
// 收到新消息
messageListener = LanChat.addListener('onMessageReceived', (msg) => {
const fromId = msg.fromDeviceId || msg.from
// 存到消息列表
if (!messages.value[fromId]) messages.value[fromId] = []
messages.value[fromId].push(msg)
// 未读计数:不在当前聊天窗口才计数
if (fromId !== activeChatDeviceId.value) {
unreadCounts.value[fromId] = (unreadCounts.value[fromId] || 0) + 1
}
})
// 设备上线
onlineListener = LanChat.addListener('onDeviceOnline', (device) => {
// 去重后加入在线列表
onlineDevices.value = [...onlineDevices.value, device]
})
// 设备离线
offlineListener = LanChat.addListener('onDeviceOffline', (device) => {
onlineDevices.value = onlineDevices.value.filter(d => d.deviceId !== device.deviceId)
})
// —— 提供给 UI 调用的方法 ——
function start() { /* 调用 LanChat.startService() */ }
function sendText(targetDeviceId, content) { /* 调用 LanChat.sendMessage() */ }
function setActiveChat(deviceId) {
activeChatDeviceId.value = deviceId
unreadCounts.value[deviceId] = 0
}
8.3 UI 层(Vue)—— 用户界面
文件:contacts/index.vue
这一层就是三个界面:
① 在线设备列表
┌──────────────────────────────┐
│ ← 通信 群聊 → │
├──────────────────────────────┤
│ 🔍 搜索在线用户... │
├──────────────────────────────┤
│ 在线用户(3) │
│ ┌────────────────────────┐ │
│ │ 📱 张三 在线 → │ │
│ │ 📟 李四的平板 在线 → │ │
│ │ 💻 王五的电脑 在线 → │ │
│ └────────────────────────┘ │
│ 离线设备(1) │
│ ┌────────────────────────┐ │
│ │ 📱 赵六 离线 │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
核心模板代码(简化):
<!-- 每个在线设备渲染为一个 contact-item -->
<div v-for="d in onlineDevices" :key="d.deviceId" class="contact-item"
@click="openLanChat(d.deviceId)">
<div class="contact-avatar">
<div class="online-dot" /> <!-- 绿色在线圆点 -->
{{ d.userName[0] }} <!-- 头像显示首字 -->
</div>
<div class="contact-name">
{{ d.userName }}
<!-- 平台图标 -->
<svg v-if="d.platform === 'pc'">...电脑图标...</svg>
<svg v-else-if="d.platform === 'tablet'">...平板图标...</svg>
<svg v-else>...手机图标...</svg>
<span class="lan-tag">在线</span>
</div>
<!-- 未读消息角标 -->
<span v-if="unreadCounts[d.deviceId]" class="unread-badge">
{{ unreadCounts[d.deviceId] }}
</span>
</div>
② 聊天界面
<!-- 顶部栏 -->
<div class="chat-header">
<button @click="goBack">←</button>
<span>{{ chatTitle }}</span>
<button @click="openUserDetail">···</button>
</div>
<!-- 消息列表 -->
<div class="chat-messages">
<div v-for="msg in chatMessages" :key="msg.time"
:class="{ self: isMsgSelf(msg) }">
<div class="msg-bubble">{{ msg.content }}</div>
</div>
</div>
<!-- 输入栏 -->
<div class="chat-input-bar">
<input v-model="inputText" @keyup.enter="handleSend" />
<button @click="handleSend">发送</button>
</div>
③ 进入和退出聊天
// 进入聊天
function openLanChat(deviceId: string) {
currentContactId.value = deviceId
chatTargetIsLan.value = true
view.value = 'chat'
setActiveChat(deviceId) // 清除该设备的未读计数
}
// 返回列表
function goBack() {
view.value = 'list'
currentContactId.value = ''
chatTargetIsLan.value = false
setActiveChat('') // 重置,后续消息才能计未读
}
8.4 一条消息的完整旅程
看一条消息从 “张三按下发送” 到 “李四看到消息” 的完整路径:
张三的 App 李四的 App
┌─────────┐ ┌─────────┐
│ Vue 界面 │ │ Vue 界面 │
│ 点击发送 │ │ 显示消息 │
└────┬────┘ └────▲────┘
│ ① 调用 │ ⑧ 响应式更新
▼ │
┌──────────────┐ ┌──────────────┐
│ useLanChat │ │ useLanChat │
│ sendText() │ │ messages 更新 │
└──────┬───────┘ └──────▲───────┘
│ ② 调用 │ ⑦ 存入
▼ │
┌──────────────┐ ┌──────────────┐
│ lan-chat.ts │ │ lan-chat.ts │
│ 插件 sendMessage│ │ 事件回调接收到 │
└──────┬───────┘ └──────▲───────┘
│ ③ Capacitor 桥接 │ ⑥ Capacitor 桥接
▼ │
┌──────────────┐ WiFi ┌──────────────┐
│ LanChatService│ ──── TCP ────→ │ LanChatService│
│ Java │ 0x02帧 │ Java │
│ sendMessage() │ │ dispatchText()│
└──────────────┘ └──────────────┘
④ 查连接池 → ⑤ sendFrame(0x02, JSON)
逐步骤详解:
| 步骤 | 文件 | 做什么 |
|---|---|---|
| ① | contacts/index.vue | 用户点击发送,调用 sendLanText(targetId, text) |
| ② | useLanChat.ts | sendText() 调用 LanChat.sendMessage({targetDeviceId, text}) |
| ③ | lan-chat.ts | Capacitor 桥接,调用 Java 的 sendMessage 方法 |
| ④ | LanChatService.java | 从连接池找到对方设备的 TCP socket |
| ⑤ | LanChatService.java | sendFrame(0x02, jsonBytes),发送 TCP 帧 |
| ⑥ | LanChatService.java(对方) | TCP 收到数据,dispatchText() 触发事件 |
| ⑦ | useLanChat.ts(对方) | 事件回调,消息存入 messages、更新未读计数 |
| ⑧ | contacts/index.vue(对方) | Vue 响应式更新,UI 显示消息 |
9. 未读消息功能详解
9.1 计数逻辑
未读消息的核心是追踪"用户当前在看哪个聊天"。代码在 useLanChat.ts:
// 当前正在聊天的设备 ID(空字符串表示不在聊天界面)
let activeChatDeviceId = ref('')
// 进入聊天时调用
function setActiveChat(deviceId: string) {
activeChatDeviceId.value = deviceId
unreadCounts.value[deviceId] = 0 // 清空该设备的未读
}
// 收到消息时判断
function onMessageReceived(msg) {
const fromId = msg.fromDeviceId || msg.from
// 存消息...
messages.value[fromId].push(msg)
// ★ 关键判断:只有不在当前聊天窗口的消息才计未读
if (fromId !== activeChatDeviceId.value) {
unreadCounts.value[fromId] = (unreadCounts.value[fromId] || 0) + 1
}
}
9.2 界面展示
设备列表里的数字角标:
<!-- 在线设备列表 -->
<div v-for="d in onlineDevices" :key="d.deviceId" class="contact-item"
@click="openLanChat(d.deviceId)">
<!-- ... 头像、名字、平台图标 ... -->
<!-- 未读消息数字 -->
<span v-if="unreadCounts[d.deviceId]" class="unread-badge">
{{ unreadCounts[d.deviceId] }}
</span>
</div>
<!-- 聊天界面的标题栏也显示当前聊天对象的未读 -->
<div class="chat-header-title">{{ chatTitleText }}</div>
9.3 私聊 vs 群聊的差异
| 私聊(LAN Chat) | 群聊 | |
|---|---|---|
| 未读存储 | unreadCounts(useLanChat 内部) | Contact.unread(联系人的属性) |
| 判断依据 | activeChatDeviceId | currentContactId + view |
| 判断位置 | useLanChat.ts:262 | contacts/index.vue:590 |
| 退出聊天时重置 | ❌ 之前有 bug,已修复 → 加 setActiveChat('') | ✅ currentContactId.value = '' 已重置 |
群聊的未读判断(contacts/index.vue):
// 群聊消息处理器
setGroupMessageHandler((msg) => {
// ... 处理消息 ...
// ★ 不在聊天界面或不在此群聊 → 计未读
if (currentContactId.value !== msg.groupId || view.value !== 'chat') {
groupContact.unread = (groupContact.unread || 0) + 1
}
})
9.4 消息持久化
所有消息和已知设备列表存储在浏览器的 localStorage,刷新页面不会丢:
// 存储消息
const STORAGE_KEY = 'lanchat_messages'
function loadMessages() {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
}
function saveMessages(messages) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages))
}
// 存储已知设备(离线设备列表需要)
const KNOWN_DEVICES_KEY = 'lanchat_known_devices'
function loadKnownDevices() { /* ... */ }
function saveKnownDevices(devices) { /* ... */ }
9.5 曾遇到的 Bug
现象:用户 A 给用户 B 发消息,B 点开看后返回列表,A 再发新消息时 B 的未读数字不显示。
原因:goBack() 没有重置 activeChatDeviceId,它仍然指向上次聊天的设备。新消息来时 fromId === activeChatDeviceId,未读计数被跳过。
// ❌ 修复前
function goBack() {
view.value = 'list'
currentContactId.value = ''
// 忘了重置 activeChatDeviceId!
}
// ✅ 修复后
function goBack() {
view.value = 'list'
currentContactId.value = ''
setActiveChat('') // 重置,后续消息重新计未读
}
完整协议细节见同目录下的 lanchat-protocol.md。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_53810245/article/details/163104668




