☰
iOS与Android蓝牙权限适配全解析:差异、申请时机与跨平台设计
2026/10/29 6:55:11 网站建设 项目流程

蓝牙权限这块,我踩过的坑比大多数人都多。早些年做一款跨平台的外设配套应用,iOS 端一切正常,Android 端用户反馈“搜不到设备”,排查了整整两天才发现是 Android 12 之后新增的运行时蓝牙权限没申请,而当时团队里没人意识到这个变更。从那以后,我把两个平台的蓝牙权限体系从头到尾梳理了一遍,也积累了不少适配经验。这篇内容就是把这套梳理结果完整分享出来,讲清楚 iOS 和 Android 在蓝牙权限上的设计差异、各自的申请时机、常见报错背后的真实原因,以及跨平台项目该怎么设计一套能同时兼容两端的权限适配层。适合正在做蓝牙相关功能(外设连接、数据传输、音频设备管理、物联网配网等)的开发者参考,无论你是刚接触蓝牙开发的新手,还是被权限问题反复折磨的老手,应该都能从中找到有用的东西。

1. 两个平台蓝牙权限的整体设计差异

1.1 权限模型底层逻辑的不同

iOS 和 Android 在蓝牙权限上的差异,根源在于两个平台对隐私的管控哲学完全不同。iOS 走的是“能力即权限”的路线,蓝牙被归类为一种系统能力,应用要使用蓝牙,必须在 Info.plist 里声明用途描述字符串,系统在首次调用蓝牙相关 API 时弹出授权框,用户同意后才能继续。这套机制的核心特点是:权限粒度粗、声明在前、弹窗时机由系统控制、用户一旦拒绝就只能去设置里手动改。

Android 走的则是“权限即资源”的路线,蓝牙被拆分成多个细粒度的权限项,随着系统版本迭代不断细化。早期 Android 只需要在 Manifest 里声明 BLUETOOTH 和 BLUETOOTH_ADMIN 两个普通权限,安装即授予,用户无感知。但从 Android 6.0 引入运行时权限机制后,位置权限开始和蓝牙扫描绑定;到 Android 12,又新增了 BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE 三个运行时权限,把扫描、连接、广播拆开管理。这种演进导致同一个功能在不同 Android 版本上需要申请不同的权限组合,适配成本显著高于 iOS。

理解这个底层差异很重要,因为它决定了两端在权限申请代码上的结构完全不同。iOS 端你只需要关心“有没有声明用途描述”和“用户有没有同意”两件事;Android 端你需要关心“当前系统版本是多少”“该版本需要哪些权限”“这些权限是安装时授予还是运行时申请”“用户拒绝过几次”等一系列问题。

1.2 权限声明位置与生效时机对比

iOS 的蓝牙权限声明集中在 Info.plist 文件中,涉及两个键:NSBluetoothAlwaysUsageDescription 和 NSBluetoothPeripheralUsageDescription。前者是 iOS 13 之后的主键,用于所有蓝牙使用场景;后者是 iOS 12 及之前的旧键,主要用于外设交互。实际项目中建议两个都写上,保证向后兼容。这两个键的值是给用户看的说明文字,系统弹窗时会展示,写得含糊会被审核打回。

Android 的权限声明在 AndroidManifest.xml 中,但不同权限的生效时机不一样。BLUETOOTH 和 BLUETOOTH_ADMIN 是普通权限,声明后安装即授予,不需要运行时申请,但这两个权限在 Android 12 之后对扫描和连接的行为有了限制。BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE 是危险权限,必须在运行时动态申请。此外,Android 11 及之前扫描蓝牙还需要 ACCESS_FINE_LOCATION 权限,这个坑很多人踩过。

下面这张表把两端的关键差异列清楚,方便对照:

对比维度iOSAndroid
声明位置Info.plistAndroidManifest.xml
权限粒度粗粒度,一个能力一个权限细粒度,扫描/连接/广播分开
申请方式系统自动弹窗代码动态申请
弹窗时机首次调用蓝牙 API 时开发者主动调用申请接口时
用户拒绝后需引导去设置页开启可再次申请,多次拒绝后不再弹窗
版本差异iOS 13 前后键名不同Android 12 前后权限体系大改

1.3 为什么 Android 的适配成本更高

