恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Android蓝牙开发兼容旧版本:从权限到API的完整适配方案
首页
资讯中心
/
Android蓝牙开发兼容旧版本:从权限到API的完整适配方案
Android蓝牙开发兼容旧版本:从权限到API的完整适配方案
发布时间:2026/9/17 5:54:08
Android 蓝牙开发做久了你会发现一个扎心的事实系统版本越新API 越“漂亮”但老设备上的坑永远不会自己消失。项目里只要写了一句startLeScan低版本手机直接就给你脸色看好不容易把 Android 13 的权限适配好了结果一台 Android 6.0 的老平板连设备都搜不到。这个标题“android 蓝牙连接-兼容旧版本”说白了就是一句话在 2026 年做蓝牙功能怎么才能让 Android 4.4 到 Android 14 的机器都能跑得通同时不把自己折腾疯。这篇文章我会拿实际项目里的适配经历来讲覆盖经典蓝牙Classic Bluetooth和低功耗蓝牙BLE两条线重点讲版本差异、权限适配、UUID 服务发现、扫描接口兼容这些让人头大的点。内容比较适合正在做蓝牙相关功能的 Android 开发或者自己折腾蓝牙模块、HC-05、ESP32 这类硬件的朋友。读完你至少能把 minSdkVersion 设到 21 甚至 19并且知道每个版本上哪些 API 能碰、哪些一定要绕开。1. 核心思路拆解兼容旧版本到底在兼容什么1.1 一个目标三条主线做蓝牙兼容很多人一上来就盯着 API 名字看哪个方法废弃了就换哪个结果越改越乱。我自己的经验是先把“兼容”拆成三个维度再一个一个解决。第一个维度是系统 API 的版本差异。Android 4.3 开始提供 BLE 支持4.4 开始能作为外围设备5.0 引入了全新的扫描和连接接口6.0 加了运行时权限8.0 对后台扫描做了限制12.0 开始把蓝牙权限拆成了细粒度权限。每个大版本都是一道坎而这些坎并不会因为你只适配新版本而消失。项目里只要还有一台 Android 6.0 的测试机这些问题就必须面对。第二个维度是厂商定制系统的行为差异。同样是 Android 9.0小米、华为、OPPO、三星对后台权限、自启动、省电策略的处理差异足以让同一条代码在不同手机上产生完全不同的表现。这个问题我在第 4 章会详细展开这里先记住一个结论在国产 ROM 上系统的“省电管理”比你的代码更容易杀掉蓝牙连接。第三个维度是蓝牙协议本身的版本差异。同一条设备链路上手机端的蓝牙协议栈Bluedroid、Fluoride、各种厂商自研栈老版本和新版本在处理配对、重连、加密时的行为并不一致。比如某些老协议栈对128-bit UUID的过滤方式就是有 bug导致新手机找不到老设备上的服务——这不是你代码的问题但你得在业务层把这个坑填上。三条线互相交织这就是为什么网上那些“把 targetSdk 升到 33 然后加三个权限就好了”的教程根本不够用。真正的兼容适配是面向版本、面向厂商、面向协议栈的组合工程。1.2 先看懂系统版本和蓝牙 API 的关系我给项目里做过一张表后来发现这张表可以当团队入职培训材料用。核心不是背 API而是知道每个版本的“分界线”在哪。Android 版本蓝牙相关关键变化对兼容性的实际影响4.3 (API 18)首次支持 BLE 中心设备模式BLE 基础能力下限startLeScan可用4.4 (API 19)支持 BLE 外围设备模式可以做 Peripheral但 API 极其原始5.0 (API 21)引入BluetoothLeScanner、ScanFilter、ScanSettings新扫描体系开始替代startLeScan6.0 (API 23)运行时权限模型需要申请定位权限才能蓝牙扫描动态权限成为必须很多老代码在这里崩7.0 (API 24)对 BLE 连接数量做了限制多连接设备的噩梦开始8.0 (API 26)后台扫描 30 分钟限制后台蓝牙功能进入“灰色地带”10.0 (API 29)定位权限继续保留在蓝牙扫描链路中依然要申请定位权限很多人想不明白为什么12.0 (API 31)BLUETOOTH_SCAN/BLUETOOTH_CONNECT/BLUETOOTH_ADVERTISE新权限蓝牙权限首次单独从定位权限中拆出13.0 (API 33)新权限体系逐步强制化targetSdk 33 必须适配新权限否则直接崩这张表里最容易搞混的是 Android 12 到 13 这一段。Android 12 其实已经引入了新的蓝牙权限但如果你的targetSdk还在 30 及以下系统会帮你走旧逻辑不会强制要新权限。一旦升到 31BLUETOOTH和BLUETOOTH_ADMIN在蓝牙相关的 API 上基本就失效了必须用新的三个权限。到 Android 13 之后更是如此所以我的建议是不管你的项目目标版本是多少新权限声明先加上运行时权限兼容层也直接按旧新两套写。不写等出问题再补通常已经上线上事故了。2. 权限适配从“一键授权”到“三分天下”2.1 Android 6.0 动态权限是所有兼容性问题的分水岭Android 6.0API 23之前蓝牙权限只要在AndroidManifest.xml里声明就行安装时用户不授权也能装系统直接给。6.0 之后运行时权限模型出现危险权限必须在运行时弹窗申请。而蓝牙恰恰有一个特别容易让人误解的地方蓝牙扫描需要定位权限。为什么蓝牙要定位权限因为蓝牙扫描能拿到周边设备的信号强度RSSI理论上可以通过三角定位推断用户位置所以 Google 把蓝牙扫描归到了定位相关的权限之下。这个设计从 6.0 一直到 Android 11 都是这样。实际开发中你必须在onRequestPermissionsResult里同时处理好定位权限的授予结果否则用户拒绝了定位权限蓝牙功能就静默失效。代码上要注意targetSdk 23时Manifest.permission.ACCESS_FINE_LOCATION是必需项只写ACCESS_COARSE_LOCATION在部分机型上会导致扫描永远没结果。我踩过最离谱的一个坑是某款 Android 7.0 的三星平板上COARSE和FINE同时声明、只申请COARSE扫描结果里所有 BLE 设备的 RSSI 全是 0换真机测试没事一上平板就露馅。后来老老实实把FINE加上问题才消失。2.2 Android 12 蓝牙专属权限的出现Android 12API 31之后蓝牙权限正式“三分天下”负责扫描的BLUETOOTH_SCAN、负责连接的BLUETOOTH_CONNECT、负责广播的BLUETOOTH_ADVERTISE。这三个都是运行时权限也都需要用户在设置里手动授权。它们和定位权限不再是绑定关系扫描 BLE 设备时如果你不需要知道设备的位置信息、不做基于位置的过滤理论上可以不再申请定位权限——但前提是你的targetSdk已经到 31 以上并且完全走新接口。为了兼容旧版本我一般用这样一个权限集合常量val REQUIRED_BLUETOOTH_PERMISSIONS if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_ADVERTISE, // 如果没有广播功能可以不申请 Manifest.permission.ACCESS_FINE_LOCATION // 部分扫描场景仍需定位兜底 ) } else { arrayOf( Manifest.permission.ACCESS_FINE_LOCATION ) }这段代码看着简单但里面有一个隐藏的深坑Android 12 系统上如果你的targetSdk是 30 或更低系统会弹一个“旧的蓝牙权限申请”兼容弹窗此时你 Manifest 里声明的还是旧的BLUETOOTH/BLUETOOTH_ADMIN不会触发新权限弹窗。这意味着同一个 APK 在不同系统上的权限弹窗次数和逻辑都不一样。我建议直接把清单文件里新旧权限全部声明不要吝啬uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /neverForLocation这个 flag 是 Android 12 引入的加上它之后系统知道你拿扫描结果不是为了定位可以简化定位权限的申请。但需要注意这个 flag 对某些国产 ROM 不一定生效该申请定位权限还是申请别省这一步。2.3 兼容写法的统一封装因为权限逻辑分散在多个版本分支里我建议封装成一个工具方法业务层只关心回调结果。下面是一个简化版实际项目里可以再加超时逻辑和取消逻辑object BluetoothPermissionHelper { private const val REQ_BLUETOOTH_PERMISSION 1001 fun ensurePermissions(activity: Activity, callback: (Boolean) - Unit) { val deniedList REQUIRED_BLUETOOTH_PERMISSIONS.filter { ContextCompat.checkSelfPermission(activity, it) ! PackageManager.PERMISSION_GRANTED } if (deniedList.isEmpty()) { callback(true) return } // 这里建议把请求逻辑挂到 Activity 的 onRequestPermissionsResult 里统一处理 ActivityCompat.requestPermissions( activity, deniedList.toTypedArray(), REQ_BLUETOOTH_PERMISSION ) } }这里有个经验点不要把权限请求逻辑写在 Fragment 里然后只用getActivity()强转因为我遇到过多次 Activity 已经销毁但权限回调还在的情况导致弱引用崩溃。权限回调尽量写到基类或者独立的 Lifecycle 感知组件里避免在业务页面上堆权限代码。3. 经典蓝牙连接兼容性实战3.1 打开蓝牙和发现设备的版本差异“陷阱”经典蓝牙的连接链路从打开蓝牙到搜索设备每一步都有版本坑。先说打开蓝牙这段。老代码最经典的写法是if (!mBluetoothAdapter.isEnabled()) { mBluetoothAdapter.enable(); }这是 Android 4.x 时代最常见的代码但enable()是一个异步且隐藏的接口在 Android 5.0 之后系统会强制要求应用通过ACTION_REQUEST_ENABLE来请求用户打开蓝牙直接调用enable()在很多 6.0 机型上不仅无效还会被系统判定为“非法后台操作”。正确做法是用startActivityForResult弹系统对话框不过这个方法也要处理 Android 11 之后包可见性变化对resolveActivity的影响。我建议统一这样写fun requestEnableBluetooth(activity: Activity) { val intent Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) try { activity.startActivityForResult(intent, REQ_ENABLE_BT) } catch (e: ActivityNotFoundException) { // 有些极简 ROM 没有这个 Activity走 enable() 兜底 if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { bluetoothAdapter.enable() } } }设备发现这块老的startDiscovery()在 Android 6.0 之后也要配合定位权限否则毫无反应。另外一个大坑是Android 8.0API 26开始后台应用不能执行蓝牙扫描这里的“后台”定义是没有前台 Activity、没有前台 Service、没有其他可见的 app 组件。如果你的应用打算在后台定期扫描设备必须启动一个前台服务否则就算你的逻辑没问题系统也会直接杀死扫描。3.2 配对流程的版本差异经典蓝牙配对Bonding是另一个版本分裂严重的区域。Android 4.4 到 6.0createBond()只要调用成功系统会弹出一个 PIN 码输入框由用户手动输入确认。从 Android 6.0 开始部分 OEM 机型把配对弹窗做成了自动确认模式输入相同 PIN 码的逻辑经常失效。而 Android 10 及之后的机型很多已经默认使用 Secure Simple PairingSSP要求“确认配对码”而不是“输入 PIN 码”模式。所以你在兼容旧版本时至少要在业务层做两套逻辑老设备比如 HC-05/HC-06 模块走 PIN 码输入新设备走确认弹窗。很多模块资料上写“默认 PIN 1234”但你用新系统手机去连弹窗可能根本没有 PIN 输入框而是显示“是否与设备配对”的确认框。遇到这种场景不用慌点确认就行底层已经完成了 PIN 交换。对于主动发起配对Android 8.0API 26之前可以通过反射调用createBond()成功触发配对。8.0 之后有部分厂商设备必须调用系统的ACTION_PAIRING_REQUEST才会响应对码。这里我个人的建议是不要试图完全控制配对流程官方 API 的配对交互在不同厂商手里行为无法预测尽量把用户引导到系统配对界面保证兼容性优先。3.3 UUID 服务发现连接失败最常见的坑连接经典蓝牙设备时建立 Socket 需要拿到一个 UUID。Android 上常见的 SPP UUID 是00001101-0000-1000-8000-00805F9B34FB这个 UUID 代表串口服务。问题在于很多老设备、尤其是淘宝买的 HC-05/HC-06 蓝牙模块它们的 SDP 记录里根本没有标准 SPP UUID而是随意写了一个自定义 UUID。Android 系统在createRfcommSocketToServiceRecord(uuid)时如果远端设备的 SDP 记录里没有匹配的 UUID就会直接抛Service discovery failed异常。这种问题在新手机上更容易出现因为新的蓝牙协议栈对 SDP 记录合法性检查更严格。我遇到过一台 Android 6.0 手机上能连上的 HC-05 模块换到 Android 12 手机上就报Service discovery failed。最后解决的办法是使用反射方式创建 RFCOMM 通道绕开 SDP 查询Suppress(DEPRECATION) fun createRfcommSocketFallback(device: BluetoothDevice): BluetoothSocket? { return try { val method device.javaClass.getMethod(createRfcommSocket, Int::class.javaPrimitiveType) method.invoke(device, 1) as BluetoothSocket } catch (e: Exception) { null } }这种方法在 Android 4.x 到 Android 13 之间都有效但不能保证所有 ROM 都支持。我建议把它作为Service discovery failed异常后的兜底方案而不是默认方案因为反射调用的通道号channel 1并不一定对应你设备上实际可用的 RFCOMM 服务通道。还有一点fetchUuidsWithSdp()这个老 API 在 Android 12 之后已经基本废了因为它需要BLUETOOTH_CONNECT权限而且在部分新机型上返回的 UUID 列表为空不要依赖它。3.4 老版本系统上的 getDefaultAdapter 兼容写法BluetoothAdapter.getDefaultAdapter()从 Android 4.x 用到 Android 13 都没被标记废弃但它内部的行为在新系统上有变化。比如在 Android 13 上如果应用没有BLUETOOTH_CONNECT权限调用这个方法不会抛异常但拿到 adapter 之后你做任何 connect 操作都会抛SecurityException。所以不要用“是否非空”来判断权限状态要用上一章那种显式的checkSelfPermission判断。另外说一个冷门但实际踩过的坑部分 Android 5.0/5.1 的 ROM 上getDefaultAdapter()可能返回 null原因是蓝牙服务没有顺利启动而系统并没有把这个当严重故障处理。遇到这种场景不能直接崩应该提示用户重启蓝牙或重启系统而不是代码里继续空指针调用下去。4. BLE 蓝牙在旧版本上的适配要点4.1 minSdk 怎么选决定你少踩多少坑BLE 从 Android 4.3API 18开始支持但 API 18 和 API 21 的 BLE 接口差距大到像两个完全不同的功能。4.3 到 4.4 时代只有startLeScan/stopLeScan而且扫描结果回调是散装的每次只回调一个设备没有扫描过滤器也不能设置扫描模式。Android 5.0 之后才有BluetoothLeScanner、ScanFilter、ScanSettings这套完整体系。我的建议是如果产品没有特别极端的低版本诉求minSdk 最少定到 21Android 5.0。原因是 21 之后的 BLE API 才具备基本可用的扫描、连接、过滤能力定到 18 的话你将不得不在startLeScan和startScan之间维护两套代码并且 4.x 系统上 BLE 的稳定性本身就很差很容易掉线调试成本远高于收益。当然如果老板确实要求覆盖 Android 4.4 的存量用户那就准备好两套扫描逻辑这个跑不掉。4.2 扫描接口的差异startLeScan 与 startScan 的统一封装扫描接口是所有 BLE 兼容问题的重灾区。Android 5.0 之前的startLeScan(callback)没有过滤功能只能扫到所有设备而且回调的BluetoothDevice对象上getName()经常返回 null因为广播包里的名称在扫描响应包里。Android 5.0 之后的startScan(filters, settings, callback)功能强得多但低版本手机上你却没法用。我习惯做一个扫描器的封装层对外暴露统一接口内部判断版本走不同实现class BleScanner(context: Context) { private val adapter: BluetoothAdapter? by lazy { val manager context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager manager.adapter } private val leScanner: BluetoothLeScanner? by lazy { adapter?.bluetoothLeScanner } private val callback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { // 处理 result.device, result.rssi, result.scanRecord } } Suppress(DEPRECATION) fun startScan() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.LOLLIPOP) { val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() leScanner?.startScan(null, settings, callback) } else { adapter?.startLeScan { device, rssi, scanRecord - // 模拟 ScanResult 结构统一回调给上层 onLegacyLeScan(device, rssi) } } } }封装时注意一个细节BluetoothLeScanner.startScan的扫描回调结果一般一次能拿到多个设备而startLeScan一次只给一个。如果要保证上层逻辑一致可以先收集、定时批量回调而不必追求每条回调都对齐。这个设计在数据展示上会省很多事。4.3 Android 8.0 的后台扫描限制与保活策略Android 8.0API 26之后后台扫描 30 分钟会被系统静默停止这个机制不管你的 targetSdk 是多少都生效。如果你的产品需要长时间在后台维持扫描比如蓝牙门禁、蓝牙手环自动重连这类场景唯一稳妥的方案是启动一个前台服务通过startForegroundService把扫描任务放到前台服务里这样系统才允许你持续扫描。前台服务在 Android 8.0 上要立刻调用startForeground()Android 12 之后还需要声明前台服务类型比如connectedDevice否则会抛ForegroundServiceStartNotAllowedException。这里就体现兼容旧版本的琐碎程度一套前台服务逻辑至少要适配 8.0 的前台服务限制、10.0 的后台启动限制、12.0 的前台服务类型这三次变化。每次升级系统版本都要回去翻一遍前台服务的写法。4.4 连接不稳定与 MTU 协商的老问题BLE 连接稳定性和系统版本的关系非常明显。Android 4.x 时代的 BLE 连接经常出现偶发断连官方也承认早期实现有稳定性问题后来在 5.0、6.0 上逐步修复。真到了 Android 8.0 之后系统级的连接管理已经比较稳定但仍要面对国产 ROM 的后台清理策略比如华为的“启动管理”、小米的“神隐模式”会直接杀掉应用的蓝牙连接。针对这种问题除了引导用户把 App 加入电池优化白名单没有特别好的代码级解决方式。MTU 协商也值得单独说一下。Android 5.0API 21引入了requestMtu()方法但部分 Android 4.x 设备完全没有这个方法只能用默认 23 字节 MTU 通信。如果你的协议和硬件端约定的是用大包传输比如一次传 512 字节那么在低版本设备上就必须做分包发送否则硬件端接收会异常。分包要考虑 MTU 值动态获取不能写死 20 字节/包因为有些设备协商后能到 512 甚至更多。分包实现时最保险的做法是连接成功后请求 MTU拿不到 MTU 值就默认 20 字节每包发送端按 MTU 值做切片接收端做粘包组装协议头里带上总长度或者包序号方便对端拼装。这套逻辑在 4.x 和 13 上都验证过属于“不优雅但稳定”的方案。5. 常见问题与排查技巧实录这部分我整理成速查表基本上都是我在实际调试过程中碰到的比网上那些“重启手机试试”的建议要靠谱得多。现象可能原因排查思路与解决方式低版本手机扫描不到任何 BLE 设备没有定位权限、蓝牙未开启、扫描回调被系统静默先检查权限再检查蓝牙开关最后确认是否在后台执行扫描高版本手机连不上 HC-05/HC-06 老模块SDP 服务发现失败老模块 UUID 不标准使用反射createRfcommSocket兜底或引导用户先配对再连接新系统扫码后 RSSI 全部为 0定位权限缺失或neverForLocation影响补申请ACCESS_FINE_LOCATION确认 Manifest 权限没写错BLE 连接成功后偶尔掉线重连不上后台清理、扫描并发冲突、MTU 协商异常使用前台服务保活重连前断开旧 Gatt避免重复连接同地址Android 12 上安装后闪退targetSdk 31 但没适配新蓝牙权限在代码里动态申请BLUETOOTH_SCAN/BLUETOOTH_CONNECT调用fetchUuidsWithSdp()返回空列表新系统协议栈限制 SDP 查询改用广播接收器等待ACTION_UUID或直接反射建 RFCOMM 通道搜索到了设备但无法配对配对方式不匹配老模块要求 PIN 码输入主动触发ACTION_PAIRING_REQUEST引导系统弹 PIN 码框手环/门禁设备在 Android 9 后台收不到广播Android 8.0 后台扫描限制启动前台服务并且保持前台通知可见否则进程会被回收低版本手机上getName()返回 null扫描广播包中不含设备名称名称在扫描响应里不用依赖名称用 MAC 地址做业务标记或等待 Gatt 连接后再次获取名称排查蓝牙问题我自己的习惯是优先看底层日志而不是直接改代码。Android 系统蓝牙相关的日志标签有BluetoothAdapter、BluetoothGatt、BluetoothHci、bt_btif等通过adb logcat -s BluetoothAdapter:V BluetoothGatt:V BluetoothHci:V抓取可以看到连接状态、GATT 回调、错误码等关键信息。很多“为什么连不上”的问题日志里早就给出了答案比如133错误码表示连接被远端设备关闭257表示 GATT 内部错误。另外说一下 A2DP 切 SCO 的问题。很多做蓝牙耳机的开发者会碰到通话时要从音乐模式切到 SCO 模式但老系统切不过去。这是因为 Android 上AudioManager.startBluetoothSco()在不同 Android 版本有不同的调用时机要求Android 8.0 之后必须在ACTION_AUDIO_BECOMING_NOISY之后等一段时间再调用调用太早会失败。解决办法是先注册ScoAudioStateListener监听连接成功后再启动音频流而不是无脑startBluetoothSco()加startBluetoothSco()。6. 实测经验与后续扩展建议我最后分享两个实测经验这也是我在团队里反复强调的。第一个是测试设备矩阵不要偷懒。如果你的蓝牙项目真正要覆盖旧版本一个最基本的内测设备矩阵至少包含Android 6.0 的低端机很多 App 蓝牙连不上都是这类机器、Android 8.0 的小米/华为后台限制最严、Android 11 的原生机验证权限模型、Android 13 的 Pixel 或者主流新机验证 targetSdk 升级适配。没有条件买这么多实体机也可以用云真机平台但要注意云真机的蓝牙硬件和本地真实手机差异较大发现的版本问题种类也有限该买的老旧机器还是得买。第二个是版本分支代码要控制在最小范围。我之前见过一个项目为了适配 Android 4.4在业务逻辑里大量分散if (Build.VERSION.SDK_INT 21)判断把代码搞得一团糟后来根本没人敢动那部分。合理做法是像我在扫描器封装里做的那样把版本差异集中到一个类、一个方法里上层业务代码永远只和统一接口打交道。这样即使将来 Android 14、15 又带来一波新的 API 变更你要改的也只是底层封装这一个文件而不需要把整个项目翻一遍。继续扩展的话可以从这几个方向往下做一是把经典蓝牙和 BLE 的封装统一起来对外暴露一套“连接设备–发现服务–收发数据”的高级 API让上层业务不再感知底层是走 RFCOMM 还是 GATT二是加入自动化兼容性测试在设备矩阵上跑同一套蓝牙冒烟脚本把每一次系统升级后的回归验证变成例行流程三是把日志和状态机沉淀下来做成线上问题的排查后台这样以后线上反馈“蓝牙连不上”你能立刻看到是哪个环节、哪个错误码、哪个系统版本而不是靠用户截图在那猜。蓝牙兼容这条路注定没有终点系统版本每年都在变厂商定制永远在给你“惊喜”但把这些规律摸透之后你至少可以从容应对而不是每次升级都像拆盲盒。希望这篇文章里的细节能帮你在做 Android 蓝牙适配的时候少熬夜。