博客zhu虎康头像
实现局域网P2P通信:内网发送消息封面图

实现局域网P2P通信:内网发送消息

需求

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

在这里插入图片描述

分析

1. LanChat

LanChat 就是解决这个问题的 —— 它让同一局域网内的手机和电脑之间直接通信,不需要经过任何第三方服务器。

  ┌──────────┐         WiFi/局域网        ┌──────────┐
  │  手机 A   │ ←────────────────────────→ │  电脑 B   │
  │           │   直接连接,不经过云端       │           │
  └──────────┘                             └──────────┘

2. 核心原理(用大白话说)

2.1 设备发现 —— 就像在黑屋子里喊一嗓子

想象一下:你在一栋黑漆漆的大楼里找人,你不知道他在哪个房间。你会怎么做?

  1. 喊一嗓子(UDP 广播)—— 你大喊"张三你在哪?",整栋楼都能听到
  2. 对方回应(UDP 回复)—— 张三听到后喊"我在这!304 房间!"
  3. 走过去敲门(TCP 连接)—— 你走到 304 房间门口,敲门建立连接
  4. 后面说话就不用喊了(TCP 通信)—— 进了房间,两个人正常聊天
设备B 设备A 设备B 设备A 每3秒喊一次:"我在这里!" UDP广播 "我是A,地址192.168.1.10" UDP广播 "我是B,地址192.168.1.20" TCP连接 192.168.1.20:9876 "你好,我是A" (HELLO) "收到,我是B" (HELLO回复) 发送消息 "吃饭去吗?" "走!"

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或原始字节)  │
└────────────┴──────────────┴──────────────────┘

帧类型:

值名字用途
0x01FRAME_HELLO握手(交换身份)
0x02FRAME_TEXT文本消息
0x03FRAME_IMAGE图片
0x04FRAME_FILE_START文件传输开始
0x05FRAME_FILE_CHUNK文件数据块
0x06FRAME_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 的电脑上都能运行。

用法:

  1. 两台电脑连同一个 WiFi
  2. 分别运行这个程序
  3. 它们会自动发现对方,然后在终端里聊天
"""
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. 如何运行演示程序

步骤

  1. 把上面的完整代码保存为 lanchat_demo.py
  2. 两台电脑连同一个 WiFi
  3. 两台电脑都运行:
python lanchat_demo.py
  1. 输入你的昵称(比如"张三"和"李四")
  2. 程序会自动发现对方
  3. 输入 list 查看在线设备
  4. 输入 send 李四 然后输入消息内容
  5. 对方会收到消息

自己跟自己测试

如果只有一台电脑,开两个终端同时运行:

# 终端 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_TEXTsendMessage / 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.tssendText() 调用 LanChat.sendMessage({targetDeviceId, text})
③lan-chat.tsCapacitor 桥接,调用 Java 的 sendMessage 方法
④LanChatService.java从连接池找到对方设备的 TCP socket
⑤LanChatService.javasendFrame(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(联系人的属性)
判断依据activeChatDeviceIdcurrentContactId + view
判断位置useLanChat.ts:262contacts/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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--