
1. 先回答一个分岔问题你要连的是经典蓝牙还是BLE做Android蓝牙项目前最怕的不是不会写代码而是根本没想明白自己连的是什么设备。我最早接到android蓝牙连接-兼容旧版本这个需求时客户给的设备清单里有老式的串口蓝牙模块也有低功耗蓝牙标签扫描器还要同时跑在仓库里一批安卓5.1的老平板和员工手里的新手机上。真正动手后才发现权限、扫描、配对、连接四个环节每一环都因为版本差异埋着完全不同的坑。这篇记录就按我踩坑的顺序来写把思路和结论一起讲清楚。如果你也正在做类似的项目先别急着去查ScanFilter或者UUID第一个要回答的问题应该是你要连的到底是经典蓝牙Bluetooth Classic还是低功耗蓝牙BLE。这两个东西在日常开发里可以当成两套完全不同的协议栈来对待混为一谈的话后面所有代码都会越写越乱。1.1 两条协议栈各自为政经典蓝牙跑的是BR/EDR我们常用的SPP串口协议就在这条主线上。HC-05、HC-06、JDY-31这些嵌入式蓝牙串口模块以及大量国产音频模块都是走这条链路。经典蓝牙的特点是配对之后会建立一个稳定的RFCOMM通道两端是透明的双向字节流。对做硬件对接的人来说这个体验很像用蓝牙代替串口线数据以小包为主控制性很强缺点是功耗偏高配对流程相对繁琐。BLE走的是GATT架构扫描、连接、读写都是基于Service和Characteristic来做的。手环、BLE体重秤、温湿度计、ibeacon以及这几年流行的蓝牙标签扫描器基本都是BLE设备。BLE的API设计是异步回调式的扫描结果一个一个往外蹦连接后的读写也有一套自己的规则跟经典蓝牙的socket模型差距很大。这两条线最大的区别在于连接方式不同、扫描API不同、配对机制不同、数据读写方式完全不同。我见过不少项目在需求阶段以为反正都是蓝牙应该差不多结果中途发现模块换成BLE之后几乎整个连接层都要重写。所以第一步一定要把协议栈定死这直接决定后面的兼容方案怎么设计。1.2 常见模块的连接套路结合我接触过的硬件简单列一下各类模块在Android端最常用的连接方式模块类型协议栈常见波特率/服务Android端连接方式HC-05经典蓝牙9600/38400/115200可AT配置SPP UUIDRFCOMM socketHC-06经典蓝牙9600仅从机不可配ATSPP UUIDRFCOMM socketJDY-31经典蓝牙9600/115200可AT配置SPP UUIDRFCOMM socket杰理系列音频模块经典蓝牙/A2DP视型号而定部分走蓝牙音频部分走透传需确认HM-10/CC2541BLEGATT串口服务BluetoothLeScanner扫描后连GATT蓝牙标签扫描器BLEGATT自定义服务按厂商文档读写Characteristic比如HC-06很多人拿它接Arduino Nano流程其实很固定模块的TXD接到Arduino的RXRXD通过分压电路接到Arduino的TXGND共地VCC接5V。上电后模块不主动广播但可以被手机扫描到手机端用SPP的固定UUID建立RFCOMM连接就能双向透传。整个过程不需要createBond配对连接成功即可通信这一点跟耳机、音箱那类设备差别很大。如果是杰理这类国产音频芯片模块情况会复杂一些。它们的很多方案走的是A2DP音频通道手机和模块建立的是媒体音频连接不走SPP也有一部分是做蓝牙透传数据用的。拿到模块第一件事是看芯片方案和固件文档不要想当然地套用某一种连接方式。1.3 先定下minSdk和targetSdk兼容性的大方向就定了确定完协议栈接下来要做的不是写代码而是先把工程的minSdk和targetSdk定下来。这是兼容旧版本最根本的落脚点。我当时把minSdk定在21也就是Android 5.0。原因很简单仓库里实际在用的那批平板是Android 5.1.1再往前的Android 4.x设备存量已经很小没必要为了极少数设备扩大测试范围。如果你手里有明确需求要兼容Android 4.4那minSdk可以放到19但要做好心理准备代码里的分支判断会更多。build.gradle里的配置大概是这样的android { compileSdk 34 defaultConfig { applicationId com.example.btdemo minSdk 21 targetSdk 33 versionCode 1 versionName 1.0 } }很多人对targetSdk的理解比较模糊实际上targetSdk是系统判断要不要给你启用新行为的关键开关。Google在Android 12里对蓝牙权限做了大拆分但这个新行为只对targetSdk大于等于31的应用生效。如果你的targetSdk还停在30或者更低在Android 12手机上跑的时候系统会按旧规则放行但代价是新权限机制里的很多好处你也享受不到。现在Google Play对新上架应用有targetSdk要求国内应用市场也在跟进所以工程上建议直接把targetSdk定到33或34。这里要注意targetSdk定得高Android 12的蓝牙新权限行为就会被强制启用后面权限处理这一关必须做对否则连接时很容易出现SecurityException闪退。2. 蓝牙权限的三次改版6.0、8.0、12.0每一次都是坑蓝牙权限是我在整个兼容改造中花费时间最多的部分没有之一。Android的蓝牙权限规则大体经历了三次比较大的调整每次调整都不是简单地加一个新权限而是把整个授权模型重做了一遍。下面这张表是我后来整理给团队看的可以直接对照着用Android版本API级别扫描设备需要什么连接/配对需要什么备注4.2 ~ 5.117 ~ 22不需要定位权限清单声明BLUETOOTH即可安装时自动授权6.0 ~ 923 ~ 28动态申请定位权限清单声明BLUETOOTH即可还要开定位服务否则扫不到10 ~ 1129 ~ 30动态申请ACCESS_FINE_LOCATION清单声明BLUETOOTH即可targetSdk29时对后台扫描更严格1231BLUETOOTH_SCANBLUETOOTH_CONNECT仅当targetSdk31时启用新模型2.1 老版本权限在安装时就给完了在Android 6.0之前权限机制特别简单。你只需要在AndroidManifest.xml里声明BLUETOOTH和BLUETOOTH_ADMIN安装App的时候系统直接把权限给完代码里什么都不用问。这也是很多老项目一直能跑得痛快的根本原因——它们从不需要处理动态授权。但问题恰恰出在这里老代码在新手机上直接崩。我见过一个项目在Android 6.0以上打开蓝牙功能时没有任何权限判断结果用户点击扫描设备按钮后App先是没反应接着就是SecurityException闪退。原因就是Android 6.0引入运行时权限后高危权限必须动态申请清单里声明过还不够。由于我们minSdk定在21Android 5.0和5.1的手机还是走旧逻辑。所以兼容代码里必须留一条分支API 23以下直接放行不需要走动态权限流程。2.2 Android 6.0开始扫描蓝牙居然要位置权限Android 6.0以后扫描蓝牙设备需要申请定位权限这是无数人踩过的坑。Google当时的理由是蓝牙扫描结果可以用来推断用户位置——比如通过周围固定的BLE beacon定位所以把蓝牙扫描归到了位置权限体系里。这里有个特别容易忽略的细节仅仅申请权限还不够用户还得把系统的定位服务开关打开否则扫描结果一样是空的。很多人卡在权限我已经给了为什么还是扫不到设备这个问题上最后发现是位置信息的总开关没打开。这个现象在Android 6.0到Android 11之间非常普遍。到Android 10也就是API 29情况又收紧了一层。如果targetSdk大于等于29扫描蓝牙设备要求ACCESS_FINE_LOCATION而不是之前的ACCESS_COARSE_LOCATION也能凑合用。也就是说为了让Android 10以上设备正常工作代码里最好直接申请精确定位权限。一个好消息是如果只是做蓝牙连接不需要在后台持续扫描的话Android 6.0到11这一段的处理相对简单申请到定位权限、用户打开定位服务后面就不会有太多幺蛾子。2.3 Android 12把蓝牙权限从位置权限里拆了出来到了Android 12Google终于意识到蓝牙扫描这件事跟用户位置并不总是强相关于是把权限重新拆成了三个BLUETOOTH_SCAN负责扫描周边蓝牙设备BLUETOOTH_CONNECT负责连接、配对、以及已经配对设备的通信BLUETOOTH_ADVERTISE负责以广播者身份向外广播普通客户端基本用不到如果你的App只是个连接方普通情况下只会用到前两个。但这里有个坑很多人以为做SPP连接只需要BLUETOOTH_CONNECT不需要申请BLUETOOTH_SCAN结果在第3章的扫描阶段就丢了设备列表。反过来只申请BLUETOOTH_SCAN到了BluetoothSocket.connect()这一步又会因为缺少BLUETOOTH_CONNECT权限直接抛SecurityException。另外Android 12的这套新权限只对targetSdk 31的应用生效。如果targetSdk还停在30Android 12手机上依然按旧逻辑使用位置权限这也就是为什么同是Android 12手机A应用的targetSdk不同表现就完全不同。上架要求不断收紧所以最终还是要走新权限模型兼容分支不能只盯着旧逻辑。2.4 一段能在API 21~34上通用的权限处理代码基于上面这些规则我最后封装了一个权限Helper核心逻辑就是这样object BluetoothPermissionHelper { const val REQUEST_CODE_BLUETOOTH 1001 fun hasPermission(context: Context): Boolean { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { hasPermission(context, Manifest.permission.BLUETOOTH_SCAN) hasPermission(context, Manifest.permission.BLUETOOTH_CONNECT) } else if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) } else { true } } fun hasPermission(context: Context, permission: String): Boolean { return context.checkSelfPermission(permission) PackageManager.PERMISSION_GRANTED } fun requestPermission(activity: Activity) { val permissions mutableListOfString() when { Build.VERSION.SDK_INT Build.VERSION_CODES.S - { permissions.add(Manifest.permission.BLUETOOTH_SCAN) permissions.add(Manifest.permission.BLUETOOTH_CONNECT) } Build.VERSION.SDK_INT Build.VERSION_CODES.M - { permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } } if (permissions.isNotEmpty()) { activity.requestPermissions(permissions.toTypedArray(), REQUEST_CODE_BLUETOOTH) } } fun hasSystemLocationEnabled(context: Context): Boolean { val locationManager context.getSystemService(Context.LOCATION_SERVICE) as LocationManager return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) } }这段代码的核心思路是API 23以下直接放行API 23到30之间申请定位权限API 31以上申请两个蓝牙专用权限。在Android 12上不需要再申请位置权限因为BLUETOOTH_SCAN已经把扫描这件事独立出来了。还有一个小经验申请权限之后一定要在回调里判断是否真的拿到了。如果用户点了拒绝不要只提示一句需要权限就完事更好的做法是弹对话框说明用途然后引导用户去App设置页手动打开。尤其是Android 12上如果用户连续拒绝两次系统会把权限请求按钮藏掉再调用requestPermissions就没有弹窗了只能用Intent跳转设置页。3. 扫描与发现设备API选择与搜不到设备的排查链路权限处理完之后接下来就是扫描设备。这一部分有两个完全不同的技术栈经典蓝牙用BluetoothAdapter.startDiscovery()BLE用BluetoothLeScanner.startScan()。很多人在网上抄代码时经常把这两套混在一起导致明明权限都给了却一直收不到设备回调。3.1 经典蓝牙的startDiscovery一直没换API但要求换了经典蓝牙的扫描API从Android 2.0时代到Android 14基本没变过始终是BluetoothAdapter.startDiscovery()通过注册BroadcastReceiver接收ACTION_FOUND广播来收集设备。API的形态虽然稳定权限要求却一直在变。在Android 6.0以上调用startDiscovery()之前需要确认已经拿到定位权限在Android 12以上需要确认已经拿到BLUETOOTH_SCAN权限。此外还有一个很容易踩的细节Android 12以上如果targetSdk 31但是BLUETOOTH_SCAN权限没有授予调用startDiscovery()不会静默失败而是直接抛SecurityException。所以这段代码最好包一层try-catch防止在极端情况下闪退。广播接收器要注意动态注册和注销的成对出现。我有一个习惯在onStart()里注册在onStop()里注销同时在ACTION_FOUND里用BluetoothDevice.ACTION_FOUND过滤拿到设备后先判断device.name是否为空因为有些设备没有广播名称但一样可以连接。private val discoveryReceiver object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { BluetoothDevice.ACTION_FOUND - { val device intent.getParcelableExtraBluetoothDevice(BluetoothDevice.EXTRA_DEVICE) if (device ! null) { // 过滤掉未命名的设备也可以按需去重 devicesMap[device.address] device } } BluetoothAdapter.ACTION_DISCOVERY_FINISHED - { // 扫描结束通知UI更新列表 } } } }3.2 BLE扫描请统一使用BluetoothLeScannerBLE的扫描API有两个版本老的BluetoothAdapter.startLeScan()和后来的BluetoothLeScanner.startScan()。老接口在API 21就标记废了虽然很多旧教程还在用但我建议新代码一律走BluetoothLeScanner这样可以在Android 12的新权限模型下表现得更一致。BluetoothLeScanner从API 21开始就有了所以不需要为低版本单独准备一套扫描逻辑。核心用法是val scanner bluetoothAdapter.bluetoothLeScanner ?: return val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() val callback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult?) { val device result?.device val rssi result?.rssi // 这里收设备信息 } override fun onScanFailed(errorCode: Int) { // errorCode 1 表示已经有扫描在跑先stopScan再重试 } } scanner.startScan(null, settings, callback)这里有个实践细节SCAN_MODE_LOW_LATENCY会明显增加设备出现速度但耗电也更高。如果只是用户点击扫描按钮后的主动操作用这个模式完全没问题如果是后台持续扫描建议改用SCAN_MODE_BALANCED或者SCAN_MODE_LOW_POWER。BLE扫描的结果是异步回调短时间内会收到大量onScanResult不要在回调里频繁更新整个列表。我习惯先把结果放进一个以device.address为key的Map里等扫描结束后一次性刷新UI这样既去重又不会卡界面。3.3 搜不到设备的真实排查顺序扫不到设备是蓝牙兼容里最常见的求助帖主题。我在实机上复现过很多次整理出一条按顺序排查的链路基本能解决绝大多数问题先确认权限弹窗真的点了允许而不是只看了个弹窗就关掉。再确认系统定位服务总开关是打开的这一点在Android 6到11上几乎是硬性条件。检查targetSdk。如果是Android 10以上要确认申请的是ACCESS_FINE_LOCATION而不是只申请了ACCESS_COARSE_LOCATION。检查目标设备是否处于可发现状态。HC-06这类模块上电后默认可发现但有些模块需要先进入AT配置模式才广播或者默认广播10分钟后自动关闭。排查国产ROM的后台限制。很多手机管家类App会默认拦截应用的后台扫描行为需要在系统设置里把后台弹出界面自启动省电策略这些权限放开。检查是不是周围蓝牙设备太多导致系统还没把广播回调发给你。这种情况在商场、仓库等场景很常见可以等待几秒再看。第二条尤其重要因为定位开关这个坑和版本无关老手机新手机都会遇到。我后来在UI上做了个判断如果权限齐全但扫描结果为空就主动检查系统定位开关没开的话弹一个提示引导用户去打开。3.4 Android 8.0后台扫描限制别在后台做扫描Android 8.0也就是API 26开始系统对后台应用的蓝牙扫描行为做了限制。如果App退到后台你还想继续通过扫描维护设备列表系统会直接丢弃扫描回调让整个功能看上去像死了一样。如果你的产品确实需要后台扫描场景最稳妥的方案是启动一个前台服务让App处于前台服务的状态再去扫描。权限方面Android 10以上需要声明FOREGROUND_SERVICE权限Android 14开始对前台服务的类型卡得更严格蓝牙相关的一般要声明foregroundServiceTypeconnectedDevice。这一步在兼容方案里容易被忽略。很多老项目不需要后台扫描所以一开始没问题一旦产品经理加了进入后台自动重连之类的需求就要把前台服务这套逻辑一起考虑进去而不是简单把扫描放到Service里就完事。4. 连接与配对同一条UUID在不同版本上两种命运扫描到设备之后真正的考验才刚开始。连接这一步吃了版本差异最多的暗亏尤其是经典蓝牙的socket连接同一个UUID、同一台设备在不同Android版本上可能表现出完全不同的行为。4.1 获取BluetoothAdapter的老接口与新接口获取默认蓝牙适配器有两条路老代码直接用BluetoothAdapter.getDefaultAdapter()新代码推荐通过BluetoothManager取。虽然getDefaultAdapter()至今没废但我在兼容代码里还是会做一次判断val bluetoothAdapter: BluetoothAdapter? if (Build.VERSION.SDK_INT Build.VERSION_CODES.JELLY_BEAN_MR2) { val manager context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager manager.adapter } else { Suppress(DEPRECATION) BluetoothAdapter.getDefaultAdapter() }这段代码的意义在于让整个BluetoothService统一走新接口同时保留API 17/18这两个老版本场景。很多老模块在实际使用中还是会把系统蓝牙适配器拿到手作为一个全局入口后面扫描、连接都从它开始适配器这块理顺了后面就不会到处补版本判断。4.2 建立Socket连接时的版本陷阱经典蓝牙的连接核心是BluetoothSocket最常见的创建方式是val socket device.createRfcommSocketToServiceRecord(uuid) socket.connect()这里的UUID必须和模块端匹配。HC-06这类SPP模块用的是固定UUID00001101-0000-1000-8000-00805F9B34FB。很多硬件厂商的设备也兼容这个UUID但最好还是查一下模块的AT指令或数据手册确认。连接过程里有三个版本相关的坑我得单独说一下第一socket.connect()是阻塞调用绝对不能在主线程执行否则会ANR。我在接手的老项目里见过直接把连接写在MainActivity的按钮点击事件里的写法一按连接按钮整个App就卡死这跟Android版本无关但老项目里特别常见。第二当标准createRfcommSocketToServiceRecord连接失败时老教程会教你用反射调用隐藏的createRfcommSocket方法。这个办法在Android 7、8上确实有效但到了Android 12系统对私有API反射的限制越来越严这个hack有可能会直接失败。如果你遇到了标准方式连不上、但硬件明明是好的情况建议先试试公开的createInsecureRfcommSocketToServiceRecord它不带安全校验很多旧模块反而好连。第三Android 12上如果缺少BLUETOOTH_CONNECT权限调用connect()的时候不是返回失败而是直接抛SecurityException。这个异常一定要捕获并提示用户授权不要让它冒到最外层。4.3 给connect()加上超时控制socket.connect()还有一个很烦人的点失败的时候不一定会很快抛异常。在部分低版本手机上连接失败的等待时间可能长达30秒以上用户体验极差。我在实测中遇到过Android 5.1平板上连接一个不在线模块等了小半分钟才报错的情况。解决思路是给连接操作加一个固定的超时时间。我用的方法简单粗暴用ExecutorService提交一个子线程任务然后通过Future.get(timeout)控制等待时间private fun connectWithTimeout(device: BluetoothDevice, uuid: UUID, timeoutSec: Long 8L): BluetoothSocket? { var socket: BluetoothSocket? null val task Executors.newSingleThreadExecutor().submitBluetoothSocket? { try { socket device.createRfcommSocketToServiceRecord(uuid) socket?.connect() socket } catch (e: IOException) { try { socket?.close() } catch (_: Exception) {} null } } return try { task.get(timeoutSec, TimeUnit.SECONDS) } catch (e: Exception) { task.cancel(true) try { socket?.close() } catch (_: Exception) {} null } }8秒是我实测下来比较合理的值对于绝大多数SPP模块来说8秒还连不上基本就是设备不在线、UUID不匹配或者被其他设备占用了。等待时间太长会让UI层的自动重连逻辑变得很难做。调这个方法时要注意Future.get()抛出TimeoutException后后台的connect()可能还卡着没退出来所以最好在close掉socket的同时把任务cancel掉否则线程池里的线程会被占住积累几次重连会让线程数暴涨。4.4 配对状态机的跨版本监听部分设备连接前需要先配对createBond比如蓝牙耳机、音箱、部分工业设备。配对过程是异步的需要监听BluetoothDevice.ACTION_BOND_STATE_CHANGED广播。我在项目里写了一个统一的广播接收器private val bondReceiver object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action BluetoothDevice.ACTION_BOND_STATE_CHANGED) { val device intent.getParcelableExtraBluetoothDevice(BluetoothDevice.EXTRA_DEVICE) val newState intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR) when (newState) { BluetoothDevice.BOND_NONE - { // 未配对或配对失败 } BluetoothDevice.BOND_BONDING - { // 配对中 } BluetoothDevice.BOND_BONDED - { // 配对完成可以继续建立socket connectWithTimeout(device, sppUuid) } } } } }这里要特别提一点HC-06、JDY-31这类模块基本不需要配对流程直接connect就行。如果代码里对它们调用了createBond()反而可能被系统弹窗打断流程。所以要不要做配对完全取决于模块厂商的协议。最好的做法是把是否需要配对做成一个可配置项在连接流程的开始处判断一下。在Android 12上调用createBond()同样需要BLUETOOTH_CONNECT权限。如果权限不到位连配对这一步都会抛SecurityException。这也是为什么权限Helper里要同时申请SCAN和CONNECT两个权限而不是只申请其中一个。5. 四台实机压测兼容性不是靠感觉评出来的理论说了这么多最后还是得拿真机跑一遍才知道代码靠不靠谱。我在这次兼容改造中专门搭了一个测试矩阵覆盖从Android 5.1到Android 13的几台典型设备每台都跑了完整的权限申请、扫描、连接、收发数据流程。5.1 测试矩阵与问题记录机型系统版本主要用途遇到的核心问题三星Tab A P350Android 5.1.1模拟仓库存量老平板老版本socket连接成功后IO流偶发不稳定的现象华为平板M3Android 8.0覆盖8.0后台行为退到后台后扫描结果全部丢失小米8Android 10覆盖运行时权限定位限制定位服务关闭时扫描结果全空没有任何报错Pixel 4aAndroid 12覆盖新蓝牙权限模型首次拒绝权限后二次弹窗不出现Redmi Note 11Android 13覆盖最新系统targetSdk 31以下与新权限模型的切换问题每一台设备我都做了三轮测试第一次用targetSdk 30打出来的包第二次用targetSdk 33打出来的包第三次用最终权限Helper跑完整流程。同一个APK在不同设备上的表现差异极大但如果business逻辑层做得正确最终都能连上设备并正常收发数据。5.2 三个高频问题与修复结论第一个高频问题是Android 10的小米8上权限都给了但扫不到设备排查半天发现是系统定位开关没打开。还好权限Helper里加了hasSystemLocationEnabled()判断UI层检测到定位没开就引导用户手动打开问题迎刃而解。第二个高频问题是Android 12的Pixel 4a上用户第一次点了拒绝再次点击扫描按钮时权限弹窗不出现。按照Android系统的规范连续拒绝会导致后续请求被静默忽略所以我在权限回调里加了判断如果权限没有授予且系统不再弹窗就直接跳转到App设置页而不是干等着。第三个高频问题是Android 13的Redmi Note 11上用targetSdk 30的包连接一切正常换成targetSdk 33之后同样的代码在BluetoothSocket.connect()处抛出了SecurityException。原因就是targetSdk跨过了31这个门槛系统开始强制蓝牙新权限模型而旧包没有申请BLUETOOTH_CONNECT。这个问题用权限Helper解决了但也提醒了我以后每次升级targetSdk都要重新做一轮全量蓝牙回归测试。5.3 没有老手机时怎么模拟旧版本行为如果是做个人项目手边没有Android 5.x手机也很正常。Android模拟器虽然可以创建API 21的系统镜像但蓝牙功能在模拟器上支持得并不好扫描和连接经常是摆设。我实测下来模拟器更适合验证权限流程和UI逻辑真正要验证蓝牙收发包还是得靠真机。如果预算有限可以考虑云真机平台很多平台能按机型租用真机至少能覆盖到几个常见的旧版本系统。不过我自己的经验是真要长期做蓝牙项目二手平台淘一台Android 6.0左右的旧手机或者旧平板做回归测试价值非常高。不用买多贵的几百块的旧机型就够用关键是能稳定复现低版本上那些诡异问题。6. 如果重新做一遍我会把这些工程习惯提前定下来这个项目做完之后我复盘了整个开发过程有几个工程习惯如果一开始就定下来会少走不少弯路。写在这里算是给后来人的一点参考。第一个习惯是权限处理一定要独立成一个Helper类而不是散落在Activity和Service里。蓝牙权限的版本分支多散落各处很容易出现一个地方改了、另一处忘了的情况。统一收口之后哪个页面需要权限就统一调Helper行为保持一致也好测试。第二个习惯是所有广播接收器都动态注册、动态注销。蓝牙的ACTION_FOUND、ACTION_BOND_STATE_CHANGED、ACTION_DISCOVERY_FINISHED都是动态注册的一定要成对出现。很多老项目在Activity里注册了广播但忘了注销结果内存泄漏不说还会导致页面关闭后依然收到扫描回调莫名其妙的bug都是这么来的。第三个习惯是打日志的时候把关键环境信息一起打出来。我在权限Helper和连接层里加了统一的日志输出内容包括当前SDK_INT、targetSdk、蓝牙开关状态、定位开关状态、权限授予情况、模块地址和UUID。一旦现场出问题看日志能立刻判断是环境问题还是代码问题不用靠猜。第四个习惯是不要迷信反射连接。网上确实有大量帖子教你反射调用隐藏API来建立RFCOMM连接我自己也试过在旧版本上确实好使。但在Android 12上面反射私有接口的路越来越窄而且一旦系统更新这种hack代码随时会炸。能用公开API解决的尽量用公开API实在不行也一定要把失败分支写好不要直接抛给调用方。最后一点是关于设备状态的区分。经典蓝牙和BLE的设备它们的连接状态判断方式是完全不同的。经典蓝牙看socket的isConnected()BLE要看Gatt的状态回调不能混用。我之前见过同事把BLE的onConnectionStateChange逻辑直接抄到经典蓝牙项目里结果永远是连接失败。蓝牙兼容这件事没有银弹每一层都要回到设备到底走哪条协议栈这个原点来判断。