Android 适配成本高的核心原因在于版本碎片化叠加权限体系演进。你写一套代码,可能要同时跑在 Android 8、10、12、14 上,而这些版本对蓝牙权限的要求各不相同。Android 8 到 11 需要位置权限配合蓝牙扫描,Android 12 之后扫描改用 BLUETOOTH_SCAN 且可以声明 neverForLocation 来免除位置权限,Android 13 又对附近设备权限做了进一步规范。每跨一个版本,权限组合就可能变一次。

iOS 虽然也有版本差异,但整体收敛得多。iOS 13 之后统一用 NSBluetoothAlwaysUsageDescription,之前的旧键保留兼容即可,权限模型本身没有大的结构性变化。所以跨平台项目里,Android 端的权限适配代码量通常是 iOS 端的三到五倍,这不是夸张,是实际项目里的真实比例。

2. iOS 端蓝牙权限的声明与申请实操

2.1 Info.plist 键值的正确写法

iOS 端最基础也最容易出错的一步就是 Info.plist 的声明。很多新手以为随便写个字符串就行,结果审核被拒或者弹窗文案莫名其妙。正确的做法是:NSBluetoothAlwaysUsageDescription 的值要明确告诉用户“为什么你的应用需要蓝牙”,比如“用于连接智能手环同步运动数据”,而不是“需要蓝牙权限”这种废话。

具体配置时,在 Xcode 里打开 Info.plist,添加两个键:

<key>NSBluetoothAlwaysUsageDescription</key> <string>需要蓝牙以连接您的智能设备并同步数据</string> <key>NSBluetoothPeripheralUsageDescription</key> <string>需要蓝牙以发现并连接附近的智能设备</string>

这里有个细节:NSBluetoothPeripheralUsageDescription 在 iOS 13 之后其实已经不再触发独立弹窗,但保留它不会有害,反而能兼容极少数还在跑 iOS 12 的设备。我个人的习惯是两个都写,文案保持一致或略有区分,避免遗漏。

注意:用途描述字符串不能为空,也不能写得太笼统。系统审核会检查这段文字是否清晰说明了蓝牙的具体用途,含糊其辞是常见的被拒原因。

2.2 权限状态的判断与引导

iOS 没有“主动申请蓝牙权限”的 API,权限弹窗是系统在首次调用 CoreBluetooth 相关接口时自动触发的。所以开发者能做的其实是“判断当前授权状态”和“在用户拒绝后引导去设置”。判断状态用 CBCentralManager 的 authorization 属性,返回枚举值包括 allowedAlways、denied、restricted、notDetermined。

实际代码里,我通常会在初始化 CBCentralManager 之前先检查状态:

import CoreBluetooth let manager = CBCentralManager(delegate: self, queue: nil) func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.authorization { case .allowedAlways: // 已授权,可以开始扫描 startScanning() case .denied: // 用户拒绝,引导去设置 showSettingsGuide() case .restricted: // 受系统限制,通常无法使用 showRestrictedAlert() case .notDetermined: // 尚未决定,调用扫描会触发弹窗 startScanning() @unknown default: break } }

这段代码的关键在于 notDetermined 状态下的处理:此时调用扫描接口,系统才会弹出授权框。如果你在 notDetermined 时什么都不做,用户永远看不到弹窗,功能也就永远用不了。这是 iOS 蓝牙开发里非常经典的一个坑。

2.3 用户拒绝后的引导策略

用户点了“不允许”之后,iOS 不会再弹第二次授权框,唯一的出路是引导用户去“设置 > 隐私 > 蓝牙”里手动打开。这里有个体验细节:直接弹一个“请去设置开启蓝牙权限”的提示框,用户往往不知道怎么操作。更好的做法是在提示框里加一个“前往设置”按钮,点击后用 UIApplication.openSettingsURLString 跳转到本应用的设置页。

if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }

跳转到的是应用自己的设置页面,用户在里面能找到蓝牙开关。实测这个路径比让用户自己去系统设置里翻要顺畅得多。另外,引导文案要写得具体,比如“请在设置中开启蓝牙权限,以便连接您的设备”,而不是干巴巴的“权限被拒绝”。

2.4 iOS 蓝牙权限的几个隐藏细节

