手把手教你实现安卓蓝牙文件传输(含Android 14权限适配与踩坑实录)

前言

最近在学习《移动软件开发》课程时,我接到了一个任务:开发一个安卓App,实现两台手机通过蓝牙互传图片。听起来很简单?我一开始也这么认为。然而,随着安卓系统的飞速迭代,曾经简单的几行代码,如今需要面对权限申请、后台限制、分区存储、版本适配等一系列“现代化”的挑战。

这篇博客,既是我的学习成果总结,也是一份详尽的“踩坑避坑”指南。希望能帮助正在或将要探索安卓蓝牙开发的你,少走一些弯路。

一、 最终成果展示

  • 发送方:选择图片后,连接设备,显示发送成功。
  • 接收方:接收成功后,提示文件保存路径,并在系统相册中可见。

二、 核心原理与项目搭建

1. 蓝牙通信原理

安卓蓝牙通信是典型的 客户端-服务器 (C/S) 模型:

  • 服务端(Server): 创建一个 BluetoothServerSocket,在一个唯一的 UUID 上进行监听 (listen),然后调用 a***ept() 进入阻塞状态,等待客户端连接。
  • 客户端(Client): 通过服务端的MAC地址和同一个 UUID 创建 BluetoothSocket,然后调用 connect() 发起连接。
  • 数据交换: 连接成功后,双方通过 InputStreamOutputStream 进行数据的读写,完成文件传输。

2. 项目基础搭建

  • UI布局 (activity_main.xml): 界面很简单,包含几个核心功能的按钮和一个用于显示状态的 TextView

<Button android:id="@+id/btn_listen" android:text="等待接收文件 (服务端)" />
<Button android:id="@+id/btn_list_devices" android:text="列出已配对设备" />
<Button android:id="@+id/btn_select_file" android:text="选择照片并发送" />
<TextView android:id="@+id/tv_status" android:text="状态: 准备就绪" />
<ListView android:id="@+id/lv_devices" />

三、 Android权限

这是整个项目中最具挑战性的部分。如果你的App还在使用旧的权限申请方式,那么在Android 12以上的设备上几乎寸步难行。

1. AndroidManifest.xml 中的权限申请

我们需要一个能兼容新旧所有版本的权限声明清单。关键在于使用 maxSdkVersion 属性。

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

<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.A***ESS_FINE_LOCATION" />

<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />

2. 运行时权限请求

告别繁琐的 onRequestPermissionsResult ,使用 ActivityResultLauncher 可以让权限处理逻辑更清晰、更解耦。我们需要为“请求蓝牙权限”、“请求存储权限”和“打开文件选择器”分别创建Launcher。

// 在Activity中定义成员变量
private ActivityResultLauncher<String[]> requestBluetoothPermissionsLauncher;
private ActivityResultLauncher<String> requestStoragePermissionLauncher;
private ActivityResultLauncher<Intent> filePickerLauncher;

// 在onCreate中初始化
private void initLaunchers() {
    // 1. 蓝牙多权限请求
    requestBluetoothPermissionsLauncher = registerForActivityResult(
            new ActivityResultContracts.RequestMultiplePermissions(),
            permissions -> { /* ... 处理权限授予结果 ... */ });

    // 2. 存储单权限请求
    requestStoragePermissionLauncher = registerForActivityResult(
            new ActivityResultContracts.RequestPermission(),
            isGranted -> {
                if (isGranted) openFilePicker();
                else Toast.makeText(this, "需要权限才能选文件", Toast.SHORT).show();
            });

    // 3. 文件选择器
    filePickerLauncher = registerForActivityResult(
            new ActivityResultContracts.StartActivityForResult(),
            result -> { /* ... 处理选择的文件URI ... */ });
}

四、 核心代码实现

这里我们直接贴出经过所有调试和优化后的最终核心方法。

发送文件 (sendFile)
private void sendFile(BluetoothSocket socket) {
    // ... 省略非核心代码 ...
    try (InputStream inputStream = getContentResolver().openInputStream(selectedFileUri);
         OutputStream outputStream = socket.getOutputStream()) {

        byte[] buffer = new byte[8192];
        int bytesRead;
        while ((bytesRead = inputStream.read(buffer)) != -1) {
            outputStream.write(buffer, 0, bytesRead);
        }
        outputStream.flush();
        // [可选优化] 在这里可以加入一个Thread.sleep(100)给接收方留出反应时间
    } catch (IOException e) {
        // ...
    }
    // ...
}
接收文件 (receiveFile) - 适配分区存储与异常处理

这是整个项目的精华所在,它解决了分区存储和我们后面会提到的“假失败”问题。

private void receiveFile(BluetoothSocket socket) {
    if (socket == null) return;
    try (InputStream inputStream = socket.getInputStream()) {
        // ... 使用MediaStore API创建文件输出流 ...
        // 详细代码见上一轮回答,此处省略
        
        // --- 核心读写循环 ---
        byte[] buffer = new byte[8192];
        while (inputStream.read(buffer) != -1) {
            // ... fileOutputStream.write(...) ...
        }
    } catch (IOException e) {
        // 关键:对特定“成功”异常的处理
        if (e.getMessage() != null && e.getMessage().contains("bt socket closed, read return: -1")) {
            // 这是成功的标志,更新UI为成功
        } else {
            // 这是真正的失败,更新UI为失败
        }
    } finally {
        // ... 关闭socket ...
    }
}

五、 踩坑实录:我的调试之旅

天坑一:小米(MIUI)的“权限墙”
  • 现象:应用在其他手机上正常,在小米上安装失败,提示 INSTALL_FAILED_USER_RESTRICTED,或者选择文件时提示“没有权限”。
  • 原因:MIUI拥有独特的、更严格的权限管理机制。
  • 解法:必须手动进入 手机管家 -> 应用管理 -> 你的App -> 权限管理,要打开“文件和媒体”权限和一个隐藏的“后台弹出界面”权限。否则,系统级的权限请求对话框根本无法弹出。
天坑二:文件接收的“假失败”
  • 现象:这是最诡异的问题。发送方显示成功,接收方的相册里也确实出现了图片,但我的App却提示“接收文件失败”。
  • 探究:通过查看Logcat,我发现每当接收失败时,总会捕获到一个特定的异常:java.io.IOException: bt socket closed, read return: -1
  • 顿悟read return: -1 在Java I/O中本是数据流正常结束的标志。但安卓蓝牙的底层实现,在连接被对方迅速关闭时,会将这个“正常结束”信号包装成一个IOException抛出!所以,我的程序成功接收了所有数据,却在最后一步将“成功”误判为了“失败”。
  • 解法:如上面的receiveFile代码所示,在catch块里对这个特定的异常信息进行判断。如果是它,就执行成功的逻辑;如果是其他IOException,才认为是真正的失败。

[完整的项目代码已上传至GitHub:collapsar-git/BluetoothFiletransfer: 安卓手机间通过蓝牙传输文件]

转载请说明出处内容投诉
CSS教程网 » 手把手教你实现安卓蓝牙文件传输(含Android 14权限适配与踩坑实录)

发表评论

欢迎 访客 发表评论

一个令你着迷的主题!

查看演示 官网购买