基于flutter的开源壁纸软件
功能描述
- 一款基于flutter的开源壁纸软件,所有接口都是大佬开源的,感谢各位大佬;
- 支持设置下载壁纸;
- 支持简单收藏功能(本地收藏);
- 支持设置壁纸功能;
- 支持获取本地壁纸来设置壁纸;
- 支持扫描本地相册来设置壁纸;
- 支持获取本地视频设置壁纸(只支持mp4格式)。
没有登录/注册功能,只能简单的做一个本地存储,功能和其它壁纸软件也大差不差。不过我最喜欢的还是可以设置本地壁纸和本地视频壁纸的功能。
1、项目预览图
- 截取了部分图,其它功能有在以前的文章介绍过,懒得在叙述了,有的页面很简单也懒得截了。
2、插件使用(推荐)
- extended_image 图片展示预览插件,该插件支持图片放大缩小等各种功能,并且能够缓存图片至临时文件夹,下次加载时能够直接读取缓存中的图片,无需额外的网络开销。这里只用了基础放大缩小、缓存功能,其它功能都没用,详细用法见官网,非常强大的图片管理插件。
- flutter_cache_manager 缓存管理插件,允许用户手动清理缓存。
- image_picker 适用于 iOS 和 Android 的 Flutter 插件,用于从图像库中选择图像,并使用相机拍摄新照片。
- photo_manager 无需 UI 集成,即可在 Android、iOS、macOS 和 OpenHarmony 上获取资产(图片/视频/音频)。
- flutter_wallpaper_manager 用于在您的 Android 设备上设置壁纸。它支持主屏幕、锁屏和两种屏幕模式。
- async_wallpaper 一个 flutter 包,其中包含一些函数的集合,用于在 Android 设备上异步设置壁纸。使用此插件,您还可以本地设置视频动态壁纸 (.mp4)。
基础用法都不做介绍了,主要介绍一些可能遇到的问题:
- 下载图片、设置壁纸、获取缓存大小的逻辑都在下方;
- 下载和设置壁纸都直接从缓存中查看是否存在,存在则直接从缓存中拿,减少不必要的网络开销;
var file = await DefaultCacheManager().getSingleFile(url);
我用这种方法获取图片缓存,在图片加载成功后,第一次设置壁纸并没有从缓存中直接拿到图片数据,间接导致我使用了两个壁纸设置插件。- 使用
var cacheData = await getNetworkImageData(imagePath, useCache: true);
这个方法获取图片缓存,图片加载成功后,第一次设置壁纸就能有效的从缓存中拿到图片数据,在把图片存到临时文件夹中,返回自定义的路径即可,但是这种方法使用async_wallpaper
设置壁纸时在模拟器上没问题,真机上面就失败了,暂时不知道原因,因此用了两个壁纸设置插件。
/lib/tools/down_image.dart
dart
import 'dart:io';
import 'dart:typed_data';
import 'package:bot_toast/bot_toast.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:dio/dio.dart';
class DownImage {
/// 获取应用总缓存大小 (单位: MB)
static Future<double> getTotalCacheSize() async {
double totalSize = 0;
final tempDir = await getTemporaryDirectory();
totalSize += await _getFolderSize(tempDir);
return totalSize;
}
/// 清理所有缓存
static Future<void> clearAllCache() async {
// 1. 清理内存中的图片缓存
imageCache.clear();
imageCache.clearLiveImages();
// 2. 清理磁盘中的图片缓存
await DefaultCacheManager().emptyCache();
await clearExtendedImageCache();
// 3. 清理临时目录
final tempDir = await getTemporaryDirectory();
await _deleteFolder(tempDir);
// 4. 清理其他缓存(按需添加)
// await SharedPreferences.getInstance().then((prefs) => prefs.clear());
// await Hive.deleteFromDisk();
}
/// 清理 extended_image 特殊缓存
static Future<void> clearExtendedImageCache() async {
try {
final directory = Directory(
'${(await getTemporaryDirectory()).path}/extended_image_cache');
if (directory.existsSync()) {
await directory.delete(recursive: true);
}
} catch (e) {
debugPrint('清理extended_image缓存失败: $e');
}
}
/// 计算文件夹大小
static Future<double> _getFolderSize(Directory dir) async {
if (!dir.existsSync()) return 0;
int totalBytes = 0;
final files = dir.listSync(recursive: true);
await Future.forEach(files, (file) async {
if (file is File) {
totalBytes += await file.length();
}
});
return totalBytes / (1024 * 1024);
}
/// 删除文件夹
static Future<void> _deleteFolder(Directory dir) async {
if (dir.existsSync()) {
// ignore: body_might_complete_normally_catch_error
await dir.delete(recursive: true).catchError((e) {
debugPrint('删除文件夹失败: $e');
});
}
}
/// 下载网络图片(先读缓存资源,缓存没有再重新获取资源)
static Future<String> downloadNetworkImage(String imagePath) async {
var status = await Permission.storage.status;
if (!status.isGranted) {
//未授予
Permission.storage.request();
}
const String prefix = 'App-Save';
// 获取缓存图片
var cacheData = await getNetworkImageData(imagePath, useCache: true);
// 获取当前时间戳
int timestamp = DateTime.now().millisecondsSinceEpoch;
// 将时间戳转换为可读的日期格式
String dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp).toString();
// 拼接名字
String saveName = '$prefix-$dateTime';
var loadingBot = BotToast.showCustomLoading(
backgroundColor: const Color.fromARGB(100, 4, 4, 4),
toastBuilder: (cancel) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 10),
Text(
'下载中,请稍后···',
style: TextStyle(color: Colors.white, fontSize: 13),
),
],
);
});
// 下载逻辑
late dynamic result;
// 如果缓存图片不为空
if (cacheData != null) {
result = await ImageGallerySaverPlus.saveImage(
cacheData,
quality: 100,
name: saveName,
);
} else {
var response = await Dio()
.get(imagePath, options: Options(responseType: ResponseType.bytes));
result = await ImageGallerySaverPlus.saveImage(
Uint8List.fromList(response.data),
quality: 100,
);
}
if (result["isSuccess"]) {
loadingBot();
BotToast.showText(text: '下载成功');
return result["filePath"];
} else {
loadingBot();
BotToast.showText(text: '下载失败', contentColor: Colors.red);
return 'error';
}
}
Future<String?> setWallpaper(String url) async {
try {
final dir = await getTemporaryDirectory();
final filename = url.split('/').last;
final path = '${dir.path}/$filename';
// 先读取缓存中的图片 存在缓存则直接返回
final cacheData = await getNetworkImageData(url, useCache: true);
if (cacheData != null) {
await File(path).writeAsBytes(cacheData);
return path;
} else {
final response = await Dio().download(url, path);
if (response.statusCode == 200) {
return path;
}
return null;
}
} catch (e) {
print('下载失败: $e');
return null;
}
}
}
- 图片缓存基本上就是核心问题了,其它都没啥复杂的,各个插件官网教程都很详细,注意有的插件可能需要引入对应的权限即可。
还有一个问题,获取相册中所有图片时,会存在性能问题:
id == 'isAll' || name == 'Recent'
就是最大的相册,我是获取全部图片,就直接获取最大的相册了,没有做细分。final authState = await PhotoManager.requestPermissionExtend();
这个方法获取到的是所有相册。_loadImages()
中final assets = await album.getAssetListRange(start: start, end: end);
可以获取对应相册中所有图像(图片和视频)的信息,只需要图片,在下方做了判断。
刚开始,我是通过album.getAssetListRange(start: start, end: end);
方法一次性获取全部图片,图片多就会存在性能问题和等待时间过长问题,丢个ai优化后就是下面这种分页加载的,用这种方式我自己测试是没啥大问题了,可能是我图片不够多的原因,如果图片过多,依然存在性能问题时建议结合滚动事件,滚动到底部时在获取数据,因为提供了单独选择图片的页面,不存在性能问题,懒得弄滚动事件了就沿用下面这种方式了。
dart
Future<void> _requestPermissionAndLoadAlbums() async {
// 请求相册权限
final authState = await PhotoManager.requestPermissionExtend();
if (authState.isAuth) {
// 获取所有相册
final albumList = await PhotoManager.getAssetPathList();
for (var i = 0; i < albumList.length; i++) {
if (albumList[i].id == 'isAll' || albumList[i].name == 'Recent') {
setState(() {
album = albumList[i];
});
break;
}
}
}
_loadImages();
}
// 加载所有图片
Future<void> _loadImages() async {
// 清空之前的图片列表
setState(() {
imageList.clear();
currentPage = 0; // 重置页码
});
// 分页加载图片
while (true) {
int start = currentPage * pageSize;
int end = start + pageSize;
// 获取当前页的图片
final assets = await album.getAssetListRange(start: start, end: end);
// 如果没有更多图片,退出循环
if (assets.isEmpty) {
break;
}
// 处理图片并添加到列表
for (var asset in assets) {
if (asset.type == AssetType.image) {
final file = await asset.file;
// final compressedFile = await _compressImage(file?.path ?? '');
setState(() {
imageList.add(file!.path);
});
}
}
currentPage++;
}
}
3、问题记录
3.1、使用 photo_manager 组件报错
报错内容如下:
logs
[ ] FAILURE: Build failed with an exception.
[ +1 ms] * What went wrong:
[ ] Execution failed for task ':photo_manager:compileDebugKotlin'.
[ ] > Error while evaluating property 'compilerOptions.jvmTarget' of task ':photo_manager:compileDebugKotlin'.
[ ] > Failed to calculate the value of property 'jvmTarget'.
[ ] > Unknown Kotlin JVM target: 21
[ ] * Try:
[ ] > Run with --debug option to get more log output.
[ ] > Run with --scan to get full insights.
[ ] > Get more help at https://help.gradle.org.
[ +1 ms] * Exception is:
[ ] org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':photo_manager:compileDebugKotlin'.
[ ] at org.gradle.api.internal.tasks.execution.CatchExceptionTaskExecuter.execute(CatchExceptionTaskExecuter.java:38)
[ +36 ms] at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.executeTask(EventFiringTaskExecuter.java:77)
[ ] at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:55)
[ +1 ms] at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:52)
[ ] at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
[ ] at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
[ ] at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
[ +1 ms] at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
[ ] at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
[ ] at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
[ ] at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
- 解决办法:https://github.com/fluttercandies/flutter_photo_manager/issues/1198
- 翻了一下评论,不止 photo_manager 插件会报错,其它插件也可能报这个错。
到这里就能正常运行项目了,但是又出现了一个新的爆红警告(不影响项目启动):
- 大概就是版本不兼容的意思,按照他的提示在 android\app\build.gradle 文件中修改 ndk版本即可。
3.2、使用 async_wallpaper 插件,打包时报错。
解决 Flutter 使用 async_wallpaper 插件设置视频壁纸时 Gradle 构建错误的方法
当您在 Flutter 项目中使用 async_wallpaper
插件设置视频壁纸时,可能会遇到以下 Gradle 构建错误:
复制
sql
ERROR: Missing classes detected while running R8.
Please add the missing classes or apply additional keep rules that are generated in C:\Users\n03158\Desktop\new_wallpaper\build\app\outputs\mapping\release\missing_rules.txt.
这是因为在构建过程中,R8 检测到某些类缺失。以下是解决此问题的步骤:
检查 missing_rules.txt
文件:
-
位置:
build\app\outputs\mapping\release
,上面的报错截图不全,只能看到部分位置信息,完整报错能看到完整的路径。 -
在 Gradle 构建输出中,您会看到一个路径指向
missing_rules.txt
文件。该文件包含解决此问题所需的 ProGuard 规则。将其内容复制到您的proguard-rules.pro
文件中。 -
在 Flutter 项目中添加 ProGuard 规则以解决 R8 缩减代码时的类缺失问题,具体步骤如下:
步骤 1:找到 proguard-rules.pro
文件
- 该文件通常位于
android/app/proguard-rules.pro
路径下(我的项目下面没有这个文件,是我手动创建的)。
步骤 2:打开文件并添加规则
- 根据
missing_rules.txt
文件中的提示,将相应的规则添加到proguard-rules.pro
文件中。 - 把
missing_rules.txt
文件中的内容 copy 到proguard-rules.pro
文件中。
步骤 3:保存并重新构建项目
-
保存更改后,重新运行
flutter build apk
或flutter build appbundle
命令以重新构建项目。 -
通过上述步骤,您可以有效地解决 R8 在代码缩减过程中遇到的类缺失问题。
3.3、下拉刷新失效
imgs.isEmpty && !isLoading
逻辑是没有在加载数据中,并且数据为空时显示 Empty() 空组件,有数据时则展示图片。现在的问题是在 Empty() 状态时,下拉刷新不会触发。
dart
@override
Widget build(BuildContext context) {
super.build(context);
return RefreshIndicator(
onRefresh: () {
setState(() {
imgs.clear();
results.clear();
});
getData();
return Future.delayed(
Duration(milliseconds: OptionsBase().refreshTime));
},
child: imgs.isEmpty && !isLoading
? SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Empty(),
)
: CustomScrollView(
controller: scrollController,
slivers: [
SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: OptionsBase().imageColumns(context),
childAspectRatio: widget.sort == 'pc' ? 1.5 : 0.7,
),
delegate: SliverChildBuilderDelegate(
(context, index) => buildItem(context, index),
childCount: imgs.length,
),
),
if (isLoading)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.only(
top: MediaQuery.of(context).size.height * 0.4),
child: CircularProgressIndicator(),
),
),
),
],
),
);
}
Empty()组件代码:一个简单的图标和文字描述。
dart
import 'package:flutter/material.dart';
class Empty extends StatelessWidget {
final double width;
const Empty({super.key, this.width = 80});
@override
Widget build(BuildContext context) {
return SizedBox(
// 屏幕高度
height: MediaQuery.of(context).size.height - 100,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.upcoming_outlined,
size: width,
color: Colors.grey[400],
),
Text(
'暂无数据',
style: TextStyle(color: Colors.grey[400], fontSize: 15),
)
],
),
),
);
}
}
丢给ai,看看ai的分析:
很显然Empty()就是一个普通的静态小组件,并不具备滚动能力,因此无法触发下拉刷新事件。
根本原因还是对组件的属性不熟导致的
,丢给ai瞬间就能发现问题了。知道问题就好解决了,直接给Empty()包裹在可滚动的组件中即可。
修改后的代码:
dart
@override
Widget build(BuildContext context) {
super.build(context);
return RefreshIndicator(
onRefresh: () {
setState(() {
imgs.clear();
results.clear();
});
getData();
return Future.delayed(
Duration(milliseconds: OptionsBase().refreshTime));
},
child: imgs.isEmpty && !isLoading
? SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(),
child: Empty(),
)
: CustomScrollView(
controller: scrollController,
slivers: [
SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: OptionsBase().imageColumns(context),
childAspectRatio: widget.sort == 'pc' ? 1.5 : 0.7,
),
delegate: SliverChildBuilderDelegate(
(context, index) => buildItem(context, index),
childCount: imgs.length,
),
),
if (isLoading)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.only(
top: MediaQuery.of(context).size.height * 0.4),
child: CircularProgressIndicator(),
),
),
),
],
),
);
}
修改点说明:
-
SingleChildScrollView
和AlwaysScrollableScrollPhysics()
:- 使用
SingleChildScrollView
包裹Empty()
,并设置physics: AlwaysScrollableScrollPhysics()
,确保即使内容不足一屏也可以滚动。 - 这样可以让
RefreshIndicator
始终检测到滚动行为,从而支持下拉刷新。
- 使用
4、地址
软件体验链接: pan.baidu.com/s/1ODDzqQ0R... 提取码:68my
项目地址: gitee.com/zsnoin-can/...