
简介一份面向Unity开发者的安卓串口通信实现代码用于解决Unity应用与Arduino、树莓派等外部硬件之间的串口数据收发问题可支撑游戏外设控制、传感器数据读取等互动场景。资源围绕JNI桥接技术构建包含Java层串口接口、C/C原生源码以及Unity C#调用示例适合有一定C#和Android基础、希望绕过复杂底层直接接入硬件的开发者参考。压缩包共30个文件以so动态库、java源码、c/h原生代码和mk构建脚本为主并保留svn版本管理残留文件总大小约124KB体量虽小但结构完整便于按需提取移植。该资源已有1172人学习说明串口通信在Unity硬件交互开发中需求旺盛。通过阅读SerialPort.c与相关JNI代码可以清晰理解Android串口打开、权限申请、数据读写和回调触发等关键流程同时也能避开模拟器不支持串口、驱动适配等常见坑点节省自行摸索的时间。1. Unity 安卓串口收发别让硬件调试卡在最基础的一环做 Unity 安卓开发一碰到硬件串口就头疼Unity 的 C# 层跟安卓底层的串口设备之间隔着一层 JNIAndroidJavaObject 的坑一个接一个不是 USB 权限弹不出来就是数据读到一半乱码卡死。这套「Unity 安卓串口收发代码」解决的就是这个时间段最容易让新手放弃的环节——从 Java 层 USB 设备识别、权限弹窗、串口读写到 C# 侧 JNI 桥接、字节流解析一条链路打通。适合正在做无人车调试、单片机上位机、传感器数据采集的 Unity 开发者特别是那种行跟到一半发现安卓真机连不上串口设备、只能干瞪眼的场景。直接说结论这个资源不是把串口协议论文抄一遍而是给了能编译、能跑、能换参数调的一套最小实现。2. 串口链路拆解Unity 管不了底层那就用 JNI 把 Java 的能力借过来2.1 为什么 Unity 不能直接读写串口必须先过安卓这一层Unity 的 C# 层跑在 Mono 或 IL2CPP 之上它对底层硬件的访问能力非常有限。串口设备在安卓系统里是以 USB 设备的形式出现的读写的核心 API 是UsbManager、UsbDevice、UsbDeviceConnection这一套 Java 接口C# 侧没有直接对应的封装。所以 Unity 安卓串口收发这条路实际是两条线Java 层负责枚举 USB 设备、申请权限、打开设备、建立串口连接、建立读写线程。C# 层通过 AndroidJavaClass 和 AndroidJavaObject 拿到 Java 层的实例把「打开」「读取」「发送」这些调用翻译过去再把字节流回传给 C# 侧的 MonoBehaviour。为什么不用纯 C# 方案市面上不是没有 Managed Usb 库但在国产安卓机上兼容性太不稳定尤其是 CH340、FT232、CP210x 这类常见 USB 转串口芯片驱动识别逻辑各家 ROM 处理不一样。而且这套资源默认走的是「Java 层写死、C# 层按接口调用」的结构一旦真机跑起来出问题你只需要改 Java 工具类不用动 Unity 工程排查效率会高很多。2.2 Java 层的核心工具类识别设备、弹权限、建连接拿到资源后先看 Plugins/Android 目录下的 .aar 或 .jar 包里的 Java 源码。重点是一个叫 SerialHelper 的工具类名字可能略有差异但职责是一样的它做了三件事设备枚举与匹配通过usbManager.deviceList遍历所有连接的 USB 设备再对比 vendorId 和 productId。比如你手上的 USB 转串口芯片是 CH340vendorId 是 0x1A86productId 是 0x7523这两个数字要跟你的硬件一致不然设备根本识别不到。权限申请安卓对 USB 设备的访问需要动态权限。调用usbManager.requestPermission(device, pendingIntent)弹系统对话框这一步必须在 Activity 上下文中执行不能在 Application context 下用它。串口连接与配置拿到UsbDeviceConnection后用UsbSerialProber或者直接按 CDC 协议去 claimInterface然后设置波特率、数据位、停止位、校验位。资源里默认配的是 115200、8N1这个参数你基本都要改后面会细说。private UsbDevice findDevice(UsbManager manager) { HashMapString, UsbDevice deviceList manager.getDeviceList(); for (UsbDevice device : deviceList.values()) { if (device.getVendorId() 0x1A86 device.getProductId() 0x7523) { return device; // 匹配到 CH340 芯片的设备 } } return null; }这段代码的逻辑很直白拿到系统所有 USB 设备的 Map逐个比对芯片的 VID 和 PID。注意getDeviceList()返回的是HashMap键是设备名迭代时要取.values()。如果你手上的设备是 FT232就要把 VID/PID 换成 0x0403/0x6001CP210x 是 0x10C4/0xEA60。我一般会建议把 VID/PID 写成可配置字段别写死在类里不然换一个硬件就要重新编译打包。2.3 读写线程与数据回调让数据自己送到门口串口的特点是持续不断地有数据进来但 C# 侧不可能每帧去轮询 Java 侧拿数据那样性能太差。所以 Java 工具类里通常会起一个HandlerThread或者直接在子线程里循环bulkTransfer读数据读到字节数组后通过一个接口回调抛出来。private void startReadLoop() { readThread new Thread(() - { byte[] buffer new byte[64]; while (isRunning) { int len connection.bulkTransfer(endpointIn, buffer, buffer.length, 100); if (len 0) { byte[] data new byte[len]; System.arraycopy(buffer, 0, data, 0, len); callback.onDataReceived(data); } } }); readThread.start(); }这个bulkTransfer的第四个参数是 timeout单位毫秒设为 100 表示最多阻塞 100ms 就会返回一次即使没有数据也不会把线程彻底卡死。如果你把 timeout 设成 0会一直等到有数据才返回一旦设备拔掉线程就永远挂在那里退不出来。这是新手最容易踩的坑之一后面避坑章会单独说。回调接口在 C# 侧是一个关键点Java 层的方法参数、返回值都必须是 Java 基本类型或 String不能直接把 C# 的委托传进去。常见做法是 C# 侧用一个轮询方法每隔几帧去 Java 层取一次readBuffer里缓存的数据或者用 UnityPlayer 的 UnitySendMessage 把数据转发到 Unity 主线程。这套资源用的是后者因为 UnitySendMessage 可以直接跨 JNI 回调到 C# 的 MonoBehaviour 方法延迟更低。3. C# 桥接层实战把 Java 工具类拆成 Unity 可调用的接口3.1 AndroidJavaObject 的正确调用姿势与常见写法误区C# 侧的第一步是通过AndroidJavaObject拿到 Java 层 SerialHelper 的实例。这一步看着简单实际有不少细节决定了你后面能不能顺利调通。private AndroidJavaObject serialHelper; private AndroidJavaObject activityContext; void InitSerialPort() { using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { activityContext unityPlayer.GetStaticAndroidJavaObject(currentActivity); } serialHelper new AndroidJavaObject(com.yourpackage.serial.SerialHelper, activityContext); }这段代码里有一个信息很关键currentActivity取的是 Unity 当前的 Activity它是UnityPlayerActivity的实例。传入 SerialHelper 构造函数的这个 context 必须是 Activity 类型因为后面requestPermission依赖 Activity 上下文来弹窗。有些人图省事用ApplicationContext结果权限弹窗永远弹不出来原因就在这里。using语句不是必须的但在初始化阶段用一下有助于及时释放局部 AndroidJavaObject 的引用。不过要注意serialHelper这个字段本身不能放进using里释放因为它是你后续所有调用的入口释放了再调就是 NullReferenceException。另外还有个典型错误每次发数据都重新new AndroidJavaObject这是在 C# 侧又重新把整个 Java 类加载了一遍等于把之前的串口连接状态全丢了。正确做法是 Init 一次后面所有方法都复用一个实例。3.2 Open、Read、Send 三个接口的封装与数据格式约定Java 的工具类把串口打开后Unity 侧要做的事情其实就三件打开串口、周期性地把读到的数据处理掉、把要下发的指令发出去。bool OpenSerial(int baudRate) { bool success serialHelper.Callbool(open, baudRate); if (!success) { Debug.LogError(串口打开失败检查权限是否正确授予); } return success; } void SendBytes(byte[] data) { serialHelper.Call(send, data); } void OnDestroy() { serialHelper.Call(close); }Callbool和Call的差别在于是否有返回值。Java 层的 open 方法建议写成返回 boolean这样 C# 侧可以第一时间感知到底层是否连接成功而不是靠猜。send方法接收 byte[] 时JNI 会自动把 C# 的 byte[] 转成 Java 的 byte[]这个没问题但反过来 Java 往 C# 回传数据时会以sbyte[]的形式出现这个坑在 3.4 节单独讲。在读写约定上我一般会建议 Unity 侧统一定一个帧格式帧头0xAA 数据长度1~2 字节 数据体 校验和。Java 层只负责透传原始字节不做任何格式解析拆包和黏包的问题全部放到 C# 侧处理。这样 Java 层保持简单稳定C# 侧改动迭代速度快不用每次调格式都重新打包 AAR。3.3 线程安全不要让 UnitySendMessage 跨线程乱飞一个很容易被忽视的问题Java 层的读线程是子线程通过UnitySendMessage回调到 Unity 时Unity 要求这个调用必须在主线程执行。早期 Unity 版本对这个限制不严格但Unity 2020 之后子线程直接调 Unity API 会导致随机崩溃或者 UI 卡死。常见做法是 Java 层把读到的数据先放进一个线程安全的队列C# 侧在Update()里主动拉取void Update() { if (serialHelper null) return; string hexData serialHelper.Callstring(pollReadData); if (!string.IsNullOrEmpty(hexData)) { byte[] bytes HexStringToBytes(hexData); HandleReceivedBytes(bytes); } }Java 侧的pollReadData方法本质上就是把队列头部的数据弹出来没有数据就返回空字符串。这样把跨线程的同步问题简化成了 C# 侧固定的主线程轮询稳定性大大提升。这套资源里用的就是这个模式我强烈建议你不要改成在 Java 回调里直接调 UnitySendMessage省那几帧的延迟换来的是不可预期的崩溃。3.4 数据从 Java 到 C# 的类型转换陷阱Java 的 byte 是有符号的范围是 -128 到 127C# 的 byte 是无符号的范围是 0 到 255。JNI 在把 Java 的byte[]转成 C# 对象时通常会映射成sbyte[]直接强转成 byte[] 会导致大于 127 的数值变成负数。private byte[] ConvertSByteToByte(sbyte[] sbytes) { byte[] bytes new byte[sbytes.Length]; for (int i 0; i sbytes.Length; i) { bytes[i] (byte)sbytes[i]; } return bytes; }这个转换看着简单但漏掉的人真不少特别是从设备读上来的数据一超过 0x7F 就全乱了。建议在拿到数据的入口统一调一次这个转换不要在每个业务处理的地方分别转不然容易漏掉某一处。4. 权限、打包与兼容性这套代码能不能落地全看这一步4.1 AndroidManifest 配置清单与 USB 权限声明权限这块是 Unity 安卓串口最容易「看着没问题一跑就废」的部分。串口访问不需要INTERNET权限但需要两个跟 USB 相关的声明uses-feature android:nameandroid.hardware.usb.host android:requiredtrue / uses-permission android:nameandroid.permission.USB_PERMISSION /uses-feature里的required写成 true意味着应用只能在支持 USB Host 模式的设备上安装。大多数手机和平板都没问题但有些老设备的 ROM 对 USB Host 支持不完善装上去之后getDeviceList()始终返回空列表。如果只是开发调试阶段我建议把它设成 false保证应用能在更多设备上装进去然后在代码里做一次设备类型判断给用户一个友好提示就行。USB 权限弹窗用的是动态注册的 BroadCastReceiver不要把它写死在 Manifest 里静态注册因为不同安卓版本的广播限制策略差异很大静态注册在 Android 8.0 之后经常收不到回调。资源里的做法是在 Java 工具类里用registerReceiver动态注册这个方向是对的。4.2 自定义 Android 工程的 Gradle 配置与 AAR 打包流程这套资源里 Plugins/Android 目录下的 .aar 可以直接放进 Unity 工程使用但如果你想把 Java 源码里面的通信协议改一改比如改一下读线程的缓冲区大小、加一个超时重连逻辑就需要自己重新打包。常见流程是这样的// build.gradle 关键配置 android { compileSdkVersion 33 defaultConfig { minSdkVersion 21 targetSdkVersion 33 } } dependencies { compileOnly fileTree(dir: libs, include: [*.jar]) // Unity 提供的 classes.jar 在这里 }compileOnly是关键Unity 的classes.jar只在编译时用来解析UnityPlayer类的符号打包进 AAR 的时候要去掉否则 Unity 侧加载时会因为类重复定义直接崩掉。输出 AAR 的包名建议反域名规则比如com.example.serial这样跟 Unity 默认的com.unity3d.player不冲突。打包完成后AAR 放进Assets/Plugins/Android/目录Unity 会把它跟 Unity 工程一起构建。构建完成后检查一下输出 APK 的libs目录确认 .so 文件没有缺失特别是libil2cpp.so——IL2CPP 构建模式下必须存在否则启动即崩。4.3 常见芯片的兼容性对比表与串口调试辅助手段不换硬件、不做底层源码修改直接拿这套代码能识别哪些芯片结合我拆过的几个模拟项目按兼容梯队列了一张表芯片型号常见 VID/PID这套方案的兼容性备注CH3401A86/7523高最常见识别率稳定FT2320403/6001高驱动内置即插即用CP210x10C4/EA60中部分 ROM 需要手动授权PL2303067B/2303低新驱动冲突多不建议用调试辅助手段我一般会建议用 ReadData 的 hex 输出功能先自测也就是在 Java 层把读到的数据打成 hex 字符串C# 侧直接用 Debug.Log 打出来。把这个功能保留在工具类里不要删掉后续排查乱码和黏包问题你一定会回来找它的。5. 避坑指南USB 权限、类型越界、线程卡死5 个我踩过的坑5.1 授权弹窗不出来或点完授权立刻崩溃现象requestPermission被调用后系统一直没有弹出授权框或者点了允许后 APP 直接闪退。原因第一种情况基本是用了Application Context而不是Activity上下文来发起权限请求系统要求弹窗必须挂在 Activity 栈上。第二种情况通常是requestPermission的PendingIntent里的Intent没有指定BroadcastReceiver的包名导致回调收不到或者动态注册的 Receiver 和静态注册重名冲突系统回调进了错误的入口。解决确认传给 Java 工具类构造函数的是UnityPlayer.currentActivity不要自己在 C# 侧传来传去requestPermission的 Intent 用getBaseContext().getPackageName()显式设置包名避免隐式 Intent 被多个 Receiver 接收。5.2 串口能打开但读出来的数据全是乱码现象能识别设备、能建立连接但收到的数据跟发的不一致或者全是 0xFF、0x00 之类的固定值。原因多半是波特率配置错了。设备端程序用的是 9600Unity 侧配置的是 115200两边不一致时收到的就是乱码。另一个常见原因是数据位/停止位设错了比如设备是 8N1你这边配成了 7E1。还有一种情况是bulkTransfer的端点选错了In 端点和 Out 端点搞反。解决优先核对波特率这是最基础也是最容易翻车的一个参数再用getEndpoint(UsbConstants.USB_DIR_IN)和getEndpoint(UsbConstants.USB_DIR_OUT)显式获取端点别用getEndpoint(0)这种碰运气的写法。5.3 数据断断续续像是一秒掉一半现象日志面板里数据时有时无或者隔一段时间就断一下过几秒又自己恢复。原因读线程的bulkTransfertimeout 设太短比如小于 50ms会导致线程大部分时间都在空转没有持续等待数据到达。还有一种可能是 Java 层的读缓冲数组太小设备一次发过来的数据超过缓冲长度多余的数据直接丢了。解决timeout 设在 100~200ms 这个区间比较均衡缓冲数组大小根据你的实际报文长度来定我一般设 256 或 512别用 64——很多下位机一次上报的数据就超过 64 字节了。改完 Java 层记得在 Unity 里重新打包 AAR这个改动不会通过 C# 侧生效。5.4 Unity 打包后提示类找不到或 NoClassDefFoundError现象编辑器模式下调试正常打包成 APK 之后一运行就崩日志里能看到NoClassDefFoundError指向你的 SerialHelper 类。原因最常见的原因是 Java 工具类没有正确被打进 APK。Unity 在导出工程时如果 Java 源文件放在Assets/Plugins/Android/src目录下构建系统会把它们编译进最终 APK但如果 AAR 放置位置不对或者 AAR 内部proguard规则把类名混淆了运行时就找不到。解决先检查Assets/Plugins/Android/下是否只有 AAR 而没有源文件目录避免出现「既打包了源码又打了 AAR」的重复构建冲突再在 Unity 构建设置里开启Release模式检查输出的 APK 用apkanalyzer搜索SerialHelper.class是否存在确定类被打进去了。5.5 设备拔出再插回后串口无法重新打开现象运行中断开 USB 线再重新插回去调用 open 总是失败重启 APP 才能恢复。原因UsbDeviceConnection在设备拔出后处于失效状态之前创建的UsbDevice对象也变成了不可用的引用状态。再次枚举getDeviceList()时系统和上一次枚举的UsbDevice不是同一个对象必须重新获取设备列表并重新打开。解决增加一个设备热插拔监听在收到ACTION_USB_DEVICE_DETACHED广播后立即把本地持有的UsbDeviceConnection置空并关闭相关线程重新插上后统一走完整的「枚举设备 → 请求权限 → 打开连接」流程不要复用之前缓存的设备引用。6. 进阶技巧把串口字节流自动拼帧收数据不再看天吃饭串口数据是流式的下位机发来一条完整报文时Unity 侧可能把它切成两三次bulkTransfer才读完整也可能一次读进来两三条拼接在一起。如果直接对每次收到的字节做解析你会得到大量解析失败或跳帧的结果。我在这套代码的基础上加了一个拆包成帧的缓冲层效果很直接。private Listbyte receiveBuffer new Listbyte(); private readonly byte FRAME_HEADER 0xAA; private readonly byte FRAME_TAIL 0x55; private Listbyte[] TrySplitFrames(byte[] newData) { receiveBuffer.AddRange(newData); Listbyte[] frames new Listbyte[](); while (true) { int headerIndex receiveBuffer.IndexOf(FRAME_HEADER); if (headerIndex 0) { receiveBuffer.Clear(); break; } if (headerIndex 0) { receiveBuffer.RemoveRange(0, headerIndex); } if (receiveBuffer.Count 3) break; int len receiveBuffer[1]; if (receiveBuffer.Count 2 len 1) break; if (receiveBuffer[2 len] ! FRAME_TAIL) { receiveBuffer.RemoveAt(0); continue; } byte[] frame receiveBuffer.GetRange(0, 2 len 1).ToArray(); receiveBuffer.RemoveRange(0, frame.Length); frames.Add(frame); } return frames; }逻辑拆开看每一条都对应一个实际的现场问题。IndexOf(FRAME_HEADER)处理的是半包如果设备分两次才把一条报文发完缓冲里先留着一部分等下一次新数据来了再拼上。RemoveRange(0, headerIndex)处理的是脏前缀万一收到不完整的帧头丢掉的只是和帧头之间的残留字节不会把整帧拉垮。校验帧尾失败后只移除第一个字节这个设计避免了因为一个坏字节就把整个缓冲区清空导致后面的好帧也一起被丢掉的情况。这个方案的适用前提是你和硬件商约定了一个明确的帧格式比如帧头 0xAA、长度、数据、帧尾 0x55。如果数据里可能包含 0x55就要把长度校验算进帧头里或者再加上一个简单的校验和字段。从那以后我每写一个 Unity 串口项目都会强制自己在第一版就把帧协议定好动手写任何业务逻辑之前先把这个 TrySplitFrames 跑通收数不乱、解析不崩后面的功能才不会在调试时四处漏风。这套资源的核心价值也在这里它把 Java 层和 C# 层的架子都搭好了你拿来改一改帧格式和报文解析就能直接用在自己的硬件项目上希望帮到你。本文还有配套的精品资源点击获取