Skip to content

Commit 53157f8

Browse files
committed
perf: ⚡ async icon loading via isolates with fallback queue
1 parent bfa267b commit 53157f8

2 files changed

Lines changed: 215 additions & 51 deletions

File tree

lib/screens/desk_tidy_home_page.dart

Lines changed: 110 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:io';
22
import 'dart:math' as math;
33
import 'dart:ui';
44
import 'dart:async';
5+
import 'dart:typed_data';
56

67
import 'package:flutter/gestures.dart';
78
import 'package:flutter/material.dart';
@@ -56,6 +57,7 @@ class _DeskTidyHomePageState extends State<DeskTidyHomePage>
5657
Timer? _dragEndTimer;
5758
Timer? _autoRefreshTimer;
5859
bool _isRefreshing = false;
60+
int _shortcutLoadToken = 0;
5961
List<ShortcutItem> _shortcuts = [];
6062
List<AppCategory> _categories = [];
6163
String? _activeCategoryId;
@@ -443,8 +445,13 @@ class _DeskTidyHomePageState extends State<DeskTidyHomePage>
443445
}
444446

445447
const requestIconSize = 256;
448+
final existingIcons = <String, Uint8List?>{};
449+
for (final shortcut in _shortcuts) {
450+
existingIcons[shortcut.path] = shortcut.iconData;
451+
}
446452