第一个细节:iOS 的蓝牙权限和定位权限是独立的,蓝牙扫描不需要定位权限,这点和 Android 11 及之前完全不同,别把两端的逻辑搞混。第二个细节:后台使用蓝牙需要在 Capabilities 里勾选“Background Modes”中的“Uses Bluetooth LE accessories”,否则应用进入后台后连接会断。第三个细节:iOS 14 之后增加了本地网络权限,如果蓝牙设备涉及局域网通信,可能还需要处理本地网络权限,这是另一个独立的话题。

还有一个容易被忽略的点:CBCentralManager 的初始化本身就可能触发权限弹窗,所以不要在应用启动时就急着初始化,最好等到用户真正触发蓝牙功能时再初始化,这样弹窗时机更自然,用户同意率也更高。我试过在启动页初始化,结果弹窗在用户还没搞清楚应用是干什么的时候就冒出来,拒绝率明显偏高。

3. Android 端蓝牙权限的分版本适配

3.1 Android 12 之前的权限组合

Android 12 之前,蓝牙相关的权限主要是 BLUETOOTH、BLUETOOTH_ADMIN 和 ACCESS_FINE_LOCATION。前两个是普通权限,Manifest 里声明即可;ACCESS_FINE_LOCATION 是危险权限,必须运行时申请。为什么扫描蓝牙需要位置权限?因为蓝牙扫描理论上可以推断用户位置,系统出于隐私保护把它归到了位置权限范畴。

Manifest 声明如下:

<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

运行时申请位置权限的代码:

if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), LOCATION_PERMISSION_REQUEST_CODE ) }

这里有个坑:Android 10 之后,即使你只做蓝牙扫描不做定位,也必须申请 ACCESS_FINE_LOCATION,ACCESS_COARSE_LOCATION 不够用。很多应用因为只申请了粗略位置权限,导致扫描不到设备,排查半天才发现是权限粒度问题。

3.2 Android 12 及之后的权限体系

Android 12 引入了 BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE 三个运行时权限,把蓝牙操作拆得更细。这三个权限的用途分别是:扫描附近设备、连接已配对设备、对外广播。同时,如果应用不通过蓝牙推断位置,可以在 Manifest 里给 BLUETOOTH_SCAN 加上 android:usesPermissionFlags="neverForLocation" 属性,这样就不需要再申请位置权限了。

Manifest 配置:

<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />

加上 neverForLocation 之后,扫描就不再依赖位置权限,这对只做设备连接不做定位的应用来说是巨大的简化。但要注意,这个标志是一个承诺,如果你的应用实际上通过蓝牙扫描推断用户位置,就不该加这个标志,否则属于违规。

运行时申请这三个权限:

private val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_ADVERTISE ) } else { arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) }

这段代码用版本判断来切换权限数组,是跨版本适配的标准写法。实际项目里我会把它封装成一个工具方法,根据当前 SDK 版本返回对应的权限列表,避免在每个调用点都写一遍判断。

3.3 分版本权限适配的封装思路

前面说了 Android 适配成本高,实际项目里必须做封装,否则代码会乱成一团。我的做法是写一个 BluetoothPermissionHelper,内部根据 Build.VERSION.SDK_INT 判断版本,对外暴露统一的 checkPermissions 和 requestPermissions 方法。调用方不需要关心当前是什么版本,只需要问“蓝牙权限有没有”“没有就申请”。

封装的核心逻辑分三层:第一层判断版本,Android 12 及以上用新权限组,以下用位置权限;第二层检查权限状态,区分已授予、未授予、永久拒绝;第三层处理申请结果,永久拒绝时引导去设置页。这三层拆开后,每个方法都很短,维护起来清晰。

object BluetoothPermissionHelper { fun requiredPermissions(context: Context): Array<String> { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT ) } else { arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) } } fun hasPermissions(context: Context): Boolean { return requiredPermissions(context).all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } } }

这个封装看起来简单,但省去了大量重复判断。实际项目里还可以加上 shouldShowRequestPermissionRationale 的判断,用来区分“首次申请”和“用户拒绝过但没勾选不再询问”,从而决定是否先展示解释文案。

3.4 Android 13 及以上的补充变化

