2. 创建 Android 项目并配置蓝牙权限

本章将从零搭建 BluetoothStorageDemo 项目骨架,完成 AndroidManifest 蓝牙权限声明和运行时权限请求代码的编写。蓝牙权限是后续所有扫描、连接、数据传输操作的前提,错误的权限配置将直接导致功能不可用或应用崩溃。本章内容严格区分 Android 12(API 31)前后的权限模型差异,确保项目适配主流机型。

2.1 项目结构概览

项目采用单 Activity 架构,核心文件及职责如下表所示:

路径 / 文件 类型 职责
app/src/main/java/com/example/bluetoothstorage/ 源码根目录 存放所有 Kotlin 源文件
MainActivity.kt Activity 主入口,处理权限请求与蓝牙初始化
BluetoothPermissionHelper.kt 工具类 封装权限检查、请求及回调逻辑
app/src/main/AndroidManifest.xml 清单文件 声明蓝牙相关权限与组件
app/build.gradle.kts 构建配置 指定 compileSdk、minSdk、targetSdk
app/src/main/res/values/strings.xml 资源文件 权限弹窗提示文案
项目基调: compileSdk = 34,minSdk = 24,targetSdk = 34。minSdk 设为 24 可覆盖 Android 7.0 以上设备,同时确保运行时权限 API 完整可用。

2.2 AndroidManifest 蓝牙权限声明

蓝牙权限声明需要根据目标设备的 API 级别分别配置。以下代码同时兼容 Android 12(API 31)以上和以下设备:

<!-- AndroidManifest.xml 中声明蓝牙相关权限 -->

<!-- ========== 通用权限(所有版本) ========== -->
<!-- 访问粗略位置(蓝牙扫描需要,Android 10+ 可用 coarse 替代 fine) -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
    android:maxSdkVersion="30" />

<!-- ========== Android 12+ 细粒度蓝牙权限 ========== -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />

<!-- ========== Android 12 以下 传统蓝牙权限 ========== -->
<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-feature android:name="android.hardware.bluetooth" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />

权限版本差异对照表:

权限名称 适用 API 范围 用途 是否运行时请求
BLUETOOTH ≤ 30 基础蓝牙通信(连接、数据传输) 否
BLUETOOTH_ADMIN ≤ 30 蓝牙管理(扫描、开关) 否
BLUETOOTH_SCAN ≥ 31 扫描蓝牙设备 是
BLUETOOTH_CONNECT ≥ 31 连接已配对的蓝牙设备 是
BLUETOOTH_ADVERTISE ≥ 31 蓝牙广播(外设模式) 是
ACCESS_FINE_LOCATION ≤ 30 蓝牙扫描需位置权限(Android 10-12 强制) 是
ACCESS_COARSE_LOCATION ≤ 30 替代 FINE_LOCATION(Android 10+ 可用) 是
关键提醒: Android 12 以上 BLUETOOTH_SCAN 权限已不再隐含位置权限,但部分厂商定制系统仍可能要求位置权限。建议在运行时同时检查 BLUETOOTH_CONNECT 和 BLUETOOTH_SCAN,并在 API 30 以下请求 ACCESS_FINE_LOCATION。

2.3 运行时权限请求代码

使用 registerForActivityResult + RequestMultiplePermissions 实现权限请求,避免已废弃的 onRequestPermissionsResult 回调分散逻辑。

2.3.1 权限检查与请求封装

// BluetoothPermissionHelper.kt
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat

object BluetoothPermissionHelper {

    /**
     * 获取当前设备需要的蓝牙权限列表
     */
    fun getRequiredPermissions(): Array<String> {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            // Android 12+
            arrayOf(
                Manifest.permission.BLUETOOTH_SCAN,
                Manifest.permission.BLUETOOTH_CONNECT
            )
        } else {
            // Android 12 以下
            arrayOf(
                Manifest.permission.ACCESS_FINE_LOCATION
            )
        }
    }

    /**
     * 检查所有蓝牙权限是否已授予
     */
    fun hasBluetoothPermissions(context: Context): Boolean {
        return getRequiredPermissions().all {
            ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
        }
    }

    /**
     * 获取未授予的权限列表
     */
    fun getDeniedPermissions(context: Context): List<String> {
        return getRequiredPermissions().filter {
            ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
        }
    }
}

2.3.2 在 Activity 中发起请求

