☰
Android蓝牙通信开发实战:从选型到联调避坑全解析
2026/10/14 17:37:43 网站建设 项目流程

做Android开发这些年,蓝牙通信一直是我觉得最能体现“手上功夫”的模块之一。乍一看官方文档就那么几个类,但真把自己关进一个联调现场,你会发现权限、配对、UUID、线程、厂商定制ROM各自埋了一堆雷。这篇是系列文章的第六篇,前几篇我们搭好了项目骨架、界面框架和数据持久化,今天就专门把蓝牙通信这条链路整个打通:从选型、权限适配,到设备发现、建立连接、数据收发,再到联调过程中真正踩过的坑,一次性讲透。

蓝牙通信适合什么场景?一句话概括就是设备之间短距离、无网络依赖的本地数据交换。常见的落地场景包括:给某个硬件工具写配套App、两台手机之间点对点传文件、控制一台蓝牙串口模块连接的嵌入式设备。不管你是做毕设、做公司内部工具,还是自己折腾智能家居,这套流程都能直接用得上。

我会按自己平时开发的顺序来写:先想清楚用哪种蓝牙,再配置环境,然后写关键代码,最后把我在真机上发现的各种诡异问题整理成一份排查手册。

1. 蓝牙通信方案选型:先想清楚你要哪一种蓝牙

1.1 经典蓝牙与低功耗蓝牙的取舍

蓝牙通信在Android上其实分两大流派,一个是经典蓝牙(BR/EDR),一个是低功耗蓝牙(BLE,Bluetooth Low Energy)。很多新手上来就直接找代码,结果找到的教程一半讲的是经典蓝牙的socket通信,另一半讲的是BLE的GATT回调,两套API完全不是一回事,照着抄必然会翻车。

我建议你先用下面这张表做个快速判断:

维度经典蓝牙(BR/EDR)低功耗蓝牙(BLE)
通信方式RFCOMM通道,类似TCP socketGATT协议,基于服务/特征值
典型速率能到1-2Mbps级别,实际几十KB/s理论2Mbps,实际看MTU协商
功耗较高极低,适合长期待机
连接过程需要先配对,再建立socket先扫描,再连接GATT
适用场景文件传输、串口透传、持续数据流传感器上报、信标、小数据包交互
开发复杂度线程模型清晰,接近普通Socket编程回调嵌套多,状态机复杂

如果你想实现的是两台设备之间“聊天式”的数据互传,比如传文本、传文件、控制外部硬件模块,我强烈建议直接用经典蓝牙。它走的是RFCOMM通道,底层思想跟TCP socket非常接近,写起来更顺手,数据吞吐也够用。

如果你只是每隔几秒采集一次温湿度、心率之类的小数据,或者需要设备在后台低功耗待机,那才需要考虑BLE。要注意的是,Android的BLE扫描方式跟经典蓝牙完全不同,它在API 21之后有一套独立的扫描回调机制,很多做传感器的博主也容易在这两个体系之间切换时绕晕。

这个系列项目做的是通用的设备间通信工具,所以我下面的内容全部基于经典蓝牙的RFCOMM方案展开。选它的另一个原因是它不需要服务端架构,两台手机或者手机加开发板就能点对点跑起来,调试成本最低。

1.2 通信双方的角色分配:谁是服务端,谁是客户端

蓝牙通信里最重要的一步不是写代码,而是先确定两端各自的角色。一发一收,一个设备充当服务端,进入监听状态等待连接;另一个设备充当客户端,主动发起连接。

这个角色关系跟TCP很像,但有一个容易忽略的差异:蓝牙的“服务端”并不是一个常驻进程,而是指当前设备调用监听接口、等待别人连过来的那一方。一旦连接建立,双方的socket就变成对等的了,谁都可以往里写数据,谁都可以读对方发来的数据。你不需要区分“主设备”“从设备”,只需要在建立连接的瞬间把两个线程写好。

