简介:Android开发者若需实现蓝牙设备间数据通信并将接收值实时绘制为折线图,可参考这份基于Eclipse的完整项目源码。资源包含105个文件,涵盖Java源码、XML布局、PNG图标、JAR依赖库、配置文件以及可直接安装的APK安装包,压缩后体积约2.68MB,便于快速导入与运行验证。已有3127人学习下载,说明其在蓝牙通信与图表开发场景中具有实际参考价值。项目核心逻辑集中在MainActivity与RecvData_Thread等类中,完整演示了通过BluetoothAdapter扫描配对设备、利用BluetoothSocket建立连接、在后台线程读取输入流、对字节数据执行解析与格式化,再借助MPAndroidChart将数据封装为Entry对象并实时刷新折线图的关键流程;同时兼顾Android各版本兼容性、连接失败与传输异常处理、UI流畅度优化等实践细节,适合进阶开发者作为蓝牙实时数据展示的参考模板,也可在此基础上扩展设备选择界面、优化传输协议或调整图表交互样式。
1. 先想清楚:这个标题里的“蓝牙接收数据”,到底在接收什么
一个很常见的需求:手里一块 MCU 开发板,每 100ms 通过蓝牙串口向外发一条数据,类似H=35.2,T=26.8\n,手机 App 需要实时收下来,并在折线图上同时画出两条曲线。听起来就是“连蓝牙、读数据、画线”三步,真做过的都知道,难点全在连上之后的链路处理:数据流跨线程、半包粘包、折线图画着画着越来越卡。这篇文章把整条链路拆开讲清楚,从蓝牙通道选型、线程模型、帧解析,到折线图实时刷新的参数配置,再到我在这条路上踩过的几个大坑。适合手里已经有蓝牙外设、准备做实时监控类 Android App 的开发者,也适合想从“能收数据”升级到“稳定实时曲线”的人。
2. 蓝牙接收链路定调:SPP、BLE 二选一,以及最小连通代码
2.1 串口透传设备与低功耗设备的差异,决定你怎么写代码
做蓝牙接收,第一步不是写代码,而是确认手上设备走的是哪一类蓝牙通道。常见的有两类。
第一类是经典蓝牙的 SPP 通道,很多“蓝牙串口透传模块”就是这种模式。它把蓝牙封装成一个虚拟串口,App 一端连上之后,收发数据就像读写一个文件流一样,双向字节流,协议完全由你的硬件决定。优点是模型简单,socket 建好后只管 read;缺点是功耗高,适合数据量大、持续传输的场景。
第二类是 BLE(低功耗蓝牙),走 GATT 协议。App 需要先扫描到设备,连上后还要发现 Service、Characteristic,开启通知回调,数据是硬件主动推送给 App 的,一次一包。这个模型和 SPP 完全不同,代码复杂度高不少,但功耗低,适合电池供电的传感器节点。
两者对“折线图实时显示”这个需求都满足,但从写代码的角度出发,我一般把 SPP 作为首选方案,因为它在协议层上最接近普通 IO,调试起来最快。BLE 也不是不能做,只是要多处理一层 GATT 回调。下面主线的代码都以 SPP 为例,BLE 的差异点会在避坑章节单独说。
SPP 方案里有一个绕不开的标准 UUID:00001101-0000-1000-8000-00805F9B34FB。这是蓝牙 SPP 服务的标准 SDP UUID,绝大多数串口透传模块都认它。如果你的模块比较特殊,连接时一直报 service discovery failed,那通常是厂商改过 UUID,需要从模块说明里找它自己的串口服务 UUID,然后把下面的代码换成那个值。
| 对比项 | 经典蓝牙 SPP | BLE GATT |
|---|---|---|
| 连接模型 | 建 socket,双向流,像读文件 | 发现服务、特征,回调推数据 |
| 数据时效性 | 持续流式,延迟低 | 按通知包,间隔由连接参数决定 |
| 功耗 | 较高 | 低 |
| 代码复杂度 | 较低 | 较高 |
| 适用场景 | 串口透传模块、数据量大 | 电池供电的小型传感器 |
2.2 权限适配:不是加两个权限那么简单
Android 蓝牙权限这几年改过一轮,新手经常会在这个地方翻车:换了一台手机,App 就扫描不到设备,或者连接直接被系统拒绝。原因多半是权限没有按系统版本适配。
Android 11 及以下,蓝牙权限主要是BLUETOOTH和BLUETOOTH_ADMIN,这两个都是普通权限,在 Manifest 里声明就会授予。但从 Android 12(API 31)开始,系统把蓝牙权限升级成了运行时权限:BLUETOOTH_SCAN负责扫描,BLUETOOTH_CONNECT负责连接,而且必须动态申请。如果还沿用老写法,在 API 31+ 的设备上,连接相关 API 会直接抛 SecurityException。
还有个更容易被忽略的:扫描蓝牙设备时,部分机型要求定位权限。原因是蓝牙扫描结果会被系统用来推算位置,所以很多设备在扫描阶段还额外要求ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION。我最初的方案是只申请蓝牙权限,结果在部分国产 ROM 上扫不到任何设备,加了定位权限后一切正常。这块要一起处理,别等测试反馈了才补。
// Manifest 里声明 <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />// 动态申请,建议在页面初始化时统一请求 val permissions = mutableListOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT ) if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) { permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Build.VERSION.SDK_INT <= 30) { permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } // 使用 Activity Result API 申请代码逻辑不复杂,核心是按 SDK 版本决定要申请哪些权限。Android 12 以上必须申请 scan 和 connect 两个新权限;Android 6 到 11 之间的设备,需要定位权限作为扫描的兜底。两个 if 判断不能省,否则高版本设备会漏掉新权限,低版本设备又拿不到定位权限。
2.3 建立连接并启动接收线程的最小实现
蓝牙的connect()和InputStream.read()都是阻塞调用。阻塞意味着会一直卡在那里等数据,如果在主线程调用,系统几秒后就会判定无响应,直接弹 ANR。所以连接和接收必须在子线程里做,这是整条链路的第一条铁律。
下面是一段最小可用的 SPP 连接和接收代码,基于 Kotlin 的thread {}起了一个子线程,适用于已经配对过的设备。实际项目里如果要做设备扫描,建议用系统推荐的BluetoothLeScanner或BluetoothAdapter.startDiscovery来做,但核心连接逻辑和这里一样。
private var socket: BluetoothSocket? = null @Volatile private var isRunning = false fun startReceiving(deviceAddress: String) { val adapter = BluetoothAdapter.getDefaultAdapter() ?: return val device = adapter.getRemoteDevice(deviceAddress) val uuid = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") thread { try { socket = device.createRfcommSocketToServiceRecord(uuid) socket?.connect() // 阻塞,等待连接完成 val input = socket?.inputStream // 获取输入流 val byteBuf = ByteArray(256) isRunning = true while (isRunning) { val len = input?.read(byteBuf) ?: -1 if (len > 0) { onDataReceived(byteBuf.copyOf(len)) // 原始字节交给上层 } } } catch (e: IOException) { // 连接失败或 socket 被关闭都会走到这里 } finally { runCatching { socket?.close() } } } } fun stopReceiving() { isRunning = false runCatching { socket?.close() } // 关闭 socket 会让 read 立即抛出异常退出 }逻辑说明:createRfcommSocketToServiceRecord(uuid)指定了 SPP 标准服务;connect()在子线程里等待握手完成;read(byteBuf)会一直阻塞直到有数据到达,返回实际读到的字节数;byteBuf.copyOf(len)只截取有效部分,避免把上次残留的脏数据一起传上去。停止接收时,用socket.close()让阻塞的 read 抛异常退出,这比用线程中断要可靠得多。
这里的字节缓冲区 256 字节,是针对文本型数据的典型配置。如果你的一帧数据可能超过 256 字节,就调大到 1024 或 2048。但别盲目调到很大,每帧都拷一份大数组反而浪费内存。另外isRunning必须用@Volatile标记,否则在多线程下可能看不到变量更新,导致线程退不出来。
2.4 跑通最小连接后,先确认“数据进来了”再继续
很多人写完连接代码就急着去画图,结果图是空的,又回头查不出是蓝牙没连上,还是解析出了问题。我惯用的做法是:先把上一步读到的原始字节同时打两行日志,一行按十六进制打印,一行按 ASCII 打印。这样能看到两个信息:数据有没有在流动、数据长什么样。
看到类似[48 3d 33 35 2e 32 0d 0a]这样的十六进制,对照 ASCII 表就知道是H=35.2\r\n,说明链路完全通。这一步解决的问题是把“蓝牙环节”和“解析环节”隔离,后面再怎么排查,都不会再去怀疑蓝牙本身。
3. 数据帧:把蓝牙字节流变成可绘制的数值序列
3.1 先和硬件定好协议,再写解析代码
蓝牙底层是流式传输,它不管你的帧边界。换句话说,硬件发的H=35.2\nT=26.8\n到达 App 后,可能是两条挤在一次read里返回,也可能一条被拆成两次read返回。这就是“半包”和“粘包”,是每个做串口/蓝牙接收的人都要面对的问题。
要解决它,前提是收到数据之前先定好帧格式。常见的有三种。
第一,换行分隔文本。每帧以\n或\r\n结尾,内容是人类可读文本,适合调试,解析最简单。第二,固定帧头加长度字段的二进制帧。例如帧头AA 55,后面一个字节表示数据长度,接着是数据区和校验和,适合数据量大、需要精确控制的场景。第三,JSON 报文。可读性好,但解析开销大,适合低频数据。
| 格式 | 帧示例 | 优点 | 缺点 | 拆包难度 |
|---|---|---|---|---|
| 换行文本 | H=35.2,T=26.8\n | 可读性强,调试方便 | 占用字节多 | 低 |
| 二进制帧 | AA 55 04 xx xx xx cs | 紧凑、效率高 | 可读性差 | 中 |
| JSON | {"h":35.2,"t":26.8}\n | 结构清晰 | 解析慢、体积大 | 中 |
我见过最典型的错误是:把一次read拿到的数据直接当成“一帧”,然后取前两个字节当温度。这样画面上的曲线时断时续,数值还经常莫名其妙地跳变。正确的做法是把每次 read 的数据先丢进一个缓冲区,再从缓冲区里按帧格式切分,下面这个类就是干这个事的。
3.2 FrameBuffer 拆包器:半包拼接、粘包切分一次搞定
拆包器的设计目标很简单:把任意长度的输入字节流,切成一帧帧完整的数据。这里给出按分隔符拆包的实现,适合上面第一种文本格式。
class FrameBuffer(private val delimiter: ByteArray = byteArrayOf('\n'.code.toByte())) { private val buffer = java.io.ByteArrayOutputStream() private val maxBufferSize = 1024 * 8 @Synchronized fun push(data: ByteArray) { buffer.write(data) if (buffer.size() > maxBufferSize) { // 缓冲区爆炸说明长时间拼不出完整帧,大概率硬件协议不一致 buffer.reset() } drain() } private fun drain() { val bytes = buffer.toByteArray() val idx = indexOf(bytes, delimiter) if (idx < 0) return // 还没等来分隔符,等待下一块数据 val frame = bytes.copyOf(idx) buffer.reset() buffer.write(bytes, idx + delimiter.size, bytes.size - idx - delimiter.size) // 处理完第一帧后,缓冲区里可能还粘着第二帧,递归继续切 drain() } private fun indexOf(data: ByteArray, key: ByteArray): Int { outer@ for (i in data.indices) { if (i + key.size > data.size) break for (j in key.indices) { if (data[i + j] != key[j]) continue@outer } return i } return -1 } }逻辑说明:push(data)先把新字节写进ByteArrayOutputStream,再尝试从缓冲区里找分隔符。找到分隔符,说明缓冲区里至少有一帧完整数据,就把分隔符之前的字节取出来作为一帧,分隔符之后的内容重新放回缓冲区。如果没找到,说明是半包,数据留着等下一次。粘包也不怕,drain()递归处理,第一个分隔符之后的残余内容里还有可能再切出第二帧。
缓冲上限这个参数容易被忽略。蓝牙数据量一般不大,但硬件在连接瞬间可能发一堆乱序数据,如果缓冲区和帧格式对不上,缓冲区会不断膨胀。这里设了 8KB 的上限,超过就清空,宁丢数据也不让内存泄漏。实际项目里这个值按最大帧长的 4 到 8 倍来设比较稳妥。
3.3 把帧解析成数值:文本字段提取和二进制取值
拆包后拿到了完整帧,下一步就是解析成可绘制的数值。如果是文本帧,处理方式很直接:按自己的设计把内部拆分出来。
fun parseTextFrame(frame: ByteArray): Map<String, Float> { val text = String(frame, Charsets.UTF_8).trim() val result = mutableMapOf<String, Float>() for (field in text.split(',')) { val kv = field.split('=') if (kv.size == 2) { result[kv[0].trim()] = kv[1].trim().toFloatOrNull() ?: continue } } return result }这段代码把H=35.2,T=26.8按逗号切成两段,再按等号切成键和值,转成Float后放进 Map。要注意toFloatOrNull()不能省,蓝牙链路上偶尔出现乱码是正常的,直接toFloat()会在解析失败时抛出异常,导致接收线程崩溃。解析失败的字段跳过,不影响其他字段,这是实时监控类 App 的基本容错思路。
如果是二进制帧,解析逻辑就得按字节偏移来。假设协议是“帧头 AA 55 + 1 字节长度 + 2 字节温度 + 2 字节湿度 + 1 字节校验”,那取值时要注意大小端的问题。很多单片机用的是小端模式,低字节在前,这个时候需要用ByteBuffer指定LITTLE_ENDIAN,否则数值会差出一个数量级。这是新手最容易踩的坑,之前排查过一个问题:温度显示 365.5 度,实际上字节序反了。
fun parseBinaryFrame(frame: ByteArray): Map<String, Float>? { if (frame.size < 7) return null val buffer = java.nio.ByteBuffer.wrap(frame).order(ByteOrder.LITTLE_ENDIAN) val temp = buffer.getShort(2).toFloat() / 10f // 温度,放大10倍传输 val humi = buffer.getShort(4).toFloat() / 10f // 湿度 // 校验位在 frame[6],按协议核对 return mapOf("T" to temp, "H" to humi) }这里解释了为什么要除以 10:硬件端为了避免小数传输麻烦,通常会把浮点数放大 10 倍或 100 倍再用整型发送,App 端解析后还原。这个缩放系数建议写进协议文档,和帧格式一起约定好。
3.4 接收线程到 UI 线程:不要让折线图死在子线程里
Android 规定 UI 只能在主线程更新,而蓝牙数据是在子线程读出来的。跨线程传数据最稳妥的方式是 Handler,它天然支持线程间的消息队列,不会出现并发写 UI 的问题。这里我定义一个持有主线程 Looper 的 Handler,子线程所有数据都通过它发到主线程。
private val uiHandler = object : Handler(Looper.getMainLooper()) { override fun handleMessage(msg: Message) { if (msg.what == MSG_FRAME_DATA) { val bundle = msg.data val t = bundle.getFloat("T", 0f) val h = bundle.getFloat("H", 0f) chartHelper.appendData("温度", t) chartHelper.appendData("湿度", h) } } } fun onDataReceived(bytes: ByteArray) { val frame = frameBuffer.parse(bytes) ?: return val values = parseTextFrame(frame) val msg = Message.obtain() msg.what = MSG_FRAME_DATA val bundle = Bundle() bundle.putFloat("T", values["T"] ?: 0f) bundle.putFloat("H", values["H"] ?: 0f) msg.data = bundle uiHandler.sendMessage(msg) }这段代码的核心价值是把“数据到达”和“UI 刷新”解耦。子线程握有原始数据,解析成数值后打包到 Message,通过 Handler 投递到主线程执行。主线程的 handleMessage 里只做 UI 更新,不做任何耗时操作。如果你用的是协程,思路一样,只是把 Handler 换成Channel或Flow,核心原则不变:数据计算在子线程,UI 更新在主线程。
需要注意一个隐患:高频数据下 Message 有可能积压。如果硬件每 10ms 发一条,而 UI 刷新需要 100ms,那主线程队列里会堆很多消息。解决办法是不要每条消息都触发 UI 刷新,而是先在子线程做聚合,比如累计 10 条后合并成一批发出去,刷新频率控制在每秒 10 次就够。下一章会展开讲这个。
4. 折线图实时刷新:图表库配置要点与轻量自绘兜底
4.1 选型先判断数据量和刷新频率,别看到一个库就往上冲
折线图这块,常见的做法有两个方向:接入一个开源图表库,或者自定义 View 自己画。两个方向没有绝对的优劣,关键看数据量。
开源图表库的特点是开箱即用,坐标轴、网格、图例、缩放这些都有现成的实现,配置量小。但如果你每秒钟接收几十个点,而且每个点都要刷新到图上,图表库的动画和坐标计算会成为负担,刷着刷着主线程压力就上来了。我自己做过的几个项目里,数据频率超过 20Hz 之后,图表库的方案基本都顶不住,会出现明显的掉帧。
自绘 View 则简单粗暴:只画你需要画的东西,数据点在Path里连成线,坐标范围自己算,几十上百个点画一条折线对 Canvas 来说是零压力。代价是坐标轴、网格、时间标签这些都要自己手动画,工作量稍微大一点,但对于“实时监控一条线”这种简单场景,这点工作量换来的流畅度很值得。
| 方案 | 适合频率 | 内存占用 | 开发量 | 维护成本 |
|---|---|---|---|---|
| 开源图表库 | 低于 10Hz | 较高 | 低 | 中 |
| 自绘 View | 任意频率 | 低 | 中 | 低 |
判断标准很简单:先问硬件“每秒发多少个点”。只有几个点或者几十个点,用图表库省时间;如果一秒上百个点,直接考虑自绘。下面的小节先把图表库方案讲清楚,再给一个自绘的兜底实现,两套都能跑。
4.2 图表库动态追加数据的关键配置,一个都不能少
以常见开源图表库为例,实时折线图有几个配置直接决定画面表现。下面这段是一个可复用的初始化封装,我把最关键的几个参数都标了出来。
class ChartHelper(private val lineChart: XLineChart /* 对应你接的开源图表库折线图组件 */) { private val maxVisibleCount = 50 // 窗口内最多显示点数 fun initChart() { // 1. 只保留最近 50 个点,更早的数据不绘制,防止点无限累积到内存爆掉 lineChart.setMaxVisibleDataCount(maxVisibleCount) // 2. x 轴按索引显示,不按时间戳,时间只用于标签格式化 lineChart.xAxis.position = XAxisPosition.BOTTOM lineChart.xAxis.granularity = 1f // 强制 x 轴刻度按整数索引递增 // 3. 关闭 y 轴自动缩放,避免每个新点都重算范围导致曲线上下狂跳 lineChart.setAutoScaleMinMaxEnabled(false) // 4. 禁止不必要的动画和触控缩放,减少刷新开销 lineChart.description.isEnabled = false } fun appendData(seriesName: String, value: Float) { // 找到对应曲线,追加 entry // 数据超出窗口后,屏幕会自动滑动到最右边 lineChart.notifyDataSetChanged() // 通知数据变化 lineChart.invalidate() // 触发重绘 } }配置说明:setMaxVisibleDataCount(50)对应“实时”窗口的概念,用户最多看到最近 50 个点,而不是从开机到现在的所有点。granularity = 1f很重要,如果不设置,x 轴刻度会按小数自动分割,导致标签乱跳;这里固定成 1,表示每个索引一个刻度。setAutoScaleMinMaxEnabled(false)是防止 y 轴范围反复横跳的关键,你可以自己控制 y 轴上下限,或者每收到固定数量的点后再更新一次 y 轴。
invalidate()是手动触发重绘的方法。图表库的刷新逻辑是“你改数据,我重绘”,但如果你改了数据不调用 invalidate,画面不会自动更新。这两个调用必须成对出现,漏一个就会看到数据“存进去了但不显示”的诡异现象。
4.3 高频数据下的“聚合刷新”:把刷新频率和接收频率解耦
订阅接收线程每秒可能收到 100 个点,但主线程一秒钟刷新 100 次 UI 是完全没必要的。人的视觉对大于 50ms 的变化很快会失去感知,所以把刷新频率控制在 10 到 20Hz 就足够。做法是:数据先进入一个待刷新缓冲区,到达固定时间间隔或攒够一定数量后,打包一次刷新。
private var lastRefreshTime = 0L private val pendingValues = ArrayList<Float>() fun onFrameArrived(ts: Long, value: Float) { pendingValues.add(value) val now = SystemClock.elapsedRealtime() if (now - lastRefreshTime >= 100) { // 100ms 聚合一次,等效 10Hz 刷新 flushPendingToChart() lastRefreshTime = now } } private fun flushPendingToChart() { // 把 pendingValues 批量追加到折线图 pendingValues.clear() }这个聚合逻辑很简单但很关键。它把“接收频率”和“刷新频率”彻底解耦,即使硬件调整了发送频率,App 端也不受影响。你甚至可以把这个 100ms 做成可配置的,在设置界面交给用户选择“刷新速度”,数据量大时调慢一点,画面流畅度会明显提升。
4.4 轻量自绘折线 View:简单场景的可靠兜底方案
如果数据频率高,或者你不想引入体积较大的图表库,那一个自绘 View 就够了。核心代码不长,关键是守住两个边界:数据点数量的上限和刷新频率的控制。
class RealTimeLineView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val points = ArrayDeque<Float>() private val maxPoints = 100 private val linePath = Path() private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFF4CAF50.toInt() strokeWidth = 3f style = Paint.Style.STROKE } fun pushValue(v: Float) { points.addLast(v) if (points.size > maxPoints) { points.removeFirst() } invalidate() // 触发 onDraw } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (points.isEmpty()) return val stepX = width.toFloat() / maxPoints linePath.rewind() points.forEachIndexed { index, value -> val x = index * stepX val y = height - value // 简单线性映射,实际要根据最大最小值归一化 if (index == 0) linePath.moveTo(x, y) else linePath.lineTo(x, y) } canvas.drawPath(linePath, paint) } }逻辑说明:ArrayDeque维护一个最新数据点的滑动窗口,超过maxPoints就把最早的点挤出去,保证内存恒定。linePath.rewind()复用同一个 Path 对象,避免每次刷新都 new 一个导致 GC 频繁。invalidate()触发系统重绘,但注意它不等于立刻重绘,而是向主线程的消息队列发了一条绘制请求,所以高频调用时系统会自动合帧,不会真的每帧都画。
这个自绘方案的场景定位很清晰:单条曲线、简单监控页、不需要缩放和坐标轴交互。如果需求里有多曲线、图例、时间轴拖拽,那还是回到图表库,别自己造轮子。自绘最大的优势就是可控,遇到性能问题你只需要盯着这一个 View,不需要深入第三方库的源码。
5. 避坑指南:蓝牙实时曲线最常见的 5 个翻车现场
5.1 一收数据 App 就无响应,Logcat 里飘着 ANR
现象:蓝牙连接成功,设备一开始发数据,App 直接卡死,过几秒弹“无响应”对话框,Logcat 里能看到ANR in ...的关键字。
原因:这是最经典的错误——把socket.connect()或inputStream.read()写在了主线程上。read()是阻塞调用,没有数据时它会一直挂着,主线程被占满后,系统判定无响应,直接杀 ANR。
解决:把连接和读取全部移到单独的接收线程。线程创建方式不限,但原则是收发数据的过程绝不碰主线程。这一条没有例外,属于新手 100% 会踩的第一个坑。
5.2 曲线越画越卡,最后整个页面像幻灯片
现象:刚开始显示正常,跑几十秒后折线图刷新明显变慢,最后整个页面拖动都卡顿,内存占用持续上涨。
原因:数据点无限累积。每次收到新点都往图表的数据集里 append,后台点数越来越多,每次刷新时图表库要重新计算所有点的位置和坐标范围,计算量随点数线性增长。
解决:设置可视窗口上限,例如setMaxVisibleDataCount(50),让图表只保留最近 50 个点;同时把刷新频率限制到 10Hz 左右,拒绝每条数据都触发重绘。这是一对组合策略,窗口限制解决内存,频率限制解决 CPU。只做其中一个,卡顿问题都还会再回来。
5.3 数据总缺一半,曲线像被啃过一样
现象:硬件明明每秒发 20 条数据,App 收到的曲线却缺一块,有时候连续几秒没有新点,然后又一下子冒出好几个点。
原因:没有做粘包和半包处理,直接把一次read()的结果当成一帧。当硬件发送频率高时,多条帧会挤在一次read()里返回,解析只取第一条;当发送频率不规律时,一条帧又可能被拆成两次read(),第二次读到的只有半条,解析出来的是残缺数据。
解决:接上第三节的 FrameBuffer 拆包器。所有 read 到的原始字节先进缓冲区,再按分隔符或帧头长度从缓冲区切帧。这个拆包层是蓝牙接收和串口接收都必须要有的基础设施,绝对不能省。
5.4 换一台手机就搜不到设备,或者连不上
现象:在 A 手机上一切正常,换到另一台较新的手机上,扫描列表是空的,或者点击连接就报“认证失败”。
原因:没有适配新版蓝牙权限。Android 12+ 的设备上,BLUETOOTH和BLUETOOTH_ADMIN已经不能用于扫描和连接,必须用BLUETOOTH_SCAN和BLUETOOTH_CONNECT这两个新权限。部分机型扫描阶段还需要定位权限,这个需求在低版本代码里没体现,换了新机器就暴露出来了。
解决:按 2.2 节的权限适配方式,对 Android 6、Android 11、Android 12 各写一套判断。注意权限先申请完再扫描,如果你在权限弹窗还没确认时就调 startDiscovery,部分 ROM 会静默返回空结果,这就有点像玄学了——所以把权限申请流程放到扫描动作之前,保证时序正确。
5.5 x 轴时间标签乱跳,曲线在水平方向压成一团
现象:折线图的 x 轴标签忽前忽后,或者所有点的 x 坐标都挤在同一个位置附近,曲线看起来像一根竖线。
原因:x 轴用了不稳定的时间戳。实时场景下,硬件延迟和系统时间偏差会让时间戳不是均匀递增的,甚至出现相同的时间戳;如果 x 轴按时间戳自动分布,折线图就会把这些点按真实时间位置排列,导致前面的点挤在左边,后面的点全堆在右边。
解决:把 x 轴当成纯索引轴,即第 0 个点、第 1 个点……索引递增,和时间戳无关。时间信息只用来格式化成标签文字,比如“12:00:03”,但不参与坐标定位。这样曲线严格按到达顺序均匀排列,画面才能稳定向前滚动。这个细节非常隐蔽,不跑真实设备很难发现。
6. 进阶技巧:把帧格式与采样率做成配置化,一套接收链路适配多套硬件
6.1 用配置描述协议,而不是把解析逻辑写死在代码里
做实时曲线类 App 的人都会遇到同一个问题:这周接的是温度传感器,下周又换了块带 PM2.5 的板子,协议格式完全变了。如果每种硬件的解析都写一套代码,项目里会堆满if (deviceName == "xxx")这样的分支,维护起来很痛苦。
我的做法是把帧格式抽成配置。用 JSON 描述这套协议的核心要素:帧分隔符是什么、文本还是二进制、有哪些字段、字段的字节偏移和缩放系数是多少。App 启动时读配置,解析器按配置生成,接收线程本身完全不关心协议细节。
// 协议配置示例 data class FrameProtocol( val frameMode: String, // "text" 或 "binary" val delimiter: String, // 分隔符,如 "\n" val fields: List<FieldSpec> // 字段定义 ) data class FieldSpec( val name: String, // 字段名,如 "T" val label: String, // 显示名,如 "温度" val offset: Int, // 二进制帧字节偏移 val scale: Float // 缩放系数,如 0.1f )JSON 对应的大致形态是这样的:frameMode是 text 还是 binary,delimiter给出帧分隔符,fields里列举所有要解析的字段和它们的缩放系数。解析器拿到这个配置后,生成 Map 数据,上层折线图只需要按fields[i].label取数值。换新硬件时,加一个配置文件就行,不用改代码重新打包。
6.2 配置化的收益,只有被协议变更折磨过的人才懂
这套设计的价值不是炫技。我最初做的一个采集项目,接第一块板子时把协议写死在代码里,一行if (byteArray[0] == 0xAA.toByte())启动解析。第二块板子拿到手,发现帧头不一样、字段顺序也不一样,硬着头皮加分支。到第三块板子时已经意识到不对了,整个解析模块改成配置驱动之后,后来对接新硬件的时间从半天压缩到半小时。
数据源和 UI 分离,也让你可以脱离硬件调试。配置化之后,我可以从文件直接导入一段模拟数据流,喂给接收通道,验证折线图表现。没有硬件在手边时,也可以照常开发 UI 和排查问题。这个习惯帮我省了很多事。
如果你现在只是做一个固定硬件的 Demo,不一定要上配置化;但如果你计划做多条业务线、多套硬件复用的 App,建议尽早把这个抽象做起来。最后提醒一句:协议分析是这条链路里最花时间的,所以先和硬件同事把帧格式文档定好,再动手写解析器,这比什么都重要。我吃过“文档不全、临时看逻辑分析仪波形猜协议”的亏,那真是拿玄学来编程。希望帮到你。
本文还有配套的精品资源,点击获取