资讯动态

Unity安卓串口通信实战:USB转串口收发代码与避坑指南

发布时间:2026/10/21 5:10:25 来源:尧图企业网站定制
简介这份资源面向需要在Unity中实现安卓串口通信的开发者尤其适合不熟悉Android原生开发、又希望让Unity应用与Arduino、树莓派等外部硬件交互的初中级工程师。包内共30个文件以so动态库、java接口类、c与h源码、mk构建脚本及svn版本文件为主压缩包约124KB完整覆盖从Java层串口接口、JNI桥接到Unity调用的关键环节。已有1174人学习下载说明该方案在硬件交互场景中具有一定参考价值。读者可据此了解如何借助JNI打通C#与Android底层串口资源掌握打开关闭串口、收发数据、事件回调与权限声明等实现思路并参考现成构建脚本快速搭建自己的串口通信模块减少重复摸索成本。1. 安卓设备上跑 Unity 串口通信一份能直接落地的收发代码包如果你手头有一台安卓设备需要通过 USB 或板载串口跟单片机、传感器、工控模块对话而你的应用又是用 Unity 做的那你大概率已经踩过这个坑Unity 的System.IO.Ports在 Android 上根本用不了。这不是配置问题是运行时压根没有对应实现。我见过太多人在SerialPort构造函数那里卡一整天报错信息还特别含糊最后才发现方向就错了。这份资源给的就是一套能在安卓上真正跑通的 Unity 串口收发代码核心思路是绕开 .NET 的串口库直接走 Android 原生层的 USB 通信接口再通过 C# 回调把数据接回 Unity 主线程。它解决的不是怎么写串口助手而是怎么让 Unity 在安卓上稳定收发字节流这件事。适合做工业手持终端、外设控制面板、嵌入式调试工具的人也适合刚接触 Unity 安卓原生交互、想找一个完整可复现案例的开发者。2. 为什么 Unity 在安卓上收不了串口原理与选型2.1 桌面端能跑、安卓端崩掉的根因在 Windows 或 macOS 上System.IO.Ports.SerialPort是能用的因为 Mono 运行时在这些平台上提供了串口实现。但 Unity 打包到 Android 后底层换成了 IL2CPP 加 Android 运行时System.IO.Ports这个命名空间虽然编译期不报错运行时却会抛PlatformNotSupportedException或者干脆静默失败。很多人第一次遇到时以为是权限没给去 AndroidManifest 里加了一堆权限结果毫无变化——因为问题不在权限层在于这个 API 在安卓上就没有后端实现。安卓系统本身对串口的支持方式是另一套逻辑。它把 USB 转串口设备当作 USB 设备来管理通过android.hardware.usb.UsbManager枚举设备、申请权限、打开端点然后用UsbDeviceConnection.bulkTransfer做批量读写。板载串口比如工控板上的/dev/ttyS*则通常需要 root 或者厂商提供的 JNI 接口不在通用方案范围内。所以这份代码包走的是 USB 转串口这条路覆盖面最广也不需要系统权限。选型上还有一个岔路是用现成的安卓串口库比如某些第三方 aar还是自己写 JNI 桥接。第三方库的问题是版本参差、文档缺失出了 bug 你只能等作者更新自己写桥接虽然前期麻烦但每一层都可控出问题能定位到具体是 Java 层还是 C# 层。这份资源采用的是后者Java 侧封装 USB 操作C# 侧做调用和回调分发中间用AndroidJavaObject和AndroidJavaProxy连接。2.2 整体架构三层桥接怎么搭整个方案分三层。最底层是 Java 侧的UsbSerialBridge类负责设备枚举、权限请求、端点打开和bulkTransfer读写。中间层是 Unity 的 C# 脚本通过AndroidJavaObject调用 Java 方法同时用AndroidJavaProxy实现一个回调接口接收 Java 侧抛上来的数据。最上层是业务脚本订阅 C# 事件拿到字节数组做协议解析。数据流向是这样的Java 侧开一个读线程循环调用bulkTransfer读端点读到数据后通过回调接口传到 C# 侧C# 侧的回调代理收到数据后不能直接操作 Unity 的 GameObject因为回调线程不是主线程必须用一个线程安全队列缓存在Update里出队处理。这个细节是很多人翻车的地方——直接在回调里更新 UI轻则报错重则崩溃而且崩溃日志指向的位置往往跟真实原因差很远。写数据的方向相对简单C# 调 Java 的write方法Java 侧调bulkTransfer往输出端点写。但要注意bulkTransfer是阻塞的写大量数据时不能放在主线程否则会卡帧。常见做法是开一个写队列Java 侧单独一个线程消费。提示USB 转串口芯片型号很多CH340、CP2102、FT232 等不同芯片的端点配置和波特率设置方式略有差异。代码包里对波特率做了通用处理但如果你用的是冷门芯片可能需要调整setBaudRate的调用位置。3. 把代码跑起来从 Java 桥接到 C# 回调的完整步骤3.1 Java 侧USB 设备枚举与权限申请第一步是把 Java 桥接类放进 Unity 工程。在Assets/Plugins/Android目录下建一个UsbSerialBridge.java包名跟你的 Unity 工程一致。核心逻辑是枚举已连接的 USB 设备找到目标设备后请求权限。权限请求是异步的安卓会弹一个系统对话框问用户是否允许所以不能申请完立刻打开设备必须等onReceive回调。// UsbSerialBridge.java package com.example.usbserial; // 替换为你的包名 import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.hardware.usb.*; import android.os.Build; import java.util.HashMap; public class UsbSerialBridge { private UsbManager usbManager; private UsbDevice targetDevice; private UsbDeviceConnection connection; private UsbEndpoint inEndpoint; private UsbEndpoint outEndpoint; private Context context; private boolean isReading false; private Thread readThread; // 回调接口由 C# 侧实现 public interface SerialCallback { void onDataReceived(byte[] data); void onStatusChanged(String status); } private SerialCallback callback; public UsbSerialBridge(Context ctx, SerialCallback cb) { this.context ctx; this.callback cb; this.usbManager (UsbManager) ctx.getSystemService(Context.USB_SERVICE); } // 枚举设备返回设备列表的描述字符串 public String[] listDevices() { HashMapString, UsbDevice deviceList usbManager.getDeviceList(); String[] result new String[deviceList.size()]; int i 0; for (UsbDevice device : deviceList.values()) { result[i] device.getDeviceName() | device.getVendorId() | device.getProductId(); i; } return result; } // 请求权限异步 public void requestPermission(int vendorId, int productId) { for (UsbDevice device : usbManager.getDeviceList().values()) { if (device.getVendorId() vendorId device.getProductId() productId) { targetDevice device; break; } } if (targetDevice null) { callback.onStatusChanged(DEVICE_NOT_FOUND); return; } if (usbManager.hasPermission(targetDevice)) { openDevice(); return; } // 注册权限广播 PendingIntent pi PendingIntent.getBroadcast(context, 0, new Intent(com.example.usbserial.USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); IntentFilter filter new IntentFilter(com.example.usbserial.USB_PERMISSION); context.registerReceiver(new BroadcastReceiver() { Override public void onReceive(Context ctx, Intent intent) { if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { openDevice(); } else { callback.onStatusChanged(PERMISSION_DENIED); } ctx.unregisterReceiver(this); } }, filter); usbManager.requestPermission(targetDevice, pi); } private void openDevice() { connection usbManager.openDevice(targetDevice); if (connection null) { callback.onStatusChanged(OPEN_FAILED); return; } // 遍历接口和端点找到第一个 IN 和 OUT 端点 for (int i 0; i targetDevice.getInterfaceCount(); i) { UsbInterface iface targetDevice.getInterface(i); connection.claimInterface(iface, true); for (int j 0; j iface.getEndpointCount(); j) { UsbEndpoint ep iface.getEndpoint(j); if (ep.getDirection() UsbConstants.USB_DIR_IN) { inEndpoint ep; } else if (ep.getDirection() UsbConstants.USB_DIR_OUT) { outEndpoint ep; } } } if (inEndpoint null || outEndpoint null) { callback.onStatusChanged(ENDPOINT_NOT_FOUND); return; } callback.onStatusChanged(CONNECTED); } }这段代码的关键点有三个。第一listDevices返回的字符串用|分隔了设备名、VID 和 PIDC# 侧解析后可以展示给用户选择避免硬编码设备 ID。第二权限请求必须用PendingIntent加广播接收器这是安卓的标准流程没有捷径。第三claimInterface的第二个参数传true表示强制占用接口如果设备已经被其他驱动占用这里会失败需要先释放。3.2 Java 侧读写线程与数据回调设备打开后读写要各走一个线程。读线程循环调bulkTransfer超时设短一点比如 100ms这样线程能及时响应停止信号。写操作可以同步做但为了不阻塞调用方建议也放到独立线程里消费队列。// 启动读线程 public void startReading() { if (isReading) return; isReading true; readThread new Thread(new Runnable() { Override public void run() { byte[] buffer new byte[1024]; while (isReading) { // bulkTransfer 返回实际读到的字节数-1 表示失败 int len connection.bulkTransfer(inEndpoint, buffer, buffer.length, 100); if (len 0) { byte[] data new byte[len]; System.arraycopy(buffer, 0, data, 0, len); callback.onDataReceived(data); } } } }); readThread.start(); } // 停止读线程 public void stopReading() { isReading false; if (readThread ! null) { try { readThread.join(500); } catch (InterruptedException e) { } readThread null; } } // 写数据返回实际写入字节数 public int write(byte[] data) { if (connection null || outEndpoint null) return -1; return connection.bulkTransfer(outEndpoint, data, data.length, 1000); } // 设置波特率部分芯片需要发控制命令这里用标准 CDC 命令 public boolean setBaudRate(int baudRate) { if (connection null) return false; int baud baudRate; // CDC SET_LINE_CODING 请求 byte[] lineCoding new byte[]{ (byte)(baud 0xFF), (byte)((baud 8) 0xFF), (byte)((baud 16) 0xFF), (byte)((baud 24) 0xFF), 0x00, // 1 停止位 0x00, // 无校验 0x08 // 8 数据位 }; return connection.controlTransfer(0x21, 0x20, 0, 0, lineCoding, 8, 1000) 0; } // 关闭连接 public void close() { stopReading(); if (connection ! null) { connection.close(); connection null; } callback.onStatusChanged(DISCONNECTED); } }bulkTransfer的超时参数很关键。读操作设 100ms 是为了让线程有机会检查isReading标志如果设成 0无限等待停止时线程会卡死。写操作设 1000ms 是因为写通常不会阻塞太久但也不能无限等。setBaudRate用的是 USB CDC 标准的SET_LINE_CODING请求大部分 USB 转串口芯片都支持但 CH340 这类芯片可能需要额外的厂商命令如果设完波特率不通先检查芯片手册。3.3 C# 侧AndroidJavaProxy 回调与主线程分发C# 侧要做两件事一是用AndroidJavaProxy实现 Java 定义的SerialCallback接口二是把收到的数据通过线程安全队列转到主线程。AndroidJavaProxy的构造函数参数是 Java 接口的完整类名方法名和签名必须跟 Java 侧一致否则回调不会被触发而且不会有任何报错——这是最隐蔽的坑之一。// UsbSerialManager.cs using System; using System.Collections.Concurrent; using System.Threading; using UnityEngine; public class UsbSerialManager : MonoBehaviour { // Java 侧回调接口的代理 class SerialCallbackProxy : AndroidJavaProxy { private UsbSerialManager manager; public SerialCallbackProxy(UsbSerialManager mgr) : base(com.example.usbserial.UsbSerialBridge$SerialCallback) { manager mgr; } // 方法名和参数必须与 Java 接口完全一致 void onDataReceived(byte[] data) { manager.EnqueueData(data); } void onStatusChanged(string status) { manager.EnqueueStatus(status); } } private AndroidJavaObject bridge; private SerialCallbackProxy proxy; private ConcurrentQueuebyte[] dataQueue new ConcurrentQueuebyte[](); private ConcurrentQueuestring statusQueue new ConcurrentQueuestring(); // 对外事件 public event Actionbyte[] OnDataReceived; public event Actionstring OnStatusChanged; void Awake() { proxy new SerialCallbackProxy(this); // 获取 Unity 当前的 Activity using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) { bridge new AndroidJavaObject( com.example.usbserial.UsbSerialBridge, activity, proxy); } } // 线程安全入队由 Java 回调线程调用 internal void EnqueueData(byte[] data) { dataQueue.Enqueue(data); } internal void EnqueueStatus(string status) { statusQueue.Enqueue(status); } void Update() { // 主线程出队分发 while (dataQueue.TryDequeue(out byte[] data)) { OnDataReceived?.Invoke(data); } while (statusQueue.TryDequeue(out string status)) { OnStatusChanged?.Invoke(status); } } // 对外接口 public string[] ListDevices() { return bridge.Callstring[](listDevices); } public void RequestPermission(int vid, int pid) { bridge.Call(requestPermission, vid, pid); } public void StartReading() { bridge.Call(startReading); } public int Write(byte[] data) { return bridge.Callint(write, data); } public bool SetBaudRate(int baudRate) { return bridge.Callbool(setBaudRate, baudRate); } public void Close() { bridge.Call(close); } void OnDestroy() { Close(); bridge?.Dispose(); } }AndroidJavaProxy的类名写法要注意内部接口用$分隔比如UsbSerialBridge$SerialCallback。如果写成UsbSerialBridge.SerialCallback代理不会生效而且不会有异常抛出只是回调永远不触发。byte[]在 Java 和 C# 之间传递时Unity 会自动做转换但要注意 Java 侧的byte是有符号的C# 侧也是sbyte语义如果协议里有大于 127 的字节解析时要按无符号处理。Update里出队的写法用了ConcurrentQueue这是 .NET 提供的线程安全队列不需要额外加锁。每帧把队列清空数据量大时可能一帧处理多个包业务层需要自己处理粘包问题。3.4 业务层一个最小可用的收发示例把上面的管理器挂到一个 GameObject 上再写一个业务脚本订阅事件就能跑通完整链路。下面这个例子演示了连接、发指令、收数据的流程。// SerialDemo.cs using UnityEngine; public class SerialDemo : MonoBehaviour { public UsbSerialManager serialManager; private int targetVid 0x1A86; // CH340 的 VID按实际设备改 private int targetPid 0x7523; void Start() { serialManager.OnStatusChanged HandleStatus; serialManager.OnDataReceived HandleData; // 列出设备确认目标在线 string[] devices serialManager.ListDevices(); foreach (var d in devices) { Debug.Log(Device: d); } // 请求权限成功后会自动触发 CONNECTED 状态 serialManager.RequestPermission(targetVid, targetPid); } void HandleStatus(string status) { Debug.Log(Serial status: status); if (status CONNECTED) { serialManager.SetBaudRate(115200); serialManager.StartReading(); // 发一条测试指令 byte[] cmd new byte[] { 0xAA, 0x01, 0x00, 0x55 }; serialManager.Write(cmd); } } void HandleData(byte[] data) { // 这里只做打印实际项目要按协议解析 string hex System.BitConverter.ToString(data); Debug.Log(Received: hex); } void OnApplicationQuit() { serialManager.Close(); } }RequestPermission是异步的所以不能在调用后立刻StartReading必须等CONNECTED状态回调。SetBaudRate要在打开设备之后、开始读之前调用顺序反了波特率不生效。Write返回实际写入的字节数如果小于传入长度说明写超时了需要重试或检查设备是否还在线。4. 避坑与排查那些让你怀疑人生的瞬间4.1 权限弹窗不出现状态一直卡在 DEVICE_NOT_FOUND现象是调了RequestPermission但系统不弹权限对话框回调也没反应。原因通常是 VID/PID 写错了或者设备根本没被UsbManager枚举到。安卓对 USB 设备的枚举依赖硬件层有些 OTG 线质量差会导致设备时认时不认。解决方法是先用listDevices把当前所有设备打印出来确认目标设备的 VID/PID 跟代码里写的一致。如果列表为空换一根 OTG 线或者检查设备是否处于需要额外供电的状态。4.2 回调方法不触发但没有任何报错这是AndroidJavaProxy最坑的地方。代理类名写错、方法签名不匹配、参数类型不对都不会抛异常只是静默不回调。排查方法是先在 Java 侧的onDataReceived里加一行Log.d确认 Java 层有没有调到。如果 Java 层有日志但 C# 层没反应就是代理的问题。重点检查三处类名是否用了$分隔内部接口、方法名大小写是否完全一致、参数类型是否是byte[]而不是sbyte[]或其他。4.3 收到数据但解析出来是乱码现象是OnDataReceived能触发但字节内容跟预期对不上。常见原因有两个一是波特率没设对setBaudRate调了但没生效或者调用时机在打开设备之前二是数据位、停止位、校验位跟设备端不一致。代码里默认是 8 数据位、1 停止位、无校验如果你的设备用的是 7 数据位或偶校验需要改setBaudRate里的lineCoding数组。另外注意 Java 的byte是有符号的如果协议里有0xFF这样的字节打印出来会是-1解析时要用 0xFF转成无符号。4.4 读写一段时间后卡死或崩溃跑几分钟后读线程不再回调或者直接闪退。原因通常是bulkTransfer在设备异常断开时返回 -1但读线程没有处理这个返回值继续循环调用导致底层 USB 栈状态异常。解决方法是判断bulkTransfer返回值如果连续多次返回 -1主动停止读线程并触发DISCONNECTED状态让业务层做重连。另外close方法要先停读线程再关连接顺序反了会导致读线程访问已释放的connection对象直接崩溃。4.5 写数据丢包或只发出去一部分现象是Write返回的字节数小于传入长度或者设备端收到的数据不完整。bulkTransfer本身不保证一次写完所有数据特别是数据量大于端点缓冲区时。解决方法是把Write改成一个循环每次写剩余部分直到全部写完或超时。代码包里给的是单次写实际项目里建议封装一个WriteAll方法循环调用直到写完。注意安卓 12 及以上版本对 USB 权限的管理更严格如果应用在后台时设备被拔出再插入权限会失效需要重新申请。业务层要监听DISCONNECTED状态并做重连逻辑。5. 进阶技巧用状态机管住串口生命周期跑通基本收发之后真正让代码稳定的是状态管理。我见过太多项目把串口逻辑写成面条式代码权限、连接、读、写、断开全混在一起出了问题根本不知道当前处于哪个阶段。一个简单的状态机能把这件事理清楚。定义五个状态IDLE、REQUESTING、CONNECTED、READING、ERROR。每次操作前检查当前状态是否允许操作后迁移到下一个状态。比如RequestPermission只能在IDLE或ERROR状态下调用调用后进入REQUESTING收到CONNECTED回调后进入CONNECTEDStartReading后进入READING任何一步失败进入ERROR在ERROR状态下只允许Close和重新RequestPermission。public enum SerialState { IDLE, REQUESTING, CONNECTED, READING, ERROR } private SerialState currentState SerialState.IDLE; public bool CanRequest currentState SerialState.IDLE || currentState SerialState.ERROR; public bool CanStartReading currentState SerialState.CONNECTED; public bool CanWrite currentState SerialState.READING;这个状态机的好处是所有非法操作在入口就被拦住了不会出现在没连接的时候调 Write这种问题。配合日志把每次状态迁移打出来出问题时看日志就能定位到是哪一步卡住了。另一个实用技巧是给读到的数据加时间戳。OnDataReceived触发时记录Time.realtimeSinceStartup业务层解析协议时如果发现两个包间隔异常可以判断是设备发送频率变了还是数据粘包了。对于需要做超时重发的场景这个时间戳是必需的。验证方法上我一般会先用一个 USB 转串口模块加串口助手做对端发固定模式的字节流确认 Unity 侧能完整收到。然后再换成真实设备逐步加协议解析。不要一上来就接真实设备调对端不可控的话你分不清是代码问题还是设备问题。从那以后我每次接串口项目都强制先跑一遍枚举设备→申请权限→设波特率→发一条已知指令→收一条已知回复的最小闭环确认链路通了再写业务逻辑。这个习惯帮我省掉了至少一半的调试时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