做安卓串口通信,在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:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-feature android:name="android.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:name="android.hardware.usb.host" android:required="true" /> <application> <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter" /> </application> </manifest>同时在res/xml/device_filter.xml中定义允许的vendorId和productId,这样才能在系统层面识别并授权你的APP访问该USB设备。不同USB转串口芯片的vendorId/productId不同,比如CH340是1A86:7523,FT232是0403:6001,CP2102是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库有安全修复,尽早同步。串口通信属于设备端和移动端的桥接层,一旦底层库挂了,整套采集系统直接瘫痪。
这个方案跑到现在已经有几个月了,我最大的感受是:串口通信的上层业务逻辑其实很简单,真正的复杂度全部藏在数据完整性、异常恢复、设备兼容这三座大山下面。把这三座大山翻过去,后面的路就顺了。希望这篇实战笔记能帮你少走几步弯路。