角色确定后,接着就要选一个UUID。UUID在蓝牙通信里相当于“服务的门牌号”,它标识了你提供的是一个什么样的服务。通信双方必须使用同一个UUID才能握手成功。Android官方示例里固定用那个00001101-0000-1000-8000-00805F9B34FB,这其实是蓝牙串口服务(SPP)的标准UUID,很多串口模块都用它。

在实际开发中我建议不要直接照抄官方示例里的固定值。你可以在本地随便生成一个随机UUID,比如用UUID.randomUUID().toString(),然后把同一个值写到两端代码里。原因很简单:如果你跟附近其他同样用官方示例值的设备连在一起,有可能串线。自定义UUID能保证你的服务不会被别人的设备误连接。

1.3 完整通信链路的数据流向

把整条链路的全貌画在脑海里之后再动手,会从容很多。实现蓝牙通信整个环节依次是:权限申请→适配器初始化→扫描设备→触发配对→服务端监听/客户端连接→建立socket→读写数据→关闭连接。

这里有个容易被忽略的关键点:蓝牙连接从开始到结束,整个过程至少要协调两个线程。一个线程负责阻塞等待连接,另一个线程负责建立连接后的持续读写。如果你在主线程里调用connect()方法,界面基本几秒后就ANR了。所以设计代码时,我习惯先把线程模型想清楚,再填充API调用。

2. 环境准备:先把权限和适配器跑通

2.1 Android 12前后的权限模型差异

蓝牙权限是整个模块最容易出问题的地方。以Android 12(API 31)为分水岭,权限模型发生了很大变化。

Android 11及以下(API 30及以下),在清单文件里声明BLUETOOTH和BLUETOOTH_ADMIN就够了。但扫描蓝牙设备还要额外申请定位权限ACCESS_FINE_LOCATION,这一步经常被人漏掉。因为Android系统认为你能扫描到周围蓝牙设备,就等于间接获取了用户位置信息,所以必须给用户一个明确的授权入口。

Android 12及以上,蓝牙权限被拆分成了两个独立的运行时权限:BLUETOOTH_SCAN(扫描)和BLUETOOTH_CONNECT(连接)。如果targetSdk还在30及以下,APP跑在Android 12手机上时系统会自动帮你做兼容,但一旦targetSdk升到31以上,没申请这两个权限,一调用蓝牙API就会直接抛SecurityException。

下面是我现在项目里使用的清单声明,兼顾了新旧版本:

<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.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />

如果你确定App不依赖蓝牙做位置推断,可以在BLUETOOTH_SCAN权限上加一个android:usesPermissionFlags="neverForLocation"属性。加上之后,在Android 12及以上系统里就不再强制要求定位权限了。但要注意,targetSdk 30及以下的设备仍然需要ACCESS_FINE_LOCATION,这也是为什么我保留它并限制最高版本号。

2.2 动态权限申请的正确姿势

有了清单声明还不够,Android 6.0之后运行时权限必须动态申请。我用的是registerForActivityResult这套Activity Result API,它是目前最推荐的方式,代码比原来onRequestPermissionsResult回调清晰太多。

private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> val scanGranted = result[Manifest.permission.BLUETOOTH_SCAN] ?: false val connectGranted = result[Manifest.permission.BLUETOOTH_CONNECT] ?: false if (scanGranted && connectGranted) { initBluetooth() } else { toast("缺少蓝牙权限,无法继续") } } private fun checkPermission() { val needPermissions = mutableListOf<String>() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) { needPermissions.add(Manifest.permission.BLUETOOTH_SCAN) } if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { needPermissions.add(Manifest.permission.BLUETOOTH_CONNECT) } } else { if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { needPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } } if (needPermissions.isNotEmpty()) { permissionLauncher.launch(needPermissions.toTypedArray()) } else { initBluetooth() } }

