P2P Demo实战:Android端到端局域网通讯从0到1
2026/9/9 6:55:14 网站建设 项目流程

简介:P2P技术演示包面向网络通信、分布式系统开发者,尤其适合希望学习NAT穿透与P2P直连原理的初学者。示例以P2PClientTest为核心,配合服务端与穿透配置,展示设备如何通过P2P服务器建立连接、完成数据交换。压缩包共24个文件,以h头文件、cpp源码、lib库、dll动态库及exe可执行程序为主,另有readme说明文档,整体仅2.09MB,结构紧凑便于快速上手。已有446人学习下载。通过运行P2PClientTest并阅读对应源码,可以直观理解P2P服务中的IP、端口与密钥管理,掌握UDP打洞、STUN/TURN等NAT穿透思路,同时熟悉实际工程中的目录划分与依赖配置。对于想深入P2P应用开发的读者,这是一份低门槛的实操参考。 我之前在做技术分享时,最常被问到的问题就是:P2P到底怎么写Demo?很多人觉得点对点通讯很玄乎,其实抛开名词,它就是一个“两个端点直接连起来”的通道。这篇我拿一个实际跑通的p2pDemo示例来拆,它是Android端到端的局域网通讯工程,可以实现两台设备互相发现、发文本、传文件,核心代码量不大。不管你是想入门P2P开发、做毕业设计,还是要在公司快速做一个局域网互传工具的POC,这篇应该都能给你省点时间。

顺带说一句,我后台看热词的时候,发现“MCP服务demo”“鸿蒙hap/hsp/har的demo”“MoveIt2 demo”“3DGS三维重建demo”这些词热度都很高。这些方向各有各的复杂度,但落到底层,几乎都逃不开一条端到端的通讯链路,P2P就是其中最常见的一种。

1. 为什么需要一个P2P Demo

P2P全称Peer to Peer,和C/S最大的区别是没有一个中心服务器来转发全部业务数据。平时我们写接口调HTTP、发消息走MQTT,本质上都有个“中转站”。P2P想做的事情是:让两个节点自己建立通道,数据直接在两端之间流动。好处是延迟低、成本低,传大文件的时候不会把服务端带宽撑爆。

很多开发者在面试或项目里聊到P2P都觉得懂原理,但真动手写Demo就卡住了:不知道怎么让两个设备互相发现、不知道怎么处理断线重连、对粘包问题完全没概念。这篇博客就是把p2pDemo示例里涉及的这些环节拆开讲清楚,重点是“为什么这么做”,而不是只贴一堆代码。

1.1 我理解的P2P到底解决什么问题

举一个最直观的对比。你在局域网里要给同事传一个2GB的视频,方案一:先传到公司服务器,再让同事从服务器下载。数据走了两遍出口带宽,传得慢,服务器还要占磁盘。方案二:你用P2P工具直接把文件从你的网卡推到同事的网卡,数据只经过交换机,速度直接拉满。

这个区别在公网环境下更明显。中心化服务器是所有流量的瓶颈,P2P则把压力分摊到每个节点上。所以P2P不是替代HTTP,也不是万能药,它适合的是“端与端之间高频、大量数据交换”的场景,比如文件互传、音视频通话、屏幕共享、实时协作编辑。

1.2 这个Demo适合谁来读

这个p2pDemo示例不是那种几十个类堆起来的“炫技工程”,而是一条完整且能跑通的链路,适合三类人:

  • 想学P2P通讯原理的开发者:从信令服务器到Socket连接,再到粘包处理和心跳保活,每一步都有对应代码和注释。
  • 要交课程设计或毕业设计的学生:这个工程可以当基础骨架,能演示、能截图、能答辩,扩展空间也大。
  • 需要做技术预研或者POC的工程师:想验证“局域网内两台设备能否端到端互传”,跑一遍这个Demo,结论就出来了。

2. 整体设计拆解:先跑通,再谈分布式

在实现这个p2pDemo示例前,我先定了三条设计原则:第一,信令服务必须极简,只负责牵线搭桥;第二,传输层用TCP做可靠通道;第三,代码结构必须能一眼看清谁在做什么。这三条决定了后续所有的选型。

2.1 信令只做“介绍人”,不碰业务数据

信令这个概念最容易搞混。它不是一个业务服务器的替代品,而是在连接建立之前,帮助双方交换网络信息的“媒人”。

流程是这样:设备A和设备B先分别连上同一个信令服务器,上报自己的IP和监听端口。信令服务器把A的地址告诉B,把B的地址告诉A。拿到地址后,A和B直接建立TCP连接,之后所有业务数据都不再经过信令服务。