Android 13 对附近设备权限做了进一步规范,把蓝牙、Wi-Fi 等近距离通信权限统一归到 NEARBY_DEVICES 权限组下。这个变化本身不改变申请方式,但影响权限分组展示和用户理解。用户在设置里看到的权限名称可能变成“附近的设备”,而不是单独的“蓝牙”。适配时不需要改代码,但引导文案要跟着调整,别还写着“请开启蓝牙权限”,用户找不到对应开关。

另外 Android 14 对前台服务使用蓝牙做了更严格的要求,如果应用在后台通过前台服务维持蓝牙连接,需要声明 FOREGROUND_SERVICE_CONNECTED_DEVICE 权限并说明用途。这个变化主要影响后台常驻类应用,比如持续同步数据的健康监测应用,普通的前台使用场景不受影响。

4. 跨平台权限适配层的设计要点

4.1 统一权限抽象层的必要性

做过跨平台蓝牙项目的人都有体会:如果两端各写各的权限逻辑,代码会迅速失控。iOS 端是“声明加状态判断”,Android 端是“版本判断加动态申请”,两套逻辑混在业务代码里,改一处漏一处。我的做法是在业务层和平台层之间加一个统一的权限抽象层,业务层只调用抽象接口,平台层各自实现。

抽象层定义统一的接口,比如 checkBluetoothPermission、requestBluetoothPermission、openPermissionSettings 三个方法,返回值统一成枚举:Granted、Denied、PermanentlyDenied、NotDetermined。iOS 实现里把 CBManager.authorization 映射到这个枚举,Android 实现里把 checkSelfPermission 和 shouldShowRequestPermissionRationale 的组合映射过来。这样业务层拿到的永远是这四个状态,不需要关心平台差异。

4.2 权限申请时机的统一策略

两端权限弹窗的触发机制不同,但用户体验应该尽量一致。我的策略是:不在应用启动时申请任何蓝牙权限,等到用户主动触发蓝牙功能(比如点击“搜索设备”按钮)时再申请。这个策略在两端都适用,iOS 端通过延迟初始化 CBCentralManager 实现,Android 端通过延迟调用 requestPermissions 实现。

这样做的理由是:用户在明确知道“我要用蓝牙了”的时候看到权限弹窗,同意率远高于启动时莫名其妙的弹窗。实测下来,延迟申请的同意率能高出不少,而且用户拒绝后重新触发的路径也更自然。另外,申请前最好先展示一句解释文案,比如“需要蓝牙权限来搜索附近的设备”,让用户有心理预期。

4.3 拒绝后的降级与引导设计

用户拒绝权限是常态,两端都要设计好降级路径。iOS 端拒绝后无法再次弹窗,只能引导去设置;Android 端可以再次申请,但多次拒绝后系统不再弹窗,同样要引导去设置。统一抽象层里,PermanentlyDenied 状态对应的动作就是展示引导弹窗加跳转设置按钮。

引导弹窗的文案要分平台微调:iOS 说“请前往设置 > 隐私 > 蓝牙开启权限”,Android 说“请前往设置 > 应用 > 权限开启附近设备权限”。路径描述准确能显著降低用户流失。我见过不少应用只写“请开启权限”,用户根本不知道去哪开,最后直接卸载。

4.4 权限状态的缓存与刷新

还有一个容易忽略的点:权限状态不是一成不变的,用户可能在你应用运行期间去设置里改了权限。所以每次使用蓝牙功能前都应该重新检查权限状态,而不是缓存一次就一直用。iOS 端可以通过 CBCentralManager 的代理回调感知状态变化,Android 端则需要在 onResume 时重新检查。

实际项目里我会在权限抽象层加一个 refresh 方法,在页面 onResume 或应用回到前台时调用,确保状态是最新的。这个细节看起来小,但能避免很多“明明开了权限还是用不了”的诡异问题。

5. 常见问题排查与避坑实录

5.1 扫描不到设备的排查思路

扫描不到设备是蓝牙开发最高频的问题,原因可能出在权限、系统开关、设备本身三个层面。排查时按顺序来:先确认权限是否授予,再确认系统蓝牙是否开启,最后确认目标设备是否在广播。下面这张表整理了常见原因和对应解法:

现象可能原因排查方法
iOS 无弹窗直接失败Info.plist 缺用途描述检查两个蓝牙键是否都存在
iOS 扫描无结果权限被拒但未引导检查 authorization 状态
Android 12+ 扫描无结果未申请 BLUETOOTH_SCAN检查运行时权限
Android 11- 扫描无结果未申请位置权限检查 ACCESS_FINE_LOCATION
两端都扫不到设备未广播或距离过远用其他工具验证设备状态
连接成功但无数据特征值 UUID 不匹配核对服务和特征值 UUID

排查时我习惯先用系统自带的蓝牙设置页验证设备是否可见,如果系统都扫不到,那肯定是设备或距离问题,跟应用权限无关。这一步能快速排除一半的干扰因素。

5.2 权限申请被系统静默拒绝

Android 上有个经典问题:用户勾选了“不再询问”后,再调用 requestPermissions 系统直接回调拒绝,不弹任何框。很多开发者没处理这个分支,导致用户以为应用坏了。正确的处理是用 shouldShowRequestPermissionRationale 判断:返回 false 且权限未授予,基本就是永久拒绝了,此时应该展示引导去设置的弹窗。

if (!shouldShowRequestPermissionRationale(permission) && !hasPermission) { // 永久拒绝,引导去设置 showGoToSettingsDialog() }

这个判断逻辑要放在权限回调里,结合申请结果一起判断。我踩过的坑是只在申请前判断,结果用户第一次拒绝后逻辑就乱了,后来改成在 onRequestPermissionsResult 里统一处理才稳定。

5.3 iOS 审核被拒的权限文案问题

iOS 审核对权限用途描述查得很严,文案写得不清楚直接被拒。常见的被拒原因包括:描述太笼统(“需要蓝牙权限”)、描述与实际功能不符(说连接手环实际做别的)、缺少某个键。解决办法是把用途写到具体场景,比如“用于连接智能体脂秤并同步测量数据”,让审核员一眼看懂。

另外,如果应用同时用到蓝牙和定位,两个用途描述要分别写清楚,别混在一起。审核员会逐条核对权限和功能的对应关系,含糊其辞是自找麻烦。

5.4 后台蓝牙连接的权限陷阱

后台维持蓝牙连接是另一个高发问题区。iOS 端需要在 Background Modes 里勾选蓝牙外设选项,否则应用一进后台连接就断。Android 端从 12 开始对后台蓝牙连接有额外限制,长时间后台运行需要前台服务配合,而前台服务又需要声明对应权限。这块的适配比较复杂,建议在项目早期就把后台场景考虑进去,别等上线后才发现后台断连。

我在实际项目里的经验是:如果产品需求涉及后台持续连接,一定要在技术方案评审阶段就把两端的后台权限和保活机制列清楚,包括 iOS 的 Background Modes、Android 的前台服务类型和通知渠道配置。这些内容一旦遗漏,后期补起来牵涉面很广。

5.5 权限适配的检查清单

最后整理一份上线前的权限检查清单,两端对照着过一遍,能挡掉大部分低级问题:

  • iOS:Info.plist 两个蓝牙键是否都写了,文案是否具体
  • iOS:CBCentralManager 是否延迟初始化,弹窗时机是否合理
  • iOS:拒绝后的设置引导是否实现
  • iOS:后台蓝牙是否勾选 Background Modes
  • Android:Manifest 是否按版本声明了对应权限
  • Android:BLUETOOTH_SCAN 是否正确配置 neverForLocation
  • Android:运行时申请是否按 SDK 版本分支
  • Android:永久拒绝的引导逻辑是否实现
  • Android:Android 13+ 的权限分组文案是否更新
  • 两端:权限状态是否在回到前台时刷新

这份清单我每次发版前都会过一遍,尤其是跨版本升级 Android targetSdk 的时候,权限相关的回归测试必须做,因为新版本系统往往会收紧旧权限的行为,不测就容易出线上问题。

蓝牙权限这块没有一劳永逸的方案,系统在演进,权限体系就会跟着变。我个人的体会是,与其记住每个版本的具体权限名,不如把“版本判断加统一抽象”这个结构搭好,新版本来了只需要在抽象层里加一个分支,业务代码不用动。另外,权限引导的文案和跳转路径要当成产品功能来做,别当成技术细节随便糊弄,用户能不能顺利连上设备,很大程度取决于这几句提示写得好不好。

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

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

立即咨询