申请完权限并不意味着一定搜得到设备,因为某些国产定制ROM还会在系统设置里额外加一个“附近设备”开关,代码里申请了权限但系统设置没打开,照样返回空列表。这个我在后面的排查章节再细说。

2.3 蓝牙适配器检查与开启流程

权限到位后,第一步是拿到本机的蓝牙适配器实例。代码很简单:

private val bluetoothAdapter: BluetoothAdapter? by lazy { BluetoothAdapter.getDefaultAdapter() } private fun initBluetooth() { val adapter = bluetoothAdapter if (adapter == null) { toast("当前设备不支持蓝牙") return } if (!adapter.isEnabled) { val enableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) enableBluetoothLauncher.launch(enableIntent) } else { startDiscovery() } }

getDefaultAdapter()返回null说明设备没有蓝牙硬件,这种情况在平板和模拟器上很常见。请求开启蓝牙时,系统会弹出一个对话框询问用户是否允许开启,不要直接在代码里调用enable()强开蓝牙,系统现在也不建议这么做。

有一点经验值得分享:注册蓝牙状态广播ACTION_STATE_CHANGED可以帮你监听用户最终点了“允许”还是“拒绝”,再决定后续逻辑是否继续。我最初忽略这个广播,用户拒绝开启蓝牙后,界面一直卡在“开启中”的状态,非常反直觉。所以我会在初始化时就注册一个蓝牙状态接收器。

3. 核心代码实现:从扫描设备到建立连接

3.1 设备扫描与列表展示

经典蓝牙的设备发现依赖系统广播。注册一个BroadcastReceiver监听两个关键动作:ACTION_FOUND(发现一个设备)和ACTION_DISCOVERY_FINISHED(发现完毕)。

private val discoveryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { BluetoothDevice.ACTION_FOUND -> { val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) device ?: return val name = device.name ?: "未知设备" if (!deviceList.contains(device)) { deviceList.add(device) adapter.notifyItemInserted(deviceList.size - 1) } } BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> { toast("扫描完成,共发现 ${deviceList.size} 台设备") } } } } private fun startDiscovery() { val adapter = bluetoothAdapter ?: return if (adapter.isDiscovering) { adapter.cancelDiscovery() } deviceList.clear() adapter.startDiscovery() }

这里有个细节,BluetoothDevice.EXTRA_DEVICE在低版本里是Parcelable,直接用getParcelableExtra就行。但在Android 13及以上(targetSdk 33+),系统要求用getParcelableExtra的话要指定Class,否则会有类型安全警告。我在项目里写了个兼容方法,通过判断SDK版本分别处理。

另外,扫描过程中如果已经找到了目标设备,最好是立刻调用cancelDiscovery()停止扫描。原因很好理解:蓝牙在扫描过程中是以跳频方式不断切换信道搜索,会占用大量射频资源,不仅拖慢当前连接速度,还可能影响旁边的WiFi。我实测过,两台手机距离很近的情况下,一边扫描一边连接,连接建立速度会肉眼可见地变慢。

设备列表出来后,下一步是配对。调用device.createBond()会触发系统的配对对话框。大多数场景下,系统对话框会给出确认码让你确认,或者让你输入PIN码(比如某些老式蓝牙串口模块默认PIN是1234)。我们作为开发者,一般不需要自己处理配对流程,等系统弹窗即可。

但如果你的App要自动化操作、不想人工介入,可以通过监听ACTION_PAIRING_REQUEST广播来自动应答。下面是个简单的PIN码自动填充逻辑:

// 处理配对请求广播 if (action == BluetoothDevice.ACTION_PAIRING_REQUEST) { val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) if (device != null) { val pin = "1234".toByteArray(Charsets.UTF_8) device.setPin(pin) device.setPairingConfirmation(true) } }

需要注意的是,setPin和setPairingConfirmation这两个方法是隐藏API,在部分系统上加上反射也不一定稳定。我的原则是:能用系统弹窗就尽量用系统弹窗,自动配对只是兜底方案。

