首頁 > 軟體

Flutter實現資源下載斷點續傳的範例程式碼

2022-07-28 22:05:36

協定梳理

一般情況下,下載的功能模組,至少需要提供如下基礎功能:資源下載、取消當前下載、資源是否下載成功、資原始檔的大小、清除快取檔案。而斷點續傳主要體現在取消當前下載後,再次下載時能在之前已下載的基礎上繼續下載。這個能極大程度的減少我們伺服器的頻寬損耗,而且還能為使用者減少流量,避免重複下載,提高使用者體驗。

前置條件:資源必須支援斷點續傳。如何確定可否支援?看看你的伺服器是否支援Range請求即可

實現步驟

1.定好協定。我們用的http庫是dio;通過校驗md5檢測檔案快取完整性;關於程式碼中的subDir,設計上認為資源會有多種:音訊、視訊、安裝包等,每種資源分開目錄進行儲存。

import 'package:dio/dio.dart';

typedef ProgressCallBack = void Function(int count, int total);

typedef CancelTokenProvider = void Function(CancelToken cancelToken);

abstract class AssetRepositoryProtocol {
  /// 下載單一資源
  Future<String> downloadAsset(String url,
      {String? subDir,
      ProgressCallBack? onReceiveProgress,
      CancelTokenProvider? cancelTokenProvider,
      Function(String)? done,
      Function(Exception)? failed});

  /// 取消下載,Dio中通過CancelToken可控制
  void cancelDownload(CancelToken cancelToken);

  /// 獲取檔案的快取地址
  Future<String?> filePathForAsset(String url, {String? subDir});

  /// 檢查檔案是否快取成功,簡單對比md5
  Future<String?> checkCachedSuccess(String url, {String? md5Str});
  
  /// 檢視快取檔案的大小
  Future<int> cachedFileSize({String? subDir});

  /// 清除快取
  Future<void> clearCache({String? subDir});
}

2.實現抽象協定,其中HttpManagerProtocol內部封裝了dio的相關請求。

class AssetRepository implements AssetRepositoryProtocol {
  AssetRepository(this.httpManager);

  final HttpManagerProtocol httpManager;

  @override
  Future<String> downloadAsset(String url,
      {String? subDir,
      ProgressCallBack? onReceiveProgress,
      CancelTokenProvider? cancelTokenProvider,
      Function(String)? done,
      Function(Exception)? failed}) async {
    CancelToken cancelToken = CancelToken();
    if (cancelTokenProvider != null) {
      cancelTokenProvider(cancelToken);
    }

    final savePath = await _getSavePath(url, subDir: subDir);
    try {
      httpManager.downloadFile(
          url: url,
          savePath: savePath + '.temp',
          onReceiveProgress: onReceiveProgress,
          cancelToken: cancelToken,
          done: () {
            done?.call(savePath);
          },
          failed: (e) {
            print(e);
            failed?.call(e);
          });
      return savePath;
    } catch (e) {
      print(e);
      rethrow;
    }
  }

  @override
  void cancelDownload(CancelToken cancelToken) {
    try {
      if (!cancelToken.isCancelled) {
        cancelToken.cancel();
      }
    } catch (e) {
      print(e);
    }
  }

  @override
  Future<String?> filePathForAsset(String url, {String? subDir}) async {
    final path = await _getSavePath(url, subDir: subDir);
    final file = File(path);
    if (!(await file.exists())) {
      return null;
    }
    return path;
  }

  @override
  Future<String?> checkCachedSuccess(String url, {String? md5Str}) async {
    String? path = await _getSavePath(url, subDir: FileType.video.dirName);
    bool isCached = await File(path).exists();
    if (isCached && (md5Str != null && md5Str.isNotEmpty)) {
      // 存在但是md5驗證不通過
      File(path).readAsBytes().then((Uint8List str) {
        if (md5.convert(str).toString() != md5Str) {
          path = null;
        }
      });
    } else if (isCached) {
      return path;
    } else {
      path = null;
    }
    return path;
  }
  
