ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

uniApp安卓串口通信实战:Fvv-UniSerialPort插件接入与数据解析

uniApp安卓串口通信实战:Fvv-UniSerialPort插件接入与数据解析 做安卓串口通信的uniApp项目最难受的不是写业务而是官方一没给串口API二没给现成教程。我在一个物联网项目里要直接读取RS485温湿度传感器的数据设备端用的是STM32下位机手头又已经定了uniApp做跨端App当时第一反应是完了这怕是要写原生安卓插件了。后来在插件市场翻到Fvv-UniSerialPort这个串口通信插件试了几天总算把整条链路跑通从OTG接USB转串口模块到打开设备、发送Modbus指令、接收字节流、解析出温度和湿度再到页面实时刷新完整走了一遍。这篇文章就是把这段实战过程重新梳理一遍重点放在串口通信的参数配置、数据收发、字节解析以及真机联调时的各种坑。适合正在做uniApp 安卓设备串口接入的开发者也适合第一次碰串口、对RS485和十六进制协议不太熟的同学参考。1. 为什么在uniApp里做串口通信比想象中麻烦1.1 真实的业务场景哪些硬件会用到串口很多人一提硬件通信先想到蓝牙和WiFi但工业、农业、嵌入式调试场景里串口依然是性价比最高、最稳定的通信方式。比如我用过的温湿度传感器输出走的RS485差分信号接一个USB转485模块插到安卓设备上就是一个串口设备又比如一些扫码枪、电子秤、PLC控制器、STM32开发板很多都保留着UART口。项目里如果要用安卓平板或工控机直接连这些设备靠App通过串口读写是绕不开的方案。这类设备通常通信数据量不大但要求实时、稳定、不能丢包。串口做到这一点很成熟而且协议透明出了问题上位机或下位机都能定位。1.2 uniApp官方能力覆盖不到串口我在uniApp官网上翻过内置API有蓝牙、NFC、WiFi等但没有串口相关能力。也就是说uniApp应用如果想直接读取/dev/ttyUSB0这类设备节点自己是做不到的必须借助原生扩展。这也是为什么一开始我想着自己写一个安卓原生插件再把数据桥接到uniApp层。不过自写原生插件有几个门槛要懂Android的FileDescriptor、SerialPort库、JNI或UTSBridge还要处理USB设备授权、线程管理、数据回调。对业务开发来说成本不低。所以当看到Fvv-UniSerialPort这个插件时我更愿意先评估它能不能直接覆盖需求。1.3 方案对比自写原生插件、Fvv-UniSerialPort、蓝牙透传模块我把自己考察过的三条路线整理成表格方便大家选型方案优势劣势适合场景自写Android原生串口插件完全可控不受插件作者维护影响开发周期长需要熟悉Android底层桥接代码量大串口需求复杂、长期维护的产品Fvv-UniSerialPort等插件市场现成插件上手快API封装好省去原生开发依赖插件更新遇到硬件兼容问题需要自己排查中小型项目、快速验证原型串口转蓝牙透传模块无线连接不需要OTG线增加硬件成本蓝牙连接存在不稳定因素延迟比有线高对实时性要求不高的场景我最终选了Fvv-UniSerialPort因为它已经把串口打开、读写、监听封装成了JS可调的API省了我大量原生开发时间。要注意的是插件市场里的插件版本、维护状态参差不齐选之前最好看下最近更新时间和评论区的问题反馈尤其是是否适配你当前的HBuilderX版本。2. 把Fvv-UniSerialPort安装进项目前置准备与权限细节2.1 从插件市场引入插件在HBuilderX里可以直接打开插件市场页面搜索Fvv-UniSerialPort点击使用插件选择当前项目。插件会自动以uni_modules或nativeplugins的形式出现在项目里。如果是nativeplugins方式在manifest.json的App原生插件配置里会看到对应插件。这个环节最容易踩的坑是版本不匹配。HBuilderX的版本和插件编译时依赖的版本如果差太多可能会出现没有找到插件或运行时方法不存在的问题。我建议引入插件后先看插件详情页要求的HBuilderX最低版本再对比自己的版本。2.2 manifest.json里必须配置的内容用uniApp开发安卓原生相关功能时manifest.json不只是用来填应用名称关键权限和插件声明都在这里。我的配置里做了这几件事在App模块配置里找到Native Plugins相关项确认Fvv-UniSerialPort已经勾选并且配置了插件变量如果插件要求的话。在App权限配置或Android权限列表里添加USB设备权限。串口设备本质是USB外设需要android.permission.USB_PERMISSION部分设备还需要android.permission.READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE主要看插件实现有没有读写文件或配置文件的需求。部分安卓设备访问串口节点还需要root权限或对/dev下的文件有读写权限。这个问题在真机上尤其明显如果打开串口时一直返回Permission denied就要检查设备节点权限。很多人在这一步会漏掉USB权限导致插上串口设备后App完全没有反应或者弹出系统授权框但点了同意没回调。所以配置完最好把App卸载重装重新触发授权避免权限缓存干扰。2.3 真机调试准备OTG线、USB转串口模块、设备节点真机调试时硬件链路是安卓设备通过OTG线连接USB转串口模块模块另一头连接你的设备RS485传感器或单片机开发板。我用的USB转串口芯片是CH340驱动比较通用安卓系统一般能直接识别成/dev/ttyUSB0。如果芯片是CP2102设备节点也可能是/dev/ttyUSB0具体可以通过串口调试工具或者插拔前后对比ls /dev/tty*来确认。连接好之后打开App调用插件扫描串口或直接尝试打开已知节点。Fvv-UniSerialPort如果支持获取串口列表会返回设备路径列表如果不支持就需要知道目标设备路径再从代码里写死。这里建议先安装一个安卓串口调试APP确认设备路径和收发正常再回到uniApp里调试能省很多时间。2.4 检查插件是否注入成功引入插件后先别急着写读写逻辑先在页面里执行一行代码确认插件已经注入const serial uni.requireNativePlugin(Fvv-UniSerialPort); console.log(serial plugin: , serial);如果打印出来是undefined说明插件没有正确进入打包流程。这种情况常见于项目没有关联插件、HBuilderX本地插件目录不对、或者使用了自定义基座但没有重新编译。真机调试时必须使用自定义基座因为标准基座不包含第三方原生插件这一点特别容易忽略。我当时第一次运行没打自定义基座插件方法全部报错后来重新制作基座才正常。3. 核心API实战打开串口、读写数据与释放资源3.1 插件接口的设计逻辑Fvv-UniSerialPort不同版本API命名可能有差异但功能逃不开几个核心方法打开串口、关闭串口、发送数据、接收数据。我用的版本暴露的方法大致是openSerialPort(options)closeSerialPort()writeData(data)onDataReceived(callback)onError(callback)这类插件的设计逻辑和Android原生串口库一致先打开串口拿到Socket/文件流然后监听系统的可读事件一旦有数据到达就回调到JS层。理解了这个逻辑写起来就很顺。3.2 打开串口参数详解打开串口时参数不能乱填必须和下位机约定一致。以下是我项目中用到的典型参数参数示例值说明path/dev/ttyUSB0串口设备节点baudRate9600波特率需与设备一致dataBits8数据位常用8stopBits1停止位常用1parity0校验位0无校验1奇校验2偶校验flowControl0流控0无代码示例const serial uni.requireNativePlugin(Fvv-UniSerialPort); serial.openSerialPort({ path: /dev/ttyUSB0, baudRate: 9600, dataBits: 8, stopBits: 1, parity: 0, flowControl: 0 }, (res) { if (res.code 0) { console.log(串口打开成功); } else { console.log(打开失败 res.message); } });关于波特率多说一句传感器或下位机的协议文档里明确写了9600就不要自己改成115200两边不一致时收到的全是乱码。RS485半双工设备通常波特率不高9600是很多工业设备默认值但也要按实际设备来。还有部分USB转串口模块初始状态下芯片上的跳线或驱动会影响波特率映射先在PC端串口调试助手验证一下再改到安卓端。3.3 写入数据字符串、十六进制、ArrayBuffer的转换串口通信最常用的是发送十六进制指令。比如我的Modbus RTU读取温湿度指令是01 03 00 00 00 02 C4 0B在JS里不能直接把这个带空格的字符串丢给插件。需要转成Uint8Array或ArrayBuffer。我在项目里封装了一个工具函数function hexStringToBuffer(hexString) { const clean hexString.replace(/\s/g, ); const length clean.length / 2; const buffer new ArrayBuffer(length); const dataView new DataView(buffer); for (let i 0; i length; i) { dataView.setUint8(i, parseInt(clean.substr(i * 2, 2), 16)); } return buffer; }调用时const command hexStringToBuffer(01 03 00 00 00 02 C4 0B); serial.writeData(command);如果插件要求参数是字符串或字节数组也按对应格式传。我建议统一走十六进制因为串口协议基本都是十六进制定义用字符串反而容易搞混。3.4 读取数据回调模式与缓存策略Fvv-UniSerialPort接收数据通常是通过事件回调每次来一段字节流。数据在JS层是以ArrayBuffer、Uint8Array或十六进制字符串形式返回的具体看插件封装的格式。我用的是直接返回ArrayBufferserial.onDataReceived((res) { const bytes new Uint8Array(res.data); console.log(收到字节, Array.from(bytes).map(b b.toString(16).padStart(2, 0)).join( )); });但是这里有个非常关键的问题串口数据不是每次回调都是完整的一帧。下位机可能分几次把数据发过来也可能一次发好几帧这就是常说的粘包和半包。所以不能拿到回调就直接解析必须先把收到的字节追加到一个缓存数组里再尝试按照协议格式切帧。这个缓存逻辑我在第4章详细展开。3.5 页面生命周期与串口释放很多人在页面销毁后忘了关闭串口结果下一次打开时提示端口被占用或者资源泄漏。我在onUnload里做了关闭操作onUnload() { if (this.serial) { this.serial.closeSerialPort(); } }另外还要考虑onHide当App切到后台时串口设备可能被系统回收回到前台后应重新打开并重新初始化回调。我遇到过一次App息屏再亮屏后设备断连重新打开串口就好了。处理思路是在onShow里判断一个状态标记如果串口之前打开过但现在不可用就重新执行打开逻辑。4. 数据解析全流程从串口字节流到业务数据4.1 先搞清楚下位机的帧协议串口通信的难点不是读写而是解析。你面对的不是JSON不是字符串而是一串十六进制字节。如果不了解帧格式解析无从谈起。以Modbus RTU为例一帧数据通常包含地址码、功能码、起始地址、寄存器数量、CRC校验码。比如读取设备温湿度的请求是01 03 00 00 00 02 C4 0B其中01是设备地址03是读保持寄存器功能码00 00是起始寄存器地址00 02是寄存器个数最后两个字节C4 0B是CRC16校验。设备返回的帧就是01 03 04 [数据4字节] [CRC低] [CRC高]。不同设备协议差异很大所以第一步是拿到设备协议文档搞清楚帧头是什么一帧多长数据域长度是固定还是可变校验算法是CRC16、和校验还是异或校验这些信息决定了解析代码怎么写。4.2 字节缓冲与切帧思路有了协议就能设计缓存解析类。我的做法是维护一个数组作为缓冲区每次收到数据就push进去然后尝试循环切帧class SerialFrameParser { constructor() { this.buffer []; } pushChunk(bytes) { this.buffer.push(...bytes); return this.tryExtractFrames(); } tryExtractFrames() { const frames []; while (true) { // 找帧头我这里以 0x01 0x03 为例 const startIndex this.buffer.findIndex((byte, idx) { return this.buffer[idx] 0x01 this.buffer[idx 1] 0x03; }); if (startIndex -1) { this.buffer []; break; } if (startIndex 0) { // 帧头前面的脏数据直接丢弃 this.buffer.splice(0, startIndex); } // 判断长度是否够一帧地址(1)功能码(1)长度(1)数据NCRC(2) if (this.buffer.length 5) break; const dataLength this.buffer[2]; const frameLength 3 dataLength 2; if (this.buffer.length frameLength) break; // 半包继续等待 const frame this.buffer.slice(0, frameLength); frames.push(frame); this.buffer.splice(0, frameLength); } return frames; } }注意这里的帧头识别逻辑完全依赖协议。如果协议里帧头不是01 03而是固定的AA 55之类就替换掉。还有的协议一帧长度固定那更简单每次缓存够长度就切一帧。4.3 CRC校验计算切出帧之后一定要校验CRC否则可能把脏数据当正经数据用。Modbus RTU的CRC16算法是标准的JS实现如下function calculateCRC16(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) { crc (crc 1) ^ 0xA001; } else { crc crc 1; } } } return crc 0xFFFF; }校验时把帧里除CRC外的所有字节算一遍CRC和帧尾的CRC比较。Modbus协议里CRC低字节在前高字节在后注意判断顺序。如果校验不通过我一般会打印一帧完整hex对比设备返回和协议文档先确认是不是协议理解错了。4.4 数据域解析有符号、无符号、高字节在前切帧、校验都过了最后一步是从数据域里把业务值算出来。比如温湿度传感器的返回帧是01 03 04 02 3C 01 2A B4 F0数据域4个字节02 3C 01 2A可能是温度占前两个字节湿度占后两个字节。但这两个字节是大端模式还是小端模式有没有符号要不要除以10我在项目里见过几种常见解析方式无符号16位value byte0 8 | byte1再乘以系数比如0.1单位就是摄氏度。有符号16位如果最高位是符号位需要用DataView.getInt16方法。32位浮点数比如IEEE754标准的float需要拼成4字节再用DataView.getFloat32。所以在解析前必须确认协议文档。如果文档没说可以先发指令读一次通过已知物理值反推。比如温度实际是25.6度返回字节是0x01 0x00那么256代表25.6度缩放系数就是10。4.5 解析结果上抛给页面数据解析出来后要通过Vue的数据绑定更新界面。我通常是解析完成后emit一个自定义事件或者直接把结果this.currentTemp temp。注意串口回调不是运行在UI线程的话uniApp很多版本会自动处理线程切换但保险起见可以用uni.$emit或nextTick确保数据渲染正常。最终页面上实时显示温度、湿度再配合一个连接状态指示器整个串口通信的闭环就完成了。5. 和硬件对接的工程化经验RS485、STM32与真机联调5.1 安卓设备看到的是虚拟串口安卓设备本身没有串口物理层我们用的USB转串口模块其实是把UART信号转成USB差分信号。安卓系统通过USB驱动识别到设备后会暴露出一个虚拟串口节点比如/dev/ttyUSB0。所以你在代码里操作的是文件节点但物理链路是USB转UART芯片在起作用。常见的芯片有CH340、CP2102、FT232等不同芯片驱动支持度不一样建议选CH340或FT232安卓支持比较友好。如果你接的是RS485设备注意RS485是差分信号不能直接连UART的TX/RX必须经过一个RS485转TTL模块。模块上通常有A/B两线分别接设备A/B。RS485是半双工同一时刻只能收或发所以发完一条指令要稍微等一下再读数据否则可能把还没稳定下来的回包读丢了。5.2 波特率、收发间隔和指令时序串口通信的时序问题在RS485上特别明显。半双工意味着下位机收到完整指令后需要切换线路状态再回复这个切换时间可能几毫秒到几十毫秒。我在代码里发送指令后不会立刻等待回调而是用一个延时再开启读取监听或者通过帧解析器自动处理乱序两种方式结合更好。一个简单的处理逻辑是发送指令后记录时间戳收到数据后校验帧地址和功能码如果对不上就丢弃并重新监听。不要每发一条指令就sleep那样效率太低也不符合异步回调的模型。5.3 STM32等MCU联调时的常见坑经常有同学把PC上串口调试助手能收到的数据放到App里就收不到或者乱码问题多半出在以下几方面串口路径不对。有些设备不是ttyUSB0而是ttyS0或ttyACM0可以通过设备节点列表或插拔前后对比确认。数据位/停止位/校验位不一致。这一项最容易忽略PC端调试工具默认可能自动容错但App这边必须严格一致。下位机返回的是字符串而不是十六进制。比如STM32直接printf(temp25\n)那么App收到的就是ASCII字节需要按ASCII转字符串解析而不是当十六进制协议处理。设备供电不足。有些USB转串口模块和外部设备共用供电安卓OTG口供电电流不够导致设备工作异常表现为偶发性超时或乱码。建议外接一个带隔离供电的USB HUB。5.4 真机联调三板斧遇到问题不要直接改代码按这个顺序排查先用PC串口调试助手验证物理链路。把USB转串口模块插到电脑打开串口调试助手设置和项目相同的参数发送同样的指令看能不能收到预期数据。如果PC端都收不到先查接线、波特率、设备地址。再用安卓串口调试APP验证安卓设备识别情况。很多串口类APP能列出设备节点直接手动打开测试收发确认硬件链路在安卓设备上可用。最后才到uniApp代码里排查。给每个关键环节打日志打开串口成功了吗发送数据成功了吗收到回调了吗帧解析到哪一步断了日志要带时间戳和hex内容方便对照协议文档。我在项目里就是靠这三板斧定位到的问题很多代码Bug其实是硬件接触不良或协议理解错误。6. 打包发布阶段最容易被卡住的点6.1 云打包与离线打包的差别如果你用的是HBuilderX云打包插件引入通常比较顺利打包服务器已经预置了大部分插件环境。但离线打包是另一回事你要在本地Android工程里手动添加插件依赖配置gradle、AndroidManifest.xml和插件包。Fvv-UniSerialPort如果依赖原生so库还需要确认so库文件有没有被正确打进APK。离线打包时我最开始没注意插件文档里的要求漏了packages配置导致App启动直接报找不到插件或Caused by: java.lang.ClassNotFoundException。解决方法是按插件文档里的离线打包说明把原生插件aar或module添加进工程并在dcloud_uniplugins.json里声明插件。6.2 真机调试正常打包后无读数的原因排查遇到过一种情况自定义基座跑得好好的打正式包后串口打开成功但读取数据一直为空。排查下来发现是APK的权限配置和基座不同。因为自定义基座本身带了一套权限和插件配置而正式打包如果没有在manifest里勾选对应权限USB设备授权就不会触发。另外要注意混淆问题。离线打包如果开启代码混淆可能把插件里的类给混淆掉了。解决方案是在proguard-rules.pro里加keep规则保留插件的包名和类名。6.3 上架应用市场前先自查权限串口通信用到USB权限在部分应用市场审核时会被质疑用途。如果你的App目标用户就是内部设备管理或工业场景尽量在应用说明里写清楚硬件外设通信用途并保证没有越界获取隐私权限。USB权限本身不算高风险但如果你还申请了定位、存储等权限要记得在代码里做动态申请并在应用描述中说明权限用途否则容易被驳回。另外设备节点读取通常涉及Linux底层文件读写某些环境下可能需要root权限。如果插件需要root上架风险会增大建议优先选那些通过USB授权即可操作的设备方案。7. 我的工程实践总结与一个可复用的小工具整个项目做完我对Fvv-UniSerialPort在uniApp中的串口通信流程形成了几个固定套路。写代码之前先确认硬件链路和协议文档写代码时把串口管理封装成一个单例或独立工具类调试时所有收发数据一律以hex格式输出日志遇到问题按PC调试助手 - 安卓串口助手 - uniApp日志的顺序排查。这里分享一个我封装过的串口工具类核心结构包含打开、发送、接收回调注册、关闭四件事class SerialPortManager { constructor() { this.plugin uni.requireNativePlugin(Fvv-UniSerialPort); this.isOpened false; this.parser new SerialFrameParser(); } open(path /dev/ttyUSB0, baudRate 9600) { return new Promise((resolve, reject) { this.plugin.openSerialPort({ path, baudRate, dataBits: 8, stopBits: 1, parity: 0, flowControl: 0 }, (res) { if (res.code 0) { this.isOpened true; this._startListen(); resolve(); } else { reject(res); } }); }); } write(hexString) { if (!this.isOpened) return; const buffer hexStringToBuffer(hexString); this.plugin.writeData(buffer); } _startListen() { this.plugin.onDataReceived((res) { const bytes new Uint8Array(res.data); const frames this.parser.pushChunk(Array.from(bytes)); frames.forEach((frame) { if (this.onFrame) this.onFrame(frame); }); }); } close() { if (this.isOpened) { this.plugin.closeSerialPort(); this.isOpened false; } } }这个工具类可以放到utils/serial.js页面里全局维护一个实例。用的时候import SerialPortManager from /utils/serial.js; const serial new SerialPortManager(); serial.open(/dev/ttyUSB0, 9600).then(() { serial.onFrame (frame) { // 这里做帧解析和页面数据更新 }; serial.write(01 03 00 00 00 02 C4 0B); });页面销毁时调用serial.close()避免资源泄漏。这套流程我已经在两个不同类型的设备上验证过一个是RS485温湿度传感器一个是STM32自研主板。只要协议文档清楚串口通信的核心逻辑基本不需要大改真正需要调试的时间大多花在硬件接线、设备地址、波特率这些看起来很小但影响巨大的参数上。最后再分享一个小技巧如果手头暂时没有真实传感器可以用一个STM32开发板烧一个简单的串口回环程序收到什么就回什么甚至主动定时发送模拟报文。这样你可以在不依赖外部设备的情况下开发好App的串口解析逻辑等真机设备到了再联调进度会快很多。串口通信本身不复杂复杂的是数据背后的协议把协议吃透整个项目就稳了大半。
返回列表