3.2 服务端监听与主动连接:两个线程搞定

连接建立阶段是写法最讲究的部分。我把服务端和客户端分别封装成两个线程。

先看服务端。服务端调用listenUsingRfcommWithServiceRecord打开一个监听socket,然后阻塞在accept()方法上等待客户端连入:

private inner class AcceptThread : Thread() { private val serverSocket: BluetoothServerSocket? = bluetoothAdapter?.listenUsingRfcommWithServiceRecord(SERVICE_NAME, MY_UUID) override fun run() { var socket: BluetoothSocket? = null try { socket = serverSocket?.accept() socket?.let { connectedThread = ConnectedThread(it).also { thread -> thread.start() onConnected(it.remoteDevice) } } } catch (e: IOException) { e.printStackTrace() } finally { serverSocket?.close() } } }

再看客户端。客户端通过device.createRfcommSocketToServiceRecord(MY_UUID)创建socket,然后调用connect()主动连接:

private inner class ConnectThread(device: BluetoothDevice) : Thread() { private val socket: BluetoothSocket? by lazy { device.createRfcommSocketToServiceRecord(MY_UUID) } override fun run() { // 连接前必须先取消扫描,否则会大幅降低连接成功率 bluetoothAdapter?.cancelDiscovery() try { socket?.connect() connectedThread = ConnectedThread(socket!!).also { it.start() } onConnected(device) } catch (e: IOException) { e.printStackTrace() runOnUiThread { toast("连接失败:${e.message}") } } } }

客户端连接时有个特别经典的坑:如果当前另一线程正在执行startDiscovery(),必须先调用cancelDiscovery(),否则socket连接经常报Service discovery failed。这个坑官方文档里其实写了,但极容易被忽视。

还有个大坑是部分国产芯片或者老模块在连接时会走厂商私有协议,导致标准createRfcommSocketToServiceRecord方式连不上。这时可以用一个隐藏接口createRfcommSocket(int channel)来兜底,常见做法是通过反射调用:

private fun createInsecureSocket(device: BluetoothDevice): BluetoothSocket? { return try { val method = device.javaClass.getMethod("createRfcommSocket", Int::class.javaPrimitiveType) method.invoke(device, 1) as BluetoothSocket } catch (e: Exception) { null } }

这个方法本质上是在访问系统的隐藏API,虽然很多老项目都用它,但不同厂商系统对反射的限制不同,概率会越来越不好用。我的建议是:优先使用标准API,只有确认连接失败且怀疑是RFCOMM通道问题时,再尝试这种反射方案,并且一定要用try-catch兜住。

3.3 建立连接后的数据读写循环

连接一旦建立,双方手里都是一条BluetoothSocket,接下来的数据收发就跟操作普通IO流一样。我写了一个ConnectedThread统一管理读写:

private inner class ConnectedThread(private val socket: BluetoothSocket) : Thread() { private val inputStream: InputStream = socket.inputStream private val outputStream: OutputStream = socket.outputStream private val writeLock = Any() override fun run() { val buffer = ByteArray(1024) while (!isInterrupted) { try { val bytes = inputStream.read(buffer) if (bytes > 0) { val message = String(buffer, 0, bytes, Charsets.UTF_8) runOnUiThread { onMessageReceived(message) } } else if (bytes == -1) { break } } catch (e: IOException) { e.printStackTrace() break } } } fun write(data: ByteArray) { synchronized(writeLock) { outputStream.write(data) outputStream.flush() } } }

这段代码看起来简单,实际使用时我需要强调两个问题。

第一个问题:消息边界。InputStream.read()不知道对方的消息从哪开始、到哪结束,可能一次读到半条消息,也可能一次黏住好几条消息。做聊天类应用时这是必踩的坑。我通常在传输协议里加一个简单的消息帧格式:前4个字节表示消息长度,后面跟着消息内容。接收端先读满4个字节的Int,再读指定长度的内容。这样粘包半包问题就彻底解决了。

第二个问题:线程生命周期。read()会一直阻塞,当Activity销毁时,如果只顾着关socket,没有把线程置为中断状态,下次尝试重新连接时旧线程还占着旧socket,新连接必然失败。所以我在关闭连接的工具方法里会同时做三件事:关闭socket、中断线程、置空引用。

write()方法里我用synchronized锁包住OutputStream的写操作。Android的蓝牙socket内部虽然对并发写有一定限制,但多个线程同时调这个方法时仍然可能出问题,加一个同步锁成本很低,换来的是长期稳定性。

4. 联调实录:常见问题与排查技巧

4.1 扫描不到设备,先检查这五件事

扫描不到设备是新手反馈最多的问题,也是我在几个跨部门协作项目里反复被问到的。以下是我的排查顺序,建议按这个顺序逐项检查:

第一,运行时权限是否真正申请成功。很多人只在清单文件里声明了权限,没做动态申请,结果扫描直接返回空。Android 12及以上要看BLUETOOTH_SCAN,Android 11及以下要看ACCESS_FINE_LOCATION,不能只看蓝牙本身的权限。第二,是否注册了广播接收器。设备发现结果是通过广播返回的,如果你没有动态注册BluetoothDevice.ACTION_FOUND,扫描生命周期再正常也收不到任何数据。第三,系统蓝牙是否真的开启了。不要以adapter.isEnabled作为唯一依据,有些定制ROM在省电策略下会把蓝牙扫描静默暂停。第四,扫描超时问题。蓝牙发现默认持续约12秒,超过后自动停止。如果你的界面没有监听ACTION_DISCOVERY_FINISHED,用户会感觉“好像一直没搜完”。我通常会让用户在扫描结束后手动再点一次刷新,或者干脆做一个"扫描中/完成"的状态动画。第五,部分国产定制ROM的系统设置里还有独立的“附近设备”权限开关。你在App里申请了权限,但在系统设置里被单独关闭,照样扫不到。我在某款机型上就遇到过这种诡异情况,最后是让用户去设置里手动打开开关才解决。

如果做了以上五步还是扫不到,换一台不同品牌设备交叉测试。我在一个双机互传的调试任务中就遇到过:某台手机扫描列表稳定为空,另一台却能正常发现,最后定位是该手机系统蓝牙服务异常,重启一下就好。

4.2 连接失败与UUID不匹配的经典坑

连接失败时最常见的报错是Service discovery failed或read failed, socket might closed。我把原因分成三类,逐个排查。

第一类是UUID不一致。服务端和客户端必须使用同一个UUID。注意BluetoothServerSocket里的UUID和createRfcommSocketToServiceRecord的UUID必须一模一样。我曾经为了“看起来更通用”,在客户端用了另一个UUID,结果每次连接都失败,查了半天才发现两端串了。第二类是同一设备上多个server socket冲突。如果你多次调用了listenUsingRfcommWithServiceRecord而没有关闭上一个监听socket,系统可能只允许一个同名服务实例,后续监听会失败。我记得在开发某白板投屏工具时,就反复遇上重启监听后失败的情况。后来我在进入监听前先调用close()清理上次遗留的serverSocket,问题才消失。第三类是连接后没有及时取消发现。这个问题我在3.2节里提过,再次强调:连接前调用cancelDiscovery()是降低失败率的关键。在嵌入式蓝牙模块上,同时扫描和连接时,模块响应不及时的概率极高。

还有一类值得单独说:连接成功了,但读写老掉线或者发一会儿就断开。这种多见于低功耗的廉价蓝牙串口模块,它们的固件对数据缓冲处理不严谨。我处理这类问题时,会把单次写入的数据控制在256字节以内,并且两次写入之间加一点短延迟,防止模块缓冲区溢出。虽然牺牲了一点速度,但稳定性明显提升。

4.3 卡顿、ANR与大文件传输的内存问题

蓝牙通信里的ANR大多数是因为在主线程直接做了socket操作。一个很常见的错误写法是在按钮点击事件里直接执行socket.connect(),如果目标设备不在范围内,connect会一直尝试到超时,界面直接白屏,最后弹出“无响应”。

正确做法是像3.2节那样,把连接逻辑放进子线程。读循环天然是阻塞的,无论如何都不能放到主线程。UI刷新必须通过runOnUiThread或Handler切回主线程。

大文件传输的坑则更隐蔽。很多人从输入流读数据时,喜欢一次性read进一个超大byte数组,比如几十MB的文件想用byteArrayOfSize(fileSize)直接装下。这在大内存手机上可以运行,但遇到文件特别大时极其危险,直接OOM。正确姿势是边读边写入文件:

val buffer = ByteArray(8192) FileOutputStream(targetFile).use { fos -> while (true) { val bytes = inputStream.read(buffer) if (bytes <= 0) break fos.write(buffer, 0, bytes) // 更新进度 progress += bytes runOnUiThread { updateProgress(progress) } } }

经典蓝牙的实际传输速率受设备射频和天线影响,普遍在50KB/s到150KB/s之间。如果你要做文件互传功能,一定要给用户一个进度条和传输速率显示,否则几MB的文件等上一分多钟,用户早就以为卡死了。

4.4 调试阶段的日志与辅助工具

最后分享一个调试习惯。我在联调蓝牙时几乎都会打开蓝牙HCI日志,Android的开发者选项里有一个“蓝牙HCI snoop日志”开关。打开后系统会把蓝牙HCI层收发的所有数据包记录到文件里,配合日志查看工具分析,定位配对、加密、连接失败这类底层问题非常有用。

但要注意,开启HCI日志会明显影响蓝牙传输速度,平时测试完记得关掉。我在一次性能测试中发现,开着HCI日志时传输速度只有几十KB/s,关掉后立刻恢复到了100多KB/s。这个细节容易被忽略,很容易导致你误判“代码有问题”。

5. 后续优化方向与一点个人体会

蓝牙通信做完了基本链路,后面想再深入可以从三个方向扩展。第一是支持多设备同时连接。用HashMap<String, ConnectedThread>按设备地址管理多条socket,收发消息时用设备地址做路由。第二是断线重连机制。用指数退避策略控制重连频率,间隔从1秒翻倍到5秒、10秒、30秒,杜绝死循环式重连。第三是把整个蓝牙模块抽成一个单例服务,用接口把连接状态、数据回调暴露给业务层,这样以后切换成BLE方案,或者换一套传输协议,都不需要改动UI层。

讲到个人体会,我在实际项目中感受最深的还是那句老话:先跑通再优化。蓝牙涉及设备、系统、射频环境各种变量,不要在写代码阶段纠结太过完美的协议设计。我见过很多新同事第一步就想设计一套复杂的帧格式和加密策略,结果连两端都没连通过,白白浪费调试时间。我自己的习惯是先用最简单的String消息把手拉通,确认扫描、配对、连接、读写链路完全正常后,再逐步替换成正式的二进制协议。

另外一个确定性很高的经验是:一定要备两台以上的真机,并且尽量是不同的品牌和Android版本。模拟器对蓝牙的支持非常有限,测试难度大不说,很多协议细节跟真机完全不同。我在一个项目中就是靠一套老安卓机和一台新手机交叉测试,才把权限适配、系统弹窗行为这些差异问题排查清楚。

蓝牙通信这个模块做完之后,你会发现它对程序员的锻炼是全方位的:线程调度、IO流处理、系统权限、状态机管理,每一样都是通用能力。即使以后这个项目结束,这套代码的架构思路和分析方法也能直接迁移到其他通信场景上去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询