Flutter实现一个边读边处理边发送文件的功能
相信大家在开发 flutter 的过程中或多或少都会接触文件处理,读取文件并上传后端就是一个经典的场景。小林在开发这个功能的过程中就遇到了不少麻烦,尤其是大文件的处理,在这里给大家分享一下处理经验。
小林要做一个选取本地文件或安卓本机应用上传到服务器的功能。其中上传应用是写了个安卓插件获取本机应用的名称、目录、大小、图标。用拿到的目录直接 new 一个 MultipartFile 对象放到 FormData 里面再用 dio 上传。这时候就会遇到问题:MultipartFile 是会把整个文件都放到缓冲区,意味着文件越大,也就占用更多的内存,最终导致崩溃的发生,所以我想要的是按需获取文件,以流的形式分段获取文件上传。
一开始并没有太多的思路,在网上搜索资料的过程中也是一番曲折,最终在博客园看到了一篇对思路的实现很有启发性的文章 大文件上传之随传随处理(避免占用大量内存)。
经过一番资料的搜索,参考网上大佬用 HttpClient 实现提供边读边处理边发送的方法,改造如下:
try {
File file = File(filePath);
RandomAccessFile randomAccessFile = file.openSync();
// 初始化一个Http客户端
HttpClient client = HttpClient();
// 这里用openUrl而不是client.post是因为后者会自动拼接header
HttpClientRequest req = await client.openUrl('POST', Uri.parse(url));
// 读取偏移量,从0开始
int x = 0;
// 文件长度
int size = length;
// 单次读取的长度
int chunkSize = 65536;
// 单次读取的数据
var chunkValue;
while (x < size) {
// 如果剩余文件大于单次读取的长度,那就读取单次读取的长度,否则读取剩下的文件大小
int readSize = size - x >= chunkSize ? chunkSize : size - x;
chunkValue = randomAccessFile.readSync(readSize).toList();
x = x + readSize;
// 加入http发送缓冲区
req.add(chunkValue);
// 立即发送并清空缓冲区
await req.flush();
}
// 文件发送完成
await req.close();
// 获取返回数据
final response = await req.done;
final contents = StringBuffer();
response.transform(utf8.decoder).listen((data) {
contents.write(data);
}, onDone: () => print(contents.toString()));
} catch (e) {
// 错误处理
print(e);
}
这里就已经实现了分段上传的功能了,然后我们还需要添加请求头。
// 这里参照了dio的两个方法
String getBoundary() {
var random = Random();
String boundary = '--custom-boundary-' +
random.nextInt(4294967296).toString().padLeft(10, '0');
return boundary;
}
String _browserEncode(String name) {
final _newlineRegExp = RegExp(r'\r\n|\r|\n');
if (name == null) {
return null;
}
return name.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22');
}
拼接请求头
// form-data的key
String name = 'name';
String fileName = 'name.txt';
// 拼接请求头
String boundary = getBoundary();
List<int> _boundary = utf8.encode('--$boundary\r\n');
List<int> endBoundary = utf8.encode('\r\n--$boundary--\r\n');
String header =
'content-disposition: form-data; name="${_browserEncode(name)}"';
if (fileName != null) {
header = '$header; filename="${_browserEncode(fileName)}"';
}
String contentType = "application/octet-stream";
header = '$header\r\n'
'content-type: $contentType\r\n\r\n';
List<int> fileHeader = utf8.encode(header);
int contentLength =
_boundary.length + endBoundary.length + fileLength + fileHeader.length;
// 加入请求头
req.headers.add('content-type', 'multipart/form-data; boundary=$boundary');
req.headers.add('content-length', contentLength);
req.add(_boundary);
await req.flush();
req.add(fileHeader);
await req.flush();
while (x < size) {
//...
}
req.add(endBoundary);
await req.flush();
await req.close();
final response = await req.done;
//...
如果我们要中途取消请求怎么办呢?这里我借鉴dio写了一个UploadCancelToken用来托管Upload的Future:
class UploadCancelToken {
static final String cancelTag = 'cancelTag';
HttpClientRequest httpClientRequest;
UploadCancelToken({this.httpClientRequest}) {
_completer = Completer<String>();
}
Completer<String> _completer;
/// 当取消时, future 也被resolve了
Future<String> get whenCancel => _completer.future;
/// 取消请求
void cancel([dynamic reason]) {
httpClientRequest?.abort(HttpException(UploadCancelToken.cancelTag));
String _reason = reason ?? cancelTag;
_completer.completeError(_reason);
}
}
使用如下:
try {
while (x < size) {
// ...
req.add(chunkValue);
// 每次发送并清空缓冲区之前及之后都检查是否请求已经被取消
if (completer.isCompleted) {
break;
}
await req.flush();
if (completer.isCompleted) {
break;
}
}
if (completer.isCompleted) {
await req.close();
req.abort(HttpException(UploadCancelToken.cancelTag));
return completer.future;
}
// ...
return completer.future;
} catch (e) {
if (completer.isCompleted) {
return Future.error(UploadCancelToken.cancelTag);
}
return Future.error(e);
}
到这里我们就完成了一个边读边处理边发送文件且能中途取消的文件上传功能。思路都是来源于大神代码的启发~