
记录一下Unity PICO4开发里这块硬骨头——局域网通信。写完这篇我的学习记录就已经到第5篇了前面几篇分别摸了渲染、手柄、串流、工程结构这次则是完全不同的领域让PICO4头显跟PC之间在局域网里把数据传起来。在做多人VR演示、中控台远程控制、或者多台设备状态同步时你迟早会撞到这堵墙。这篇文章不是官方文档的复述是我自己从零把PICO4的局域网TCP通信跑通的全过程包括踩过的坑、改过的协议、重连逻辑和实测数据适合正在做PICO4开发、卡在真机连不上PC或者不知道选哪种通信方案的开发者参考。1. 先搞清楚PICO4的局域网通信到底在解决什么问题1.1 从一次失败的多人演示说起项目背景很朴素客户要求用一台PC做中控同时驱动几台PICO4头显进入同一个演示场景。比如PC端按下开始体验所有头显同时开始播放又比如PC端修改某个参数头显里的画面跟随变化。最开始我图省事想的是每台头显放一个Demo全靠人走过去按键启动。但实际演示现场根本不是那么回事——中控人员不可能拿着遥控器逐个去点头显也不可能保证每台设备操作节奏一致。这时候PC发一条指令头显收到后干活就成了刚需局域网通信是我绕不开的一环。这个场景的核心需求拆开看就三条PC能主动给PICO4发指令而不是PICO4主动来轮询。头显能把自己的状态比如连接状态、当前场景名回传给PC。双向消息都要稳定断线了至少能提醒操作员而不是默默失效。除了中控演示局域网通信还能用在联调阶段人站在PC前改代码头显戴在另一个工位跑起来的日志直接通过网络送回PC省得每改一次就往头显上打一次包。这也是我后来发现的价值。1.2 可选方案很多但多数不适合从0跑通的场景做Unity的人都知道通信不是没有方案反而方案太多容易挑花眼。我梳理了一下手里可选的路线方案类型上手难度可控性适合场景原生TCP/UDP Socket底层传输中完全可控自定义协议、跨平台、想搞懂原理Unity Transport (UTP)Unity官方传输层中低依赖Unity底栈和Netcode配套的多人方案Mirror / UNet系高层网络库低框架限制较多带状态同步的多人游戏Photon / Netcode云端/局域网服务低依赖外部服务不限于本地的联机需求HTTP REST接口应用层低单向为主低频指令不适合实时互传我最后选了原生的TCP Socket核心考虑不是它最好而是它最能暴露问题。学习阶段如果直接上个Mirror很多连不上的原因会被框架捂在底层你在工程里根本看不见是哪一环断了。原生Socket从IP、端口、防火墙、权限、到粘包全得自己处理看起来麻烦但每处理一层就多一份掌控感等把这条路走通了后面想切换哪个方案都有底气。中途我也试过Unity Transport最后被劝退的原因倒不是性能而是它的地址绑定和连接事件机制在真机上排查起来比较绕。如果你的项目里已经有NetcodeUTP是顺理成章的选项但一个只做局域网控制指令的项目为它引入一整套Netcode体系属实没必要。2. 通信设计IP、端口、TCP/UDP和消息序列化2.1 局域网IP和PICO4在路由器里的身份通信的前提是能找到对方。在局域网里每台设备都有一个内网IP比如192.168.1.105。PC做服务端PICO4做客户端那么PICO4要连接的目标就是PC的内网IP。查IP这件事看着简单实操里有个容易忽略的点PICO4连的是同一个Wi-Fi吗很多公司或场地的路由会开访客网络和主网络两个网络之间做了隔离设备虽然在同一个SSID广播下但IP段都不同互相根本不通。我的运气更差一点最初把PC连到了有线网口、头显连到无线Wi-FiPC拿到的IP是192.168.50.10头显拿到的是192.168.31.123两个网段任凭代码怎么写都连不上。这不是代码问题是网络拓扑问题。开发阶段最简单的做法是让PC和PICO4都连同一个Wi-Fi路由器并且把PC的局域网IP固定下来路由器后台做DHCP静态分配或者直接在PC网卡设置里用固定IP。固定IP的好处是客户端的连接目标不用频繁改动写死在配置里也行。我个人的习惯是写一个ConnectionConfig类把IP、端口、超时时间都放在里面后续改成广播发现机制时只动这一个类。2.2 为什么低频控制指令我坚持选TCP而不是UDP选TCP还是UDP取决于你要传什么。TCP是可靠连接数据包丢失会自动重传对应用层来说像是一定会到达UDP是尽力而为速度快但丢包了自己处理。用个不太严谨的类比TCP像寄挂号信慢点但丢不了UDP像发手机短信快但偶发收不到。我的场景是PC向PICO4发送开始播放切换场景修改参数这类控制指令频率低、单条数据短、丢了影响大。所以TCP这种宁可慢必须到的特性完全匹配。实际测下来在同一个无线路由器下200字节的TCP消息往返延迟在10毫秒以内对控制指令来说没有任何体感问题。那什么时候用UDP如果你要做的是连续位姿流传输——比如把PICO4的头手姿态每秒传60次给PC做渲染或者多台头显之间的位置同步——TCP的可靠性反而变成缺点某次重传会阻塞后续数据造成延迟尖刺。这种场景应该用UDP加插值平滑丢包了靠预测补偿。我在文章后面会再提这条扩展路线。开发时先用TCP打通全链路后续如果真要上高频数据只替换传输层就行应用层的协议设计是可以复用的。2.3 消息协议一版足够清晰的JSON封装协议是整个通信的灵魂。很多人写Socket通信时随便发字符串收到再split这样维护两三次就不行了。我一开始就定义了一套简单的消息协议以JSON为载体{ ver: 1, type: command, cmd: move_cube, data: { x: 1.5, y: 0.2, z: -3.0 }, id: msg-1024 }字段含义ver协议版本号以后协议变了接收端可以根据版本走不同解析逻辑。type消息类型先简单分成command、heartbeat、response。cmd业务指令名接收方根据它分发逻辑。data业务参数用可扩展的嵌套结构。id消息编号用于回应匹配排查问题时很好用。选JSON而不是直接拼字符串是因为它自带结构能直接塞进Unity的JsonUtility。跨语言调试时PC端可以用任意语言的JSON库解析肉眼也能读出来这对开发期排障极其友好。缺点是多占了点字节、序列化略慢但对控制指令来说无所谓。真到高吞吐场景再换成MessagePack或者扁平二进制结构不迟。一个容易踩的细节是字节编码。Unity里string默认是UTF-16而网络传输我统一用UTF-8编码。发送时byte[] bytes Encoding.UTF8.GetBytes(jsonString);接收时反过来string jsonString Encoding.UTF8.GetString(byteBuffer, 0, readCount);千万别在一边用UTF-8另一边用ASCII中文参数和特殊字符会出各种诡异结果。2.4 粘包与半包TCP字节流必须自己划边界TCP是字节流协议不是消息协议。它不保证你每次Read拿到的正好是一整条消息。两条消息可能粘在一起一条消息也可能被拆成两半。处理办法很成熟每条消息前面加4字节长度头。发送顺序是 [4字节长度][消息内容]接收端先读4字节得知后续消息体长度再“恰好”读取这么多字节。凡是长度不足的部分留在缓冲区里等下一次数据到达再拼装。这个细节看着基础但实际写漏了会出现——第一台PICO4收一堆乱码、偶尔只收到半条JSON、消息多了就互相覆盖——这些现象特别像网络不稳其实全是协议边界问题。3. 跑通双向消息PC服务端与PICO4客户端的完整代码3.1 PC服务端TcpListener接入的骨架代码PC端我用了一个独立Unity工程做中控台其实你用控制台程序也行。关键是把TcpListener和Unity的主线程分开避免阻塞。服务端基本流程就四步创建TcpListener并指定监听IPAddress.Any和端口。调用AcceptTcpClient()接受PICO4的接入。接收线程循环读取网络数据按 4字节长度消息体 的协议解析。解析出的消息放进一个线程安全的队列由Unity主线程在Update里消费这样就不会在子线程里碰Unity API。我贴一段核心的服务端代码using System; using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using UnityEngine; public class ControlServer : MonoBehaviour { public int port 45678; private TcpListener listener; private Thread acceptThread; private TcpClient connectedClient; private ConcurrentQueuestring incomingMessages new ConcurrentQueuestring(); private readonly object clientLock new object(); void Start() { acceptThread new Thread(ListenLoop); acceptThread.IsBackground true; acceptThread.Start(); } private void ListenLoop() { listener new TcpListener(IPAddress.Any, port); listener.Start(); Debug.Log($[Server] listening on 0.0.0.0:{port}); while (true) { TcpClient client listener.AcceptTcpClient(); Debug.Log($[Server] client connected: {client.Client.RemoteEndPoint}); lock (clientLock) { connectedClient?.Close(); connectedClient client; } Thread recvThread new Thread(() ReceiveLoop(client)); recvThread.IsBackground true; recvThread.Start(); } } private void ReceiveLoop(TcpClient client) { NetworkStream stream client.GetStream(); byte[] lengthBuffer new byte[4]; byte[] buffer new byte[8192]; while (true) { try { int readLength ReadFull(stream, lengthBuffer, 4); if (readLength 0) break; int msgLength IPAddress.NetworkToHostOrder( BitConverter.ToInt32(lengthBuffer, 0)); int bodyRead ReadFull(stream, buffer, msgLength); if (bodyRead 0) break; string json Encoding.UTF8.GetString(buffer, 0, msgLength); incomingMessages.Enqueue(json); } catch (Exception e) { Debug.LogWarning($[Server] receive error: {e.Message}); break; } } lock (clientLock) { connectedClient null; } Debug.Log([Server] client disconnected); } private int ReadFull(NetworkStream stream, byte[] target, int length) { int totalRead 0; while (totalRead length) { int read stream.Read(target, totalRead, length - totalRead); if (read 0) return 0; totalRead read; } return totalRead; } public void SendToClient(string json) { lock (clientLock) { if (connectedClient null) return; NetworkStream stream connectedClient.GetStream(); byte[] body Encoding.UTF8.GetBytes(json); byte[] head BitConverter.GetBytes(IPAddress.HostToNetworkOrder(body.Length)); stream.Write(head, 0, head.Length); stream.Write(body, 0, body.Length); } } void Update() { while (incomingMessages.TryDequeue(out string json)) { HandleJsonMessage(json); } } private void HandleJsonMessage(string json) { // 解析和按cmd分发逻辑 Debug.Log($[Server] recv: {json}); } void OnDestroy() { listener?.Stop(); lock (clientLock) { connectedClient?.Close(); } } }这里ReadFull是重点NetworkStream.Read不保证一次读满请求的字节数必须循环直到读够否则半包时会错误地按不完整数据解析。3.2 PICO4客户端接入、发送与主线程回调头显端我写了一个LanClient组件挂到场景物体上。连接、接收、发送全部封装在这个类里。using System; using System.Collections.Concurrent; using System.Net.Sockets; using System.Text; using System.Threading; using UnityEngine; public class LanClient : MonoBehaviour { public string serverIP 192.168.50.10; public int serverPort 45678; private TcpClient tcpClient; private Thread recvThread; private volatile bool isRunning; private ConcurrentQueuestring messageQueue new ConcurrentQueuestring(); void Start() { Connect(serverIP, serverPort); Screen.sleepTimeout SleepTimeout.NeverSleep; } public void Connect(string ip, int port) { isRunning true; tcpClient new TcpClient(); tcpClient.Connect(ip, port); Debug.Log($[Client] connected to {ip}:{port}); recvThread new Thread(ReceiveLoop); recvThread.IsBackground true; recvThread.Start(); } private void ReceiveLoop() { byte[] lengthBuffer new byte[4]; byte[] buffer new byte[8192]; while (isRunning) { try { NetworkStream stream tcpClient.GetStream(); int readLength ReadFull(stream, lengthBuffer, 4); if (readLength 0) break; int msgLength IPAddress.NetworkToHostOrder( BitConverter.ToInt32(lengthBuffer, 0)); int bodyRead ReadFull(stream, buffer, msgLength); if (bodyRead 0) break; string json Encoding.UTF8.GetString(buffer, 0, msgLength); messageQueue.Enqueue(json); } catch (Exception e) { Debug.LogWarning($[Client] receive error: {e.Message}); break; } } } public void SendJson(string json) { if (tcpClient null || !tcpClient.Connected) return; NetworkStream stream tcpClient.GetStream(); byte[] body Encoding.UTF8.GetBytes(json); byte[] head BitConverter.GetBytes(IPAddress.HostToNetworkOrder(body.Length)); stream.Write(head, 0, head.Length); stream.Write(body, 0, body.Length); } void Update() { while (messageQueue.TryDequeue(out string json)) { Dispatch(json); } } private void Dispatch(string json) { // 解析json按cmd执行场景操作 Debug.Log($[Client] recv: {json}); } void OnDestroy() { isRunning false; tcpClient?.Close(); } }注意我在Start里加了一行Screen.sleepTimeout SleepTimeout.NeverSleep;。这是Android平台上很关键的一行不加的话PICO4戴在头上一段时间不动屏幕会休眠一旦休眠网络连接就断。Unity在Android上默认会根据系统息屏策略走这里必须显式设置成永不休眠。3.3 心跳包与断线重连跑通基本通信之后我在两端的代码里加了心跳机制。原因是TCP连接偶尔会悄然死亡比如路由器会话过期、头显休眠、Wi-Fi切换连接表面上还挂着但数据已经不通了。没有心跳的话服务端永远不知道客户端已经跑了。心跳包很简单。客户端每秒发送一条轻量JSON{type:heartbeat,ts:1750000000}服务端每5秒检查一次如果超过10秒没收到任何来自该客户端的数据就判定超时清理连接。断线重连的策略我做得也偏保守客户端在ReceiveLoop退出后不要立刻疯狂重连而是间隔2秒尝试一次连续5次失败则把间隔拉到5秒同时通过Unity事件通知UI显示连接断开正在重连。实测中这个策略不会把Wi-Fi打满也不会在路由器侧留下大量半开连接。如果你在做多人联机还需要根据设备唯一标识比如PICO4的设备序列号或者自己生成一个ClientID区分不同客户端。服务端保存一个Dictionarystring, TcpClient消息里带上deviceId字段。这个我在后面扩展时会细说。4. 踩坑记录解决明明同一Wi-Fi却连不上的完整排查链路4.1 第一层Android权限与PICO4的安装行为第一次真机测试我把APK装进PICO4打开应用界面正常但PICO4始终连不上PC。当时第一反应是服务器代码问题后来排查了一圈才发现是权限。Unity打包Android应用时网络权限不是默认加的。你需要到 Player Settings - Other Settings - Configuration 下找到 Internet Access把它设为 Require。如果你用的是旧版Unity可能还需要手动改AndroidManifest.xml加上uses-permission android:nameandroid.permission.INTERNET/检查方法也简单用aapt dump permissions your.apk看APK实际有没有这个权限声明或者直接解包APK看AndroidManifest。这里还有一个PICO特有的坑PICO4安装APK时如果开发者没开开发者模式应用在设置里连安装选项都不完整。你必须在PICO4上打开“通用设置-开发者”并且用PICO账号登录、开启USB调试才能顺利装包调试。网络权限是代码层面的事但安装不了会让你误以为是权限问题。4.2 第二层路由器AP隔离与Windows防火墙权限解决后PICO4能连了但连上后立即断开。日志显示Authentication失败还是读不到数据都不是——其实是Windows防火墙把入站连接挡了。第一次启动PC服务端时Windows弹了防火墙询问框我随手点了“取消”结果所有来自局域网的TCP连接都在TCP层被拦掉。TcpListener明明在监听但客户端的Connect要么超时要么服务端收到连接后立刻异常。处理方法是到 Windows Defender 防火墙的“允许应用通过防火墙”里把PC中控程序的专用网络勾选上。注意要选“专用网络”不要选“公用网络”除非你确认PC和头显在一个信任的Wi-Fi环境下。在同一Wi-Fi但网段不同或者路由器开了AP隔离的场合即使防火墙放行也连不上。判断AP隔离最容易的办法在PC上pingPICO4的IP如果能通说明二层网络没问题如果Ping不通去路由器管理后台找“AP隔离”或者“客户端隔离”选项关掉它。我在做外面场地演示时就遇过类似情况场地路由器开了访客隔离最后只能拿手机开热点把PC和PICO4都挂到热点下才绕过。4.3 第三层PICO4的Wi-Fi休眠与Unity生命周期还有一个极其隐蔽的问题PICO4只要屏幕进入休眠Wi-Fi就会在系统层面挂起TCP连接随之断开。有时你摘下头显放了一会儿再戴上发现应用还在但网络已经悄悄断了。前面提到Screen.sleepTimeout SleepTimeout.NeverSleep能解决大部分情况但还不够——头显戴着时的加速度计若长期静止系统仍可能进入待机。我自己还做过一个防御性处理在PICO4端写一个看门狗逻辑连接断开后自动重连之外用WakeLock保持CPU活跃。Unity侧做这个要引入Android原生调用走AndroidJNI接口。具体代码如下using UnityEngine; public class WakeLockHelper { private static AndroidJavaObject wakeLock; public static void Acquire() { if (wakeLock ! null) return; using (var activity new AndroidJavaObject(com.unity3d.player.UnityPlayer) .GetStaticAndroidJavaObject(currentActivity)) { var powerManager activity.CallAndroidJavaObject(getSystemService, power); int level 0x0000001a; // PARTIAL_WAKE_LOCK | ACQUIRE_CAUSES_WAKEUP wakeLock powerManager.CallAndroidJavaObject(newWakeLock, level, neon:lanclient); wakeLock?.Call(acquire); } } public static void Release() { wakeLock?.Call(release); wakeLock null; } }这个WakeLock不是必须的如果只是短时间演示SleepTimeout.NeverSleep就够了但如果你要长时间挂机测试还是建议把WakeLock加上。我当时挂了一整晚跑稳定性测试没有WakeLock时平均半小时左右就断一次。4.4 排查工具组合调试这类问题我总结了一套顺手的工具流PC端先用IPConfig确认IP再用netstat -an | findstr 45678确认端口在监听然后用telnet PC_IP 45678做粗测最后才是Unity日志和PICO4头显里的Logcat。PICO4的日志读取比手机麻烦一点。推荐用PICO配套的设备助手开启无线调试后用ADB连到头显执行adb shell dmesg -w adb logcat -s Unity如果用的是有线调试记得先打开USB调试、连接时点头显上的允许授权。这一步卡住过我好一阵因为PICO4的授权弹窗经常躲在虚拟环境后面头显里看不到电脑端已经连上但设备不识别最后想办法切到开发者选项才出来。5. 实际效果、性能数据与后续扩展方向5.1 实测200字节消息的往返延迟与稳定性整个链路打通后我做了一组简单实测。测试环境PC连有线PICO4连同一路由器的5GHz Wi-Fi中间无其他设备并发发送一条约200字节的控制指令PICO4收到后立即回一条response记录发送到收到回包的往返时间。项目结果往返延迟5G Wi-Fi5~10ms偶发跳到20ms丢包率0%TCP重传保障长连接稳定性带心跳WakeLock环境下8小时未断连续收发压力测试支撑每秒50条消息无明显积压这个延迟对控制指令来说完全没有感知。如果你要同步头显位姿这个值已经偏高你就该走UDP 插值路线了。5.2 扩展到多台PICO4ID管理、广播发现、帧同步现在只跑通了一对一离多人演示还差扩展能力。按我的计划下一步是在现有TCP服务端基础上改造出多客户端管理服务端用Dictionarystring, TcpClient维护多个客户端连接。客户端首次连接时发送注册消息携带设备唯一ID。服务端转发消息时按targets字段决定广播还是定向发送。还有一个开发体验上的升级点让客户端自动发现服务器IP而不是手填。可以在客户端启动时发一条UDP广播包服务端收到后回单播包告知自己的IP和端口。流程是客户端发UDP broadcast到255.255.255.255:45800。服务端监听45800端口收到后向客户端IP回一条{ip:192.168.50.10,port:45678}。客户端拿这个地址去连TCP。这样PICO4应用里就不需要配置任何IP双击就能连上客户现场部署省心很多。至于多台头显之间需要高频率同步位置做同一空间协作的场合TCP就不够了。那种需求本质是每一帧互相同步姿态应该用UDP加帧号、时间戳、目标预测插值同时对丢包做容错。但那是另一个更复杂的故事这篇文章记录的还是一对一控制指令通信这个地基。地基打牢了上面想盖什么房子就看你项目需求了。最后说一点个人建议做PICO4局域网通信一定不要急着上框架。先用原生TCP把连接、收包、协议边界、断线重连整个流程走一遍哪怕最后只是用来写日志。这套经验在你切换到任何上层网络库时都能帮你快速判断到底是我的问题、框架的问题、还是网络环境的问题。希望这篇学习记录能帮你少走那几天的弯路。