简介:这套资源是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不需要公网IP,Unity也不需要对端地址,只需要知道云端的Broker地址和主题名。
这套链路还有个隐藏优点:断网重连由两端的客户端库自动处理。家里WiFi重启、出差路上切4G,只要ESP32重新连上云端,Unity之前订阅的主题还在,消息就能继续流动。我最早做的时候也试过用TCP裸连接自己写心跳,后来发现要处理粘包、半包、断线重连,代码越写越复杂,最后还是回到MQTT生态。
2.2 协议选型对比:MQTT、HTTP轮询、WebSocket、TCP长连接的取舍
做双端通讯之前先把协议定下来,不然后面全是返工。我把这几种常见方案的实际体验列一张对比表,都是实测感受,不是抄文档。
| 协议 | 连接方式 | 实时性 | 消息开销 | 落地难点 |
|---|---|---|---|---|
| MQTT | TCP长连接,发布订阅 | 高 | 极低,2字节固定头 | 需要自建或接入Broker |
| HTTP轮询 | 短连接,频繁请求 | 低,秒级延迟 | 高,每次带完整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 | 云端接入域名,不是设备IP |
| TCP端口 | 1883 | 明文通讯,局域网联调用 |
| SSL端口 | 8883 | 公网环境建议走TLS |
| ClientID | esp32_01 / unity_app_01 | 全局唯一,重复会被踢 |
| Username | user_key | 平台分配的鉴权信息 |
| Password | secret_key | 配合Username使用 |
| 设备状态主题 | /user/esp32_01/status | ESP32发布,Unity订阅 |
| 设备命令主题 | /user/esp32_01/command | Unity发布,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与ArduinoJson
ESP32端我用的Arduino框架,版本2.x,配合三个库:WiFi.h是ESP32自带的网络库,PubSubClient负责MQTT协议交互,ArduinoJson负责生成和解析JSON消息。PubSubClient建议用2.8及以上版本,老版本在部分ESP32模块上会出现连接后不定时断线的问题。
ArduinoJson要注意版本差异。6.x用DynamicJsonDocument动态分配内存,7.x改成了JsonDocument,API变化比较大。代码仓库里如果是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()); StaticJsonDocument<128> doc; if (deserializeJson(doc, msg) == DeserializationError::Ok) { const char* action = doc["action"]; if (strcmp(action, "switch") == 0) { int value = doc["value"].as<int>(); // 这里写继电器或LED控制逻辑 Serial.printf("Switch to %d\n", value); } } } void publishStatus() { StaticJsonDocument<128> 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或iOS,MQTTnet的反射调用会被IL2CPP裁剪掉,运行时直接抛异常。解决办法是在Assets目录下建一个link.xml文件,告诉Unity保留MQTTnet程序集。
<linker> <assembly fullname="MQTTnet" preserve="all"/> </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 ConcurrentQueue<string> _messageQueue = new ConcurrentQueue<string>(); 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.FromJson<DeviceStatusData>(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.text,Unity会报“can only be called from main thread”,Android上甚至直接卡死。
Unity自带的JsonUtility解析有限制,变量名必须跟JSON字段完全一致,而且不支持字典和数组套对象。小项目够用,字段多了之后建议换Newtonsoft.Json,扩展性更好。
4.3 排查记录:主题拼写、中文乱码、掉线重连与重复消息
这一节是我自己趟出来的血泪经验,每一条都是实际遇到过的问题,按现象、原因、解决的顺序整理。
问题一:Unity连上了云端,但收不到ESP32的任何消息。现象:MQTT连接建立成功,订阅也没报错,但日志里一直没有数据。原因是主题不一致,ESP32发布到/user/esp32_01/status,Unity订阅的是/user/esp32_01/Status,大小写不同在MQTT协议里是两个主题。还有一种隐蔽情况是免费云平台会自动在主题前面加一层系统前缀。解决方法是把两端的主题字符串原样打印到日志里逐字符对比,不要用眼睛扫。
问题二:Unity收到中文乱码。现象:设备状态里有个name字段是中文,解析出来全是乱码。原因是ESP32用ArduinoJson序列化时输出的编码和Unity解码时不一致。Arduino侧char*默认按平台编码输出UTF-8,Unity侧在回调里用了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收到后立刻显示“设备离线”,不用等心跳超时。设置方法是在连接时调用WithWillQualityOfServiceProfile,3.0里是WillMessage属性,4.x用WithWillTopic和WithWillPayload。保留消息更适合设备状态上报,把Retain标志设为true,Unity订阅成功后立刻会收到设备最后一次发布的保留状态。批量控制则利用主题通配符,ESP32订阅/user/group/livingroom/#,Unity往livingroom/light1、livingroom/switch发消息就能按房间批量管理。
从那以后,我每次接这种跨网通讯,都先把主题命名表、ClientID规则、JSON字段定义写进项目文档再动代码,宁可多花半小时做规划,也不在联调时对着日志猜来猜去。希望帮到你。
本文还有配套的精品资源,点击获取