
做安卓串口通信在uniApp里绕不开一个现实问题官方没有现成的串口插件市场上能用的第三方模块又良莠不齐。半年前我接手一个农业环境监测项目需要在安卓平板上通过RS485总线读取温湿度、光照、土壤墒情等多路传感器数据最后选了Fvv-UniSerialPort这个插件从选型到上线前前后后踩了十几个坑。这篇就把整个流程拆开讲清楚包括插件引入、权限配置、串口参数设置、RS485差分信号的粘包解析以及那些文档里基本不写的隐藏问题。1. 项目整体设计思路与方案选型1.1 为什么选Fvv-UniSerialPort而不是其他方案先说结论在uniApp生态里做串口通信可选路径其实就三条。第一条是原生插件通过Android Studio封装AAR再在uniApp里云打包或离线打包引用比如Fvv-UniSerialPort就是这类第二条是使用HTML5的Native.js直接调用安卓底层API看似灵活但串口权限、文件描述符、异步回调处理起来极其痛苦第三条是走蓝牙转串口模块用蓝牙透传绕开物理串口但延迟和稳定性都打折扣。Fvv-UniSerialPort的好处在于它把JNI层的串口操作封装成了uniApp能直接调用的JS接口底层用的是经典的android-serialport-api方案将SerialPort类通过JNI映射到Linux的open()系统调用。这意味着你不需要自己写Java代码也不需要懂NDK编译只需要在页面的onLoad生命周期里引入模块并调用方法即可。对于大部分物联网展示类项目来说这个路径是投入产出比最高的。我对比过另一个同样热门的插件它在连接断开时偶尔会抛出未捕获的Java异常导致整个应用崩溃而Fvv-UniSerialPort在异常处理上明显更稳重失败回调都会以JSON格式返回到JS侧方便统一拦截。1.2 系统架构与数据流设计这个项目的硬件拓扑是这样的安卓工业平板通过USB转RS485模块连接总线总线上挂了五路MODBUS-RTU协议的传感器每路设备地址不同分别是01到05。平板端运行uniApp打包的APK通过串口轮询各传感器地址读取到的数据是十六进制字节流需要按照MODBUS协议解析成实际物理量。数据流分三层UI层负责展示实时数据和历史曲线逻辑层负责定时轮询、数据解析、异常重试驱动层Fvv-UniSerialPort插件负责字节流的收发这里要特别强调一个设计取舍轮询频率不宜过高。RS485是半双工通信同一时刻只能有一个设备占用总线主机发出查询帧后必须等待从机回应如果超时或冲突需要跳过当前地址继续下一个。我在代码里把轮询间隔控制在了600ms既不会让传感器觉得总线拥塞也能保证UI上数据刷新率足够流畅。1.3 核心业务场景与适用范围这个方案典型应用在以下场合农业大棚环境监测、水产养殖水质在线监测、工业设备状态采集、智能楼宇的灯光或空调控制等。凡是传感器走RS485总线、设备端只提供串口透传的都可以用Fvv-UniSerialPort来连接。需要注意区分的是如果设备本身支持以太网或Wi-Fi走TCP或HTTP明显更简单没必要硬上串口如果设备是蓝牙BLE协议那应该找蓝牙插件而不是串口插件。串口通信适合设备只有物理输出口DB9、端子排、USB转串口且没有网络模块的场景这个前提先在项目启动前确认清楚不然方向就歪了。2. 核心配置与插件接入细节2.1 引入插件前的manifest配置要点在uniApp项目中引入Fvv-UniSerialPort第一步不是在代码里写插件名而是去manifest.json中配置原生插件。打开manifest.json切到“App原生插件配置”页面点击“在线安装”按钮在弹出的插件市场中搜索Fvv-UniSerialPort选中后直接云打包即可自动集成。如果你使用的是本地打包或者离线打包需要把插件提供的android目录下的文件放到原生工程里并在dcloud_uniplugins.json中手动注册。这张表里的plugins数组需要增加一条记录{ id: Fvv-UniSerialPort, moduleName: Fvv-UniSerialPort, version: 1.0.0, android: { class: uni.dcloud.io.plugins.fvvserialport.FvvUniSerialPortModule, packageName: uni.dcloud.io.plugins.fvvserialport } }然后还需要在AndroidManifest.xml申请串口权限uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE / uses-feature android:nameandroid.hardware.usb.host /这里有个坑如果只申请存储权限安卓6.0以上设备在首次打开串口时会因为缺少动态权限申请而直接返回失败。Fvv-UniSerialPort插件内部确实做了权限检查但它只会返回一个错误码不会弹系统授权框所以你需要在项目里自行调起权限请求。我是在App.vue的onLaunch里先请求一遍所有危险权限虽然粗暴但省事。2.2 插件模块的加载与生命周期绑定插件加载方式比较特殊它并不是常规的import语法直接引用的而是通过uni.requireNativePlugin(Fvv-UniSerialPort)动态加载。这个调用在页面onLoad生命周期内执行最合适页面销毁时记得释放资源。加载模块的整体流程我贴一下核心代码const serialPort uni.requireNativePlugin(Fvv-UniSerialPort) export default { data() { return { // 存储插件实例 serial: null } }, onLoad() { this.initSerialPort() }, methods: { initSerialPort() { this.serial serialPort console.log(插件加载成功) } } }注意uni.requireNativePlugin在应用冷启动后第一次调用时耗时较高因为引擎需要加载动态库所以建议在启动页停留期间就完成加载不要等到用户点击“开始采集”才去加载。我在真机上测试过第一次调用大约耗时300~500ms后续就快很多了。2.3 权限申请与硬件检测的常见遗漏串口设备插入后系统会弹出USB授权对话框这个交互流程容易在用户无操作时超时。插件提供了checkPermission方法你可以在确定要打开串口前先检测一次const res this.serial.checkPermission() if (res.code ! 0) { uni.showModal({ title: 提示, content: 需要授予USB权限后才能使用串口功能, success: (r) { if (r.confirm) { this.serial.requestPermission({}, (granted) { console.log(权限回调, granted) }) } } }) }这个requestPermission调用不建议放在onLoad里直接触发因为此时用户还不知道为什么弹窗容易造成疑虑甚至误点拒绝。放在“开始采集”按钮的点击事件里配合提示文案体验会顺畅很多。3. 串口打开、读写与RS485数据解析实操3.1 串口设备枚举与查找目标串口这是让很多新手卡壳的地方设备明明插上了但不知道串口号是/dev/ttyS0还是/dev/ttyUSB0。Fvv-UniSerialPort提供了listDevices方法可以枚举当前系统所有可用的串口设备const devices this.serial.listDevices() console.log(可用的串口设备, devices) // 输出示例[{name: /dev/ttyS0}, {name: /dev/ttyUSB0}, {name: /dev/ttymxc2}]实测中USB转RS485模块在安卓设备上通常生成的是/dev/ttyUSB0或/dev/ttyACM0而设备自带的核心板串口一般是/dev/ttyS0或/dev/ttymxc2。工业平板上往往同时存在多个设备节点这时需要根据实际硬件连接情况来选定。这里有个判断小技巧如果设备是USB转出来的串口插拔后设备节点名会变化你可以分别执行一次listDevices对比两次结果多出来的那个就是你要用的。另外打开前最好用getState确认一下串口是否被其他进程占用避免打开失败const state this.serial.getState({ path: /dev/ttyUSB0 }) console.log(串口状态, JSON.stringify(state))3.2 打开串口与波特率、数据位参数选择选对串口后打开串口的参数就是决定通信能否成功的关键。Fvv-UniSerialPort的openSerialPort方法需要的参数如下this.serial.openSerialPort({ path: /dev/ttyUSB0, baudRate: 9600, dataBits: 8, parity: 0, stopBits: 1, flowCon: 0 }, (res) { if (res.code 0) { console.log(串口打开成功) } else { console.log(打开失败 res.message) } })参数含义分别是波特率9600、数据位8、校验位无、停止位1。这套参数是MODBUS-RTU协议最常用的默认值大部分工业传感器出厂默认就是9600/8-N-1。如果你的传感器是其他波特率比如4800或19200一定要在设备说明书里确认后再设置。校验位的取值有讲究0表示无校验1表示奇校验2表示偶校验。如果传感器配置了校验位但你在代码里设成无校验接收到的数据会乱解析也必然失败。还有个容易被忽略的参数是flowCon这是流控开关。RS485通信一般不需要流控设成0即可。3.3 数据读取与RS485差分信号字节流解析数据解析是最能体现功力的一步。RS485传输的是差分信号硬件层已经把电压差转为串口字节流到应用层我们看到的就是一组十六进制数据。关键是要能识别一帧完整的数据以及从帧里提取有效负载。以MODBUS-RTU协议为例读取保持寄存器的查询帧是8个字节01 03 00 00 00 05 85 C9其中01是设备地址03是功能码读保持寄存器0000是起始寄存器地址0005是读取数量读取5个寄存器85C9是CRC16校验码的低字节在前。对应的响应帧格式是01 03 0A 02 1B 00 15 00 00 00 00 00 00 01 02 校验其中0A表示后续数据字节数10个字节后续每2个字节是一个寄存器值。比如021B和0015组合后需要根据传感器量程换算成真实温度或湿度值。插件提供的读取回调是这样的this.serial.start({ timeout: 500 }, (data) { // data 是一个ArrayBuffer const bytes new Uint8Array(data) this.handleResponse(bytes) })解析前必须做两件事第一是判断帧是否完整第二是校验CRC。CRC16-MODBUS算法如下function crc16Modbus(buffer) { let crc 0xFFFF for (let i 0; i buffer.length; i) { crc ^ buffer[i] for (let j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ 0xA001 } else { crc 1 } } } return crc }判断帧结束不能只依赖返回字节数因为大部分USB转串口芯片的驱动是分块上报的一帧数据可能被切成两段甚至三段到达。我在项目中通过解析累计缓冲、按CRC16校验是否完整来拼帧。帧不完整就暂存直到拿到完整帧再触发解析逻辑。3.4 MODBUS轮询算法与数据超时重试轮询多个传感器地址时代码的结构需要认真设计。我采用了一个经典的顺序轮询状态机维护一个设备地址数组[0x01, 0x02, 0x03, 0x04, 0x05]用一个currentIndex指针记录当前查询到哪个设备每轮循环发送一个地址的查询帧等待响应收到响应则解析并存储指针向后移动超过超时时间未收到响应则记录该设备离线指针向后移动核心代码结构如下let timer null let currentIndex 0 const slaveAddrs [0x01, 0x02, 0x03, 0x04, 0x05] const REG_START 0x0000 const REG_NUM 0x0005 function readNextDevice() { const addr slaveAddrs[currentIndex] const queryFrame buildReadFrame(addr, REG_START, REG_NUM) this.serial.write({ array: queryFrame.buffer }, (wres) { if (wres.code ! 0) { console.log(写入失败, wres.message) } }) } function buildReadFrame(addr, regStart, regNum) { const buffer [addr, 0x03, (regStart 8) 0xFF, regStart 0xFF, (regNum 8) 0xFF, regNum 0xFF] const crc crc16Modbus(buffer) buffer.push(crc 0xFF, (crc 8) 0xFF) return Uint8Array.from(buffer) }轮询定时器建议用setInterval间隔设定为600ms。不要用setTimeout嵌套因为响应时间不稳定可能导致请求堆积。每次发送前先清空一下接收缓冲区避免上一次残留字节干扰本轮的解析。3.5 数据转换原始寄存器值到物理量拿到寄存器值后换算规则在传感器说明书里写得明明白白。比如环境温度传感器的精度是0.1℃寄存器值0x021B是十进制539那么实际温度就是53.9℃。光照强度可能是32位无符号整数需要把相邻两个寄存器值做拼接先读取高16位、再读取低16位function parseResponse(resp) { if (resp.length 5) return null const addr resp[0] const func resp[1] const byteCount resp[2] const payload resp.slice(3, 3 byteCount) if (func 0x03) { const values [] for (let i 0; i payload.length; i 2) { const high payload[i] const low payload[i 1] const value (high 8) | low values.push(value) } // 根据传感器类型解析不同地址的值 return { temperature: values[0] / 10, humidity: values[1] / 10, light: values[2], soilH: values[3], soilT: values[4] / 10 } } return null }如果是32位的数据需要把相邻的两组寄存器合并成一个32位整数const high16 values[0] const low16 values[1] const combined (high16 16) | low16注意JavaScript中位运算默认转成32位有符号整数超过0x7FFFFFFF的值会变成负数这里要使用 0转成无符号数再继续运算。4. 常见问题与排查技巧实录4.1 串口打开失败的原因排查这个错误出现频率极高一般有四个方向排查权限未授予检查刚才提到的checkPermission和系统USB授权弹窗是否允许设备节点错误确认listDevices返回的路径是否正确通过对比插拔前后节点变化来判断串口被占用可能是因为上一次没有正确关闭或者别的应用占用了该串口root权限问题部分设备上的/dev/ttyS*节点需要root权限才能打开Fvv-UniSerialPort本身没有做提权处理普通APP可能打不开如果说串口打开失败第一步不是去改代码而是先在adb shell下验证串口节点是否存在adb shell ls -l /dev/ttyUSB*如果ls显示节点不存在说明USB转串口模块没有被系统识别很大概率是硬件驱动没加载换个USB口或者说模块本身故障的可能性更大。4.2 串口能打开但读不到数据的排查流程这是第二个高频问题。串口打开成功但自己发送查询帧后读取回调始终不触发。我总结了一套排查流程先确认发送是否真的成功写入了字节。Fvv-UniSerialPort虽然返回写入成功但不能完全信任可以用支持日志抓取的串口调试工具做对比测试。用同一根USB线在电脑上用串口助手发送同样的帧看传感器是否响应。然后检查接线。RS485接线现场经常出A/B反接的问题如果A和B接反了发送方和接收方都无法通信。这在工业现场非常常见。如果接线没问题则检查是否半双工时序问题主机发送查询帧后在短时间内立即切换收发状态有些廉价的USB转485模块切换时间较慢需要在发送后加一个短延时再开启读取。Fvv-UniSerialPort的start方法可以设置timeout不要设成0建议设300~500ms给硬件留出切换时间。4.3 数据错乱或乱码的原因定位数据能读回来但解析出来完全是乱码。这个问题首先检查串口参数是否和传感器匹配尤其是波特率和校验位。9600和19200的字节形态完全是两回事如果设置不一致读回来的数据没有任何规律。其次检查字节序。MODBUS协议规定CRC的低字节在前、高字节在后寄存器值也是高字节在前。如果解析出来的数值和实际物理量差很多将寄存器值的高低位做一次交换试试。最后还有一种可能性总线上地址冲突多个设备设了同一个地址导致返回帧数据乱七八糟的。用串口调试助手单独连接每个传感器依次改地址确保每个地址唯一。4.4 打包后插件不生效的问题云打包时如果选择普通打包而没有勾选“使用原生插件”插件是不会被编进APK里的。这时调用uni.requireNativePlugin会返回undefined页面直接报错。确保在云打包配置中勾选了需要使用的原生插件或者使用自定义基座进行调试。离线打包时特别容易踩的坑是Gradle依赖冲突。Fvv-UniSerialPort内部依赖了com.github.licheedev:Android-SerialPort-API如果你的主工程也引用了这个库版本不一致会直接导致构建失败。建议统一使用插件内置的版本或者直接删除主工程里的重复依赖。4.5 安卓10及以上版本串口读权限的新问题安卓10以上对串口设备的访问有新的安全机制USB转串口设备可能不能直接被App读取。如果排查了所有逻辑都没问题尝试在AndroidManifest.xml中加入USB设备过滤配置manifest uses-feature android:nameandroid.hardware.usb.host android:requiredtrue / application meta-data android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED android:resourcexml/device_filter / /application /manifest同时在res/xml/device_filter.xml中定义允许的vendorId和productId这样才能在系统层面识别并授权你的APP访问该USB设备。不同USB转串口芯片的vendorId/productId不同比如CH340是1A86:7523FT232是0403:6001CP2102是10C4:EA60根据自己的实际硬件填写。4.6 内存泄漏与性能问题串口通信本身就涉及大量的缓冲区操作如果不注意回收很容易造成内存增长。Android平台上JS侧的内存回收并不及时我在采集页面退出时手动清掉定时器并关闭串口onUnload() { if (this.timer) { clearInterval(this.timer) this.timer null } if (this.serial) { this.serial.stop({}) this.serial.closeSerialPort({}, (res) { console.log(串口关闭, JSON.stringify(res)) }) } }如果页面需要频繁进入退出且每次打开串口的间隔很短建议在全局维护一个单例的串口管理模块不要每次进入页面都去openSerialPort退出时马上closeSerialPort。频繁开关串口容易触发系统底层的文件描述符泄漏时间长了会导致所有串口都无法打开。5. 数据处理进阶高性能解析与多设备扩展5.1 环形缓冲区与状态机解析当传感器数量增多或者单轮询周期要读取很多寄存器时单纯靠“每帧判断CRC”的方式偶尔会遇到半帧和跨帧粘包的问题。我后来重构了一遍解析模块引入了一个简单的环形缓冲区把不完整的帧数据暂存起来每次收到新数据就拼到缓冲区尾部再做完整帧提取。核心思路是这样的定义一个能容纳最大帧长的字节数组收到数据先塞进缓冲区并更新写指针然后连续尝试从缓冲区中提取完整帧如果检查到CRC正确就从缓冲区中移除该帧并交给业务解析如果数据不完整或者CRC错误就把后续字节继续累加等待下一轮这样能有效避免半包/粘包问题。状态机关注三种状态找帧头、收数据体、校验CRC。推荐新人们直接按这个结构来写解析器不要图省事使用一次性数据回调直接切片。5.2 多设备轮询的性能调优当总线上的设备比较多时轮询节奏就要精打细算。假设有10个传感器每个响应需要100ms轮询间隔设为600ms那么所有设备遍历一遍就需要接近7秒。对某些实时性要求高的场景这个延迟可能有点大。优化手段有几个方向缩短超时时间传感器响应时间一般在50~200ms超时设为200ms即可使用并发读取对于支持MODBUS广播地址0x00的传感器可以一次性读取多个寄存器减少轮询次数调整轮询策略将实时性要求高的设备放在前面的地址位低优先级的设备拉长轮询周期我最终的做法是优先级分级温度、湿度检测用快速轮询每500ms一次光照、土壤数据用慢速轮询每2秒一次其他状态类设备只在页面可见时才轮询。5.3 协议层容错与异常恢复真实工业场景里RS485总线的干扰是不可完全避免的。除了正常的CRC校验之外我还加了几个防御机制连续3次读取失败的设备标记为离线不再占用轮询时间在线状态机当设备恢复后自动重新参与轮询数据异常时例如物理量超过量程自动丢弃而不覆盖上一次正常值每次查询前先写一次清零指令或发送“同步帧”来重置总线上可能的残留数据这三个机制合在一起整套系统在连续运行7天测试中没有再出现过一次因总线干扰导致的数据卡死。之前不加容错时大概运行2~3天就会出现某个地址的设备“假死”实际上是总线状态异常让整个轮询卡住了。6. 打包发布与后续扩展建议6.1 离线打包时的关键配置如果你不是用云打包而是走离线打包记得要在工程的build.gradle里设置正确的packagingOptions。插件里的SO库文件路径必须跟打包工程保持一致否则运行时会报java.lang.UnsatisfiedLinkError。检查一下APK里的lib/arm64-v8a或lib/armeabi-v7a目录是否包含了插件的so文件。另外如果插件是UTS版本需要确保你的HBuilderX版本和插件要求的uts编译器版本是对应的。UTS插件在编译时需要生成对应的java类版本不匹配会导致“模块加载失败”之类的报错。我用的是HBuilderX 3.8版本配合Fvv-UniSerialPort当前最新版就没有问题。6.2 ABI兼容与包体积管理串口插件的SO库体积不大但如果你引用了多个原生插件APK的ABI目录会膨胀。建议在打包时只保留arm64-v8a和armeabi-v7a两个ABI放弃x86和x86_64能省大概30%的体积。真机调试时如果用到x86模拟器再临时加上即可。6.3 后续扩展方向日志回传与远程监控当前项目跑稳定后我加了两个扩展功能本地日志循环写入SD卡以及串口数据每隔5分钟通过MQTT上报到云端。这样现场设备出问题时可以远程拉日志定位不需要跑到现场接调试线。日志模块需要特别注意串口接收到的原始字节流你要同时保存原始hex和解析后的物理量两个都要写。只有解析后的数据一旦解析逻辑出bug后期很难复现只有原始hex又不利于业务排查。我当时在日志里加了时间戳、设备地址、功能码、原始hex、解析值、CRC校验结果排查效率提升明显。7. 几个容易踩坑的操作细节再强调一遍在收尾之前还有几个细节不吐不快。串口通信最关键的往往是“参数一刀切”的误区。不要想当然地用9600作为所有传感器的默认波特率有的传感器出厂是4800有的是115200一定要一个设备一个设备地去确认。我在现场碰到过最离谱的情况五个传感器来自三个厂商波特率竟然有三种最后统一改成9600之后才算正常。另外是RS485总线的终端电阻匹配。如果总线距离超过100米或者设备数量多A、B线两端必须并联120欧姆终端电阻否则反射信号会导致误码率急剧上升。这个属于硬件问题但直接影响软件解析在排查CRC错误前先确认硬件连接。最后一点关于Fvv-UniSerialPort插件的版本迭代。插件在安卓14权限收紧后有更新但是旧版本不会自动提示你升级。建议每隔一段时间去插件市场看看更新日志如果底层SO库有安全修复尽早同步。串口通信属于设备端和移动端的桥接层一旦底层库挂了整套采集系统直接瘫痪。这个方案跑到现在已经有几个月了我最大的感受是串口通信的上层业务逻辑其实很简单真正的复杂度全部藏在数据完整性、异常恢复、设备兼容这三座大山下面。把这三座大山翻过去后面的路就顺了。希望这篇实战笔记能帮你少走几步弯路。