基于flutter的开源壁纸软件

基于flutter的开源壁纸软件

功能描述

  1. 一款基于flutter的开源壁纸软件,所有接口都是大佬开源的,感谢各位大佬;
  2. 支持设置下载壁纸;
  3. 支持简单收藏功能(本地收藏);
  4. 支持设置壁纸功能;
  5. 支持获取本地壁纸来设置壁纸;
  6. 支持扫描本地相册来设置壁纸;
  7. 支持获取本地视频设置壁纸(只支持mp4格式)。

没有登录/注册功能,只能简单的做一个本地存储,功能和其它壁纸软件也大差不差。不过我最喜欢的还是可以设置本地壁纸和本地视频壁纸的功能。

1、项目预览图

  • 截取了部分图,其它功能有在以前的文章介绍过,懒得在叙述了,有的页面很简单也懒得截了。

2、插件使用(推荐)

  1. extended_image 图片展示预览插件,该插件支持图片放大缩小等各种功能,并且能够缓存图片至临时文件夹,下次加载时能够直接读取缓存中的图片,无需额外的网络开销。这里只用了基础放大缩小、缓存功能,其它功能都没用,详细用法见官网,非常强大的图片管理插件。
  2. flutter_cache_manager 缓存管理插件,允许用户手动清理缓存。
  3. image_picker 适用于 iOS 和 Android 的 Flutter 插件,用于从图像库中选择图像,并使用相机拍摄新照片。
  4. photo_manager 无需 UI 集成,即可在 Android、iOS、macOS 和 OpenHarmony 上获取资产(图片/视频/音频)。
  5. flutter_wallpaper_manager 用于在您的 Android 设备上设置壁纸。它支持主屏幕、锁屏和两种屏幕模式。
  6. 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)

到这里就能正常运行项目了,但是又出现了一个新的爆红警告(不影响项目启动):

  • 大概就是版本不兼容的意思,按照他的提示在 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 apkflutter 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(),
                      ),
                    ),
                  ),
              ],
            ),
    );
  }

修改点说明:

  • SingleChildScrollViewAlwaysScrollableScrollPhysics()

    • 使用 SingleChildScrollView 包裹 Empty(),并设置 physics: AlwaysScrollableScrollPhysics(),确保即使内容不足一屏也可以滚动。
    • 这样可以让 RefreshIndicator 始终检测到滚动行为,从而支持下拉刷新。

4、地址

软件体验链接: pan.baidu.com/s/1ODDzqQ0R... 提取码:68my

项目地址: gitee.com/zsnoin-can/...

相关推荐
LinXunFeng9 小时前
Flutter - iOS编译加速
flutter·xcode·apple
pengyu11 小时前
系统化掌握Flutter开发之隐式动画(一):筑基之旅
android·flutter·dart
AntG14 小时前
flutter webview crash 问题
flutter
勤劳打代码1 天前
烽火连营——爆杀 Jank 闪烁卡顿
flutter·面试·性能优化
书弋江山2 天前
Flutter 调用原生IOS接口
flutter·ios·cocoa
怀君2 天前
Flutter——最详细原生交互(MethodChannel、EventChannel、BasicMessageChannel)使用教程
flutter·交互·flutter与原生交互
星海拾遗2 天前
debug_unpack_ios failed: Exception: Failed to codesign 解决方案(亲测有效)
flutter·ios
爱学习的大牛1232 天前
flutter环境最新踩坑
flutter·androidstdio
bst@微胖子2 天前
Flutter管理项目实战
android·flutter
B.-2 天前
Flutter 实现消息推送的方法
android·学习·flutter·macos·ios·cocoa