简介这套资源是ESP32接入巴法云后续工程的Unity通讯部分源码包面向物联网与Unity开发者解决设备无公网IP时经外网穿透实现内网数据互通的典型需求。压缩包共522个文件、约922KB含34个C#脚本、29个Asset配置、43个JSON及87个MVFRM文件等C#脚本是Unity端连接巴法云、收发数据的核心Asset与JSON负责场景和配置PNG与DLL等支撑界面展示和运行依赖。已有111人学习。直接将工程导入Unity即可对照阅读有助于理解MQTT协议从设备注册、连接认证到云端上报数据的完整链路以及TCP/IP、WebSocket等实时通讯写法和SSL/TLS安全配置思路内附的meta、dll等关联文件也便于排查工程依赖适合正在构建智能家居、远程监测类物联网交互项目的开发者参考。1. 一套打通ESP32与Unity的MQTT外网通讯方案先聊清楚再动手家里那台ESP32设备在局域网里控制得好好的一旦人不在家Unity应用就连不上了。我拆过的这套方案思路是把ESP32和Unity都接到某MQTT云平台上两边作为客户端主动建立长连接由云端完成消息中转。设备不用暴露公网IP也不会被运营商吞掉端口映射这就是常说的逻辑外网穿透。适合两类人一类是做完硬件但卡在远程访问的ESP32开发者另一类是Unity端想接设备数据却不知道怎么跟硬件对话的应用开发者。这篇笔记会把三端链路、代码和坑都写清楚。2. 通讯链路设计为什么选MQTT云平台中转而不是公网IP直连2.1 外网穿透的真正含义两台内网设备各自主动连接云端很多人在这一步就被概念带偏了。以为“外网穿透”一定要给路由器做端口映射或者买一台带公网IP的云服务器把流量转进来。实际做智能家居控制时最常见的需求是“设备在家里应用在任意网络下都能访问”。路由器端口映射有两个硬伤一是大部分家庭宽带的公网IP是动态的IP一换映射就失效二是运营商对80、443之外的端口扫描很敏感裸奔一个端口在公网等于给自己找麻烦。MQTT云平台中转的模型是反过来的。ESP32不监听任何端口它主动向云端的Broker发起TCP连接。Unity应用也不关心设备的IP它同样主动连到同一个Broker。两边各自维护一条长连接消息通过Broker做主题匹配和转发。设备端到云端之间是“设备先伸手”不是“外面伸手进设备”所以叫逻辑外网穿透。这个模型下ESP32不需要公网IPUnity也不需要对端地址只需要知道云端的Broker地址和主题名。这套链路还有个隐藏优点断网重连由两端的客户端库自动处理。家里WiFi重启、出差路上切4G只要ESP32重新连上云端Unity之前订阅的主题还在消息就能继续流动。我最早做的时候也试过用TCP裸连接自己写心跳后来发现要处理粘包、半包、断线重连代码越写越复杂最后还是回到MQTT生态。2.2 协议选型对比MQTT、HTTP轮询、WebSocket、TCP长连接的取舍做双端通讯之前先把协议定下来不然后面全是返工。我把这几种常见方案的实际体验列一张对比表都是实测感受不是抄文档。协议连接方式实时性消息开销落地难点MQTTTCP长连接发布订阅高极低2字节固定头需要自建或接入BrokerHTTP轮询短连接频繁请求低秒级延迟高每次带完整Header设备端费电、费流量WebSocket长连接全双工高中有帧头开销没有QoS和遗嘱机制TCP裸连接长连接自定义协议高最低粘包、鉴权、重连全要自己写MQTT胜在消息模型完整。发布订阅天然解耦ESP32发布状态Unity订阅状态Unity下发命令ESP32订阅命令。两边不需要知道对方的存在。QoS机制能保证消息至少到达一次遗嘱消息能在设备异常掉线时自动广播“我挂了”保留消息能让Unity一上线就拿到设备当前状态这三个特性是其他协议不具备的。还有一点容易被忽略ESP32用的PubSubClient和Unity用的MQTTnet这两个库都非常成熟不需要自己造轮子。做项目的时间应该花在业务逻辑上而不是去调自定义二进制协议的解析。2.3 平台侧参数与主题规划Broker地址、端口、ClientID与Topic命名规则接入云端前先把平台分配的几个关键参数理清楚。我一般在项目启动时就整理成一张表发给团队一起用避免每个人起主题名各写各的。参数示例值说明Broker地址broker.example-mqtt.com云端接入域名不是设备IPTCP端口1883明文通讯局域网联调用SSL端口8883公网环境建议走TLSClientIDesp32_01 / unity_app_01全局唯一重复会被踢Usernameuser_key平台分配的鉴权信息Passwordsecret_key配合Username使用设备状态主题/user/esp32_01/statusESP32发布Unity订阅设备命令主题/user/esp32_01/commandUnity发布ESP32订阅主题规划是我这次踩坑最深的区域。免费云平台通常要求主题以平台分配的用户前缀开头比如必须在/user/下面建。主题名区分大小写/user/ESP32_01/status和/user/esp32_01/status是两个完全不同的主题。我习惯把设备ID统一写成小写所有端共用同一份主题映射表不允许在代码里手写主题字符串。另一个容易翻车的是ClientID。MQTT协议规定ClientID代表一个会话如果两个客户端用同一个ClientID连接云端会把先连的那个踢下线。我见过有人ESP32和Unity用了同一个设备编号结果ESP32一上线Unity就掉线日志里全是“Connection lost”。这里的规矩是每个客户端一个独立ID设备端用设备编号应用端用应用标识加随机后缀。3. ESP32端接入云平台连接代码、发布订阅与掉线重连3.1 开发环境与依赖库Arduino框架、PubSubClient与ArduinoJsonESP32端我用的Arduino框架版本2.x配合三个库WiFi.h是ESP32自带的网络库PubSubClient负责MQTT协议交互ArduinoJson负责生成和解析JSON消息。PubSubClient建议用2.8及以上版本老版本在部分ESP32模块上会出现连接后不定时断线的问题。ArduinoJson要注意版本差异。6.x用DynamicJsonDocument动态分配内存7.x改成了JsonDocumentAPI变化比较大。代码仓库里如果是6.x的老写法直接升级到7.x编译会报错。我这边统一用6.21版本API稳定社区资料也多新手搜问题更容易搜到答案。库安装直接用Arduino IDE的库管理器搜索安装不需要手动下载源码包。3.2 连接与重连代码WiFi、MQTT、心跳与回调处理下面这段代码是ESP32端的基础骨架包含WiFi连接、MQTT连接、消息回调、定时上报和断线重连。我按实际项目精简过丢到任意支持Arduino的ESP32开发板上改一下WiFi和平台参数就能跑。#include WiFi.h #include PubSubClient.h #include ArduinoJson.h const char* ssid your_wifi_ssid; const char* password your_wifi_password; const char* mqtt_server broker.example-mqtt.com; const int mqtt_port 1883; const char* mqtt_user user_key; const char* mqtt_pass secret_key; const char* client_id esp32_01; const char* topic_status /user/esp32_01/status; const char* topic_command /user/esp32_01/command; WiFiClient espClient; PubSubClient client(espClient); unsigned long lastPublishTime 0; const unsigned long publishInterval 10000; // 10秒上报一次 void connectWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); while (WiFi.status() ! WL_CONNECTED) { delay(500); Serial.print(.); } Serial.println(\nWiFi connected, IP: WiFi.localIP().toString()); } void reconnectMQTT() { while (!client.connected()) { Serial.print(Attempting MQTT connection...); if (client.connect(client_id, mqtt_user, mqtt_pass)) { Serial.println(connected); client.subscribe(topic_command); // 订阅命令主题 } else { Serial.print(failed, rc); Serial.print(client.state()); Serial.println( retry in 5s); delay(5000); } } } void onMessage(char* topic, byte* payload, unsigned int length) { String msg; for (unsigned int i 0; i length; i) { msg (char)payload[i]; } Serial.printf(Command received on %s: %s\n, topic, msg.c_str()); StaticJsonDocument128 doc; if (deserializeJson(doc, msg) DeserializationError::Ok) { const char* action doc[action]; if (strcmp(action, switch) 0) { int value doc[value].asint(); // 这里写继电器或LED控制逻辑 Serial.printf(Switch to %d\n, value); } } } void publishStatus() { StaticJsonDocument128 doc; doc[device_id] client_id; doc[status] online; doc[value] 42; // 示例传感器数值 char buffer[128]; serializeJson(doc, buffer); client.publish(topic_status, buffer); Serial.printf(Published: %s\n, buffer); } void setup() { Serial.begin(115200); connectWiFi(); client.setServer(mqtt_server, mqtt_port); client.setCallback(onMessage); client.setKeepAlive(30); reconnectMQTT(); } void loop() { if (!client.connected()) { reconnectMQTT(); } client.loop(); if (millis() - lastPublishTime publishInterval) { publishStatus(); lastPublishTime millis(); } }这段代码的核心逻辑在loop()里先检查MQTT连接断了就重连然后调用client.loop()驱动底层网络收发这个函数必须高频调用阻塞超过几秒就会触发心跳超时最后用millis()定时器控制上报频率避免在loop()里写delay(10000)那会让网络完全卡死。参数上要注意几点。setKeepAlive(30)表示客户端每30秒发一次心跳包云端如果在约1.5倍时间没收到心跳就会判定连接失效。在一些免费云平台上默认的15秒心跳太密我调成30秒后掉线频率明显下降。client.connect()的返回值是布尔类型但client.state()返回错误码调试时一定要打印这个值常见的-2表示连接超时-4表示用户名密码错误-5表示ClientID冲突。设了WiFi.mode(WIFI_STA)是强制只走Station模式不启用SoftAP省电也省内存。3.3 消息上报与命令下发JSON格式约定与解析两端约定好JSON格式能省掉一半的联调时间。ESP32上报用下面这种结构字段名全部小写键值对顺序不敏感Unity端解析时按字段名取值就行。{ device_id: esp32_01, type: status, status: online, value: 23.5 }命令下发用另一套结构把动作和参数分开。action是操作类型value是参数值这样一条消息能覆盖开关、调速、模式切换多种指令不用每个功能单独建一个主题。{ device_id: esp32_01, type: command, action: switch, value: 1 }用ArduinoJson序列化时浮点数默认会输出尽量短的字符串比如23.50会变成23.5两端都要接受这个格式。我踩过的一个小坑是serializeJson(doc, buffer)不会自动追加字符串结束符到char数组末尾buffer一定要开得比实际消息长一点我用128字节的数组装上面的JSON足够但如果后面加字段记得同步加大。4. Unity端接入与常见问题排查回调线程、JSON解析与IL2CPP裁剪4.1 Unity工程准备MQTTnet库导入与link.xml设置Unity端我用的MQTTnet库版本4.2.0。这个库在GitHub上有源码包Unity 2021以上可以直接把源码文件夹拖进Assets目录也可以下载编译好的DLL。需要注意网上大量教程用的是MQTTnet 3.x的API跟4.x差别很大3.x里MqttClient是静态工厂类4.x改成了MqttFactory照着旧教程写新库必然编译报错这个坑我踩过一整晚。真正麻烦的是IL2CPP构建。Unity默认在Editor里用Mono运行一切正常一打包成Android或iOSMQTTnet的反射调用会被IL2CPP裁剪掉运行时直接抛异常。解决办法是在Assets目录下建一个link.xml文件告诉Unity保留MQTTnet程序集。linker assembly fullnameMQTTnet preserveall/ /linker这段配置的意思是保留MQTTnet里所有类型和成员不让裁剪器动它。虽然会让包体变大几百KB但换来的是打包后不崩。如果没有这个文件打出来的APK可能在启动连接时闪退而且Editor日志里看不到任何报错只会看到“Execution failed”这类黑匣子信息。4.2 连接、订阅与消息回调代码后台线程与主线程的解耦下面这段C#代码建立Unity到云端的连接并订阅设备状态主题。核心点是消息回调不在Unity主线程不能直接在回调里改UI我用了ConcurrentQueue做线程间消息传递在Update()里统一处理。using System.Collections.Concurrent; using UnityEngine; using MQTTnet; using MQTTnet.Client; public class MqttClientManager : MonoBehaviour { private IMqttClient _mqttClient; private ConcurrentQueuestring _messageQueue new ConcurrentQueuestring(); async void Start() { var factory new MqttFactory(); _mqttClient factory.CreateMqttClient(); var options new MqttClientOptionsBuilder() .WithTcpServer(broker.example-mqtt.com, 1883) .WithCredentials(user_key, secret_key) .WithClientId(unity_app_ Random.Range(1000, 9999)) .WithCleanSession(true) .Build(); _mqttClient.ApplicationMessageReceivedAsync e { string payload System.Text.Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment.Array); _messageQueue.Enqueue(payload); return System.Threading.Tasks.Task.CompletedTask; }; await _mqttClient.ConnectAsync(options); await _mqttClient.SubscribeAsync(/user/esp32_01/status); Debug.Log(MQTT connected and subscribed); } void Update() { while (_messageQueue.TryDequeue(out string json)) { ProcessDeviceMessage(json); } } private void ProcessDeviceMessage(string json) { var data JsonUtility.FromJsonDeviceStatusData(json); // 在这里更新UI组件此时保证在主线程 Debug.Log($Device status: {data.status}, value: {data.value}); } void OnApplicationQuit() { _mqttClient?.DisconnectAsync(); } } [System.Serializable] public class DeviceStatusData { public string device_id; public string status; public float value; }代码里用WithClientId生成带随机后缀的ClientID是为了避免多个Unity实例同时连接时互相顶掉线。WithCleanSession(true)表示每次连接都是全新会话不保存历史消息适合应用端设备端则建议用false断线重连后能恢复之前的订阅关系。消息回调里我直接按UTF-8解码成字符串然后存进ConcurrentQueue。这个队列是线程安全的回调线程只管写入主线程在每一帧的Update()里取出来处理。如果不做这一步直接在回调里操作Text.textUnity会报“can only be called from main thread”Android上甚至直接卡死。Unity自带的JsonUtility解析有限制变量名必须跟JSON字段完全一致而且不支持字典和数组套对象。小项目够用字段多了之后建议换Newtonsoft.Json扩展性更好。4.3 排查记录主题拼写、中文乱码、掉线重连与重复消息这一节是我自己趟出来的血泪经验每一条都是实际遇到过的问题按现象、原因、解决的顺序整理。问题一Unity连上了云端但收不到ESP32的任何消息。现象MQTT连接建立成功订阅也没报错但日志里一直没有数据。原因是主题不一致ESP32发布到/user/esp32_01/statusUnity订阅的是/user/esp32_01/Status大小写不同在MQTT协议里是两个主题。还有一种隐蔽情况是免费云平台会自动在主题前面加一层系统前缀。解决方法是把两端的主题字符串原样打印到日志里逐字符对比不要用眼睛扫。问题二Unity收到中文乱码。现象设备状态里有个name字段是中文解析出来全是乱码。原因是ESP32用ArduinoJson序列化时输出的编码和Unity解码时不一致。Arduino侧char*默认按平台编码输出UTF-8Unity侧在回调里用了Encoding.Default在部分Windows机器上会转成GBK。解决方法是Unity侧统一用Encoding.UTF8.GetString()两端JSON文件保存格式也强制UTF-8。问题三ESP32长时间运行后自动失联。现象设备刚上电时一切正常跑了几小时或隔夜后云端显示离线重启又恢复。原因是WiFi模块休眠ESP32在空闲时会进入省电模式把WiFi基带挂起TCP长连接被系统静默断开但代码里检测不到。解决方法是WiFi初始化时加一行WiFi.setSleep(false)禁掉WiFi省电功能。问题四同一台设备收到重复的开关指令。现象Unity发了一条“灯亮”命令ESP32执行了两次。原因是QoS设置为1时消息至少到达一次云端重发或客户端确认丢失都会导致重复投递。解决方法是消息体里加一个msg_id字段ESP32维护一个最近处理过的消息ID缓存重复ID直接丢弃。5. 从局域网联调到外网实测验证方法与进阶控制技巧5.1 完整链路验证步骤用手机热点模拟外网环境联调阶段先在局域网跑通再切外网。最靠谱的外网模拟方式手机开热点ESP32连手机热点Unity跑在另一台用4G网络的手机上。两端不在同一局域网数据能通就说明外网穿透逻辑是通的。验证顺序我固定走四步。第一步用在线调试工具订阅ESP32的状态主题确认设备上报正常第二步Unity改成订阅同一个主题确认应用能收到消息第三步Unity发一条命令主题消息用调试工具订阅命令主题看是否到达第四步把两端都切到不同网络做一次完整双向通信。5.2 进阶技巧遗嘱消息、保留状态与批量控制基础链路通了之后有几个MQTT特性强烈建议加上。遗嘱消息让ESP32异常掉线时云端自动广播一条离线消息Unity收到后立刻显示“设备离线”不用等心跳超时。设置方法是在连接时调用WithWillQualityOfServiceProfile3.0里是WillMessage属性4.x用WithWillTopic和WithWillPayload。保留消息更适合设备状态上报把Retain标志设为trueUnity订阅成功后立刻会收到设备最后一次发布的保留状态。批量控制则利用主题通配符ESP32订阅/user/group/livingroom/#Unity往livingroom/light1、livingroom/switch发消息就能按房间批量管理。从那以后我每次接这种跨网通讯都先把主题命名表、ClientID规则、JSON字段定义写进项目文档再动代码宁可多花半小时做规划也不在联调时对着日志猜来猜去。希望帮到你。本文还有配套的精品资源点击获取