Flutter-http分块下载

191 阅读1分钟

Http协议定义了分块传输的响应header字段,但是具体是否支持取决于Server的实现,可以指定请求头的range字段来验证服务器是否支持分块传输。例如可以利用curl命令来验证:

bogon:~ duwen$ curl -H "Range: bytes=0-10" http://download.dcloud.net.cn/HBuilder.9.0.2.macosx_64.dmg -v
# 请求头
> GET /HBuilder.9.0.2.macosx_64.dmg HTTP/1.1
> Host: download.dcloud.net.cn
> User-Agent: curl/7.54.0
> Accept: */*
> Range: bytes=0-10
# 响应头
< HTTP/1.1 206 Partial Content
< Content-Type: application/octet-stream
< Content-Length: 11
< Connection: keep-alive
< Date: Thu, 21 Feb 2019 06:25:15 GMT
< Content-Range: bytes 0-10/233295878

在请求头中添加"Range:bytes=0-10"的作用是,告诉服务器本次请求只想获取文件0-10(包括10,共11个字节)这块内容。如果服务器支持分块传输,则响应状态码为206,表示"部分内容",并且同时响应头中包含"COntent-Range"字段,如果不支持则会不包含:

Content-Range: bytes 0-10/233295878

0-1表示本次返回的区块,233295878代表文本的总长度,单位都是byte,也就是该文件大概233M。
基于此,设计一个简单的多线程的文件分块下载器,实现思路:

  1. 先检测是否支持分块传输,如果不支持,则直接下载;若支持,则将剩余内容分块下载。
  2. 各个分块下载时保存到各自临时文件,等到所有分块下载完成后合并临时文件。
  3. 删除临时文件。

实现

import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';

class SSLHttpChunkingDownload{
  const SSLHttpChunkingDownload();
  Future downloadChunks(url, savePath, {ProgressCallback? onReceiveProgress,}) async {
    const firstChunkSize = 102;
    const maxChunk = 3;
    int total = 0;
    var dio = Dio();
    var progress = <int>[];
  //进度回调
    createCallback(no){
      return (int received, _){
        progress[no] = received;
        debugPrint("ssl current number $no received $received\n");
        if (onReceiveProgress != null && total != 0){
          onReceiveProgress(progress.reduce((value, element) => value + element), total);
        }
      };
    }
  //单块下载
    Future<Response> downloadChunk(url, start, end, no) async{
      progress.add(0);
      --end;
      return dio.download(
          url,
          savePath + "temp$no",
          onReceiveProgress: createCallback(no),
          options:Options(
            headers: {"range": "bytes=$start - $end"}
          )
      );
    }

    Future<Response>downloadFile(url) async{
      return dio.download(url, savePath,onReceiveProgress: onReceiveProgress);
    }
    //合并文件
    Future mergeTempFiles(chunk) async{
      //原文件
      File f = File(savePath+"temp0");
      //打开文件并设置读写模式为后面追加
      IOSink ioSink = f.openWrite(mode: FileMode.writeOnlyAppend);
      for (int i = 1; i < chunk; ++ i){
        //打开下载块文件
        File tem = File(savePath + "temp$i");
        //读取下载块文件,并将块文件追加到源文件
        await ioSink.addStream(tem.openRead());
        //删除 块文件
        await tem.delete();
      }
      //关闭IO
      await ioSink.close();
      //源文件重命名
      await f.rename(savePath);
    }
    Response response = await downloadChunk(url, 0, firstChunkSize, 0);
    //判断是否支持分块下载
    if (response.statusCode == 206){
      debugPrint("current response header ${response.headers}\n");
      total = int.parse(response.headers.value(HttpHeaders.contentRangeHeader)!.split("/").last);
      int reserved = total = int.parse(response.headers.value(HttpHeaders.contentLengthHeader)!);
      //获取返回数据,判断是否需要分块
      int chunk = (reserved - firstChunkSize).ceil() + 1;
      debugPrint("ssl chunk$chunk reserved$reserved\n");
      if (chunk > 1){
        int chunkSize = firstChunkSize;
        if (chunk > maxChunk + 1){
          chunk = maxChunk + 1;
          chunkSize = (reserved / maxChunk).ceil();
        }
        var futures = <Future>[];
        for (int i = 0; i < maxChunk; ++ i){
          int start = firstChunkSize + i * chunkSize;
          //分块后将每个块请求进行分发
          futures.add(downloadChunk(url, start, start + chunkSize, i + 1));
        }
        debugPrint("ssl chunk num${futures.length}");
        await Future.wait(futures);
        await mergeTempFiles(chunk);
      }else{
        Future response = (await downloadFile(url)) as Future;
        debugPrint("ssl response $response");
      }

    }
  }
  
}

class SSLTestChunkDownload extends StatefulWidget{
  const SSLTestChunkDownload({Key? key}):super(key: key);

  @override
  State<StatefulWidget> createState() {
    // TODO: implement createState
    return SSLTestChunkDownloadState();
  }
}

class SSLTestChunkDownloadState extends State<SSLTestChunkDownload>{
  int progress = 0;
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return Scaffold(
      appBar: AppBar(
        title: const Text("SSL Chunk Test"),
      ),
      body: Center(
        child: Column(
          children: [
            Text("progress $progress %"),
            ElevatedButton(
                onPressed: (){
                  startChunksDownAction();
                },
                child: const Text("start download")
            ),
          ],
        ),
      ),
    );
  }

  void startChunksDownAction() async {
    var url = "http://download.dcloud.net.cn/HBuilder.9.0.2.macosx_64.dmg";
    var path = (await getTemporaryDirectory()).path;
    var savePath = "$path/HBuilder.9.0.2.macosx_64.dmg";
    debugPrint("ssl save path $savePath");
    var down = const SSLHttpChunkingDownload();
    down.downloadChunks(url, savePath, onReceiveProgress: (received, total){
      debugPrint("ssl current progress$received total $total\n");
      if (total != -1){

        setState(() {
          progress =  (received/ total * 100).floor();
          debugPrint("current $progress%");
        });

      }
    });
  }
}

总结与思考:
分块下载是否能提高下载速度?
下载速度瓶颈取决于网络速度和服务器的出口速度,如果同一个数据源,分块下载的意义并不大,因为服务器是同一个,出口速度是确定的,主要取决于网速。即使我们设备的带宽大于任意一个源,下载速度依然不一定比单源单线下载快。
分块下载的实际用处是便于断电续传,可以将文件分为若干个,然后维护下载状态文件,用以记录每一块的状态,这样即使网络断开,还是可以恢复中断前的状态。