  @override
  Future<int> cachedFileSize({String? subDir}) async {
    final dir = await _getDir(subDir: subDir);
    if (!(await dir.exists())) {
      return 0;
    }

    int totalSize = 0;
    await for (var entity in dir.list(recursive: true)) {
      if (entity is File) {
        try {
          totalSize += await entity.length();
        } catch (e) {
          print('Get size of $entity failed with exception: $e');
        }
      }
    }

    return totalSize;
  }

  @override
  Future<void> clearCache({String? subDir}) async {
    final dir = await _getDir(subDir: subDir);
    if (!(await dir.exists())) {
      return;
    }
    dir.deleteSync(recursive: true);
  }

  Future<String> _getSavePath(String url, {String? subDir}) async {
    final saveDir = await _getDir(subDir: subDir);

    if (!saveDir.existsSync()) {
      saveDir.createSync(recursive: true);
    }

    final uri = Uri.parse(url);
    final fileName = uri.pathSegments.last;
    return saveDir.path + fileName;
  }

  Future<Directory> _getDir({String? subDir}) async {
    final cacheDir = await getTemporaryDirectory();
    late final Directory saveDir;
    if (subDir == null) {
      saveDir = cacheDir;
    } else {
      saveDir = Directory(cacheDir.path + '/$subDir/');
    }
    return saveDir;
  }
}

3.封裝dio下載,實現資源斷點續傳。

這裡的邏輯比較重點,首先未快取100%的檔案,我們以.temp字尾進行命名,在每次下載時檢測下是否有.temp的檔案,拿到其檔案位元組大小;傳入在header中的range欄位,伺服器就會去解析需要從哪個位置繼續下載;下載全部完成後,再把檔名改回正確的字尾即可。

final downloadDio = Dio();

Future<void> downloadFile({
  required String url,
  required String savePath,
  required CancelToken cancelToken,
  ProgressCallback? onReceiveProgress,
  void Function()? done,
  void Function(Exception)? failed,
}) async {
  int downloadStart = 0;
  File f = File(savePath);
  if (await f.exists()) {
    // 檔案存在時拿到已下載的位元組數
    downloadStart = f.lengthSync();
  }
  print("start: $downloadStart");
  try {
    var response = await downloadDio.get<ResponseBody>(
      url,
      options: Options(
        /// Receive response data as a stream
        responseType: ResponseType.stream,
        followRedirects: false,
        headers: {
          /// 加入range請求頭,實現斷點續傳
          "range": "bytes=$downloadStart-",
        },
      ),
    );
    File file = File(savePath);
    RandomAccessFile raf = file.openSync(mode: FileMode.append);
    int received = downloadStart;
    int total = await _getContentLength(response);
    Stream<Uint8List> stream = response.data!.stream;
    StreamSubscription<Uint8List>? subscription;
    subscription = stream.listen(
      (data) {
        /// Write files must be synchronized
        raf.writeFromSync(data);
        received += data.length;
        onReceiveProgress?.call(received, total);
      },
      onDone: () async {
        file.rename(savePath.replaceAll('.temp', ''));
        await raf.close();
        done?.call();
      },
      onError: (e) async {
        await raf.close();
        failed?.call(e);
      },
      cancelOnError: true,
    );
    cancelToken.whenCancel.then((_) async {
      await subscription?.cancel();
      await raf.close();
    });
  } on DioError catch (error) {
    if (CancelToken.isCancel(error)) {
      print("Download cancelled");
    } else {
      failed?.call(error);
    }
  }
}

寫在最後

這篇文章確實沒有技術含量,水一篇,但其實是實用的。這個斷點續傳的實現有幾個注意的點:

  • 使用檔案操作的方式,區分字尾名來管理快取的資源;
  • 安全性使用md5校驗,這點非常重要,斷點續傳下載的檔案,在完整性上可能會因為各種突發情況而得不到保障;
  • 在資源管理協定上,我們將下載、檢測、獲取大小等方法都抽象出去,在業務呼叫時比較靈活。

以上就是Flutter實現資源下載斷點續傳的範例程式碼的詳細內容,更多關於Flutter資源下載斷點續傳的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com