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后,就可以读取文件内容了
    }
}
小提示:拿到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 断点续传思路:不怕中断

这是最硬核的部分。文件传了一半,蓝牙断了怎么办?用户总不能重新传吧?

断点续传的思路其实不复杂,说白了就是:记录已经传了多少,下次从断点处继续

具体怎么做?我分享一个我在项目中用过的方案:

  1. 发送端记录已发送的字节数:每传完一片,就把当前已发送的字节数写入一个本地文件(比如SharedPreferences或一个JSON文件)。
  2. 接收端记录已接收的字节数:同样,每收到一片,就记录已接收的字节数。
  3. 重连时交换断点信息:蓝牙重连后,发送端先发一个特殊指令,告诉接收端「我要续传」。接收端回复「我已经收到了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);
}
核心要点:断点续传的关键在于「状态持久化」。不管是发送端还是接收端,都要把当前进度持久化到本地存储。这样即使App被杀死、蓝牙断开,下次启动时还能从上次的位置继续。

不过说实话,断点续传在蓝牙开发中实现起来还是有些挑战的。比如:

  • 如果文件在传输过程中被修改了怎么办?我一般会记录文件的最后修改时间,如果发现文件变了,就放弃续传,重新开始。
  • 如果接收端已经收到了完整文件,但发送端不知道怎么办?所以双方需要有一个「握手」过程,确认文件状态。

我曾经在一个项目中,用户传一个200MB的视频文件,传了三次都断了。后来加上断点续传,用户终于能传完了。用户反馈说「你们App真靠谱」,其实背后就是这些细节在支撑。

知识体系总览

下面这张图,我把文件传输的核心流程画出来了。你可以对照着看,心里有个整体框架:

蓝牙文件传输核心流程 ① 文件选择 SAF框架 · 获取Uri ② 文件分片 每片1024字节 · 加长度头 ③ 蓝牙传输 RFCOMM通道 进度回调:每传完一片 → 更新UI(每1%刷新一次) onProgress(bytesTransferred, totalBytes) 断点续传机制 记录断点 每传一片记录已传字节 持久化到本地存储 重连握手 发送端询问断点位置 接收端回复已收字节 继续传输 跳过已传部分 从断点处继续发送 文件传输 = 文件选择 + 分片传输 + 进度回调 + 断点续传

好了,这一章的内容就这些。文件传输这块,说白了就是「切碎、发送、汇报、续传」八个字。你把这些细节都处理好,用户用起来就会觉得「这App真稳」。

下一章我们会聊蓝牙配网和IoT设备的交互,到时候再接着聊。


公众号:蓝海资料掘金营,微信deep3321