相信大家在開發 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);
}
複製程式碼
到這裡我們就完成了一個邊讀邊處理邊傳送檔案且能中途取消的檔案上傳功能。思路都是來源於大神程式碼的啟發~