公司动态
Unity安卓串口通信:连接虚拟与物理世界的单片机数据通道
1. 项目概述Unity与单片机联动的安卓串口通信最近在做一个挺有意思的项目核心是把Unity做的3D界面或游戏逻辑跑在安卓手机或平板上然后通过手机的USB口或者OTG转接跟外部的单片机比如常见的STM32、ESP32或者51单片机进行实时数据交换。这个场景其实特别广泛比如做工业现场的便携式HMI人机界面、智能硬件的调试与监控工具、教育类的互动实验装置甚至是自己DIY的一些智能家居中控面板。你可能会问为什么非得是Unity安卓单片机这个组合直接用安卓原生开发不行吗这里面的门道就在于效率和表现力。Unity在渲染复杂的2D/3D界面、处理动画和交互反馈上有着原生安卓开发难以比拟的优势。想象一下你需要一个实时显示传感器数据波形图、并且能通过炫酷的3D模型来反映设备状态的监控界面用Unity来实现会顺畅得多。而安卓系统作为载体提供了几乎无处不在的硬件平台和USB/蓝牙等丰富的连接能力。单片机则是物理世界的执行者和感知者负责采集温度、控制电机、读取开关量等具体任务。所以这个项目的核心就是打通从Unity虚拟世界到单片机物理世界的这条“数据通道”。而串口通信UART因其协议简单、可靠性高、几乎所有的单片机都原生支持成为了这条通道最常用、最直接的桥梁。整个项目的挑战与乐趣也就在于如何让运行在安卓系统上的Unity应用稳定、高效地与串口背后的单片机“对话”。2. 核心思路与架构设计要实现这个目标不能指望Unity直接去操作安卓底层的USB串口驱动它没这个能力。我们需要一个“中间人”也就是一个桥梁层。通用的架构思路是Unity (C#脚本) - Android Java插件 (或AAR库) - 安卓系统USB Host API - USB转串口芯片驱动 - 物理串口 - 单片机。2.1 通信链路拆解我们来一步步拆解这个链路Unity层 (C#)这是我们的业务逻辑和表现层。在这里我们编写脚本定义需要发送的命令比如SET_LED_ON和需要解析的数据格式比如解析单片机上传的“温度:25.6℃”。但是Unity的C#运行环境Mono或IL2CPP无法直接调用安卓的Java API。Android Java插件层这是关键桥梁。我们需要用Android Studio编写一个Java库通常打包成.jar或.aar文件这个库负责所有“脏活累活”发现和枚举连接到安卓设备的USB设备。根据设备的Vendor ID和Product ID匹配我们指定的USB转串口芯片如CH340、CP2102、FTDI等。向系统申请USB设备的使用权限。打开设备找到对应的接口和端点Endpoint配置串口参数波特率、数据位、停止位、校验位。建立读写数据流实现数据的收发。Unity与Java的交互Unity提供了AndroidJavaClass和AndroidJavaObject这两个类允许C#脚本通过反射机制去调用我们编写的Java插件中的静态方法或实例方法。数据传递需要通过一些基本类型如int,string,byte[]或者JSON字符串来进行。驱动与硬件层安卓系统需要内置或安装对应的USB转串口芯片的驱动。幸运的是像CH340、FTDI这类常见芯片从安卓 3.1API Level 12支持USB Host模式开始其驱动大多已被系统集成或可通过Google Play安装。单片机端则只需按照约定的波特率等参数正常进行UART通信即可。2.2 单串口 vs 多串口架构考量根据网络上的讨论多串口通信是一个常见的进阶需求。比如你的设备需要同时连接一个负责电机控制的STM32和一个负责传感器采集的ESP8266。架构上就需要仔细考量单串口架构简单一个Java插件实例管理一个USB设备。通信逻辑清晰资源占用少。适合绝大多数单一功能设备联动的场景。多串口复杂度呈指数上升。你需要考虑插件管理是设计一个支持多实例的Java插件类每个实例管理一个串口还是在一个插件内部维护多个连接句柄。线程安全每个串口的读写操作必须在独立的线程中进行避免阻塞主线程UI线程或相互阻塞。在Unity中数据从Java线程回调到C#主线程也需要考虑线程安全。资源竞争USB Host控制器的带宽和资源是有限的同时进行多个高速串口通信可能会遇到瓶颈。错误隔离一个串口通信失败如设备意外拔出不应导致其他串口通信崩溃。对于新手我强烈建议从单串口开始彻底掌握从Unity到单片机的完整数据流。等到单串口稳定无误后再考虑将单串口的插件模块进行“组件化”封装然后通过创建多个实例的方式来支持多串口。这比一开始就设计一个庞杂的多路复用管理系统要可靠得多。注意安卓系统对USB设备的权限管理是“一次性的”。即使用户在弹出对话框时授予了权限这个权限也只会在当前设备连接期间有效。如果设备断开重连或者应用重启通常需要重新申请权限。你的插件逻辑必须妥善处理这个流程否则用户体验会非常糟糕。3. 安卓Java插件开发核心细节这是整个项目中最需要耐心和技术的一环。下面我以一个管理单个USB转串口设备的插件为例拆解核心步骤。3.1 设备发现与权限申请首先你需要在AndroidManifest.xml中声明使用USB Host功能和对应的设备过滤器。过滤器可以指定具体的VID厂商ID和PID产品ID这样系统只会为你关心的设备弹出权限申请。!-- AndroidManifest.xml -- uses-feature android:nameandroid.hardware.usb.host / uses-sdk android:minSdkVersion12 / !-- 至少API Level 12 -- activity ... ... !-- 用于系统自动弹出权限申请对话框 -- intent-filter action android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED / /intent-filter meta-data android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED android:resourcexml/device_filter / /activity在res/xml/device_filter.xml中你可以列出所有需要支持的芯片。如果不指定则会尝试匹配所有USB设备这通常不是好主意。!-- device_filter.xml -- resources !-- 支持CH340芯片 -- usb-device vendor-id6790 product-id29987 / !-- 支持CP2102芯片 -- usb-device vendor-id4292 product-id60000 / !-- 支持FTDI FT232R芯片 -- usb-device vendor-id1027 product-id24577 / /resources在Java代码中你需要使用UsbManager来枚举设备、申请权限。// Java插件代码片段 public class USBSerialPlugin { private UsbManager usbManager; private PendingIntent permissionIntent; private static final String ACTION_USB_PERMISSION com.yourcompany.yourapp.USB_PERMISSION; public void initialize(Context context) { usbManager (UsbManager) context.getSystemService(Context.USB_SERVICE); // 创建一个PendingIntent用于接收权限回调广播 permissionIntent PendingIntent.getBroadcast(context, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_MUTABLE); // 注册广播接收器... } public boolean requestPermission(UsbDevice device) { if (usbManager.hasPermission(device)) { return true; // 已有权限 } else { usbManager.requestPermission(device, permissionIntent); // 弹出系统对话框申请 return false; // 等待异步回调 } } }3.2 连接建立与串口配置获得权限后就可以打开设备并建立连接了。USB转串口芯片在内部实现了一个CDC通信设备类或厂商特定类。你需要遍历设备的接口找到通信接口Interface和对应的数据输入IN、输出OUT端点。public boolean connect(UsbDevice device) { UsbDeviceConnection connection usbManager.openDevice(device); if (connection null) { return false; } // 通常接口0是通信接口但最好遍历查找 for (int i 0; i device.getInterfaceCount(); i) { UsbInterface usbInterface device.getInterface(i); // 寻找CDC Data类接口或者根据接口类/子类协议判断 if (usbInterface.getInterfaceClass() UsbConstants.USB_CLASS_CDC_DATA) { connection.claimInterface(usbInterface, true); // 遍历此接口的端点 for (int j 0; j usbInterface.getEndpointCount(); j) { UsbEndpoint endpoint usbInterface.getEndpoint(j); if (endpoint.getDirection() UsbConstants.USB_DIR_IN) { inEndpoint endpoint; // 保存输入端点 } else if (endpoint.getDirection() UsbConstants.USB_DIR_OUT) { outEndpoint endpoint; // 保存输出端点 } } break; } } if (inEndpoint ! null outEndpoint ! null) { // 连接成功启动读写线程 startReadThread(connection, inEndpoint); return true; } else { connection.close(); return false; } }串口参数波特率的配置对于CDC类设备通常通过发送一个特殊的“控制传输”Control Transfer请求来完成即设置线路编码Set Line Coding。这是一个标准请求。private boolean setBaudRate(UsbDeviceConnection connection, int baudRate) { // CDC协议中设置线路编码的请求类型和值 int requestType 0x21; // 方向Host-to-Device 类型Class 接收者Interface int request 0x20; // SET_LINE_CODING int value 0; int index usbInterface.getId(); // 接口号 // 线路编码数据结构波特率(4字节)、停止位(1)、校验位(1)、数据位(1) byte[] lineCoding new byte[7]; // 将波特率如115200转换为小端序字节数组 lineCoding[0] (byte) (baudRate 0xff); lineCoding[1] (byte) ((baudRate 8) 0xff); lineCoding[2] (byte) ((baudRate 16) 0xff); lineCoding[3] (byte) ((baudRate 24) 0xff); lineCoding[4] 0; // 1个停止位 lineCoding[5] 0; // 无校验 lineCoding[6] 8; // 8位数据 int result connection.controlTransfer(requestType, request, value, index, lineCoding, lineCoding.length, 5000); return result lineCoding.length; }3.3 数据读写与线程管理数据读写必须在后台线程中进行绝对不能阻塞主线程。通常我们会为读操作单独开辟一个线程。private void startReadThread(UsbDeviceConnection connection, UsbEndpoint inEndpoint) { readThread new Thread(new Runnable() { Override public void run() { byte[] buffer new byte[4096]; // 缓冲区 while (!Thread.interrupted() connection ! null) { try { // 阻塞式读取数据 int bytesRead connection.bulkTransfer(inEndpoint, buffer, buffer.length, 5000); if (bytesRead 0) { // 将读取到的数据回调给Unity byte[] receivedData Arrays.copyOf(buffer, bytesRead); notifyUnityOnDataReceived(receivedData); } else if (bytesRead 0) { // 读取错误可能是连接断开 break; } } catch (Exception e) { e.printStackTrace(); break; } } // 线程结束清理资源 } }); readThread.start(); } // 写数据方法可在Unity线程调用内部处理线程安全 public void writeData(byte[] data) { if (connection ! null outEndpoint ! null) { // 简单起见这里直接在主线程写。对于大量数据建议放入队列由写线程发送。 int bytesWritten connection.bulkTransfer(outEndpoint, data, data.length, 5000); // 处理写入结果... } }如何将数据从Java线程“安全地”传递回Unity的C#环境一个常见做法是Java插件将接收到的byte[]数据转换为Base64字符串然后通过UnityPlayer.UnitySendMessage方法调用Unity中某个GameObject上某个脚本的某个方法。private void notifyUnityOnDataReceived(byte[] data) { String base64Data Base64.encodeToString(data, Base64.NO_WRAP); // “USBManager”是Unity场景中的GameObject名“OnSerialDataReceived”是C#脚本中的方法名 UnityPlayer.UnitySendMessage(USBManager, OnSerialDataReceived, base64Data); }实操心得UnitySendMessage虽然方便但它是一种反射调用性能有开销且只能传递一个字符串参数。对于高频数据通信比如每秒上百条数据这可能会成为瓶颈。更高效的做法是使用AndroidJavaProxy在C#端创建Java接口的代理或者通过共享内存如MemoryMappedFile等方式进行大量数据交换。但对于大多数串口应用波特率在115200以下UnitySendMessage完全够用且实现简单。4. Unity C#端的整合与封装在Unity这一侧我们的目标是创建一个易于使用、稳定的串口通信管理器。4.1 插件调用与初始化首先需要将编译好的.jar或.aar文件放到Unity项目的Assets/Plugins/Android目录下。然后创建C#脚本来封装与Java插件的交互。// USBSerialManager.cs using UnityEngine; using System; using System.Text; using System.Runtime.InteropServices; public class USBSerialManager : MonoBehaviour { // 定义Java插件类名和方法名 private const string PluginClassName com.yourcompany.unityusb.USBSerialPlugin; private AndroidJavaObject usbPluginInstance null; private AndroidJavaClass unityPlayerClass null; private AndroidJavaObject currentActivity null; void Awake() { // 防止场景切换时被销毁 DontDestroyOnLoad(this.gameObject); InitializePlugin(); } void InitializePlugin() { #if UNITY_ANDROID !UNITY_EDITOR try { // 获取当前Activity上下文 unityPlayerClass new AndroidJavaClass(com.unity3d.player.UnityPlayer); currentActivity unityPlayerClass.GetStaticAndroidJavaObject(currentActivity); // 实例化Java插件类并传入Activity上下文 AndroidJavaClass pluginClass new AndroidJavaClass(PluginClassName); usbPluginInstance pluginClass.CallStaticAndroidJavaObject(getInstance, currentActivity); // 调用插件的初始化方法 usbPluginInstance.Call(initialize); Debug.Log(USB Serial Plugin Initialized.); } catch (Exception e) { Debug.LogError(Failed to initialize USB plugin: e.Message); usbPluginInstance null; } #endif } }4.2 数据收发与协议设计初始化后就可以提供对外的接口供其他游戏脚本调用例如连接指定设备、发送数据。public bool ConnectToDevice(int vendorId, int productId) { if (usbPluginInstance null) return false; return usbPluginInstance.Callbool(connectToDevice, vendorId, productId); } public void SendData(byte[] data) { if (usbPluginInstance null) return; usbPluginInstance.Call(writeData, data); } // 发送字符串自动编码为字节 public void SendString(string message) { byte[] data Encoding.UTF8.GetBytes(message); // 或ASCII取决于单片机端 SendData(data); }从Java端回调的数据需要处理。我们在Awake中初始化插件时已经将当前GameObject的名字传递给了Java端假设为“USBManager”。现在我们需要在这个GameObject上挂载脚本并实现对应的回调方法。// 这个方法是供Java插件通过UnitySendMessage调用的 // 方法名必须与Java端调用时指定的字符串完全一致 public void OnSerialDataReceived(string base64Data) { try { // 将Base64字符串解码回字节数组 byte[] rawData Convert.FromBase64String(base64Data); // 处理原始字节数据... ProcessReceivedBytes(rawData); // 或者解码为字符串 // string receivedString Encoding.UTF8.GetString(rawData); // Debug.Log(Received: receivedString); } catch (Exception e) { Debug.LogError(Error processing received data: e.Message); } } private void ProcessReceivedBytes(byte[] data) { // 这里是你的协议解析层 // 例如你的单片机协议可能是帧头(0xAA) 数据长度(1字节) 命令字(1字节) 数据载荷 校验和(1字节) // 你需要在这里实现拆包、校验、分发给不同逻辑处理器的代码 // 这是一个简单的示例假设单片机直接发送文本字符串 string text Encoding.ASCII.GetString(data).Trim(); if (!string.IsNullOrEmpty(text)) { Debug.Log($来自单片机的消息: {text}); // 可以触发事件让其他脚本订阅 OnMessageReceived?.Invoke(text); } } // 定义一个事件方便其他脚本订阅数据到达 public event Actionstring OnMessageReceived;协议设计是重中之重。单片机与上位机之间绝不能只发送“裸数据”。一个最简单的可靠协议至少应包含帧头用于标识一帧数据的开始如0xAA、0x55或特定的字符$。长度字段指明后续数据段的长度便于接收方正确截取一帧。数据载荷实际要传递的信息。校验字段如CRC8、累加和等用于验证数据在传输过程中是否出错。例如一个简单的协议帧可以是[0xAA][Len][Cmd][Data...][Checksum]。在ProcessReceivedBytes方法中你需要实现一个状态机逐个字节读取识别帧头根据长度字段收取指定数量的数据最后计算校验和并与帧尾的校验字段对比一致则认为收到一帧有效数据。4.3 Unity端的线程安全与性能需要注意的是OnSerialDataReceived方法是由Java线程通过UnitySendMessage调用的它并非运行在Unity的主线程中。虽然Unity内部处理UnitySendMessage时似乎做了一些线程同步但为了绝对安全特别是当你需要修改Unity场景中的对象如更新UI Text、改变物体位置时应该将数据先缓存起来在Unity主线程的Update()方法中处理。private Queuestring receivedMessageQueue new Queuestring(); public void OnSerialDataReceived(string base64Data) { // 这个方法在非主线程被调用 lock (receivedMessageQueue) // 加锁保证线程安全 { // 只做最轻量的工作解码和入队 try { byte[] rawData Convert.FromBase64String(base64Data); string message Encoding.ASCII.GetString(rawData).Trim(); receivedMessageQueue.Enqueue(message); } catch { /* 忽略解码错误 */ } } } void Update() { // Update在主线程运行 lock (receivedMessageQueue) { while (receivedMessageQueue.Count 0) { string msg receivedMessageQueue.Dequeue(); // 在这里安全地更新UI或游戏对象 if (messageDisplayText ! null) messageDisplayText.text 最新数据: msg; // 触发事件 OnMessageReceived?.Invoke(msg); } } }5. 单片机端的配合与调试通信是双向的单片机端的程序也必须可靠。以下是一些关键点参数匹配波特率、数据位、停止位、校验位必须与安卓端配置完全一致。最常用的是115200, 8N1波特率1152008位数据无校验1位停止位。稳定的发送避免在中断服务程序里进行长时间的数据发送。最好将待发送数据放入缓冲区在主循环中发送。对于高速数据要确保发送速度不超过串口的承载能力。协议实现单片机端也需要实现相同的通信协议。发送时组帧加帧头、长度、校验接收时解帧。这能极大提高通信的可靠性。流控制如果数据量很大考虑启用硬件流控RTS/CTS。但大多数USB转串口线并未引出这些引脚所以更实际的做法是设计一个简单的软件流控协议比如接收方回复“ACK”确认帧。一个简单的单片机以STM32 HAL库为例发送示例// 假设协议: $开头数据回车换行结尾 char buffer[64]; float temperature read_temperature(); int len snprintf(buffer, sizeof(buffer), $TEMP,%.2f\r\n, temperature); HAL_UART_Transmit(huart1, (uint8_t*)buffer, len, 1000); // 阻塞式发送超时1秒6. 完整集成、测试与避坑指南将Java插件、Unity C#脚本和单片机程序都准备好后就可以开始集成了。6.1 打包与部署流程导出Android Studio工程在Unity的Build Settings中选择Android平台勾选Export Project然后导出。整合插件将你的.aar库文件复制到导出的Android Studio工程的app/libs/目录下。并在app/build.gradle文件中添加依赖implementation files(libs/your-plugin.aar)。权限与配置复查确保AndroidManifest.xml中USB权限和设备过滤器已正确合并。编译APK使用Android Studio或Gradle命令编译生成最终的APK文件。6.2 常见问题与解决方案实录在实际操作中我踩过不少坑这里总结一下问题一Unity调用Java方法返回false或直接崩溃。排查首先检查插件是否初始化成功。在InitializePlugin后加日志。确保.aar文件放对了位置且依赖已添加。检查点所有从C#传到Java的int参数在Java端要用int接收bool参数用boolean。字符串传递是安全的。线程问题确保对Java插件实例AndroidJavaObject的调用尽可能在Unity主线程进行。不要在子线程中创建或频繁调用它。问题二能找到USB设备但申请权限后没反应或连接失败。排查检查device_filter.xml中的VID/PID是否与你的USB转串口芯片完全一致。可以用lsusbLinux/Mac或设备管理器Windows查看。权限回调确保你的BroadcastReceiver正确注册并且能收到ACTION_USB_PERMISSION广播。在收到授权广播后再执行连接操作。驱动问题某些较老的安卓设备或定制ROM可能缺少特定芯片驱动。可以尝试安装厂商提供的驱动APK或换用更通用的芯片如CH340支持度较好。问题三连接成功但发送数据单片机收不到或接收数据乱码/不全。波特率这是最常见的原因。双端波特率必须绝对一致。检查Unity C#脚本初始化时传入的波特率值和单片机程序初始化串口的波特率值。编码问题乱码通常是字符编码不一致。Unity用UTF8.GetBytes发送单片机端就用UTF8解析。如果单片机程序简单可以统一使用ASCII编码它兼容英文字符和数字。数据结尾符确保发送的字符串包含单片机程序期望的结尾符如\r\n回车换行。很多单片机端的简单示例程序是靠检测\n来判定一帧结束的。缓冲区溢出单片机端UART接收缓冲区大小有限。如果Unity发送过快可能导致数据丢失。需要在协议中加入流量控制或者降低Unity端的发送频率。问题四应用切换到后台再回来串口通信断了。原因安卓系统在应用进入后台时可能会释放USB设备资源。这是正常行为。解决需要在Unity的OnApplicationPause事件中处理。当pause为true进入后台时主动关闭串口连接。当pause为false回到前台时重新尝试枚举和连接设备。你的Java插件需要提供disconnect和重新连接的接口。问题五多串口同时工作时通信不稳定。资源限制USB Host控制器带宽有限。尝试降低每个串口的波特率。插件设计确保每个串口连接都由独立的插件实例管理且每个实例都有自己的读写线程。避免静态变量共享导致的冲突。电源管理同时连接多个USB设备可能供电不足尤其是使用OTG线连接时。考虑使用带外接供电的USB HUB。6.3 调试技巧分步调试先用电脑调试单片机使用串口调试助手如SecureCRT、Putty、Arduino IDE串口监视器确保单片机端的收发程序完全正确。再用安卓原生App调试插件写一个简单的安卓原生测试App调用你的Java插件确保插件本身能在安卓环境下正常工作。最后集成到Unity前两步都通了Unity集成的问题范围就小了很多。日志是生命线在Java插件的关键步骤如发现设备、申请权限、打开连接、收发数据都加上Log.d()输出。在Unity C#端也用Debug.Log()。通过adb logcat命令可以实时查看所有日志这是定位问题最强大的工具。使用虚拟串口工具在电脑上安装虚拟串口软件如VSPD创建一对虚拟COM口。一个口被串口调试助手打开另一个口被你的Unity安卓应用通过OTG连接电脑打开。这样你可以在电脑端模拟单片机方便地进行双向数据测试而无需真实的单片机硬件。最后我想分享一个个人体会这种跨层应用层、框架层、驱动层、硬件层的项目最大的挑战不是某一项技术有多难而是当问题出现时如何快速定位是哪一层出了问题。建立清晰的调试链路和日志体系养成“分而治之”的排查习惯比掌握任何单一技术点都重要。从最简单的“单片机回显你发送的字符串”功能开始逐步增加复杂度每走通一步信心就增加一分。当你第一次在Unity的UI上实时看到来自真实物理世界传感器的数据跳动时那种成就感绝对是纯软件开发无法比拟的。