6、经典蓝牙实战-文件传输:文件选择器(SAF框架)、文件分片传输、进度回调、断点续传思路
好,咱们继续往下走。前面几章我们把蓝牙的连接、配对、数据收发都捋了一遍。现在终于到了最硬核的部分——文件传输。
说实话,文件传输才是蓝牙开发里真正考验功底的地方。为什么?因为蓝牙的传输速率有限,而且链路不稳定。你想想看,一个几十兆的文件,如果一次性发过去,中间断一下,整个就废了。所以我们必须把文件切成小块,一块一块地发,还得告诉用户传了多少,万一断了还能续传。
这一章,我就带你一步步搞定这些。
6.1 文件选择器:用SAF框架优雅选文件
首先,用户得选一个文件吧?Android里选文件,我强烈建议你用SAF(Storage Access Framework)。别再用老旧的Environment.getExternalStorageDirectory()了,那玩意儿在Android 10以后基本废了。
SAF的好处很明显:用户不需要给你的App授予整个存储的读写权限,只需要授权你访问他选中的那个文件。这对用户来说更安全,对我们开发者来说也更省心。
启动文件选择器的代码很简单:
// 启动文件选择器
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); // 所有类型文件
startActivityForResult(intent, REQUEST_CODE_SELECT_FILE);
然后在onActivityResult里拿到Uri:
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_SELECT_FILE && resultCode == RESULT_OK) {
Uri fileUri = data.getData();
// 拿到Uri后,就可以读取文件内容了
}
}
ContentResolver来获取文件的输入流。另外,如果你需要持久化这个Uri(比如断点续传时要记住文件位置),记得调用takePersistableUriPermission()。
6.2 文件分片传输:把大文件切成小块
好,文件选好了。接下来怎么传?
我个人习惯的做法是:把文件切成固定大小的数据块,每块单独发送。为什么?因为蓝牙的RFCOMM通道虽然看起来像个流,但底层其实有MTU限制。你一次性发太多数据,底层会帮你分包,但万一丢了一包,整个数据就乱了。
我一般把每片大小设为1024字节。这个大小比较适中,既不会因为太小导致频繁发送,也不会因为太大而超出MTU。
分片的核心逻辑:
private void sendFileInChunks(InputStream inputStream, OutputStream outputStream, long fileSize) throws IOException {
byte[] buffer = new byte[CHUNK_SIZE]; // 1024字节
int bytesRead;
long totalSent = 0;
while ((bytesRead = inputStream.read(buffer)) != -1) {
// 先发送数据长度(4字节)
byte[] lengthBytes = intToBytes(bytesRead);
outputStream.write(lengthBytes);
// 再发送数据本身
outputStream.write(buffer, 0, bytesRead);
outputStream.flush();
totalSent += bytesRead;
// 这里可以回调进度
onProgress(totalSent, fileSize);
}
// 发送结束标志
byte[] endBytes = intToBytes(-1);
outputStream.write(endBytes);
outputStream.flush();
}
接收端呢?反过来,先读4字节得到长度,再读对应长度的数据:
private void receiveFile(InputStream inputStream, OutputStream fileOutputStream) throws IOException {
byte[] lengthBuffer = new byte[4];
while (true) {
// 读取数据长度
readExactly(inputStream, lengthBuffer);
int chunkLength = bytesToInt(lengthBuffer);
if (chunkLength == -1) {
break; // 结束标志
}
// 读取数据本身
byte[] dataBuffer = new byte[chunkLength];
readExactly(inputStream, dataBuffer);
fileOutputStream.write(dataBuffer);
}
}
inputStream.read()不一定能一次性读满你指定的字节数。我曾经就因为这个踩过坑,接收到的文件总是损坏。所以一定要写一个readExactly()方法,循环读取直到读满指定长度。
6.3 进度回调:让用户知道传了多少
用户最关心什么?传了多少了,还剩多少。所以进度回调是必须的。
我一般定义一个回调接口:
public interface FileTransferCallback {
void onProgress(long bytesTransferred, long totalBytes);
void onComplete();
void onError(String errorMessage);
}
然后在发送/接收的循环里,每传完一片就调用一次onProgress。注意,这个回调是在子线程里触发的,所以如果你要更新UI,记得切到主线程:
new Handler(Looper.getMainLooper()).post(() -> {
int percent = (int) (totalSent * 100 / fileSize);
progressBar.setProgress(percent);
textView.setText("已传输: " + percent + "%");
});
嗯,这里要注意一点:不要每传一片都刷新UI。如果每片1024字节,一个10MB的文件要传一万多次,每次都刷新UI,界面会卡成PPT。我一般每传完1%才更新一次进度条。
6.4 断点续传思路:不怕中断
这是最硬核的部分。文件传了一半,蓝牙断了怎么办?用户总不能重新传吧?
断点续传的思路其实不复杂,说白了就是:记录已经传了多少,下次从断点处继续。
具体怎么做?我分享一个我在项目中用过的方案:
- 发送端记录已发送的字节数:每传完一片,就把当前已发送的字节数写入一个本地文件(比如SharedPreferences或一个JSON文件)。
- 接收端记录已接收的字节数:同样,每收到一片,就记录已接收的字节数。
- 重连时交换断点信息:蓝牙重连后,发送端先发一个特殊指令,告诉接收端「我要续传」。接收端回复「我已经收到了X字节」,发送端就从X字节处继续发送。
断点信息的格式可以很简单:
{
"fileUri": "content://...",
"fileName": "document.pdf",
"fileSize": 10485760,
"bytesSent": 5242880,
"lastModified": 1699000000
}
重连后的续传流程:
// 发送端
private void resumeTransfer(OutputStream outputStream, long resumePosition) {
// 跳过已发送的部分
inputStream.skip(resumePosition);
// 从resumePosition开始继续发送
sendFileInChunks(inputStream, outputStream, fileSize - resumePosition);
}
不过说实话,断点续传在蓝牙开发中实现起来还是有些挑战的。比如:
- 如果文件在传输过程中被修改了怎么办?我一般会记录文件的最后修改时间,如果发现文件变了,就放弃续传,重新开始。
- 如果接收端已经收到了完整文件,但发送端不知道怎么办?所以双方需要有一个「握手」过程,确认文件状态。
我曾经在一个项目中,用户传一个200MB的视频文件,传了三次都断了。后来加上断点续传,用户终于能传完了。用户反馈说「你们App真靠谱」,其实背后就是这些细节在支撑。
知识体系总览
下面这张图,我把文件传输的核心流程画出来了。你可以对照着看,心里有个整体框架:
好了,这一章的内容就这些。文件传输这块,说白了就是「切碎、发送、汇报、续传」八个字。你把这些细节都处理好,用户用起来就会觉得「这App真稳」。
下一章我们会聊蓝牙配网和IoT设备的交互,到时候再接着聊。