打个比方,信令就像交友平台的资料展示页:它帮你交换了联系方式,但后续的聊天、见面是你自己的事情,平台不会监听你们的通话。设计这个Demo时,我刻意把信令服务做到只有两个功能:维护在线设备列表、转发“对方地址信息”。没有数据库、没有鉴权、没有消息队列,这反而让初学者能看懂关键链路。

2.2 技术栈选择的三个理由

这个Demo最终选型:Android端用Kotlin + Jetpack Compose,信令服务用Python FastAPI + WebSocket,传输通道用TCP Socket,组网形态是局域网。

理由逐条说:

  • Compose适合快速搭演示界面:状态管理直观,State + ViewModel + Flow 的组合写起来比传统XML布局省一半代码,而且界面漂亮的Demo在企业内部演示时很有说服力。
  • Python服务端代码量最小:FastAPI对WebSocket支持非常成熟,十行代码就能起一个WebSocket服务,改起来也快。如果换Node.js也可以,但Python做这类小工具我效率最高。
  • TCP可靠性强,适合文件传输:对于文本和文件这类对完整性要求高的场景,TCP的可靠字节流能省去大量重传逻辑。

这里插一个热词相关的话题。很多人搜索“5090可以p2p通讯吗”,如果指的是显卡层面的P2P,那确实有GPUDirect P2P这种技术,允许GPU之间直接交换数据,不走CPU内存中转。这说明P2P思想贯穿了很多层级:从网络设备通讯到GPU显存直传,本质都是减少中间拷贝、让两端直接交互。理解这个,再看网络层的P2P,会有一种“万物皆可直连”的通透感。

2.3 WebRTC和自定义TCP怎么选

不少读者会问我为什么不用WebRTC,毕竟它自带NAT穿透能力。这里放一张对比表,方便你根据场景选:

对比维度自定义TCP SocketWebRTC DataChannel
适用网络局域网最稳,公网穿透需自己实现自动STUN/TURN,跨NAT也能打洞
开发量小,协议、心跳、粘包全自己写中,信令交互要按SDP/ICE规范实现
可靠性TCP自带可靠传输可靠模式下等价于TCP,但头开销略大
跨NAT打通成功率不支持穿透,基本失败大部分场景可打通
理解难度低,适合入门中,需要理解ICE候选、SDP交换

我的观点是:如果只是做个Demo、跑课程设计,自定义TCP是性价比最高的路径——它能让你看清楚P2P从“发现对端”到“建立连接”的每一步。WebRTC则像一个封装好的黑盒,你用起来很爽,但出了问题往往不知道从哪排查。先把自定义链路跑通,再切WebRTC,心里会有底得多。

3. 核心细节解析:协议、连接、心跳

这一部分是这个p2pDemo示例的真正精髓。网络通讯Demo最容易出问题的就三个点:消息格式怎么定、连接怎么建立、断线怎么感知。逐个说。

3.1 消息报文设计:如何避免“粘包半包”

TCP是面向字节流的,发送端调用两次send(),接收端可能一次收到两条消息,也可能一条消息分两次收到,这就是“粘包”和“半包”。解决思路固定:给每条消息加边界。我的做法是“4字节长度头 + JSON消息体”,长度头用大端序。

Python服务端接收端:

def recv_exact(sock, n): buf = bytearray() while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise RuntimeError("connection closed") buf.extend(chunk) return bytes(buf) def recv_packet(sock): header = recv_exact(sock, 4) length = int.from_bytes(header, byteorder="big") body = recv_exact(sock, length) return json.loads(body.decode("utf-8")) def send_packet(sock, payload): body = json.dumps(payload).encode("utf-8") sock.sendall(len(body).to_bytes(4, byteorder="big") + body)

Android Kotlin端对应片段:

fun recvExact(input: InputStream, n: Int): ByteArray { val buf = ByteArray(n) var offset = 0 while (offset < n) { val read = input.read(buf, offset, n - offset) if (read < 0) throw IOException("connection closed") offset += read } return buf } // 先读4字节头得到长度,再按长度读消息体 fun recvPacket(input: InputStream): JSONObject { val header = recvExact(input, 4) val length = ((header[0].toInt() and 0xFF) shl 24) or ((header[1].toInt() and 0xFF) shl 16) or ((header[2].toInt() and 0xFF) shl 8) or (header[3].toInt() and 0xFF) val body = recvExact(input, length) return JSONObject(String(body, Charsets.UTF_8)) }

这段逻辑是通讯模块的地基。我自己就见过不少项目,各种功能都写好了,最后卡在“消息偶尔乱掉”上,一查,全是没做长度边界导致的。老老实实按长度头解析,能根治这类问题。

3.2 连接建立的完整时序:谁是服务端,谁是客户端

