手把手教你实现安卓蓝牙文件传输(含Android 14权限适配与踩坑实录)
前言
最近在学习《移动软件开发》课程时,我接到了一个任务:开发一个安卓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: 安卓手机间通过蓝牙传输文件]
最新发布
-
武侠乂 官方中文版
2025-11-03 11:16:04 -
未来之役:2025全球精英挑战赛暨未来科技主题狂欢庆典
2025-05-12 23:38:59 -
永恒边境·破晓之战:时空裂隙的守护者与2025初夏庆典联动特别行动
2025-05-05 07:32:34 -
刀剑乱舞-Online-2025夏日祭典:刀剑男士的清凉试炼与秘宝探索之旅
2025-07-20 21:28:11 -
《我的回合》2025盛夏狂欢盛典:回合制策略巅峰对决暨周年庆特别活动
2025-07-06 13:58:57 -
“2分利”算不算高息?如何计算?
2025-10-15 11:53:29 -
怒海争霸2116:深海探险与资源争夺赛2025年5月8日盛大开启
2025-05-08 15:06:36 -
《壁咚那三国》2025盛夏狂欢庆典:跨服争霸赛暨限定武将皮肤免费送
2025-06-18 07:56:53 -
绝境突击者2025盛夏突围战:全球玩家极限生存挑战赛火热开启
2025-07-05 16:10:42 -
大唐剑侠之“剑指苍穹,侠影纵横”全球争霸赛盛大开启
2025-05-23 21:05:52