前言
最近在学习《移动软件开发》课程时,我接到了一个任务:开发一个安卓App,实现两台手机通过蓝牙互传图片。听起来很简单?我一开始也这么认为。然而,随着安卓系统的飞速迭代,曾经简单的几行代码,如今需要面对权限申请、后台限制、分区存储、版本适配等一系列“现代化”的挑战。
这篇博客,既是我的学习成果总结,也是一份详尽的“踩坑避坑”指南。希望能帮助正在或将要探索安卓蓝牙开发的你,少走一些弯路。
一、 最终成果展示
发送方:选择图片后,连接设备,显示发送成功。接收方:接收成功后,提示文件保存路径,并在系统相册中可见。
二、 核心原理与项目搭建
1. 蓝牙通信原理
安卓蓝牙通信是典型的 客户端-服务器 (C/S) 模型:
服务端(Server): 创建一个 BluetoothServerSocket,在一个唯一的 UUID 上进行监听 (listen),然后调用 accept() 进入阻塞状态,等待客户端连接。客户端(Client): 通过服务端的MAC地址和同一个 UUID 创建 BluetoothSocket,然后调用 connect() 发起连接。数据交换: 连接成功后,双方通过 InputStream 和 OutputStream 进行数据的读写,完成文件传输。
2. 项目基础搭建
UI布局 (activity_main.xml): 界面很简单,包含几个核心功能的按钮和一个用于显示状态的 TextView。
三、 Android权限
这是整个项目中最具挑战性的部分。如果你的App还在使用旧的权限申请方式,那么在Android 12以上的设备上几乎寸步难行。
1. AndroidManifest.xml 中的权限申请
我们需要一个能兼容新旧所有版本的权限声明清单。关键在于使用 maxSdkVersion 属性。
2. 运行时权限请求
告别繁琐的 onRequestPermissionsResult ,使用 ActivityResultLauncher 可以让权限处理逻辑更清晰、更解耦。我们需要为“请求蓝牙权限”、“请求存储权限”和“打开文件选择器”分别创建Launcher。
// 在Activity中定义成员变量
private ActivityResultLauncher
private ActivityResultLauncher
private ActivityResultLauncher
// 在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: 安卓手机间通过蓝牙传输文件]