447-
final shortcutFutures = shortcutsPaths.map((shortcutPath) async {
453+
final shortcutItems = <ShortcutItem>[];
454+
for (final shortcutPath in shortcutsPaths) {
448455
final name = shortcutPath.split('\\').last.replaceAll('.lnk', '');
449456

450457
String targetPath = shortcutPath;
@@ -459,32 +466,22 @@ class _DeskTidyHomePageState extends State<DeskTidyHomePage>
459466

460467
// Don't treat folder shortcuts as "apps".
461468
if (isFolderShortcut) {
462-
return null;
469+
continue;
463470
}
464471

465-
final primaryIcon = await extractIconAsync(
466-
shortcutPath,
467-
size: requestIconSize,
468-
);
469-
final iconData =
470-
primaryIcon ??
471-
await extractIconAsync(targetPath, size: requestIconSize);
472-
473-
return ShortcutItem(
474-
name: name,
475-
path: shortcutPath,
476-
iconPath: '',
477-
description: '桌面快捷方式',
478-
targetPath: targetPath,
479-
iconData: iconData,
472+
shortcutItems.add(
473+
ShortcutItem(
474+
name: name,
475+
path: shortcutPath,
476+
iconPath: '',
477+
description: '桌面快捷方式',
478+
targetPath: targetPath,
479+
iconData: existingIcons[shortcutPath],
480+
),
480481
);
481-
}).toList();
482-
483-
final shortcutItems = (await Future.wait(
484-
shortcutFutures,
485-
)).whereType<ShortcutItem>().toList();
482+
}
486483

487-
// 插入已启用的系统项目
484+
// 插入已启用的系统项目(先用占位图标,后续异步补齐)
488485
final systemItemsToLoad = <SystemItemType>[];
489486
if (_showThisPC) systemItemsToLoad.add(SystemItemType.thisPC);
490487
if (_showRecycleBin) systemItemsToLoad.add(SystemItemType.recycleBin);
@@ -493,32 +490,40 @@ class _DeskTidyHomePageState extends State<DeskTidyHomePage>
493490
if (_showUserFiles) systemItemsToLoad.add(SystemItemType.userFiles);
494491

495492
for (final type in systemItemsToLoad) {
496-
final info = SystemItemInfo.all[type]!;
497-
final icon = await extractIconAsync(
498-
info.iconResource,
499-
size: requestIconSize,
493+
final virtualPath = SystemItemInfo.virtualPath(type);
494+
shortcutItems.add(
495+
ShortcutItem.system(
496+
type,
497+
iconData: existingIcons[virtualPath],
498+
),
500499
);
501-
shortcutItems.add(ShortcutItem.system(type, iconData: icon));
502500
}
503501

504502
// 按名称自然排序(字母顺序),使系统项目与普通应用自然混合
505503
shortcutItems.sort((a, b) {
506504
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
507505
});
508506

509-
// 只在数据变化时更新UI,实现无感更新
510-
if (forceReloadIcons || !_shortcutsEqual(_shortcuts, shortcutItems)) {
511-
final searchIndex = _buildSearchIndex(shortcutItems);
512-
setState(() {
513-
_shortcuts = shortcutItems;
514-
_searchIndexByPath = searchIndex;
515-
});
516-
_syncCategoriesWithShortcuts(shortcutItems);
517-
}
507+
final searchIndex = _buildSearchIndex(shortcutItems);
508+
setState(() {
509+
_shortcuts = shortcutItems;
510+
_searchIndexByPath = searchIndex;
511+
});
512+
_syncCategoriesWithShortcuts(shortcutItems);
518513

519514
if (shouldShowLoading) {
520515
setState(() => _isLoading = false);
521516
}
517+
518+
final loadToken = ++_shortcutLoadToken;
519+
unawaited(
520+
_hydrateShortcutIcons(
521+
shortcutItems,
522+
loadToken,
523+
requestIconSize,
524+
forceReloadIcons,
525+
),
526+
);
522527
} catch (e) {
523528
print('加载快捷方式失败: $e');
524529
if (shouldShowLoading) {
@@ -529,6 +534,74 @@ class _DeskTidyHomePageState extends State<DeskTidyHomePage>
529534
}
530535
}
531536

537+
Future<void> _hydrateShortcutIcons(
538+
List<ShortcutItem> baseItems,
539+
int loadToken,
540+
int requestIconSize,
541+
bool forceReloadIcons,
542+
) async {
543+
final updatedIcons = <String, Uint8List?>{};
544+
final tasks = <Future<void>>[];
545+
546+
for (final item in baseItems) {
547+
if (!forceReloadIcons && item.iconData != null) {
548+
continue;
549+
}
550+
tasks.add(() async {
551+
try {
552+
Uint8List? icon;
553+
if (item.isSystemItem && item.systemItemType != null) {
554+
final info = SystemItemInfo.all[item.systemItemType]!;
555+
icon = await extractIconAsync(
556+
info.iconResource,
557+
size: requestIconSize,
558+
);
559+
} else {
560+
final primary = await extractIconAsync(
561+
item.path,
562+
size: requestIconSize,
563+
);
564+
final targetPath =
565+
item.targetPath.isNotEmpty ? item.targetPath : item.path;
566+
icon = primary ??
567+
await extractIconAsync(targetPath, size: requestIconSize);
568+
}
569+
570+
if (icon != null && icon.isNotEmpty) {
571+
updatedIcons[item.path] = icon;
572+
}
573+
} catch (_) {
574+
// Ignore icon failures to keep UI responsive.
575+
}
576+
}());
577+
}
578+
579+
if (tasks.isEmpty) return;
580+
await Future.wait(tasks);
581+
582+
if (!mounted || loadToken != _shortcutLoadToken) return;
583+
if (updatedIcons.isEmpty) return;
584+
585+
final updated = baseItems.map((item) {
586+
final icon = updatedIcons[item.path];
587+
if (icon == null) return item;
588+
return ShortcutItem(
589+
name: item.name,
590+
path: item.path,
591+
iconPath: item.iconPath,
592+
description: item.description,
593+
targetPath: item.targetPath,
594+
iconData: icon,
595+
isSystemItem: item.isSystemItem,
596+
systemItemType: item.systemItemType,
597+
);
598+
}).toList();
599+
600+
setState(() {
601+
_shortcuts = updated;
602+
});
603+
}
604+
532605
void _syncCategoriesWithShortcuts(List<ShortcutItem> shortcuts) {
533606
final existingPaths = shortcuts.map((e) => e.path).toSet();
534607
bool changed = false;

lib/utils/desktop_helper.dart

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const int _iconCacheVersion = 9;
4141
const int _iconCacheCapacity = 64;
4242
final LinkedHashMap<String, Uint8List?> _iconCache =
4343
LinkedHashMap<String, Uint8List?>();
44+
final Map<String, Future<Uint8List?>> _iconInFlight = {};
4445

4546
class _IconTask {
4647
final String path;
@@ -55,6 +56,14 @@ final Queue<_IconTask> _iconTaskQueue = Queue<_IconTask>();
5556
int _activeIconIsolates = 0;
5657
// Limit concurrent isolates to avoid creating too many DCs at once.
5758
const int _maxIconIsolates = 3;
59+
final Queue<_IconTask> _mainIconTaskQueue = Queue<_IconTask>();
60+
bool _mainIconDrainScheduled = false;
61+
62+
void _debugLog(String message) {
63+
if (kDebugMode) {
64+
debugPrint(message);
65+
}
66+
}
5867

5968
math.Point<int> getPrimaryScreenSize() {
6069
final w = GetSystemMetrics(SM_CXSCREEN);
@@ -1065,6 +1074,8 @@ Uint8List? extractIcon(String filePath, {int size = 64}) {
10651074
// Try to locate the icon resource via shell, then extract a high-res icon via
10661075
// PrivateExtractIconsW (handles PNG-in-ICO as well). Fallback to SHGetFileInfo
10671076
// HICON if needed.
1077+
final comReady = _ensureComReady();
1078+
try {
10681079
final desiredSize = size.clamp(16, 256);
10691080

10701081
final primaryKey = _cacheKeyForFile(filePath, desiredSize);
@@ -1125,14 +1136,12 @@ Uint8List? extractIcon(String filePath, {int size = 64}) {
11251136
calloc.free(pathPtr);
11261137
if (hr == 0) {
11271138
calloc.free(shFileInfo);
1128-
_writeIconCache(primaryKey, null);
11291139
return null;
11301140
}
11311141

11321142
final iconHandle = shFileInfo.ref.hIcon;
11331143
calloc.free(shFileInfo);
11341144
if (iconHandle == 0) {
1135-
_writeIconCache(primaryKey, null);
11361145
return null;
11371146
}
11381147

@@ -1145,6 +1154,11 @@ Uint8List? extractIcon(String filePath, {int size = 64}) {
11451154
: primaryKey;
11461155
_writeIconCache(finalKey, cachedValue);
11471156
return cachedValue;
1157+
} finally {
1158+
if (comReady) {
1159+
CoUninitialize();
1160+
}
1161+
}
11481162
}
11491163

11501164
Uint8List? _extractJumboIconPng(String filePath, int desiredSize) {
@@ -1639,7 +1653,11 @@ Future<Uint8List?> extractIconAsync(String filePath, {int size = 64}) {
16391653
final cached = _readIconCache(cacheKey);
16401654
if (cached.found) return Future.value(cached.value);
16411655

1656+
final existing = _iconInFlight[cacheKey];
1657+
if (existing != null) return existing;
1658+
16421659
final completer = Completer<Uint8List?>();
1660+
_iconInFlight[cacheKey] = completer.future;
16431661
_iconTaskQueue.add(_IconTask(filePath, desiredSize, cacheKey, completer));
16441662
_drainIconTasks();
16451663
return completer.future;
@@ -1649,22 +1667,30 @@ void _drainIconTasks() {
16491667
while (_activeIconIsolates < _maxIconIsolates && _iconTaskQueue.isNotEmpty) {
16501668
final task = _iconTaskQueue.removeFirst();
16511669
_activeIconIsolates++;
1670+
final path = task.path;
1671+
final size = task.size;
16521672

1653-
Isolate.run<Uint8List?>(() => extractIcon(task.path, size: task.size))
1673+
_runIconIsolate(path, size)
16541674
.then((data) {
1655-
// Fallback: if isolate extraction fails/returns empty, retry in main
1656-
// isolate to avoid missing icons (may block briefly but recovers).
1657-
Uint8List? result = data;
1658-
if (result == null || result.isEmpty) {
1659-
result = extractIcon(task.path, size: task.size);
1675+
final result = (data == null || data.isEmpty) ? null : data;
1676+
if (result != null) {
1677+
_writeIconCache(task.cacheKey, result);
1678+
task.completer.complete(result);
1679+
_iconInFlight.remove(task.cacheKey);
1680+
} else {
1681+
_debugLog(
1682+
'icon isolate empty: ${task.path} size=${task.size}',
1683+
);
1684+
_mainIconTaskQueue.add(task);
1685+
_scheduleMainIconDrain();
16601686
}
1661-
_writeIconCache(task.cacheKey, result);
1662-
task.completer.complete(result);
16631687
})
1664-
.catchError((_, __) {
1665-
final result = extractIcon(task.path, size: task.size);
1666-
_writeIconCache(task.cacheKey, result);
1667-
task.completer.complete(result);
1688+
.catchError((err, st) {
1689+
_debugLog(
1690+
'icon isolate error: ${task.path} size=${task.size} err=$err\n$st',
1691+
);
1692+
_mainIconTaskQueue.add(task);
1693+
_scheduleMainIconDrain();
16681694
})
16691695
.whenComplete(() {
16701696
_activeIconIsolates--;
@@ -1673,6 +1699,33 @@ void _drainIconTasks() {
16731699
}
16741700
}
16751701

1702+
void _scheduleMainIconDrain() {
1703+
if (_mainIconDrainScheduled) return;
1704+
_mainIconDrainScheduled = true;
1705+
Future<void>(() async {
1706+
while (_mainIconTaskQueue.isNotEmpty) {
1707+
final task = _mainIconTaskQueue.removeFirst();
1708+
Uint8List? result;
1709+
try {
1710+
result = extractIcon(task.path, size: task.size);
1711+
} catch (_) {
1712+
_debugLog(
1713+
'icon main fallback error: ${task.path} size=${task.size}',
1714+
);
1715+
result = null;
1716+
}
1717+
_writeIconCache(task.cacheKey, result);
1718+
if (!task.completer.isCompleted) {
1719+
task.completer.complete(result);
1720+
}
1721+
_iconInFlight.remove(task.cacheKey);
1722+
// Yield between tasks to keep UI responsive.
1723+
await Future<void>.delayed(const Duration(milliseconds: 8));
1724+
}
1725+
_mainIconDrainScheduled = false;
1726+
});
1727+
}
1728+
16761729
_IconCacheResult _readIconCache(String key) {
16771730
if (!_iconCache.containsKey(key)) return const _IconCacheResult(found: false);
16781731
return _IconCacheResult(found: true, value: _iconCache[key]);
@@ -1697,6 +1750,44 @@ String _cacheKeyForSystemIndex(int index, int size) =>
16971750
String _cacheKeyForFile(String filePath, int size) =>
16981751
'v$_iconCacheVersion|file:${path.normalize(filePath)}|$size';
16991752

1753+
bool _ensureComReady() {
1754+
final hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
1755+
return hr == S_OK || hr == S_FALSE;
1756+
}
1757+
1758+
Uint8List? _extractIconIsolate(String path, int size) {
1759+
return extractIcon(path, size: size);
1760+
}
1761+
1762+
Future<Uint8List?> _runIconIsolate(String path, int size) async {
1763+
final port = ReceivePort();
1764+
await Isolate.spawn(
1765+
_iconIsolateEntry,
1766+
<Object>[port.sendPort, path, size],
1767+
);
1768+
final message = await port.first;
1769+
port.close();
1770+
if (message is TransferableTypedData) {
1771+
return message.materialize().asUint8List();
1772+
}
1773+
if (message is Uint8List) {
1774+
return message;
1775+
}
1776+
return null;
1777+
}
1778+
1779+
void _iconIsolateEntry(List<Object> args) {
1780+
final sendPort = args[0] as SendPort;
1781+
final path = args[1] as String;
1782+
final size = args[2] as int;
1783+
final bytes = _extractIconIsolate(path, size);
1784+
if (bytes == null || bytes.isEmpty) {
1785+
sendPort.send(null);
1786+
return;
1787+
}
1788+
sendPort.send(TransferableTypedData.fromList([bytes]));
1789+
}
1790+
17001791
List<String> getClipboardFilePaths() {
17011792
if (!Platform.isWindows) return [];
17021793
if (OpenClipboard(NULL) == 0) return [];

0 commit comments

Comments
 (0)