使用flutter开发的Windows桌面壁纸软件
1、项目背景介绍、以 Flutter 重构波奇壁纸软件:从臃肿到轻盈的蜕变之旅
在探索桌面应用开发的历程中,我曾使用 Electron 打造了一款 壁纸集 软件。项目初衷是为了熟悉 Electron 开发流程,同时满足自己对个性化壁纸管理的需求。Electron 的跨平台特性和与前端技术栈的无缝结合,让我能快速搭建起应用框架。在开发过程中,采用了 electron-vite 提升构建效率,搭配 vue3、pinia 和 ts 构建现代化前端架构,element plus 则丰富了 UI 组件库,使得界面设计更加美观实用。从首页展示,到多级分类菜单,再到不同壁纸源的分页加载、搜索功能等,都逐步实现和完善,最终成功开发出这款简易但功能较为全面的壁纸软件,并将其开源共享。
然而,当面对软件打包后的体积问题时,我陷入了沉思。90M 左右的打包体积,安装后膨胀至几百 M,这对于一款壁纸应用来说显然是难以接受的。过大的体积不仅增加了用户的下载负担,还可能导致用户设备存储压力骤增,影响使用体验,违背了开发一款轻量化、便捷工具的初衷。
痛定思痛,我决定另辟蹊径,采用 Flutter 对壁纸软件进行重构,打造出全新的波奇壁纸软件。Flutter 凭借其独特的架构和编译机制,在跨平台开发中展现出了卓越的性能优势和体积控制能力。它使用 Dart 语言开发,拥有高效的渲染引擎,能够将代码和资源进行高度优化整合。在 Flutter 的加持下,波奇壁纸软件的打包体积大幅缩减至 11M,安装后的体积也仅有 30M 左右,体积相较于 Electron 版本缩小了 10 倍不止。这不仅显著降低了软件对用户设备存储空间的占用,还让软件的分发和安装变得更加高效便捷,极大地提升了软件的可用性和竞争力。
通过这次从 Electron 到 Flutter 的重构之旅,我深刻体会到技术选型对于软件质量的深远影响。在追求功能实现的同时,更应注重软件性能和用户体验的平衡。Flutter 以其出色的性能表现和轻量化特性,为我提供了一种全新的开发思路和解决方案,助力我成功打造出了波奇壁纸软件这一优质的桌面壁纸管理工具。未来,我将继续探索 Flutter 开发的更多可能性,不断优化和拓展波奇壁纸软件的功能与体验,满足用户对壁纸应用的更高期待。
上面都是ai帮忙吹的,一句话就是Electron打包体积太大,使用flutter重构。
- 使用flutter开发的windows桌面端壁纸软件
- 支持收藏、壁纸下载、设为桌面壁纸等功能
- 不支持设置锁屏壁纸、不支持视频壁纸(这两个功能没写出来、相应的插件也没没有,没办法实现)
- 开源免费、所有api均来自网络
- 开源地址:https://gitee.com/zsnoin-can/windows_wallpaper
- 该项目仅包含windows,只需注意flutter版本不要低于开发使用的版本,copy过去随便就能跑起来的,不像Android项目,需要处理各种问题。
- 软件也内置了安卓下载地址,安卓的以前文章有介绍过,安卓要处理各种问题,要跑起来可能有点麻烦。
2、项目截图
对比其他花里胡哨的,更喜欢这种简约风格的
3、插件和工具介绍
3.1、主题设置(优化)
主题和国际化以前文章介绍过,这里就不再做过多赘述了(该软件没有做国际化),主要是介绍一下优化点。
- 在Windows上面,不设置字体会出现字体大小不一致的情况,默认设置字体为 微软雅黑。
- 主题色提取出来,允许用户自定义主题色。
- 怎么用,以前文章详细介绍过,和以前一样,感兴趣的可以翻一下以前的文章,这里只快速贴一下优化后的代码。
lib/themes/theme.dart,定义亮色和暗色默认,字体统一设置为 "Microsoft YaHei"。
import 'dart:io';
import 'package:flutter/material.dart';
ThemeData createThemeData(Brightness brightness, Color primaryColor) {
return ThemeData(
brightness: brightness,
colorScheme: brightness == Brightness.light
? ColorScheme.light(
surface: Colors.grey.shade200,
onSurface: Colors.grey.shade900,
primary: primaryColor,
primaryContainer: Colors.white,
secondary: Colors.grey.shade300,
inversePrimary: Colors.grey.shade800,
shadow: const Color.fromARGB(112, 80, 80, 80),
)
: ColorScheme.dark(
surface: Colors.grey.shade900,
onSurface: Colors.grey.shade100,
primary: primaryColor,
primaryContainer: const Color.fromARGB(255, 23, 23, 23),
secondary: const Color.fromARGB(107, 66, 66, 66),
inversePrimary: Colors.grey.shade200,
shadow: const Color.fromARGB(62, 75, 75, 75),
),
fontFamily: Platform.isWindows ? "Microsoft YaHei" : null,
);
}
ThemeData lightMode = createThemeData(Brightness.light, Colors.blueAccent);
ThemeData darkMode = createThemeData(Brightness.dark, Colors.blueAccent);
lib/themes/theme_provider.dart,各种设置主题的方法,注释已经很详细了。
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wallpaper_app/themes/theme.dart';
import 'dart:async';
class ThemeProvider extends ChangeNotifier {
ThemeData _themeData = lightMode;
bool _followSystem = false;
bool _isInitialized = false;
bool _isDarkMode = false;
Color _customPrimaryColor = Colors.blueAccent; // 默认自定义颜色
VoidCallback? _brightnessListener;
ThemeData get themeData => _themeData;
bool get isDarkMode => _isDarkMode;
bool get followSystem => _followSystem;
ThemeProvider() {
_initTheme();
}
// 初始化主题(异步)
Future<void> _initTheme() async {
if (_isInitialized) return;
final prefs = await SharedPreferences.getInstance();
_followSystem = prefs.getBool('followSystem') ?? false;
_customPrimaryColor =
Color(prefs.getInt('customPrimaryColor') ?? Colors.blueAccent.value);
_isDarkMode = prefs.getBool('isDark') ?? false;
if (_followSystem) {
_themeData = _getSystemTheme();
} else {
final isDark = prefs.getBool('isDark') ?? false;
_themeData = isDark
? createThemeData(Brightness.dark, _customPrimaryColor)
: createThemeData(Brightness.light, _customPrimaryColor);
}
_addSystemThemeListener();
_isInitialized = true;
notifyListeners();
}
// 系统主题监听
void _addSystemThemeListener() {
_brightnessListener = () {
if (_followSystem) {
_themeData = _getSystemTheme();
notifyListeners();
}
};
WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged =
_brightnessListener;
}
// 清理资源
@override
void dispose() {
WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged =
null;
super.dispose();
}
// 统一保存配置(异步)
Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('isDark', isDarkMode);
await prefs.setBool('followSystem', _followSystem);
await prefs.setInt('customPrimaryColor', _customPrimaryColor.value);
}
// 切换主题
void toggleTheme() {
_followSystem = false;
_themeData = _isDarkMode
? createThemeData(Brightness.light, _customPrimaryColor)
: createThemeData(Brightness.dark, _customPrimaryColor);
_isDarkMode = !_isDarkMode;
_scheduleSavePreferences(); // 异步保存设置
notifyListeners();
}
// 设置浅色主题
void setLightTheme() {
_followSystem = false;
_isDarkMode = false;
_themeData = createThemeData(Brightness.light, _customPrimaryColor);
_scheduleSavePreferences(); // 异步保存设置
notifyListeners();
}
// 设置深色主题
void setDarkTheme() {
_followSystem = false;
_isDarkMode = true;
_themeData = createThemeData(Brightness.dark, _customPrimaryColor);
_scheduleSavePreferences(); // 异步保存设置
notifyListeners();
}
// 跟随系统主题
void setFollowSystem() {
_followSystem = true;
_themeData = _getSystemTheme();
_scheduleSavePreferences(); // 异步保存设置
notifyListeners();
}
// 设置自定义主题颜色
void setCustomTheme(Color primaryColor) {
if (_followSystem) {
_customPrimaryColor = primaryColor;
_themeData = _getSystemTheme();
} else {
_customPrimaryColor = primaryColor;
_themeData = isDarkMode
? createThemeData(Brightness.dark, primaryColor)
: createThemeData(Brightness.light, primaryColor);
}
_scheduleSavePreferences(); // 异步保存设置
notifyListeners();
}
// 获取系统主题
ThemeData _getSystemTheme() {
final brightness =
WidgetsBinding.instance.platformDispatcher.platformBrightness;
return brightness == Brightness.dark
? createThemeData(Brightness.dark, _customPrimaryColor)
: createThemeData(Brightness.light, _customPrimaryColor);
}
// 异步保存设置(不会阻塞主线程)
void _scheduleSavePreferences() {
Future.microtask(() async {
await _savePreferences();
});
}
}
3.2、图片缓存插件
- 缓存管理插件 flutter_cache_manager
- 图片缓存插件 cached_network_image。为什么桌面端我使用的是cached_network_image插件而不是 extended_image,因为这两个插件默认缓存文件都是在C盘下面的,但是cached_network_image能更方便的设置缓存管理器,允许自定义缓存目录,对我来说C盘空间还是非常珍贵的,所以选择cached_network_image插件实现缓存。自定义缓存目录,什么时候清理缓存等等你都能自定义,自由度更高一点,我设置的比较简单,缓存文件超过2G,下次打开软件时会自动清理缓存。
实现:先定义一个缓存管理器,lib/tools/custom_image_cache.dart。
import 'dart:io';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:path/path.dart' as path;
class CustomCacheManager extends CacheManager {
static const key = 'custom-cache-key';
static final String _cachePath = path.join('D:', 'custom_image_cache');
static final CustomCacheManager _instance = CustomCacheManager._();
factory CustomCacheManager() => _instance;
CustomCacheManager._()
: super(Config(
key,
stalePeriod: const Duration(days: 7),
maxNrOfCacheObjects: 100,
fileService: HttpFileService(),
fileSystem: IOFileSystem(_cachePath),
));
// 直接在构造函数中使用IOFileSystem指定了缓存路径
// 确保D盘缓存目录存在
static void ensureCacheDirExists() {
final cacheDir = Directory(_cachePath);
if (!cacheDir.existsSync()) {
cacheDir.createSync(recursive: true);
}
}
static void deleteCacheDir() {
final cacheDir = Directory(_cachePath);
if (cacheDir.existsSync()) {
// 判断目录的大小,超过100M时才删除
final size = cacheDir.listSync().fold<int>(
0,
(previousValue, element) => previousValue + element.statSync().size,
);
if (size < 100 * 1024 * 1024) {
return;
}
cacheDir.deleteSync(recursive: true);
}
}
static void clearCache() {
final cacheDir = Directory(_cachePath);
if (cacheDir.existsSync()) {
cacheDir.deleteSync(recursive: true);
}
}
static Future<double> fileSize() {
final cacheDir = Directory(_cachePath);
double filesize = 0;
if (cacheDir.existsSync()) {
// 判断目录的大小,超过100M时才删除
int size = cacheDir.listSync().fold<int>(
0,
(previousValue, element) => previousValue + element.statSync().size,
);
filesize = size / 1024 / 1024;
} else {
filesize = 0;
}
// 保留两位小数
filesize = double.parse(filesize.toStringAsFixed(2));
return Future.value(filesize);
}
}
使用,lib/components/Images/image_load.dart
- 使用前,先调 CustomCacheManager.ensureCacheDirExists() 函数,确保缓存文件夹存在。
- 然后把自定义的缓存文件管理器传递给 CachedNetworkImage 即可。
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:wallpaper_app/components/AlertDialog/my_loading.dart';
import 'package:wallpaper_app/tools/custom_image_cache.dart';
class ImageLoad extends StatelessWidget {
final String imageUrl;
final BoxFit fit;
final double size;
const ImageLoad({
super.key,
required this.imageUrl,
this.fit = BoxFit.cover,
this.size = 50,
});
@override
Widget build(BuildContext context) {
// 确保缓存目录存在
CustomCacheManager.ensureCacheDirExists();
return CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
cacheManager: CustomCacheManager(),
placeholder: (context, url) => Center(
child: MyLoading(type: 1, size: size),
),
errorWidget: (context, url, error) => Icon(Icons.error),
);
}
}
可以检查图片缓存是否在D盘下面custom_image_cache文件夹中出现,同时检查C盘下面的临时缓存文件夹中是否还会继续缓存图片。检查后发现缓存已经移至D盘,不会在C盘下面缓存了,同时缓存功能正常,ok了,大功告成。
3.3、图片查看插件
easy_image_viewer,支持弹窗的模式查看图片,支持双指缩放,支持滚动缩放,支持多图。主要是多端支持,而且能通过函数直接预览,图片预览非常方便。
简单示例,详细用法见官网,我这先从缓存中读取数据在预览。
······
Tooltip(
message: '查看',
child: IconButton(
icon: Icon(
Icons.swipe_vertical,
color: Colors.white,
),
color: Theme.of(context)
.colorScheme
.primary,
onPressed: () async {
final cacheFile =
await CustomCacheManager()
.getSingleFile(
widget
.images[activeIndex].largePath,
);
if (cacheFile.path.isEmpty) return;
final imageProvider =
Image.file(cacheFile).image;
showImageViewer(
context, imageProvider);
}))
······
3.4、侧边栏插件
flutter_side_menu Flutter的完全可自定义侧边菜单已用作 Related Pages、Navigation Items、Filter side 等的目录。官网教程通俗易懂,直接前往官网查看即可。这里贴一下我自己优化后的侧边栏代码。
左侧布局,lib/components/SideBar/side_menu_nav.dart
// ignore_for_file: camel_case_types
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_side_menu/flutter_side_menu.dart';
import 'package:package_info_plus/package_info_plus.dart';
class SizeMenuNav extends StatefulWidget {
final void Function(int index) onTapSide;
const SizeMenuNav({super.key, required this.onTapSide});
@override
State<SizeMenuNav> createState() => _SizeMenuNavState();
}
class sideItem {
final String title;
final IconData icon;
sideItem(this.title, this.icon);
}
class _SizeMenuNavState extends State<SizeMenuNav> {
int activeIndex = 0;
double sidebarWidth = 180;
String version = '';
void getVersion() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
setState(() {
version = packageInfo.version;
});
}
@override
void initState() {
super.initState();
getVersion();
}
@override
Widget build(BuildContext context) {
List<sideItem> sideItems = [
sideItem('首页', Icons.home),
sideItem('最新', Icons.new_label),
sideItem('Loveanimer壁纸', Icons.real_estate_agent),
sideItem('萌虎壁纸', Icons.support_agent),
sideItem('ACG壁纸', Icons.discord),
sideItem('你的名字', Icons.handshake),
sideItem('JK', Icons.pregnant_woman),
sideItem('个人中心', Icons.account_box),
];
return SideMenu(
mode: SideMenuMode.open,
maxWidth: sidebarWidth,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
hasResizer: true,
hasResizerToggle: true,
resizerToggleData: ResizerToggleData(
iconColor: Theme.of(context).colorScheme.primary,
opacity: 1,
iconSize: 25,
),
builder: (data) => SideMenuData(
header: Column(
spacing: 10,
children: [
SizedBox(height: 30, child: MoveWindow()),
FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10),
child: Image.asset(
'lib/assets/images/icon.png',
width: 80,
height: 80,
),
),
),
FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10),
child: Text(
'波奇壁纸',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
// 分割线
const Divider(
color: Colors.grey,
height: 10,
thickness: 1,
indent: 10,
endIndent: 10,
),
],
),
items: [
for (var item in sideItems)
SideMenuItemDataTile(
hasSelectedLine: true,
borderRadius: BorderRadius.all(Radius.circular(5)),
isSelected: activeIndex == sideItems.indexOf(item),
tooltip:
data.currentWidth <= data.minWidth + 30 ? item.title : null,
titleStyle: TextStyle(
fontSize: 15,
),
selectedTitleStyle: TextStyle(
fontSize: 15,
color: Theme.of(context).colorScheme.primary,
),
onTap: () {
setState(() {
activeIndex = sideItems.indexOf(item);
});
widget.onTapSide(sideItems.indexOf(item));
},
title: item.title,
icon: Icon(item.icon),
),
],
footer: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.all(5),
child: Text(
'v$version',
style: TextStyle(
fontSize: 13,
),
),
),
),
),
);
}
}
其它功能就没啥难点了,可自行查看。
使用,和官网用法一致即可,lib/pages/main_page.dart
import 'package:flutter/material.dart';
import 'package:wallpaper_app/pages/4k/image_list_new_360.dart';
import 'package:wallpaper_app/pages/setting/personal_center.dart';
import 'package:wallpaper_app/tools/custom_image_cache.dart';
import 'package:wallpaper_app/components/SideBar/side_menu_nav.dart';
import 'package:wallpaper_app/components/windows/window_title_bar.dart';
import 'package:wallpaper_app/pages/home_page.dart';
import 'package:wallpaper_app/pages/mohu/mohu_page.dart';
import 'package:wallpaper_app/pages/suyan/random_image_pc.dart';
import 'package:wallpaper_app/pages/tuhui/acg_list.dart';
import 'package:wallpaper_app/pages/tuhui/jk_list.dart';
import 'package:wallpaper_app/pages/tuhui/your_name.dart';
import 'package:wallpaper_app/tools/update_apk.dart';
class MainPage extends StatefulWidget {
const MainPage({super.key});
@override
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
PageController pageController = PageController();
void cacheCleaning() async {
double size = await CustomCacheManager.fileSize();
// 大于2G 清除缓存
if (size > 2048) {
CustomCacheManager.deleteCacheDir();
}
}
@override
void initState() {
super.initState();
cacheCleaning();
UpdateApk().updateApk(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SizedBox(
width: double.infinity,
height: double.infinity,
child: Row(
children: [
// 侧边栏
// SidebarPage(onTapSide: (index) {
// pageController.jumpToPage(index);
// }),
SizeMenuNav(onTapSide: (index) {
pageController.jumpToPage(index);
}),
Expanded(
child: Column(
children: [
WindowTitleBar(),
Expanded(
child: PageView(
controller: pageController,
children: [
HomePage(),
ImageListNew360(),
RandomImagePc(),
MohuPage(),
AcgList(zd: 'pc'),
YourName(),
JkList(zd: 'pc'),
PersonalCenter(),
],
),
)
],
))
],
),
));
}
}
最终效果
4、壁纸设置功能
4.1 大致设置流程如下:
graph LR
点击设置按钮 --> 读取临时缓存中的文件 --> 保存到本地,防止被清理 --> 调用函数设置壁纸
4.2 读取临时缓存中的图片:
CustomCacheManager()就是3.2自定义的缓存管理器,继承自CacheManager- 具体用法见
lib/components/Images/image_view.dart
final cacheFile = await CustomCacheManager().getSingleFile(url);
print(cacheFile.path);
4.3 把临时文件保存到本地
lib/tools/save_file.dartsaveImageFile()图片保存到本地,saveFileWallPaper()设置壁纸时调用,功能一模一样,只有命名方式不同,用于标记当前桌面壁纸。- 保存成功后会返回图片新地址,传递给
4.4的设置壁纸函数即可。
import 'dart:io';
class SaveFile {
// 保存文件本地文件复制到 D盘 BoQiDown 文件夹
static Future<String> saveImageFile(String path) async {
try {
// 确保 D盘 BoQiDown 文件夹存在
Directory directory = Directory('D:\\BoQiDown\\image');
if (!directory.existsSync()) {
directory.createSync();
}
// 复制文件到 D盘 BoQiDown 文件夹
File file = File(path);
String newPath = 'D:\\BoQiDown\\image\\${file.path.split('\\').last}';
await file.copy(newPath);
return newPath; // 返回新的路径
} catch (e) {
throw Exception('保存文件失败: $e');
}
}
static Future<String> saveFileWallPaper(String path) async {
try {
// 确保 D盘 BoQiDown 文件夹存在
Directory directory = Directory('D:\\BoQiDown\\image');
if (!directory.existsSync()) {
directory.createSync();
}
// 复制文件到 D盘 BoQiDown 文件夹
File file = File(path);
String newPath = 'D:\\BoQiDown\\image\\desktop.jpg';
await file.copy(newPath);
return newPath; // 返回新的路径
} catch (e) {
throw Exception('保存文件失败: $e');
}
}
}
4.4 设置壁纸
lib/tools/wallpaper_service.dart- 设置壁纸功能会阻塞主线程,导致动画等卡顿,可以把设置壁纸这个动作放到独立线程 Isolate 中运行,在需要的地方监听 Isolate 即可。
- 同样的具体用法见
lib/components/Images/image_view.dart - 设置壁纸功能是AI写的,我只是单纯扩展来一些额外功能。
// ignore_for_file: unused_local_variable, constant_identifier_names, depend_on_referenced_packages
import 'dart:async';
import 'dart:isolate';
import 'dart:io';
import 'dart:ui';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart';
const int SPIF_UPDATEINIFILE = 0x01;
const int SPIF_SENDCHANGE = 0x02;
class WallpaperService {
// 设置Windows桌面壁纸
// [imagePath] 图片的完整路径
static Future<bool> setWallpaperInIsolate(String imagePath) async {
final receivePort = ReceivePort();
Isolate.spawn(_setWallpaper, imagePath).then((isolate) {
return true;
}).catchError((error) {
return false;
});
return true;
}
static void _setWallpaper(String imagePath) {
try {
final file = File(imagePath);
if (!file.existsSync()) {
return;
}
final pathPointer = imagePath.toNativeUtf16();
final result = SystemParametersInfo(
SPI_SETDESKWALLPAPER,
0,
pathPointer,
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
);
free(pathPointer);
if (result != 0) {
// 通过 SendPort 发送消息到主 UI 线程
final sendPort = IsolateNameServer.lookupPortByName('mainPort');
sendPort?.send({'code': 200, 'message': '设置成功'});
} else {
final sendPort = IsolateNameServer.lookupPortByName('mainPort');
sendPort?.send({'code': 400, 'message': '设置失败'});
}
} catch (e) {
final sendPort = IsolateNameServer.lookupPortByName('mainPort');
sendPort?.send({'code': 400, 'message': '设置失败: $e'});
}
}
}