// MainActivity.kt 关键片段
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {

    // 权限请求 launcher
    private val permissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { grantResults ->
        val allGranted = grantResults.values.all { it }
        if (allGranted) {
            onPermissionGranted()
        } else {
            val deniedList = grantResults.filter { !it.value }.keys.joinToString(", ")
            Toast.makeText(this, "权限被拒绝: $deniedList", Toast.LENGTH_LONG).show()
            onPermissionDenied()
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 启动时检查权限
        checkAndRequestPermissions()
    }

    private fun checkAndRequestPermissions() {
        if (BluetoothPermissionHelper.hasBluetoothPermissions(this)) {
            onPermissionGranted()
        } else {
            val denied = BluetoothPermissionHelper.getDeniedPermissions(this)
            // 可在此处添加 shouldShowRequestPermissionRationale 判断
            permissionLauncher.launch(denied.toTypedArray())
        }
    }

    private fun onPermissionGranted() {
        // 权限已授予 → 初始化蓝牙、加载已保存设备
        Toast.makeText(this, "蓝牙权限已就绪", Toast.LENGTH_SHORT).show()
        // 后续章节将在此处接入蓝牙扫描与 SharedPreferences 存储
    }

    private fun onPermissionDenied() {
        // 权限被拒绝 → 提示用户手动开启
        Toast.makeText(this, "蓝牙功能将不可用,请在设置中授予权限", Toast.LENGTH_LONG).show()
    }
}

2.3.3 可选的 Rationale 提示

当用户之前拒绝过权限时,应显示解释说明:

// 在 checkAndRequestPermissions 中增加 Rationale 判断
private fun checkAndRequestPermissions() {
    if (BluetoothPermissionHelper.hasBluetoothPermissions(this)) {
        onPermissionGranted()
    } else {
        val denied = BluetoothPermissionHelper.getDeniedPermissions(this)
        val shouldShowRationale = denied.any {
            shouldShowRequestPermissionRationale(it)
        }
        if (shouldShowRationale) {
            // 显示弹窗解释为什么需要权限
            showRationaleDialog(denied)
        } else {
            permissionLauncher.launch(denied.toTypedArray())
        }
    }
}

private fun showRationaleDialog(permissions: Array<String>) {
    androidx.appcompat.app.AlertDialog.Builder(this)
        .setTitle("需要蓝牙权限")
        .setMessage("本应用需要通过蓝牙扫描和连接设备,以存储和同步数据。")
        .setPositiveButton("授予权限") { _, _ ->
            permissionLauncher.launch(permissions)
        }
        .setNegativeButton("拒绝") { _, _ ->
            onPermissionDenied()
        }
        .show()
}

2.4 权限请求流程总览

下方 SVG 流程图展示了从权限检查到最终决策的完整路径:

Android 蓝牙运行时权限请求流程 检查权限状态 是否已授予? 已授权 → 使用蓝牙 未授权 → 请求权限 系统权限对话框 用户点击"允许" 用户点击"拒绝" 蓝牙功能正常使用 功能受限 + 提示 是 否 允许 拒绝

2.5 常见错误与最佳实践

  • 权限声明遗漏:未在 AndroidManifest 中声明 BLUETOOTH_CONNECT 会导致请求时系统直接返回拒绝,且不弹对话框。
  • SDK 版本判断错误:在 API 31 设备上仍请求 ACCESS_FINE_LOCATION 会导致权限冗余弹窗,建议按 Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 分支处理。
  • 权限被永久拒绝:用户连续两次拒绝后,系统不再弹窗。此时应引导用户进入「设置 → 应用 → 权限」手动开启。
  • shouldShowRequestPermissionRationale 误判:首次请求前调用会返回 false,第一次拒绝后才变为 true,不要将 false 等同于「不需要解释」。
最佳实践: 权限请求结果通过 registerForActivityResult 回调统一处理,避免在 onResume 中反复检查。权限就绪后使用 SharedPreferences 缓存一次授权状态,减少重复检查开销(后续章节将实现)。

2.6 调试与验证

完成上述代码后,按以下步骤验证权限配置是否正确:

  1. 首次安装启动:应弹出系统权限对话框,选择「允许」后看到 Toast "蓝牙权限已就绪"。
  2. 拒绝权限后重试:清除应用数据,再次启动并拒绝,应显示 "权限被拒绝" 提示,且后续不再次弹窗。
  3. adb 命令快速测试:
    # 授予所有蓝牙权限
    adb shell pm grant com.example.bluetoothstorage android.permission.BLUETOOTH_SCAN
    adb shell pm grant com.example.bluetoothstorage android.permission.BLUETOOTH_CONNECT
    
    # 撤销权限模拟拒绝场景
    adb shell pm revoke com.example.bluetoothstorage android.permission.BLUETOOTH_SCAN
  4. 日志过滤:在 Logcat 中搜索 Permission 或 BluetoothPermission 标签,确认权限请求生命周期完整。
注意: 使用模拟器测试蓝牙权限时,部分 API 31+ 模拟器镜像可能不包含蓝牙硬件,导致 BLUETOOTH_SCAN 请求无响应。建议在真机或带有蓝牙支持的模拟器镜像上进行验证。

至此,项目已具备完整的蓝牙权限基础设施。从第 3 章开始,将基于已获得的权限实现蓝牙设备扫描并利用 SharedPreferences 持久化设备信息。