uni-app跨端开发实战:蓝牙称重与股票行情双场景落地

uni-app跨端开发实战:蓝牙称重与股票行情双场景落地 简介面向金融移动应用开发的 uni-app 股票应用源码包适合中高级开发者系统学习跨平台股票交易类 App 的整体架构。项目以「金蝶称重」为业务场景完整覆盖实时行情展示、股票交易下单、资讯推送、自选股管理、用户中心等模块并针对行情刷新、安全授权和多端适配进行了专门设计关键技术涉及 uni-app 组件化结构、WebSocket 实时数据推送、OAuth2.0 安全授权以及 HTTPS 加密通信。压缩包共 41 个文件以 Vue 页面、JS 逻辑文件为主辅以 JSON 配置、CSS/SCSS 样式与 PNG 图标资源整体仅 161KB轻量易读非常适合快速拆解分析。源码目录按 pages、components、store、service 等模块划分结构清晰方便对照理解金融 App 的页面分层、状态管理和服务封装也能支撑后续二次开发。已有 472 人浏览学习对想从项目源码入手掌握 uni-app 金融开发实践、或基于此扩展同类应用的读者均具有较好的参考价值。1. 用 uni-app 同时扛住称重终端与股票行情工程上要解决什么一个项目标题里同时出现称重和股票平台听起来像是两条毫无交集的业务线但落到代码上它们都指向同一个技术决策用 uni-app 做跨端交付。称重场景要的是蓝牙秤连接、称重数据解析、ERP 对接和扫码联动股票场景要的是实时行情推送、K 线渲染、自选股与交易流程。前者重设备交互后者重高频数据刷新恰好是 uni-app 跨端能力的两类典型边界。如果你接到的需求也是既要跑 H5又要出 Android/iOS App还想后续兼容小程序那 uni-app 的价值不在语法多优雅而在于一套 Vue 代码同时覆盖四端把蓝牙、WebSocket、本地存储这些平台差异收敛到统一 API 上。这篇文章按我实际搭建这类项目的顺序来讲先把工程骨架立住再分别处理称重设备的硬件链路和股票行情的实时链路最后把多端排错和性能收尾。新手能照着把两个模块跑起来熟手可以重点看蓝牙分包、行情心跳和页面栈控制这几个容易翻车的参数。2. uni-app 工程搭建从初始化到跨端基建2.1 用 CLI 搭建还是用 HBuilderX 搭建接 uni-app 项目第一步先选工程创建方式。我的建议很简单团队协作选 CLI单兵作战选 HBuilderX。CLI 工程是基于 Vue 3 Vite 的标准 npm 项目代码评审、CI 构建、git 分支策略都和常规前端项目一致HBuilderX 创建的项目虽然自带云打包能力但脱离 IDE 后自动化集成比较别扭。CLI 方式下用官方 preset 初始化 Vue 3 版本工程npx degit dcloudio/uni-preset-vue#vite uni-app-stock cd uni-app-stock npm install npm run dev:mp-weixin # 微信小程序 npm run dev:h5 # H5 端 npm run build:app // App 端打包产物在 dist/build/appdev:mp-weixin和dev:h5是开发期最常用的两个命令前者把代码编译到微信开发者工具目录后者直接起一个浏览器可访问的 H5 服务。需要注意build:app生成的是离线打包资源最终仍需通过 Android Studio 或 Xcode 打包成原生应用云打包则要在 HBuilderX 里操作这一步在 CI 里需要额外配置。工程初始化后目录结构需要为双业务提前规划我一般这么分src/ ├── pages/ │ ├── scale/ # 称重业务称重主页、历史记录、ERP 同步 │ ├── stock/ # 股票业务行情列表、K线、自选、交易 │ └── common/ # 登录、设置、关于 ├── components/ # 全局组件 ├── composables/ # 组合式函数useWebSocket、useBluetooth ├── static/ # 静态资源 ├── uni_modules/ # uni-app 插件市场下载的模块 └── pages.json # 路由与 tabBar 配置pages.json是 uni-app 的路由中枢tabBar的 list 数组里要同时挂上称重和股票的首页。这里有一个容易踩的细节tabBar 页面不能使用uni.navigateTo跳转会直接报can not navigateTo a tabbar page必须用uni.switchTab。股票和称重两个主页面如果都放进 tabBar跨模块跳转时很容易在代码里混用这两个 API。2.2 代码规范ESLint Prettier 在 uni-app 里的取舍uni-app 工程引入代码规范时最常遇到的问题不是配不上而是配得太重。比如 Prettier 默认会给pages.json、manifest.json这类 JSON 文件按 2 空格重排改完以后 git diff 会变得非常大而且 HBuilderX 内置格式化器和 Prettier 对 Vue 模板属性的换行策略经常打架。我目前的方案是分段治理src下的.vue、.js、.ts文件走完整 ESLint Prettier 校验根目录的manifest.json、pages.json、uni.scss只做文件级.prettierignore排除不让格式化工具碰它们。核心配置片段如下{ scripts: { lint: eslint --ext .vue,.js,.ts src, format: prettier --write \src/**/*.{vue,js,ts}\ } }如果你使用 VSCode需要在设置里把.vue文件的默认格式化器指定为 Prettier同时把editor.formatOnSave打开。这里要提醒一下uni-app 的模板里v-if和v-for同时出现在同一个元素上时ESLint 的 vue 规则会直接报错。原因是 Vue 3 中v-if的优先级高于v-for而 uni-app 在小程序端和 H5 端的编译结果可能不一致。遇到这种场景正确做法是把列表先过滤再渲染或者用template标签包一层。lint-staged 和 husky 在 uni-app 里的作用和普通前端工程一致但要注意的是uni-app 项目通常有大量自动生成的unpackage或dist目录pre-commit 钩子里必须把这些目录加入忽略清单否则一次构建产生几千个文件变更lint-staged 会卡死。2.3 UI 组件与图标uni-icons、日期选择器与全局弹窗UI 层面uni-app 官方维护的uni-ui是默认首选但它最大的问题是对重度定制场景的支持有限。以股票行情页面的 tabBar 为例tabBar 的图标在pages.json里只能配置为静态图片路径而行情页的 tabBar 图标往往需要随主题切换。官方推荐的做法是使用uni-icons的自定义图标能力把字体文件放到static/fonts然后在pages.json的tabBar.iconPath里指向字体图标生成的 PNG 文件这样一套图标同时覆盖 iOS 和 Android不会出现分辨率模糊问题。日期选择器是另一个高频需求。uni-app 自带的picker modedate功能太简陋样式也难调。我一般直接用uni-datetime-picker组件它在uni_modules里可以直接导入支持日期范围、时间戳绑定、自定义格式化。以下是在自选股筛选页里的用法uni-datetime-picker v-modeldateRange typedaterange :clear-iconfalse changehandleDateRangeChange /// eslint-disable-next-line no-unused-vars const dateRange ref([2020-01-01, 2025-06-30]); function handleDateRangeChange(e) { // 这里 e 是该组件的日期数组格式受 start 和 end 参数影响 console.log(e); }这组代码的重点typedaterange返回的是数组不是单个日期和普通日期选择器返回值类型不同:clear-iconfalse要配合v-model使用否则清除按钮会失效。官方 toast 的问题很多人提过样式固定、只能显示一行文字、不支持确认回调。我的做法是封装一个全局弹窗组件用uni.$emit和uni.$on做事件通信取代官方uni.showToast的 90% 使用场景。2.4 H5 与 App 的通信桥webview 双向通信方案股票详情页常需要嵌套资讯 H5 页面称重模块的历史报表也可能用 H5 表格展示。这时候要打通 App 内 webview 与原生页面之间的通信。uni-app 在 App 端用plus.webview管理 webviewH5 页面本身运行在 webview 里通信方式分为两类App 传 H5 用webview.evalJS执行 JSH5 传 App 用plus.webview.postMessage或uni.postMessage。// App 端向 webview 注入数据 const currentWebview this.$scope.$getAppWebview(); const pages currentWebview.children(); pages[0].evalJS(window.receiveNativeData(${JSON.stringify({ token })}));H5 侧接收数据后通过plus对象回传// H5 页面内注意在 uni-app 的 H5 端没有 plus 对象需要判断环境 if (window.plus) { const ws plus.webview.currentWebview(); ws.postMessage({ action: loginSuccess, data: userInfo }); }要注意小程序端没有plus对象要用wx.miniProgram.postMessage和wx.miniProgram.navigateTo且小程序 webview 的消息传递是异步且受限的页面返回时才触发。如果标题里的场景要求三端同时可用建议封装一层bridge.js内部按平台分发到plus或wx对象。3. 称重模块蓝牙秤接入与数据协议解析3.1 硬件选型与蓝牙能力边界称重 App 的核心链路是手机连接蓝牙秤 → 实时读取重量 → 去皮 → 记录并与 ERP 同步。不同厂商的蓝牙秤协议差异极大但大多数便携式计价秤走的是 BLE蓝牙低功耗方案用 GATT 协议的 notify 通道持续上报重量数据。这里有一个选型上的关键分叉蓝牙 BLE 秤用 uni-app 的uni.openBluetoothAdapter、uni.startBluetoothDevicesDiscovery、uni.createBLEConnection系列 API 可以直接操作蓝牙 SPP 串口秤常见于工业台秤走的是传统蓝牙 RFCOMM 通道uni-app 官方 API 不直接支持必须通过原生插件或外接扫码枪的 HID 模式转接。如果是和某 ERP 配套的计量称重项目更要确认秤的通信方式。很多称重项目卡在Spp 串口秤因为 uni-app 的蓝牙 API 基于 CoreBluetooth根本扫不到经典蓝牙设备。3.2 uni-app 蓝牙 API 的最小连接流程这里给出一个可运行的 BLE 秤连接核心代码按步骤拆解。请注意真实项目中设备发现、连接、服务发现、数据监听是异步串行的每一步都要处理超时async function connectScale(deviceId) { // 第 1 步初始化蓝牙适配器 await uni.openBluetoothAdapter(); // 第 2 步连接设备deviceId 来自扫描回调 await uni.createBLEConnection({ deviceId }); // 第 3 步获取服务服务 ID 范围取决于秤的厂商协议 const { services } await uni.getBLEDeviceServices({ deviceId }); // 通常选包含 notify 特征的服务比如 0xFFF0 const notifyService services.find((s) s.isPrimary); // 第 4 步获取特征然后开启 notify 监听 const { characteristics } await uni.getBLEDeviceCharacteristics({ deviceId, serviceId: notifyService.uuid, }); const notifyChar characteristics.find((c) c.properties.notify); // 第 5 步监听数据变化 uni.onBLECharacteristicValueChange((res) { if (res.deviceId deviceId) { const dataView new DataView(res.value); handleScaleFrame(dataView); } }); // 第 6 步启动 notify await uni.notifyBLECharacteristicValueChange({ deviceId, serviceId: notifyService.uuid, characteristicId: notifyChar.uuid, state: true, }); console.log(连接成功等待称重数据); }这段代码里最容易出问题的不是流程而是uni.onBLECharacteristicValueChange的注册时机。它必须在notifyBLECharacteristicValueChange之前调用否则前几条数据会丢。另一个隐性参数是uni.createBLEConnection的timeout默认 30 秒在真机上体验很差我通常封装一层Promise.race设置 10 秒超时并主动断开。DataView在这里是必须的因为res.value是一个 ArrayBuffer不能直接用String.fromCharCode解析多字节数据。不同厂商秤的字节序不一样有的高位在前、有的低位在前用DataView.getUint16(2, true)的第二个参数控制字节序。解析适配时先用已知砝码重量去反推帧格式是最快的调试手段。3.3 称重数据帧解析与去皮逻辑蓝牙秤上报的常见帧格式是 7 到 16 字节不等的十六进制流包含头字节、重量高字节、重量低字节、状态位、校验位。下面是一种典型的 7 字节帧AA 55 [重高] [重低] [状态] [校验] 0D解析函数做成这样function handleScaleFrame(dataView) { const byte0 dataView.getUint8(0); const byte1 dataView.getUint8(1); if (byte0 ! 0xaa || byte1 ! 0x55) return; // 帧头校验 const weightRaw dataView.getUint16(2, false); // 大端读取重量 const status dataView.getUint8(4); // 0: 稳定 1: 动态 // 稳定性判断动态数据直接丢弃避免累计波动 if (status 1) { currentWeight.value --; return; } // 去皮偏移量存储在全局变量中默认 0 const displayWeight (weightRaw - tareOffset).toFixed(3); scaleStore.setWeight(displayWeight); }这里有一个业务细节稳定性判断必须在 UI 之前做。很多入门实现把所有帧都显示结果秤体轻微震动时 UI 上的数字一直跳动正确策略是只显示稳定帧并在连续 3 个稳定帧数值相同后再认为称重完成。去皮逻辑也不要放在 UI 层直接维护一个tareOffset变量和currentWeight一起放进 Pinia/Vuex store这样历史记录模块里可以直接读取净重。3.4 串口秤与原生插件兜底遇到 SPP 串口秤uni-app 的蓝牙 API 就使不上劲了。常见做法是走 uni-app 原生插件市场找现成的蓝牙串口插件或者自己写一个原生 module。原生插件在manifest.json里声明后JS 层通过uni.requireNativePlugin调用const serialPlugin uni.requireNativePlugin(SerialPort-Bluetooth); serialPlugin.connect({ address: 00:15:83:00:xx:xx, channel: 1 }, (ret) { if (ret.code 0) { serialPlugin.onData((buf) { // 收到的 ArrayBuffer 由 JS 层解析逻辑与 BLE 帧一致 parseFrame(new DataView(buf)); }); } });这类插件的缺点是 Android 和 iOS 可能要分别买授权而且回调数据格式各插件不一致接入时先写一层 adapter 把底层收数方式统一成onData(ArrayBuffer)后续换插件只改 adapter不用动业务代码。在 iOS 上 SPP 串口支持本身就受限如果秤的设备选型还没定优先要求供应商提供 BLE 版本。4. 股票模块实时行情链路与多端渲染4.1 行情通道WebSocket 连接管理与心跳重连称重业务的数据是低频小块股票行情则相反——高频、大流、断线敏感。金融行情 App 都会建立一个 WebSocket 长连接接收实时 tick 或分钟级 K 线。uniapp 的uni.connectSocket在 App 端基于原生 WebSocket在 H5 端就是浏览器 WebSocketAPI 保持一致这点比单独适配各端要省力。但uni.connectSocket有一个明显的坑不能像浏览器原生 WebSocket 那样直接在onmessage里拿 event.data 字符串它的data类型受平台影响H5 是 stringApp 端可能是 ArrayBuffer需要按res.data instanceof ArrayBuffer做判断。完整的连接管理我封装成 composable 函数export function useStockSocket(url, { heartbeat 15000 } {}) { let socketTask null; let heartbeatTimer null; let reconnectTimer null; let reconnectCount 0; function connect() { socketTask uni.connectSocket({ url, complete: () {} }); socketTask.onOpen(() { reconnectCount 0; startHeartbeat(); }); socketTask.onMessage((res) { // App 端数据为 ArrayBuffer需要转字符串 const text typeof res.data string ? res.data : ab2str(res.data); dispatchMessage(text); }); socketTask.onClose(() { scheduleReconnect(); }); socketTask.onError(() { socketTask.close(); }); } function startHeartbeat() { heartbeatTimer setInterval(() { socketTask.send({ data: JSON.stringify({ type: ping, ts: Date.now() }) }); }, heartbeat); } function scheduleReconnect() { if (reconnectCount 5) { console.log(重连失败需检查登录态或网络策略); return; } const delay Math.min(5000, 1000 * 2 ** reconnectCount); reconnectTimer setTimeout(() { reconnectCount 1; connect(); }, delay); } return { connect }; }心跳间隔 15 秒是常见做法但要根据行情服务端配置调整服务端如果 30 秒未收到消息就断开心跳 15 秒是安全的如果服务端 60 秒才超时心跳可以放宽到 25 秒减少无谓的包消耗。重连指数退避的上限我控制在 5 次超过后不再自动尝试因为连续失败大概率是网络切换或 token 失效继续重连只是浪费资源这时候应该抛出事件让 UI 层引导用户手动处理。4.2 K 线图与自选列表渲染方案选型股票页面的核心渲染负载在两个地方一个是大盘指数或个股 K 线一个是自选股列表的每秒刷新。K线图我建议用 uCharts 的跨端方案它本身支持 uni-app通过 canvas 在 App 和 H5 都能渲染不用像 ECharts 那样引入额外适配层。uCharts 的配置项比较贴近业务比如type: candle指定 K 线类型import uCharts from qiun/ucharts; const opts { $this: getCurrentInstance().proxy, canvasId: kLineCanvas, type: candle, categories: klineData.value.times, // X 轴时间序列 series: [{ data: klineData.value.ohlc }], // 二维数组 [[开,收,低,高], ...] pixelRatio: 1, legend: { show: false }, xAxis: { disableGrid: true }, yAxis: { gridType: dash }, }; const chart new uCharts(opts);pixelRatio参数值得展开App 端默认 1在高分屏真机上会略糊建议设为uni.getSystemInfoSync().pixelRatio但设太高 canvas 渲染压力大行情页频繁刷新时帧率会明显下降我实测 2 倍是体验和性能的平衡点。自选股列表每秒刷新最忌讳的是全量uni.setData或全量替换数组。正确做法是只更新变化的字段用 Vue 的响应式按需更新// 对比上次数据只更新变化字段 const updateMap getDiff(prevTicks.value, newTicks); updateMap.forEach((tick) { const item stockListMap[tick.code]; if (!item) return; item.price tick.price; item.percent tick.percent; });同时列表单元格用v-memo或控制子组件 re-render 的 key 策略把不涉及价格变化的行隔离掉。在 App 端如果列表过长还要考虑使用scroll-view的虚拟滚动但 uni-app 的虚拟滚动组件生态不够成熟超过 100 行且每行都是高频刷新时才建议上。4.3 交易流程与多端安全边界行情展示之外股票 App 还涉及登录态、交易密码、下单确认。金融服务对安全要求极高代码层面能做的主要有token 过期统一处理、请求参数签名、关键操作的双重确认弹窗。这里的弹窗不能使用 2.3 节做的全局 toast 组件必须用可阻塞的 confirm 弹窗防止用户连续点击提交重复委托单。我一般用uni.showModal并配合按钮 loading 状态uni.showModal({ title: 委托确认, content: 买入 100 股价格 ${price}确认提交, confirmText: 确认买入, success: async (res) { if (!res.confirm) return; submitBtn.value true; try { await placeOrder({ symbol, price, size: 100 }); } finally { submitBtn.value false; } } });要注意uni.showModal在 App 端和 H5 端表现一致但小程序端 confirm 按钮的 loading 状态会遮住按钮文字所以submitBtn.value false的时机要放在finally里确保无论成功失败都恢复点击能力。4.4 平台差异H5 与 App 的行情性能取舍行情页在 H5 端和 App 端有明显的性能差异。H5 是浏览器渲染canvas绘制 K 线的性能远不如 App 的原生 canvas 层App 端则要注意 WebView 的renderer进程被系统回收导致的页面白屏。我核对过的平台差异整理成下表方便按端裁剪功能能力H5App小程序WebSocket 拥塞控制浏览器自动需自行处理受微信限流Canvas 2D 性能中原生层优依赖 Canvas 2D 新接口本地存储上限5MB 左右原生缓存10MB页面栈深度无硬限制建议 10 层内10 层定时器后台暂停切 tab 即暂停退回后台暂停5 秒后停针对表格里的差异我做两个处理H5 端在页面onHide时断开行情 WebSocketonShow时重连避免不可见时白白消耗带宽App 端把 K 线数据缓存在本地 SQLite 或 storage等下次启动时先渲染缓存再等实时数据覆盖这样用户体感上是秒开。5. 收尾三类高频报错与一个验证技巧5.1 cannot read properties of undefined 的常见来源这个报错在原生 App 开发里出现的频率远高于普通 Web 项目。原因多集中在三处uni.getSystemInfoSync()在模拟器返回空对象、plus.*API 在非 App 环境不存在、以及行情 WebSocket 数据到达时图表实例尚未初始化。防御办法是在访问plus之前加环境判断// 判断当前环境避免 H5 端调用 plus 报错 const isApp typeof plus ! undefined plus.os ! undefined; const sysInfo isApp ? uni.getSystemInfoSync() : { statusBarHeight: 0, pixelRatio: 1 };5.2 页面栈管理与分包加载的验证建议股票 App 的页面层级普遍较深自选列表 → 个股详情 → 分时 → K 线全屏 → 下单页每一步都是一个navigateTo。当页面栈超过 10 层navigateTo会静默失败用户点击没有反馈。我用了两个策略超过 5 层后主动用uni.redirectTo替换当前页下单成功用uni.navigateBack({ delta: 2 })跳回列表页同时清理中间页。一个有效的验证方式是埋点监听onNavigationBarBackPress和onBackPress当用户从第 10 层页面返回时记录页面栈深度到日志一周内如果出现超过 8 层的路径就该优化跳转链路了。5.3 把 scale 和 stock 两套数据流分开的收益最后回到标题里两个业务并存的场景称重模块的数据流是低频事件驱动股票模块是高频推送前者需要长时间保持蓝牙连接后者需要长时间保持网络连接。如果把两套状态放在同一个全局 store蓝牙断连或行情重连时会产生大量的跨模块更新导致 UI 无关区域也被触发渲染。我在composables目录下为两个模块分别建了useScaleChannel和useStockSocket共享的只有 token 和用户信息两个字段。这样在pages.json里配置两个分包称重模块用subPackages单独打包行情模块的 webview 页面按需加载首包体积可以控制在 1MB 以内。验证这个拆分是否合理的简单指标是分别打开称重主页和股票行情页观察内存占用差如果两个页面切换时内存曲线没有明显回落说明数据流还没有完全解耦还需要继续隔离。本文还有配套的精品资源点击获取