很多第一次接触P2P的人会困惑:P2P里到底谁是服务器?答案很简单:谁主动发起连接,谁就是客户端;谁监听端口,谁就是这个连接里的服务端。但和传统C/S不同,每个节点都可以同时具备这两种角色。

在这个示例工程里,设备A的流程是:打开App、输入昵称、进入“等待连接”页面,此时它在后台启动了一个ServerSocket监听随机端口,把“IP + 端口 + 昵称”上报给信令服务器。

设备B的流程是:在“连接设备”页面输入A的ID,B通过信令服务器拿到A的地址信息,然后直接用Socket连接A监听的端口。

连接建立的三步可以总结为:

  1. 两端通过信令交换自己的监听地址(IP + Port)。
  2. 发起方用对端的地址发起TCP连接。
  3. 连接建立后,信令服务器退出后续通讯流程。

最容易搞混的地方是“信令连接”和“P2P连接”的区别。信令连接是每个端到服务器之间的WebSocket连接,P2P连接是两个端之间的TCP连接。两者是独立的通道,别混在一起管理。

3.3 心跳保活与断线重连:别让“假死”骗了你

局域网环境虽然相对稳定,但不代表不会掉线,比如对端App退到后台被杀、Wi-Fi信号抖动、网线被拔。要在应用层感知这些,最通用的手段就是心跳。

我采用的参数组合:每30秒发送一次{"type": "ping"},对端收到后回复{"type": "pong"}。本地维护lastPongTime,如果连续90秒没有收到对端心跳,就判定连接超时,触发回调通知UI层“连接已断开”,同时P2PManager自动进入重连模式。

重连策略用指数退避加抖动:首次1秒后重试,然后2秒、4秒,最大间隔30秒。加随机抖动是为了避免两端在同一时刻重连而造成碰撞。

这套参数不要照抄到所有场景。如果做的是消息秒级实时同步,心跳间隔可以缩短到10秒;如果只是文件传输,其实60秒一次都够。不要设得太激进,否则一个GC停顿就可能误判掉线。

4. 实操全程:把Demo从0跑到1

这部分是完整的上手过程,我尽量写得像你在旁边看我操作一样。整个工程已经在本地跑通过,照这个步骤走,正常情况下十到十五分钟就能看到效果。

4.1 工程目录结构先看清楚

p2pDemo/ ├── README.md ├── server/ # 信令服务器 │ └── signaling_server.py └── android/ # Android客户端 ├── app/ │ ├── src/main/java/com/example/p2pdemo/ │ │ ├── MainActivity.kt │ │ ├── ui/ │ │ │ ├── HomeScreen.kt │ │ │ ├── WaitConnectScreen.kt │ │ │ └── ChatScreen.kt │ │ ├── p2p/ │ │ │ ├── P2PManager.kt │ │ │ ├── PacketCodec.kt │ │ │ └── SignalingClient.kt │ │ └── viewmodel/ │ │ └── P2PViewModel.kt │ └── src/main/AndroidManifest.xml

目录结构映射了分层思想:ui层只负责界面渲染,p2p层负责Socket连接和信令交互,viewmodel层通过Flow向外暴露状态。这样写的好处是,万一要换信令协议或者传输协议,只需要动p2p包下的几个文件,UI完全不用改。

4.2 信令服务器:十分钟跑起来的WebSocket服务

服务端核心代码不长,我把它精简到关键部分:

import asyncio import json from fastapi import FastAPI, WebSocket app = FastAPI() online_peers = {} # {peer_id: {"ws": WebSocket, "addr": "1.2.3.4:8080"}} @app.websocket("/ws") async def signaling(ws: WebSocket): await ws.accept() peer_id = None try: while True: data = await ws.receive_text() msg = json.loads(data) msg_type = msg.get("type") if msg_type == "join": peer_id = msg["peer_id"] online_peers[peer_id] = { "ws": ws, "addr": msg["addr"] } # 广播当前的在线列表 await broadcast_peers() elif msg_type == "get_peer": target_id = msg["target_id"] target = online_peers.get(target_id) if target: await ws.send_text(json.dumps({ "type": "peer_info", "peer_id": target_id, "addr": target["addr"] })) except Exception: pass finally: if peer_id and online_peers.get(peer_id): online_peers.pop(peer_id, None) await broadcast_peers()

这个服务没有做任何业务逻辑,只维护了一张在线表。打开终端,执行uvicorn signaling_server:app --host 0.0.0.0 --port 8765,信令部分就起来了。

注意一点:--host 0.0.0.0是必须的,否则只能本机访问,真机连不上。

4.3 Android端核心代码:P2PManager

Android端最核心的类就是P2PManager,它负责三件事:连接信令、监听Socket、读写消息。关键逻辑如下:

