返回提交历史
Added
XFEToolBox.Client.Wpf.Test/RecentUsageIconCacheTests.cs
+89
-0
Modified
XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj
+2
-0
Added
XFEToolBox/Utilities/RecentUsageIconCache.cs
+62
-0
Modified
XFEToolBox/ViewModel/Pages/MainPageViewModel.cs
+36
-6
Modified
XFEToolBox/ViewModel/Pages/RecentUsageCardViewModel.cs
+72
-26
Modified
XFEToolBox/Views/Pages/DownloadPage.xaml.cs
+2
-0
Modified
XFEToolBox/Views/Pages/ToolBoxPage.xaml.cs
+7
-8
XFEstudio/XFEToolBox
优化最近使用卡片图标缓存与加载机制
新增 RecentUsageIconCache 类,支持 WPF 图像冻结与 32 容量 FIFO 缓存,提升图标加载性能。优化 MainPageViewModel 刷新与缓存复用逻辑,RecentUsageCardViewModel 优先从缓存获取图标并异步加载。ToolBoxPage、DownloadPage 详情页写入缓存,主页可直接复用。新增 RecentUsageIconCacheTests 单元测试,验证缓存冻结、复用与淘汰。更新 XFEToolBox.Client.Wpf.Test.csproj 引用。
af8b779
代码差异
7 个文件
+270
-40
@@ -0,0 +1,89 @@
1
using System.Windows.Media;
2
using System.Windows.Media.Imaging;
3
using XFEToolBox.Client.Models;
4
using XFEToolBox.Client.Utilities;
5
6
namespace XFEToolBox.Client.Wpf.Test;
7
8
public static class RecentUsageIconCacheTests
9
{
10
[Test]
11
public static void RecentUsageIconsAreFrozenReusedAndBounded()
12
{
13
Exception? failure = null;
14
var thread = new Thread(() =>
15
{
16
try
17
{
18
RecentUsageIconCache.Clear();
19
var icon = CreateIcon(0x98, 0x98, 0xE7);
20
Ensure(!icon.IsFrozen, "测试图标在写入缓存前不应被冻结。");
21
22
RecentUsageIconCache.Remember(RecentUsageKind.Tool, "Code-Line-Counter", icon);
23
Ensure(icon.IsFrozen, "最近使用图标写入缓存时没有被冻结。");
24
Ensure(RecentUsageIconCache.TryGet(
25
RecentUsageKind.Tool,
26
"code-line-counter",
27
out var cachedIcon),
28
"主页没有按不区分大小写的工具 ID 找到会话图标。");
29
Ensure(ReferenceEquals(icon, cachedIcon),
30
"主页没有直接复用工具箱已经解码完成的图标实例。");
31
32
for (var index = 0; index < 40; index++)
33
RecentUsageIconCache.Remember(
34
RecentUsageKind.Software,
35
$"software-{index}",
36
CreateIcon((byte)index, 0x98, 0xE7));
37
38
Ensure(!RecentUsageIconCache.TryGet(
39
RecentUsageKind.Tool,
40
"code-line-counter",
41
out _),
42
"图标缓存超过容量后没有淘汰最早的条目。");
43
Ensure(RecentUsageIconCache.TryGet(
44
RecentUsageKind.Software,
45
"software-39",
46
out _),
47
"图标缓存错误淘汰了最新条目。");
48
}
49
catch (Exception exception)
50
{
51
failure = exception;
52
}
53
finally
54
{
55
RecentUsageIconCache.Clear();
56
}
57
})
58
{
59
IsBackground = true,
60
Name = "Recent usage icon cache test"
61
};
62
63
thread.SetApartmentState(ApartmentState.STA);
64
thread.Start();
65
Ensure(thread.Join(TimeSpan.FromSeconds(10)), "最近使用图标缓存测试超时。");
66
if (failure is not null)
67
throw new InvalidOperationException($"最近使用图标缓存测试失败:{failure.Message}", failure);
68
}
69
70
private static BitmapSource CreateIcon(byte red, byte green, byte blue)
71
{
72
const int size = 4;
73
var pixels = new byte[size * size * 4];
74
for (var index = 0; index < pixels.Length; index += 4)
75
{
76
pixels[index] = blue;
77
pixels[index + 1] = green;
78
pixels[index + 2] = red;
79
pixels[index + 3] = 0xFF;
80
}
81
return BitmapSource.Create(size, size, 96, 96, PixelFormats.Bgra32, null, pixels, size * 4);
82
}
83
84
private static void Ensure(bool condition, string message)
85
{
86
if (!condition)
87
throw new InvalidOperationException(message);
88
}
89
}
@@ -23,6 +23,8 @@
23
23
<Compile Include="..\XFEToolBox.Client.Installer\Utilities\InstallationService.cs" Link="Installer\InstallationService.cs" />
24
24
<Compile Include="..\XFEToolBox.Client.Installer\Utilities\ZipHelper.cs" Link="Installer\ZipHelper.cs" />
25
25
<Compile Include="..\XFEToolBox\Utilities\WebImageSourceLoader.cs" Link="Utilities\WebImageSourceLoader.cs" />
26
<Compile Include="..\XFEToolBox\Models\RecentUsageEntry.cs" Link="Models\RecentUsageEntry.cs" />
27
<Compile Include="..\XFEToolBox\Utilities\RecentUsageIconCache.cs" Link="Utilities\RecentUsageIconCache.cs" />
26
28
</ItemGroup>
27
29
28
30
<ItemGroup>
@@ -0,0 +1,62 @@
1
using System.Windows.Media;
2
using XFEToolBox.Client.Models;
3
4
namespace XFEToolBox.Client.Utilities;
5
6
/// <summary>
7
/// 保存工具箱和下载专区已经解码过的冻结图标,让主页最近使用卡片直接复用,
8
/// 避免页面切换时再次解析 Base64、SVG 或 GIF。
9
/// </summary>
10
public static class RecentUsageIconCache
11
{
12
private const int MaximumEntries = 32;
13
private static readonly object SyncRoot = new();
14
private static readonly Dictionary<string, ImageSource> Images = new(StringComparer.OrdinalIgnoreCase);
15
private static readonly Queue<string> InsertionOrder = new();
16
17
public static void Remember(RecentUsageKind kind, string targetId, ImageSource? image)
18
{
19
if (string.IsNullOrWhiteSpace(targetId) || image is null)
20
return;
21
22
if (!image.IsFrozen)
23
{
24
if (!image.CanFreeze)
25
return;
26
image.Freeze();
27
}
28
29
var key = CreateKey(kind, targetId);
30
lock (SyncRoot)
31
{
32
if (Images.ContainsKey(key))
33
{
34
Images[key] = image;
35
return;
36
}
37
38
Images.Add(key, image);
39
InsertionOrder.Enqueue(key);
40
while (Images.Count > MaximumEntries && InsertionOrder.TryDequeue(out var oldestKey))
41
Images.Remove(oldestKey);
42
}
43
}
44
45
public static bool TryGet(RecentUsageKind kind, string targetId, out ImageSource image)
46
{
47
lock (SyncRoot)
48
return Images.TryGetValue(CreateKey(kind, targetId), out image!);
49
}
50
51
internal static void Clear()
52
{
53
lock (SyncRoot)
54
{
55
Images.Clear();
56
InsertionOrder.Clear();
57
}
58
}
59
60
private static string CreateKey(RecentUsageKind kind, string targetId) =>
61
$"{kind}:{targetId.Trim()}";
62
}
@@ -31,6 +31,7 @@ public partial class MainPageViewModel : ObservableObject
31
31
private readonly DispatcherTimer adminRefreshTimer;
32
32
private bool isAdminOverviewLoading;
33
33
private bool hasAdminOverviewSnapshot;
34
private bool hasRecentUsageSnapshot;
34
35
35
36
public MainPage MainPage { get; }
36
37
@@ -70,7 +71,8 @@ public partial class MainPageViewModel : ObservableObject
70
71
71
72
private async void MainPage_Loaded(object sender, System.Windows.RoutedEventArgs e)
72
73
{
73
RefreshRecentUsage();
74
if (!hasRecentUsageSnapshot)
75
RefreshRecentUsage();
74
76
var tasks = new List<Task> { LoadAdminOverviewAsync() };
75
77
if (!MainPage.mainCarousel.HasItems) tasks.Add(ReloadAsync());
76
78
await Task.WhenAll(tasks);
@@ -83,7 +85,6 @@ public partial class MainPageViewModel : ObservableObject
83
85
84
86
private void ClientSession_SessionChanged(object? sender, EventArgs e) => MainPage.Dispatcher.InvokeAsync(async () =>
85
87
{
86
RefreshRecentUsage();
87
88
await LoadAdminOverviewAsync();
88
89
if (ClientSession.IsAdministrator && MainPage.IsVisible) adminRefreshTimer.Start();
89
90
else adminRefreshTimer.Stop();
@@ -126,20 +127,49 @@ public partial class MainPageViewModel : ObservableObject
126
127
127
128
private void RefreshRecentUsage()
128
129
{
129
var recent = RecentUsageService.GetRecent()
130
var entries = RecentUsageService.GetRecent()
130
131
.Where(entry => entry.Kind is RecentUsageKind.Tool or RecentUsageKind.Software)
131
132
.Take(MaximumVisibleRecentItems)
132
.Select(entry => new RecentUsageCardViewModel(entry))
133
133
.ToArray();
134
var existing = RecentItems.ToDictionary(
135
item => CreateRecentUsageKey(item.Entry),
136
StringComparer.OrdinalIgnoreCase);
137
var recent = entries.Select(entry =>
138
{
139
var key = CreateRecentUsageKey(entry);
140
return existing.TryGetValue(key, out var card) && EntriesEquivalent(card.Entry, entry)
141
? card
142
: new RecentUsageCardViewModel(entry);
143
}).ToArray();
134
144
135
RecentItems.Clear();
136
foreach (var item in recent) RecentItems.Add(item);
145
for (var index = 0; index < recent.Length; index++)
146
{
147
if (index >= RecentItems.Count)
148
RecentItems.Add(recent[index]);
149
else if (!ReferenceEquals(RecentItems[index], recent[index]))
150
RecentItems[index] = recent[index];
151
}
152
while (RecentItems.Count > recent.Length)
153
RecentItems.RemoveAt(RecentItems.Count - 1);
137
154
155
hasRecentUsageSnapshot = true;
138
156
RecentItemsVisibility = recent.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
139
157
RecentEmptyVisibility = recent.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
140
158
RecentUsageCountText = $"{recent.Length} 项";
141
159
}
142
160
161
private static string CreateRecentUsageKey(RecentUsageEntry entry) =>
162
$"{entry.Kind}:{entry.TargetId}";
163
164
private static bool EntriesEquivalent(RecentUsageEntry left, RecentUsageEntry right) =>
165
left.Kind == right.Kind
166
&& string.Equals(left.TargetId, right.TargetId, StringComparison.OrdinalIgnoreCase)
167
&& string.Equals(left.Name, right.Name, StringComparison.Ordinal)
168
&& string.Equals(left.Description, right.Description, StringComparison.Ordinal)
169
&& string.Equals(left.Detail, right.Detail, StringComparison.Ordinal)
170
&& string.Equals(left.IconReference, right.IconReference, StringComparison.Ordinal)
171
&& left.LastUsedAtUtc == right.LastUsedAtUtc;
172
143
173
private async Task LoadAdminOverviewAsync()
144
174
{
145
175
AdminServerPanelVisibility = ClientSession.IsAdministrator ? Visibility.Visible : Visibility.Collapsed;
@@ -5,18 +5,31 @@ using CommunityToolkit.Mvvm.ComponentModel;
5
5
using XFEToolBox.Client.Models;
6
6
using XFEToolBox.Client.Profiles.CacheProfiles;
7
7
using XFEToolBox.Client.Utilities;
8
using XFEToolBox.Core.Downloads;
9
using XFEToolBox.Core.Tools;
10
8
11
9
namespace XFEToolBox.Client.ViewModel.Pages;
12
10
13
11
public partial class RecentUsageCardViewModel : ObservableObject
14
12
{
13
private static readonly object CatalogIconSyncRoot = new();
14
private static string? cachedToolCatalogJson;
15
private static string? cachedSoftwareCatalogJson;
16
private static IReadOnlyDictionary<string, string> cachedToolIcons =
17
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
18
private static IReadOnlyDictionary<string, string> cachedSoftwareIcons =
19
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
20
15
21
public RecentUsageCardViewModel(RecentUsageEntry entry)
16
22
{
17
23
Entry = entry;
18
IconSource = CreateFallbackIcon(entry);
19
_ = LoadConfiguredIconAsync(entry);
24
if (RecentUsageIconCache.TryGet(entry.Kind, entry.TargetId, out var cachedIcon))
25
{
26
IconSource = cachedIcon;
27
}
28
else
29
{
30
IconSource = CreateFallbackIcon(entry);
31
_ = LoadConfiguredIconAsync(entry);
32
}
20
33
}
21
34
22
35
public RecentUsageEntry Entry { get; }
@@ -39,17 +52,20 @@ public partial class RecentUsageCardViewModel : ObservableObject
39
52
40
53
private async Task LoadConfiguredIconAsync(RecentUsageEntry entry)
41
54
{
42
var reference = string.IsNullOrWhiteSpace(entry.IconReference)
43
? ResolveCatalogIcon(entry)
44
: entry.IconReference;
45
if (string.IsNullOrWhiteSpace(reference) || reference.StartsWith('/'))
46
return;
47
48
55
try
49
56
{
57
var reference = string.IsNullOrWhiteSpace(entry.IconReference)
58
? await Task.Run(() => ResolveCatalogIcon(entry))
59
: entry.IconReference;
60
if (string.IsNullOrWhiteSpace(reference) || reference.StartsWith('/'))
61
return;
62
50
63
var image = await WebImageSourceLoader.LoadAsync(reference);
51
64
if (image is not null)
65
{
66
RecentUsageIconCache.Remember(entry.Kind, entry.TargetId, image);
52
67
IconSource = image;
68
}
53
69
}
54
70
catch
55
71
{
@@ -72,32 +88,62 @@ public partial class RecentUsageCardViewModel : ObservableObject
72
88
73
89
private static string ResolveCatalogIcon(RecentUsageEntry entry)
74
90
{
75
try
91
var toolCatalogJson = AppCacheProfile.ToolCatalogJson;
92
var softwareCatalogJson = AppCacheProfile.SoftwareCatalogJson;
93
lock (CatalogIconSyncRoot)
76
94
{
77
if (entry.Kind == RecentUsageKind.Tool && !string.IsNullOrWhiteSpace(AppCacheProfile.ToolCatalogJson))
95
if (!ReferenceEquals(cachedToolCatalogJson, toolCatalogJson))
78
96
{
79
var tools = JsonSerializer.Deserialize<ToolPackageSummary[]>(
80
AppCacheProfile.ToolCatalogJson,
81
new JsonSerializerOptions(JsonSerializerDefaults.Web));
82
return tools?.FirstOrDefault(item =>
83
string.Equals(item.Id, entry.TargetId, StringComparison.OrdinalIgnoreCase))?.IconDataUrl ?? string.Empty;
97
cachedToolCatalogJson = toolCatalogJson;
98
cachedToolIcons = BuildIconIndex(toolCatalogJson, isToolCatalog: true);
84
99
}
85
100
86
if (entry.Kind == RecentUsageKind.Software && !string.IsNullOrWhiteSpace(AppCacheProfile.SoftwareCatalogJson))
101
if (!ReferenceEquals(cachedSoftwareCatalogJson, softwareCatalogJson))
87
102
{
88
var catalog = JsonSerializer.Deserialize<SoftwareCatalogResponse>(
89
AppCacheProfile.SoftwareCatalogJson,
90
new JsonSerializerOptions(JsonSerializerDefaults.Web));
91
return catalog?.Items.FirstOrDefault(item =>
92
string.Equals(item.Id, entry.TargetId, StringComparison.OrdinalIgnoreCase))?.IconUrl ?? string.Empty;
103
cachedSoftwareCatalogJson = softwareCatalogJson;
104
cachedSoftwareIcons = BuildIconIndex(softwareCatalogJson, isToolCatalog: false);
93
105
}
106
107
var icons = entry.Kind == RecentUsageKind.Tool ? cachedToolIcons : cachedSoftwareIcons;
108
return icons.TryGetValue(entry.TargetId, out var iconReference) ? iconReference : string.Empty;
109
}
110
}
111
112
private static IReadOnlyDictionary<string, string> BuildIconIndex(string? json, bool isToolCatalog)
113
{
114
if (string.IsNullOrWhiteSpace(json))
115
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
116
117
try
118
{
119
using var document = JsonDocument.Parse(json);
120
var items = isToolCatalog
121
? document.RootElement
122
: document.RootElement.TryGetProperty("items", out var softwareItems)
123
? softwareItems
124
: default;
125
if (items.ValueKind != JsonValueKind.Array)
126
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
127
128
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
129
var iconPropertyName = isToolCatalog ? "iconDataUrl" : "iconUrl";
130
foreach (var item in items.EnumerateArray())
131
{
132
if (!item.TryGetProperty("id", out var idProperty) ||
133
!item.TryGetProperty(iconPropertyName, out var iconProperty))
134
continue;
135
136
var id = idProperty.GetString();
137
var icon = iconProperty.GetString();
138
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(icon))
139
result[id] = icon;
140
}
141
return result;
94
142
}
95
143
catch (JsonException)
96
144
{
97
// 缓存失效时使用对应类型的内置图标。
145
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
98
146
}
99
100
return string.Empty;
101
147
}
102
148
103
149
private static string GetBundledSoftwareIcon(string id) => id.ToLowerInvariant() switch
@@ -6,6 +6,7 @@ using System.Windows.Input;
6
6
using System.Windows.Media;
7
7
using System.Windows.Media.Imaging;
8
8
using XFEToolBox.Client.Model;
9
using XFEToolBox.Client.Models;
9
10
using XFEToolBox.Client.Profiles.CacheProfiles;
10
11
using XFEToolBox.Client.Utilities;
11
12
using XFEToolBox.Client.Utilities.Server;
@@ -341,6 +342,7 @@ public partial class DownloadPage : Page
341
342
342
343
private static void ShowSoftwareDetails(SoftwareCardViewModel card)
343
344
{
345
RecentUsageIconCache.Remember(RecentUsageKind.Software, card.Id, card.IconSource);
344
346
RecentUsageService.RecordSoftware(card.Software);
345
347
PopupHelper.ShowDialog(new DownloadInfoPage(card.Software, card.IconSource), new PopupWindowOptions
346
348
{
@@ -8,6 +8,7 @@ using System.Windows.Controls;
8
8
using System.Windows.Input;
9
9
using System.Windows.Media;
10
10
using System.Windows.Media.Imaging;
11
using XFEToolBox.Client.Models;
11
12
using XFEToolBox.Client.Utilities;
12
13
using XFEToolBox.Client.Utilities.Server;
13
14
using XFEToolBox.Client.ViewModel.Pages;
@@ -418,6 +419,7 @@ public partial class ToolBoxPage : Page
418
419
419
420
card.CacheState = "已打开";
420
421
StatusText.Text = $"{card.Name} {card.LatestVersion} 已在独立窗口中打开。";
422
RecentUsageIconCache.Remember(RecentUsageKind.Tool, card.Id, card.IconSource);
421
423
RecentUsageService.RecordTool(card.Package);
422
424
return true;
423
425
}
@@ -469,14 +471,11 @@ public partial class ToolBoxPage : Page
469
471
{
470
472
var separator = dataUrl.IndexOf(',');
471
473
if (separator < 0) return DefaultToolIcon;
472
using var stream = new MemoryStream(Convert.FromBase64String(dataUrl[(separator + 1)..]));
473
var image = new BitmapImage();
474
image.BeginInit();
475
image.CacheOption = BitmapCacheOption.OnLoad;
476
image.StreamSource = stream;
477
image.EndInit();
478
image.Freeze();
479
return image;
474
var header = dataUrl[5..separator];
475
var mediaType = header.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
476
.FirstOrDefault();
477
var bytes = Convert.FromBase64String(dataUrl[(separator + 1)..]);
478
return WebImageSourceLoader.Decode(bytes, mediaType, "tool-icon");
480
479
}
481
480
catch
482
481
{