
简介这是一份面向Android开发初学者与嵌入式物联网爱好者的蓝牙BLE数据可视化实战项目聚焦单片机与Android端通过GATT协议实现低功耗无线通信及实时绘图。资源完整呈现从设备扫描、GATT连接、服务发现、特征订阅到二进制数据解析与MPAndroidChart动态绘图的全流程实现适用于智能传感、远程监控等典型IoT应用场景。压缩包共28个文件含7个核心Java类涵盖BluetoothGattCallback、数据解析器与图表更新逻辑、13个XML布局与配置文件含权限声明与UI组件定义、8张PNG图标资源整体仅205KB轻量易读目录结构清晰便于快速定位BLE连接管理、通知接收与绘图刷新等关键模块。目前已有1095人学习下载配套代码可直接导入Android Studio运行包含完整的权限适配如Android 6.0动态位置权限、线程安全处理及基础错误恢复机制是掌握BLE通信与移动端数据可视化的高实用性入门范例。1. 项目概述从零构建一个Android蓝牙数据绘图应用最近在做一个挺有意思的小项目核心目标是通过Android手机连接一个单片机比如ESP32或者STM32实时接收单片机通过蓝牙BLEBluetooth Low Energy发送过来的传感器数据比如温度、加速度、角度等等然后把这些数据在手机屏幕上实时绘制成动态变化的曲线图。这听起来像是物联网、智能硬件或者数据监控领域的一个典型需求场景。我猜你可能是电子爱好者、嵌入式开发者或者是一个需要做数据可视化原型的学生想找一个能直接上手、逻辑清晰的实现方案。这个项目的核心就是打通“硬件采集 - 蓝牙无线传输 - 手机APP解析 - 图形化展示”这条链路。它不只是一个简单的蓝牙聊天室而是涉及到稳定的数据流处理、协议解析、以及高效的UI渲染。网上很多教程只讲怎么连接蓝牙或者只讲怎么画图但把两者无缝衔接起来并且保证在数据高频到达时应用不卡顿、不崩溃这里面有不少门道。接下来我就把自己从硬件选型、协议设计到Android APP开发的完整实现过程以及踩过的坑和优化技巧详细拆解一遍。2. 核心思路与方案选型在动手写代码之前理清整体架构和每个环节的技术选型至关重要。一个错误的选择可能会让后期开发举步维艰。2.1 为什么选择蓝牙BLEBluetoothGATT首先我们需要明确通信协议。对于单片机与手机这种低功耗、间歇性传输传感器数据的场景经典蓝牙Bluetooth Classic和低功耗蓝牙BLE是主要选项。经典蓝牙像蓝牙耳机、音箱用的就是它。它的特点是带宽大、连接稳定适合持续传输音频流。但它功耗高连接建立过程复杂配对对于只需要每秒发送几十个字节数据的传感器节点来说是“大炮打蚊子”。低功耗蓝牙BLE这正是为物联网设备设计的。它的核心是GATT通用属性协议。你可以把GATT理解为一个简单的“服务-特征值”数据库。设备单片机作为“服务器”GATT Server提供各种“服务”Service比如电池服务、设备信息服务。每个服务下包含多个“特征值”Characteristic这才是真正存放数据的地方。手机作为“客户端”GATT Client去订阅Subscribe或读取Read这些特征值。对于我们的数据绘图项目BLE是毫无疑问的最佳选择。我们会在单片机上创建一个自定义服务里面放一个具有“通知”Notify属性的特征值。单片机定时把传感器数据写入这个特征值手机订阅它后就能在数据更新时自动收到通知从而实现数据的主动推送和实时接收。这种方式功耗极低连接快速。注意很多新手会混淆HC-05/HC-06模块经典蓝牙串口和BLE模块。如果你的单片机用的是HC-05那你在Android端需要使用经典蓝牙的BluetoothSocketAPI这与BLE的BluetoothGATTAPI完全不同。本项目聚焦于更现代、更省电的BLE方案硬件上推荐使用ESP32自带BLE或搭配HM-10、JDY-08等BLE模块的STM32。2.2 Android端技术栈拆解确定了BLE通信我们来看Android端如何构建。蓝牙权限与适配这是第一道坎。从Android 6.0API 23开始需要动态申请ACCESS_FINE_LOCATION权限因为蓝牙扫描可以被用于位置推断。Android 12API 31又引入了BLUETOOTH_SCAN、BLUETOOTH_CONNECT等新权限。我们的应用必须处理好这些版本兼容性问题。核心APIBluetoothGATT这是Android BLE开发的核心类。通过它我们可以连接设备、发现服务、读写特征值、开启通知。它的回调方法onConnectionStateChange,onServicesDiscovered,onCharacteristicChanged是我们处理连接状态和数据接收的“生命线”。数据解析从蓝牙特征值里读出来的数据是一个byte[]数组。单片机可能把多个传感器数据如int16型的温度、float型的电压打包在一起发送。我们需要根据事先约定好的协议比如先2字节温度再4字节电压将这个字节数组解析成有意义的数值。绘图库的选择这是UI层的核心。我们需要一个能高效、流畅绘制实时曲线的库。MPAndroidChart功能极其强大支持多种图表但库体积较大对于简单的实时曲线有点“重”。自定义View通过Canvas和Path自己绘制。这种方式最灵活性能可控适合对UI有定制化需求或追求轻量的场景。我们将采用这种方式因为它能让我们彻底理解绘图流程并进行深度优化。架构设计为了代码清晰和可维护必须将“蓝牙操作”、“数据解析”、“UI绘制”分离。我会采用一个简单的分层架构BluetoothManager负责所有蓝牙交互DataProcessor负责协议解析WaveformView是自定义的绘图视图通过LiveData或接口回调进行通信。2.3 硬件端单片机协议设计通信协议是双方对话的“语言”必须事先约定好。一个混乱的协议会导致数据解析失败。假设我们的单片机要发送三轴加速度计的数据X, Y, Z。一个简单有效的协议设计如下字段字节数类型说明帧头20xAA, 0x55用于标识一帧数据的开始方便接收方同步数据长度1uint8_t指示后面有效数据的字节数本例为6X轴数据2int16_t加速度X轴原始值高位在前Big-EndianY轴数据2int16_t加速度Y轴原始值Z轴数据2int16_t加速度Z轴原始值校验和1uint8_t前面所有字节从帧头到Z轴数据的累加和取低8位这样一帧数据总共 2161 10 个字节。单片机程序需要按照这个格式将三个int16_t变量转换成字节数组计算校验和然后通过BLE特征值发送出去。实操心得协议里一定要加校验无线传输环境复杂偶尔会有数据错位或丢失。一个简单的累加和校验就能在APP端帮你过滤掉大部分错误数据避免绘图时出现“毛刺”或跳变。帧头则能有效解决“数据粘包”问题确保每次都能从数据流的正确位置开始解析。3. Android端核心实现详解理论说完了我们进入实战环节。我会按照功能模块逐一讲解关键代码和实现细节。3.1 权限申请与蓝牙初始化首先在AndroidManifest.xml中声明所有必要的权限。为了兼容不同版本我们尽量多声明一些。uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / !-- Android 12 需要 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / !-- 蓝牙扫描需要位置权限Android 6.0 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / !-- 如果targetSdkVersion 31还需要声明蓝牙相关权限的使用目的 -- uses-permission android:nameandroid.permission.ACCESS_COARSE_LOCATION android:maxSdkVersion30 /在Activity或Fragment中我们需要动态申请危险权限。这里以ACCESS_FINE_LOCATION为例因为它通常是扫描设备的必要条件。// 检查并请求权限 private fun checkAndRequestPermissions() { val permissionsNeeded mutableListOfString() if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { permissionsNeeded.add(Manifest.permission.ACCESS_FINE_LOCATION) } // Android 12 检查 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) ! PackageManager.PERMISSION_GRANTED) { permissionsNeeded.add(Manifest.permission.BLUETOOTH_SCAN) } if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) ! PackageManager.PERMISSION_GRANTED) { permissionsNeeded.add(Manifest.permission.BLUETOOTH_CONNECT) } } if (permissionsNeeded.isNotEmpty()) { ActivityCompat.requestPermissions(this, permissionsNeeded.toTypedArray(), PERMISSION_REQUEST_CODE) } else { initBluetooth() } } override fun onRequestPermissionsResult(requestCode: Int, permissions: Arrayout String, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode PERMISSION_REQUEST_CODE) { if (grantResults.all { it PackageManager.PERMISSION_GRANTED }) { initBluetooth() } else { // 处理权限被拒绝的情况例如提示用户并关闭应用 Toast.makeText(this, 蓝牙权限被拒绝无法使用扫描功能, Toast.LENGTH_LONG).show() } } } private fun initBluetooth() { val bluetoothAdapter: BluetoothAdapter? BluetoothAdapter.getDefaultAdapter() if (bluetoothAdapter null) { // 设备不支持蓝牙 return } if (!bluetoothAdapter.isEnabled) { // 请求用户打开蓝牙 val enableBtIntent Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) } else { // 蓝牙已开启可以开始扫描 startScan() } }3.2 设备扫描、连接与GATT操作这是蓝牙交互的核心。我们创建一个BluetoothManager类来封装所有蓝牙操作。class BluetoothManager(private val context: Context) { private var bluetoothAdapter: BluetoothAdapter? null private var bluetoothGatt: BluetoothGatt? null private var dataCharacteristic: BluetoothGattCharacteristic? null // 定义你的服务UUID和特征值UUID需要与单片机端一致 private val serviceUuid UUID.fromString(0000ffe0-0000-1000-8000-00805f9b34fb) // 示例UUID private val charDataUuid UUID.fromString(0000ffe1-0000-1000-8000-00805f9b34fb) // 示例UUID // 连接状态回调接口 interface ConnectionStateCallback { fun onConnected(deviceName: String) fun onDisconnected() fun onDataReceived(data: ByteArray) // 收到数据 } private var stateCallback: ConnectionStateCallback? null init { val bluetoothManager context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager bluetoothAdapter bluetoothManager.adapter } // 开始扫描设备 fun startScan(scanCallback: ScanCallback) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.LOLLIPOP) { val scanner bluetoothAdapter?.bluetoothLeScanner val filters listOf(ScanFilter.Builder().build()) // 可以添加过滤条件如设备名 val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 低延迟模式快速发现设备 .build() scanner?.startScan(filters, settings, scanCallback) } else { // 旧API使用 bluetoothAdapter.startLeScan } } // 停止扫描 fun stopScan(scanCallback: ScanCallback) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.LOLLIPOP) { bluetoothAdapter?.bluetoothLeScanner?.stopScan(scanCallback) } } // 连接设备 fun connect(deviceAddress: String, callback: ConnectionStateCallback) { this.stateCallback callback val device bluetoothAdapter?.getRemoteDevice(deviceAddress) device?.let { // 第三个参数autoConnect设为false我们想要直接连接 bluetoothGatt it.connectGatt(context, false, gattCallback) } } // GATT回调处理连接、服务发现、数据通知 private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { super.onConnectionStateChange(gatt, status, newState) when (newState) { BluetoothProfile.STATE_CONNECTED - { // 连接成功开始发现服务 gatt.discoverServices() stateCallback?.onConnected(gatt.device.name ?: Unknown Device) } BluetoothProfile.STATE_DISCONNECTED - { // 连接断开 closeGatt() stateCallback?.onDisconnected() } } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { super.onServicesDiscovered(gatt, status) if (status BluetoothGatt.GATT_SUCCESS) { // 查找我们需要的服务和特征值 val service gatt.getService(serviceUuid) service?.let { dataCharacteristic it.getCharacteristic(charDataUuid) dataCharacteristic?.let { char - // 开启该特征值的通知Notify gatt.setCharacteristicNotification(char, true) // 有些设备需要写入Descriptor来启用通知 val descriptor char.getDescriptor(UUID.fromString(00002902-0000-1000-8000-00805f9b34fb)) descriptor?.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) } } } } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { super.onCharacteristicChanged(gatt, characteristic) // 当特征值被更新单片机发送数据时会回调到这里 val data characteristic.value if (characteristic.uuid charDataUuid data.isNotEmpty()) { // 将原始数据传递给回调 stateCallback?.onDataReceived(data) } } // 可以重写其他方法如onCharacteristicRead, onCharacteristicWrite等 } // 断开连接并释放资源 fun disconnect() { bluetoothGatt?.disconnect() } private fun closeGatt() { bluetoothGatt?.close() bluetoothGatt null dataCharacteristic null } }3.3 数据解析模块BluetoothManager把原始的ByteArray抛出来了我们需要一个专门的解析器来把它变成我们需要的浮点数或整数。根据之前设计的协议解析器代码如下object DataParser { // 帧头 private val HEADER byteArrayOf(0xAA.toByte(), 0x55.toByte()) /** * 解析一帧数据 * param rawData 从蓝牙接收到的原始字节数组 * return 解析成功返回 TripleFloat, Float, Float? (X, Y, Z)失败返回null */ fun parseSensorData(rawData: ByteArray): TripleFloat, Float, Float? { // 1. 寻找帧头 var headerIndex -1 for (i in 0..rawData.size - HEADER.size) { if (rawData[i] HEADER[0] rawData[i 1] HEADER[1]) { headerIndex i break } } if (headerIndex -1 || rawData.size - headerIndex 10) { // 没找到完整帧 return null } // 2. 提取数据部分从帧头开始 val frame rawData.copyOfRange(headerIndex, headerIndex 10) // 3. 校验和验证 var checksumCalc: Int 0 for (i in 0 until frame.size - 1) { checksumCalc frame[i].toInt() and 0xFF // 转换为无符号整数累加 } checksumCalc checksumCalc and 0xFF // 取低8位 val checksumReceived frame[9].toInt() and 0xFF if (checksumCalc ! checksumReceived) { // 校验失败丢弃该帧 return null } // 4. 解析数据长度本例中应为6 val dataLength frame[2].toInt() and 0xFF if (dataLength ! 6) { return null } // 5. 解析三轴数据 (Big-Endian) val xRaw ((frame[3].toInt() and 0xFF) shl 8) or (frame[4].toInt() and 0xFF) val yRaw ((frame[5].toInt() and 0xFF) shl 8) or (frame[6].toInt() and 0xFF) val zRaw ((frame[7].toInt() and 0xFF) shl 8) or (frame[8].toInt() and 0xFF) // 6. 将原始值转换为实际物理量例如根据传感器量程和分辨率转换 // 假设原始值是int16量程为±2g那么转换公式为 value raw * 2.0 / 32768 val scaleFactor 2.0f / 32768 val x xRaw.toShort().toFloat() * scaleFactor val y yRaw.toShort().toFloat() * scaleFactor val z zRaw.toShort().toFloat() * scaleFactor return Triple(x, y, z) } }注意事项Byte在Kotlin/Java中是有符号的-128~127而我们的协议数据是无符号的。因此在计算校验和或解析多字节数据时必须用byte.toInt() and 0xFF将其转换为无符号整数这是BLE数据解析中最常见的坑之一。3.4 自定义实时绘图View的实现这是项目的视觉核心。我们要实现一个能平滑滚动、高效渲染的波形图。核心思想是维护一个数据列表每次新数据到来时将旧数据向左移动并在最右侧绘制新点。class WaveformView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { // 绘图相关 private val paintGrid Paint(Paint.ANTI_ALIAS_FLAG).apply { color Color.LTGRAY strokeWidth 1f style Paint.Style.STROKE } private val paintLine Paint(Paint.ANTI_ALIAS_FLAG).apply { color Color.BLUE strokeWidth 3f style Paint.Style.STROKE strokeCap Paint.Cap.ROUND } private val paintText Paint(Paint.ANTI_ALIAS_FLAG).apply { color Color.DKGRAY textSize 24f } // 数据存储使用FloatArray和索引实现环形缓冲区效率远高于List private var dataBuffer: FloatArray FloatArray(500) // 存储500个点 private var bufferIndex 0 private var isBufferFull false private val path Path() // 绘图参数 private var verticalScale 100f // Y轴缩放因子将数据值映射到像素 private var horizontalSpacing 5f // 点与点之间的水平间距像素 private var currentMaxData 0f private var currentMinData 0f /** * 添加一个新的数据点 * param value 新的传感器数值 */ fun addDataPoint(value: Float) { // 更新数据缓冲区 dataBuffer[bufferIndex] value bufferIndex if (bufferIndex dataBuffer.size) { bufferIndex 0 isBufferFull true } // 更新显示范围可选可以做成自适应 if (value currentMaxData) currentMaxData value if (value currentMinData) currentMinData value // 请求重绘 postInvalidate() } /** * 清空绘图数据 */ fun clearData() { dataBuffer FloatArray(dataBuffer.size) bufferIndex 0 isBufferFull false currentMaxData 0f currentMinData 0f invalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val width width.toFloat() val height height.toFloat() val centerY height / 2 // 1. 绘制背景网格 drawGrid(canvas, width, height) // 2. 绘制波形 if (!isBufferFull bufferIndex 1) { // 数据不足不绘制 return } path.reset() val effectiveDataSize if (isBufferFull) dataBuffer.size else bufferIndex val startX width - (effectiveDataSize * horizontalSpacing) // 找到第一个有效点的位置 var firstX startX var firstY centerY - dataBuffer[0] * verticalScale // 为了处理环形缓冲区我们需要确定绘制的起点 val startDrawIndex if (isBufferFull) bufferIndex else 0 var currentX firstX path.moveTo(firstX, firstY.coerceIn(0f, height)) for (i in 1 until effectiveDataSize) { val dataIndex (startDrawIndex i) % dataBuffer.size val dataY dataBuffer[dataIndex] val y centerY - dataY * verticalScale // 减去是因为屏幕Y轴向下为正我们希望数据向上为正 currentX horizontalSpacing // 使用lineTo绘制性能比cubicTo好对于传感器数据通常足够平滑 path.lineTo(currentX, y.coerceIn(0f, height)) } canvas.drawPath(path, paintLine) // 3. 绘制当前数值可选 val latestValue if (bufferIndex 0) dataBuffer.last() else dataBuffer[bufferIndex - 1] canvas.drawText( String.format(Value: %.2f, latestValue), 20f, 40f, paintText ) } private fun drawGrid(canvas: Canvas, width: Float, height: Float) { val centerY height / 2 // 绘制中心线 canvas.drawLine(0f, centerY, width, centerY, paintGrid) // 绘制横向网格线每50像素一条 val gridStep 50f var yPos centerY % gridStep while (yPos height) { canvas.drawLine(0f, yPos, width, yPos, paintGrid) yPos gridStep } // 绘制纵向网格线每100像素一条 var xPos 0f while (xPos width) { canvas.drawLine(xPos, 0f, xPos, height, paintGrid) xPos 100f } } // 提供一些设置方法 fun setVerticalScale(scale: Float) { verticalScale scale invalidate() } fun setHorizontalSpacing(spacing: Float) { horizontalSpacing spacing invalidate() } }这个自定义View的关键点在于使用了环形缓冲区Circular BufferFloatArray来存储数据。新数据会覆盖旧数据避免了ArrayList频繁添加删除导致的内存重新分配和GC垃圾回收这在高速数据流比如100Hz下对保持UI流畅至关重要。onDraw方法中我们根据缓冲区的数据从右向左绘制路径形成波形向左移动的视觉效果。3.5 整合与UI更新最后在Activity中将所有模块串联起来。我们使用LiveData或简单的接口回调将蓝牙接收到的数据经过解析后传递给WaveformView。class MainActivity : AppCompatActivity(), BluetoothManager.ConnectionStateCallback { private lateinit var bluetoothManager: BluetoothManager private lateinit var waveformView: WaveformView private lateinit var btnScan: Button private lateinit var deviceListView: RecyclerView private lateinit var deviceAdapter: DeviceAdapter private val scannedDevices mutableListOfBluetoothDevice() private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { super.onScanResult(callbackType, result) result.device?.let { device - if (scannedDevices.none { it.address device.address }) { scannedDevices.add(device) deviceAdapter.notifyItemInserted(scannedDevices.size - 1) } } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // 初始化视图 waveformView findViewById(R.id.waveform_view) btnScan findViewById(R.id.btn_scan) deviceListView findViewById(R.id.device_list) deviceAdapter DeviceAdapter(scannedDevices) { device - // 点击列表项连接设备 bluetoothManager.stopScan(scanCallback) bluetoothManager.connect(device.address, thisMainActivity) } deviceListView.adapter deviceAdapter // 初始化蓝牙管理器 bluetoothManager BluetoothManager(this) btnScan.setOnClickListener { checkAndRequestPermissions() // 权限检查通过后会调用initBluetooth - startScan } } // 实现 BluetoothManager.ConnectionStateCallback 接口 override fun onConnected(deviceName: String) { runOnUiThread { Toast.makeText(this, 已连接: $deviceName, Toast.LENGTH_SHORT).show() // 可以更新UI状态比如隐藏扫描列表显示绘图区域 } } override fun onDisconnected() { runOnUiThread { Toast.makeText(this, 连接已断开, Toast.LENGTH_SHORT).show() } } override fun onDataReceived(data: ByteArray) { // 在后台线程解析数据 val parsedData DataParser.parseSensorData(data) parsedData?.let { (x, y, z) - // 我们只绘制X轴数据作为示例 runOnUiThread { waveformView.addDataPoint(x) } } } private fun startScan() { scannedDevices.clear() deviceAdapter.notifyDataSetChanged() bluetoothManager.startScan(scanCallback) btnScan.text 停止扫描 btnScan.setOnClickListener { bluetoothManager.stopScan(scanCallback) btnScan.text 开始扫描 } } // ... 权限检查代码同上 ... }4. 单片机端以ESP32为例关键代码为了让整个链路跑通单片机端也需要做相应配置。这里以Arduino框架下的ESP32为例展示如何设置BLE服务和发送数据。#include BLEDevice.h #include BLEUtils.h #include BLEServer.h // 定义UUID必须与手机端一致 #define SERVICE_UUID 0000ffe0-0000-1000-8000-00805f9b34fb #define CHARACTERISTIC_UUID 0000ffe1-0000-1000-8000-00805f9b34fb BLEServer *pServer; BLECharacteristic *pCharacteristic; bool deviceConnected false; // BLE服务器回调 class MyServerCallbacks: public BLEServerCallbacks { void onConnect(BLEServer* pServer) { deviceConnected true; Serial.println(Device connected); }; void onDisconnect(BLEServer* pServer) { deviceConnected false; Serial.println(Device disconnected); // 断开后可以重新开始广播以便再次连接 pServer-getAdvertising()-start(); } }; void setup() { Serial.begin(115200); Serial.println(Starting BLE work!); // 初始化BLE设备 BLEDevice::init(ESP32_Sensor_Device); // 设备名称手机扫描时能看到 // 创建BLE服务器 pServer BLEDevice::createServer(); pServer-setCallbacks(new MyServerCallbacks()); // 创建BLE服务 BLEService *pService pServer-createService(SERVICE_UUID); // 创建特征值属性为可读、可通知 pCharacteristic pService-createCharacteristic( CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); pService-start(); // 开始广播 BLEAdvertising *pAdvertising BLEDevice::getAdvertising(); pAdvertising-addServiceUUID(SERVICE_UUID); pAdvertising-setScanResponse(true); pAdvertising-setMinPreferred(0x06); // 有助于iPhone连接 pAdvertising-setMinPreferred(0x12); BLEDevice::startAdvertising(); Serial.println(Characteristic defined! Now you can connect and read/notify.); } // 模拟传感器数据并发送 void loop() { if (deviceConnected) { // 1. 模拟三轴加速度数据范围 -32768 ~ 32767 int16_t accX random(-32768, 32767); int16_t accY random(-32768, 32767); int16_t accZ random(-32768, 32767); // 2. 按照协议组帧 uint8_t txBuffer[10]; txBuffer[0] 0xAA; // 帧头1 txBuffer[1] 0x55; // 帧头2 txBuffer[2] 6; // 数据长度 (3个int16_t 6字节) // 填入数据 (Big-Endian) txBuffer[3] (accX 8) 0xFF; txBuffer[4] accX 0xFF; txBuffer[5] (accY 8) 0xFF; txBuffer[6] accY 0xFF; txBuffer[7] (accZ 8) 0xFF; txBuffer[8] accZ 0xFF; // 计算校验和帧头到数据部分的累加和 uint8_t checksum 0; for(int i0; i9; i) { checksum txBuffer[i]; } txBuffer[9] checksum; // 3. 通过BLE特征值发送 pCharacteristic-setValue(txBuffer, 10); pCharacteristic-notify(); // 发送通知手机端的onCharacteristicChanged会被触发 Serial.printf(Sent data: X%d, Y%d, Z%d\n, accX, accY, accZ); } delay(50); // 控制发送频率这里是20Hz }这段代码让ESP32作为一个BLE服务器创建一个包含可通知特征值的服务。在loop中它按照我们定义的协议格式打包模拟的传感器数据并通过notify()发送出去。5. 性能优化与常见问题排查项目基本功能完成后我们往往会遇到性能或稳定性问题。这里分享一些实战中总结的优化技巧和排错方法。5.1 性能优化要点绘图性能减少onDraw调用中的对象分配避免在onDraw里创建新的Paint、Path或String对象。所有画笔和路径都应在初始化时创建并复用。使用canvas.clipRect如果你的波形图只有一部分区域更新可以使用clipRect来限制绘制区域减少重绘开销。考虑使用SurfaceView如果数据刷新率极高60HzView的onDraw在主线程运行可能会阻塞。SurfaceView拥有独立的绘制线程更适合高频刷新但实现更复杂。蓝牙通信稳定性连接参数优化BLE连接有间隔Connection Interval、从机延迟Slave Latency等参数。更短的间隔意味着更快的通信速度和更高的功耗。可以通过BluetoothGatt的requestConnectionPriority方法请求高优先级CONNECTION_PRIORITY_HIGH但这需要设备支持。处理连接丢失蓝牙连接在弱信号下容易断开。实现自动重连逻辑是提升用户体验的关键。可以在onConnectionStateChange回调中检测到断开后延迟几秒尝试重新连接。数据流控如果单片机发送数据过快手机端可能处理不过来导致数据堆积丢失。可以在协议中加入简单的应答机制或者由手机端控制发送频率。5.2 常见问题排查表问题现象可能原因排查步骤与解决方案扫描不到设备1. 蓝牙未开启。2. 权限未授予尤其是定位权限。3. 设备不在广播状态。4. Android版本过高需要新权限。1. 检查手机蓝牙开关。2. 检查App权限设置确保定位权限已授予。3. 确认单片机程序已运行并开始广播。4. 对于Android 12在Manifest中添加BLUETOOTH_SCAN权限并检查是否声明了neverForLocation如果不需要精确定位。能扫描到但连接失败1. 设备已被其他设备连接。2. 设备距离过远或信号干扰。3. 系统蓝牙缓存问题。1. 确保设备未被手机或其他主机占用。2. 靠近设备排除干扰源。3. 尝试重启手机蓝牙或重启设备。对于开发阶段可以尝试在手机设置中“忘记”该蓝牙设备或重启手机。连接成功但收不到数据1. 服务/特征值UUID不匹配。2. 未成功开启特征值的通知Notify。3. 单片机端未正确调用notify()。4. 数据解析出错被过滤。1.最可能的原因用手机上的BLE调试工具如nRF Connect连接设备查看其服务UUID和特征值UUID与代码中写的是否完全一致。2. 确认手机端成功执行了setCharacteristicNotification和writeDescriptor。3. 确认单片机代码在发送数据时调用了characteristic-notify()。4. 在手机端onDataReceived回调中打印原始byteArray的16进制与单片机发送的对比检查协议格式和校验。绘图卡顿、不流畅1. UI主线程负担过重。2.WaveformView的onDraw方法过于复杂或频繁创建对象。3. 数据刷新过快。1. 使用Android Profiler检查主线程Main Thread的CPU使用情况。2. 确保onDraw中无对象创建使用环形缓冲区。3. 限制addDataPoint和invalidate()的调用频率例如每收到2-3帧数据才更新一次UI。数据绘图出现剧烈跳变或毛刺1. 蓝牙传输误码数据错误。2. 数据解析逻辑有bug特别是字节序处理错误。3. 校验和未生效错误数据被绘制。1. 加强协议校验如改用CRC16。2.重点检查单片机是int16_t手机端解析时是否按short处理字节序大端/小端是否一致用调试工具对比收发双方的原始字节。3. 确保校验和函数正确并在解析时严格校验校验失败的数据直接丢弃。App退到后台后蓝牙断开或数据停止1. 系统为省电限制了后台活动。2.BluetoothGatt回调所在的Service被杀死。1. 将蓝牙操作移至ForegroundService前台服务并在通知栏显示一个持续的通知。2. 使用WorkManager或JobScheduler来管理后台任务但蓝牙后台限制很严格前台服务是最可靠的方式。5.3 一个关键的避坑技巧处理GATT回调线程BluetoothGattCallback的回调如onCharacteristicChanged并不在主线程UI线程。如果你在里面直接更新UI比如操作WaveformView在低版本系统上可能偶尔工作但在高版本或某些机型上必然崩溃。正确做法使用Handler或runOnUiThread将UI更新操作抛回主线程。override fun onDataReceived(data: ByteArray) { // 这个回调在后台线程 val parsedData DataParser.parseSensorData(data) parsedData?.let { value - // 使用Handler或runOnUiThread Handler(Looper.getMainLooper()).post { waveformView.addDataPoint(value) } // 或者在Activity中可以用 // runOnUiThread { waveformView.addDataPoint(value) } } }忽略这个细节是很多BLE应用在测试时看似正常但一交给用户就随机崩溃的主要原因之一。整个项目从构思到实现最耗时的部分往往不是编码而是调试和优化。尤其是蓝牙部分涉及硬件、协议栈、操作系统多个层面问题可能出现在任何一环。掌握一套系统的排查方法比如从手机端日志、BLE调试工具、单片机串口打印三方对照能极大提升效率。希望这份超详细的拆解能帮你绕过我踩过的那些坑顺利实现自己的Android蓝牙数据绘图应用。本文还有配套的精品资源点击获取