class P2PManager( private val onMessage: (String) -> Unit, private val onPeerLost: () -> Unit ) { private var serverSocket: ServerSocket? = null private var clientSocket: Socket? = null private var isRunning = false // 启动服务端监听,并把地址上报给信令服务器 suspend fun startListening() { serverSocket = ServerSocket(0) val localPort = serverSocket!!.localPort val localAddr = getLocalIpAddress() + ":" + localPort SignalingClient.join(localAddr) isRunning = true CoroutineScope(Dispatchers.IO).launch { val socket = serverSocket!!.accept() handleSocket(socket) } } // 主动连接对端,走自定义TCP协议 suspend fun connectToPeer(targetAddr: String) { val parts = targetAddr.split(":") val socket = Socket() socket.connect(InetSocketAddress(parts[0], parts[1].toInt()), 5000) handleSocket(socket) } private suspend fun handleSocket(socket: Socket) { clientSocket = socket val input = socket.getInputStream() while (isRunning) { val packet = try { PacketCodec.recvPacket(input) } catch (e: Exception) { onPeerLost() break } when (packet.optString("type")) { "msg" -> onMessage(packet.optString("content")) "file" -> // 写入文件保存流程 "ping" -> sendText("{\"type\":\"pong\"}") } } } }

这段代码有几点值得注意:

  • ServerSocket(0)会自动分配一个空闲端口,避免了端口冲突。
  • connect()设置了5秒超时,这是局域网环境下比较合理的一个值,太短容易误判,太长用户等不起。
  • handleSocket()里的心跳回应是直接回复pong,不用走UI层,减少状态传递的开销。

4.4 完整的运行步骤

按顺序操作,基本不会出问题:

  1. 克隆工程,进入server/目录,执行pip install fastapi uvicorn websockets,然后启动信令服务器。
  2. 查看电脑的局域网IP,确保手机和电脑在同一个Wi-Fi或交换机下。
  3. 在Android工程里找到SignalingClient.kt,把WebSocket地址改成ws://<电脑IP>:8765/ws
  4. 用Android Studio编译安装到两台真机上(或者一台真机加一个模拟器)。注意模拟器访问局域网IP时经常有网络模式问题,有条件尽量用两台真机。
  5. 打开设备A,点击“等待连接”,记下页面显示的peer ID。
  6. 打开设备B,在输入框填设备A的peer ID,点击“连接”。
  7. 看到双方UI上都变成“已连接”状态后,发一条测试消息。设备A的日志里应该能看到Socket accept成功,设备B的日志里能看到连接成功。

验证是否成功,最直观的方式是看两边的日志。A端日志如果出现accept: /192.168.x.x:xxxx,说明TCP链路已经建立,信令服务器已经功成身退。

5. 常见问题与排查技巧实录

这个Demo我在本地跑的时候也踩了不少坑,整理成速查表,遇到的概率都很高。

现象可能原因排查方法
两台设备无法互相发现没连同一个Wi-Fi,或者路由器开了AP隔离检查手机和电脑IP是否在同一网段;AP隔离的话需要关闭
设备A没出现在线列表信令服务器地址配置错误,或者防火墙拦截了8765端口浏览器访问http://<IP>:8765/docs看服务是否可达
连接始终超时Android没加INTERNET权限检查AndroidManifest.xml,这是最容易漏的一步
消息偶尔乱码或丢失没按长度头解析,出现粘包半包用本文的recvExact方法
界面不刷新在Socket回调里直接操作了UI布局viewModel+ Flow 收集消息,再回调Compose重组
传文件特别慢循环读取时用了逐字节读,或每次都触发flush文件流用4KB缓冲循环,TCP层无需频繁flush
锁屏后连接断开手机关闭了后台运行权限设置里允许App后台运行;Demo场景尽量保持前台

再补充一个我自己的调试习惯。遇到连接问题,先用网络层的工具看,再到代码里找。抓包工具用Wireshark最直观,过滤条件写tcp.port == <你监听的端口>,能看到SYN包有没有发出去、对端有没有回SYN-ACK。如果SYN都没发出,问题在应用层;如果SYN发了没回,问题在网络或防火墙。这个排查思路能帮你快速定位80%的通讯问题。

另外,跨网段场景(两个设备在不同子网)下,这个Demo的表现取决于路由策略,有些路由允许互访,有些会隔离。公网环境则需要TURN服务器做中继或者手动端口映射,这就是后续要扩展的内容了。

最后分享一个我自己的小经验:把P2PManager的日志级别做成动态可切换的,平时只输出错误,联调时打开DEBUG级别,能看到每一步的连接状态、心跳时序和报文内容。很多看起来“随机出现”的问题,在完整日志面前都会变得有规律可循。这个Demo后续还可以扩展断点续传、多设备同时在线、加密传输这几个能力,核心链路已经通了,剩下的就是业务层的工作。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询