前言
最近在找项目demo练手,刚好看到Tg上图片组件这个小组件挺好玩的,顺手写了一下,希望对你有帮助
需求分析
我们从上图可以分析下需求
- 底图一张
- 高斯模糊
- 断片下载 & 中断处理
- 图片下载状态 & 中断状态相关loadding
接下来我们开始实现相关功能
功能实现
基础部分
底图和高斯模糊的UI比较好做,而且这里的CustomLoadding组件也是自己写的。
Container( margin: EdgeInsets.only(left: MediaQuery.of(context).size.width / 6), width: 300, height: 300, child: Stack( fit: StackFit.expand, children: [ Container( child: Stack( fit: StackFit.expand, children: [ //底图 Container( child: Image.network("http://175.24.177.189/mcAuthor/portrait2.jpg"), ), // 使用BackdropFilter实现模糊效果 Center( child: ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), child: Container( width: 300, height: 300, decoration: BoxDecoration( color: Colors.white.withOpacity(0.5), borderRadius: BorderRadius.circular(10.0), ), ), ), ), ), ], ), ), Center( child: Container( width: 65, height: 65, alignment: Alignment.center, child: Stack( fit: StackFit.expand, children: [ Container( decoration: BoxDecoration( color: Colors.black.withOpacity(.3), borderRadius: BorderRadius.circular(1000) ), ), Center( child: Stack( children: [ Positioned( left: 10, top: 8 , right: 10, child: Container( child: InkWell( onTap: (){ if(!startFlag){ startDownload(); }else{ stopDownload(); } }, child: SizedBox( width: startFlag ? 30 : 40, height: startFlag ? 30 : 40, child: Image.asset(startFlag ? "assets/exit.png" : "assets/xiazai.png",color: Colors.white,fit: BoxFit.fill,), ), ), ), ), IgnorePointer( child: AnimatedBuilder( animation: controller, builder: (BuildContext context, Widget? child) { return Transform.rotate( angle: rotateValue.value, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(1000) ), width: 45, height: 45, child: Stack( children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(20)) ), ), ], ), ), ); }, ), ), ], ), ) ], ) , ), ), ], ), ),
基础UI到此完毕,接下来着重讲下核心功能 断片下载的实现
断片下载
网络请求库选择:Dio(https://pub.dev/packages/dio)
定义接口
我们接下来定义几个相关方法,封装成接口
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
typedef ProgressCallBack = void Function(int count, int total);
typedef CancelTokenProvider = void Function(CancelToken cancelToken);
abstract class AbstractDownload {
//下载资源
void downloadResource(
String url,
{
ProgressCallBack? progressCallBack,
CancelTokenProvider? cancelTokenProvider,
Function(String)? done,
Function(String)? error
});
//中断下载
void cancelDownload(CancelToken cancelToken);
//获取目标资源已下载的大小
Future<double> getCacheSize(String url);
//清理缓存
Future<void> clearCache(String url);
}
以上我们定义好相关方法后,我们不着急定义实现类,我们先封装下请求类,方便后续实现
在封装请求类之前,我们先定义一个路径的处理相关类,用来定义我们后续的文件下载缓存路径
路径处理
这个没什么多讲的,插件引用(https://pub.dev/packages/path_provider)
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
class FilePathProvider{
Future<Directory> _getBaseDirectory() async{
return await getApplicationDocumentsDirectory();
}
/*
* @author Marinda
* @date 2024/7/18 16:20
* @description 获取默认缓存地址
*/
Future<String> getImageCacheDir({String? subDir}) async{
var baseDir = await _getBaseDirectory();
String path = p.join(baseDir.path,subDir ?? "image");
Directory directory = Directory(path);
if(!directory.existsSync()){
directory.createSync();
}
return path;
}
/*
* @author Marinda
* @date 2024/7/19 14:35
* @description 构建一个缓存地址
*/
String buildCachePath(String dir,String name){
return p.join(dir,name);
}
}
接下来我们再封装个工具类,用来缓存网络请求的CanelToken 以及url的处理
工具类
import 'package:dio/dio.dart';
class HttpUtil {
static Map<String,CancelToken> cancelTokenMap = {};
/*
* @author Marinda
* @date 2024/7/19 14:28
* @description 根据url获取文件名
*/
static String getUrlFileName(String url){
String fileName = url.substring(url.lastIndexOf("/")+1,url.lastIndexOf("."));
String suffix = url.substring(url.lastIndexOf(".")+1);
return "$fileName.$suffix";
}
}
封装请求
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_demo_test/common/path_provider.dart';
import 'package:flutter_demo_test/common/util/http_util.dart';
import 'package:uuid/uuid.dart';
import 'package:path/path.dart' as p;
class Request {
/// 用于记录正在下载的url,避免重复下载
static final baseOption = BaseOptions(
connectTimeout: Duration(seconds: 5),
receiveTimeout: Duration(seconds: 5)
);
static Dio _dio = Dio();
/*
* @author Marinda
* @date 2024/7/18 16:06
* @description 请求
*/
static Future request(String url,{String? savePath,String? fileName,String? method,String? header,dynamic data,ProgressCallback? onReceiverProgress,void Function(String resultPath)? done,void Function(DioException)? failed}) async {
_dio = Dio(baseOption);
int downloadStart = 0;
FilePathProvider pathProvider = FilePathProvider();
String cacheDir = await pathProvider.getImageCacheDir();
CancelToken cancelToken = CancelToken();
HttpUtil.cancelTokenMap[url] = cancelToken;
String targetDir = savePath ?? cacheDir;
String targetName = fileName ?? pathProvider.buildCachePath(cacheDir, HttpUtil.getUrlFileName(url));
String targetPath = p.join(targetDir, targetName);
//断片下载实现
int downloadStart = 0;
print(targetPath);
File file = File(targetPath);
if (file.existsSync()) {
downloadStart = file.lengthSync();
}
print("start Download: ${downloadStart}");
try {
var response = await _dio.get<ResponseBody>(url, options: Options(
responseType: ResponseType.stream,
followRedirects: false,
headers: {
"range": "bytes=$downloadStart-",
}
));
RandomAccessFile raf = file.openSync(mode: FileMode.append);
int receiver = downloadStart;
int total = await _getContentLength(response);
Stream<Uint8List> stream = response.data!.stream;
StreamSubscription<Uint8List>? subscription;
subscription = stream.listen((data) {
raf.writeFromSync(data);
receiver+=data.length;
print('当前receiver: ${receiver},总大小: ${total / (1024 * 1024)}M,进度:${(receiver / total) * 100}%');
onReceiverProgress?.call(receiver,total);
},
onDone: () async{
print("地址:$targetPath");
HttpUtil.cancelTokenMap.remove(url);
await raf.close();
done?.call(targetPath);
},
onError: (e) async{
if(CancelToken.isCancel(e)){
print("下载异常!");
}else{
failed?.call(e);
}
HttpUtil.cancelTokenMap.remove(url);
}
);
cancelToken.whenCancel.then((_) async{
await subscription?.cancel();
await raf.close();
});
} catch (e) {
print(e);
}
}
static Future<int> _getContentLength(Response<ResponseBody> response) async{
try{
var headerContent = response.headers.value(HttpHeaders.contentRangeHeader);
print("下载文件$headerContent");
if(headerContent != null){
return int.parse(headerContent.split("/").last);
}else{
return 0;
}
}catch(e){
return 0;
}
}
}
以上代码你可能会看的略微有点迷糊,我们逐步分析下断片下载的实现逻辑
首先定义一个用来缓存当前已下载的总长度变量
int downloadStart = 0;
接下来我们校验下这个缓存地址中是否存在相关文件,如果有则缓存文件当前总大小
File file = File(targetPath);
if (file.existsSync()) {
downloadStart = file.lengthSync();
}
之后我们使用Dio中的get方法,并且添加相关配置
var response = await _dio.get<ResponseBody>(url, options: Options(
responseType: ResponseType.stream,
followRedirects: false,
headers: {
"range": "bytes=$downloadStart-",
}
));
responseType为响应类型,正常为json,但是我们这里要做短片,所以改为Stream模式
followRedirects为是否重定向,我们为false
重点在headers,我们需要制定一个range属性,并且参数填写bytes=$downloadStart-
响应处理好了。接下来就是对响应流的处理
RandomAccessFile raf = file.openSync(mode: FileMode.append);
int receiver = downloadStart;
int total = await _getContentLength(response);
Stream<Uint8List> stream = response.data!.stream;
StreamSubscription<Uint8List>? subscription;
subscription = stream.listen((data) {
raf.writeFromSync(data);
receiver+=data.length;
print('当前receiver: ${receiver},总大小: ${total / (1024 * 1024)}M,进度:${(receiver / total) * 100}%');
onReceiverProgress?.call(receiver,total);
},
onDone: () async{
print("地址:$targetPath");
HttpUtil.cancelTokenMap.remove(url);
await raf.close();
done?.call(targetPath);
},
onError: (e) async{
if(CancelToken.isCancel(e)){
print("下载异常!");
}else{
failed?.call(e);
}
HttpUtil.cancelTokenMap.remove(url);
}
);
cancelToken.whenCancel.then((_) async{
await subscription?.cancel();
await raf.close();
});
} catch (e) {
print(e);
}
RandomAccessFile 定义用来对文件流的追加
receiver 和 total 变量用来配置当前range长度 以及总长度
total总量大小可以通过以下方法获取,实际也是通过响应头获取总量,这里不过多讲解,看代码
static Future<int> _getContentLength(Response<ResponseBody> response) async{
try{
var headerContent = response.headers.value(HttpHeaders.contentRangeHeader);
print("下载文件$headerContent");
if(headerContent != null){
return int.parse(headerContent.split("/").last);
}else{
return 0;
}
}catch(e){
return 0;
}
}
接下来就是对于Stream的监听处理,也就是切片逻辑核心
Stream<Uint8List> stream = response.data!.stream;
StreamSubscription<Uint8List>? subscription;
subscription = stream.listen((data) {
raf.writeFromSync(data);
receiver+=data.length;
RandomAccessFile raf = file.openSync(mode: FileMode.append);
int receiver = downloadStart;
int total = await _getContentLength(response);
Stream<Uint8List> stream = response.data!.stream;
StreamSubscription<Uint8List>? subscription;
subscription = stream.listen((data) {
raf.writeFromSync(data);
receiver+=data.length;
print('当前receiver: ${receiver},总大小: ${total / (1024 * 1024)}M,进度:${(receiver / total) * 100}%');
onReceiverProgress?.call(receiver,total);
},
onDone: () async{
print("地址:$targetPath");
HttpUtil.cancelTokenMap.remove(url);
await raf.close();
done?.call(targetPath);
},
onError: (e) async{
if(CancelToken.isCancel(e)){
print("下载异常!");
}else{
failed?.call(e);
}
HttpUtil.cancelTokenMap.remove(url);
}
);
cancelToken.whenCancel.then((_) async{
await subscription?.cancel();
await raf.close();
});
这里简单讲讲,逻辑很清楚,就是监听流并追加写入文件中,之后相加每次切片长度,并添加相关回调,cancelToken.whenCancel这里是用来做中断处理。
至此短片下载逻辑完毕。
实现接口
我们定义接口DownloadImpl,相关代码如下
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_demo_test/common/path_provider.dart';
import 'package:flutter_demo_test/common/util/http_util.dart';
import 'package:flutter_demo_test/download/abstract_download.dart';
import 'package:flutter_demo_test/download/request.dart';
class DownloadImpl implements AbstractDownload{
FilePathProvider filePathProvider = FilePathProvider();
DownloadImpl();
@override
void downloadResource(String url, {ProgressCallBack? progressCallBack, CancelTokenProvider? cancelTokenProvider, Function(String p1)? done, Function(String p1)? error}) async{
String dir = await filePathProvider.getImageCacheDir();
await Request.request(url,savePath: dir,onReceiverProgress: progressCallBack,done: (str){
done?.call(str);
},failed: (e){
print(e);
error?.call(e.error.toString());
});
// return
}
@override
void cancelDownload(CancelToken cancelToken) {
//如果存在则中断
if(HttpUtil.cancelTokenMap.containsValue(cancelToken)){
cancelToken.cancel();
String key = "";
for(var element in HttpUtil.cancelTokenMap.keys){
var token = HttpUtil.cancelTokenMap[element];
if(token == cancelToken){
key = element;
break;
}
}
HttpUtil.cancelTokenMap[key]!.cancel();
print("中断下载${key}任务成功!");
}else{
print("未查询到该任务!");
}
}
@override
Future<void> clearCache(String url) async{
String fileName = HttpUtil.getUrlFileName(url);
String dir = await filePathProvider.getImageCacheDir();
File file = File(filePathProvider.buildCachePath(dir, fileName));
if(!file.existsSync()){
print("该文件不存在!");
return;
}
file.deleteSync();
print("删除完毕");
}
@override
Future<double> getCacheSize(String url) async{
String fileName = HttpUtil.getUrlFileName(url);
String dir = await filePathProvider.getImageCacheDir();
File file = File(filePathProvider.buildCachePath(dir, fileName));
return file.lengthSync() / (1024 * 1024);
}
}
这里实现方法都很简单,稍微看下就理解了,所以不做过多介绍
目前我们已经处理了接口封装和实现,以及相关请求类的封装完毕,接下来就是对我们UI进行下调整即可
视图更新
我们接下来在视图定义以下几个变量
//下载进度文本
String downloadProgressText = "";
//下载结束标识
bool downloadFlag = false;
//开始下载标识
bool startFlag = false;
//下载实现类
DownloadImpl downloadImpl = DownloadImpl();
再定义接口回调 以及相关下载和中断方法
downloadDone(str){
setState(() {
donePath = str;
downloadFlag = true;
});
}
onReceiverProgress(receiver,total){
downloadProgressText = "${double.parse(((receiver / total) * 100).toString()).toStringAsFixed(2).toString()}%";
}
//开始下载
startDownload(){
downloadImpl.downloadResource(url,progressCallBack: onReceiverProgress,done: downloadDone);
setState(() {
startFlag = true;
});
}
stopDownload(){
downloadImpl.cancelDownload(HttpUtil.cancelTokenMap[url]!);
setState(() {
startFlag = false;
});
}
目前我们万事俱备只欠东风啦,我们调整下视图UI功能就完成啦
return Scaffold(
appBar: AppBar(
title: Text("图像实例"),
),
body: Container(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
margin: EdgeInsets.only(left: MediaQuery.of(context).size.width / 6),
width: 300,
height: 300,
child: Stack(
fit: StackFit.expand,
children: [
Container(
child: Stack(
fit: StackFit.expand,
children: [
//底图
Container(
child: Image.network("http://175.24.177.189/mcAuthor/portrait2.jpg"),
),
// 使用BackdropFilter实现模糊效果
if(!downloadFlag)
Center(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(10.0),
),
),
),
),
),
//下载进度
Positioned(
right: 10,
top: 5,
child: Container(
child: !downloadFlag ? Text.rich(
TextSpan(
children: [
if(startFlag)
TextSpan(
text: "下载进度:",
style: TextStyle(
color: Colors.black,
fontSize: 16
),
),
TextSpan(
text: "$downloadProgressText",
style: TextStyle(
color: Colors.red,
fontSize: 18
)
)
]
)
) : Text(""),
))
],
),
),
// if(!downloadFlag)
Center(
child: Container(
width: 65,
height: 65,
alignment: Alignment.center,
child: Stack(
fit: StackFit.expand,
children: [
Container(
// width: 50,
// height: 50,
decoration: BoxDecoration(
color: Colors.black.withOpacity(.3),
borderRadius: BorderRadius.circular(1000)
),
),
Center(
child: Stack(
children: [
Positioned(
left: 10,
top: 8 ,
right: 10,
child: Container(
child: InkWell(
onTap: (){
if(!startFlag){
startDownload();
}else{
stopDownload();
}
},
child: SizedBox(
width: startFlag ? 30 : 40,
height: startFlag ? 30 : 40,
child: Image.asset(startFlag ? "assets/exit.png" : "assets/xiazai.png",color: Colors.white,fit: BoxFit.fill,),
),
),
),
),
// if(startFlag)
IgnorePointer(
child: AnimatedBuilder(
animation: controller,
builder: (BuildContext context, Widget? child) {
return Transform.rotate(
angle: rotateValue.value,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(1000)
),
width: 45,
height: 45,
child: Stack(
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(20))
),
),
],
),
),
);
},
),
),
],
),
)
],
) ,
),
),
],
),
),
],
),
),
);
我们一起来看看最终效果
结束语
感谢你的观看!